@umituz/react-native-design-system 2.6.128 → 2.7.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +4 -2
- package/src/exports/onboarding.ts +6 -0
- package/src/index.ts +5 -0
- package/src/onboarding/domain/entities/OnboardingOptions.ts +104 -0
- package/src/onboarding/domain/entities/OnboardingQuestion.ts +165 -0
- package/src/onboarding/domain/entities/OnboardingSlide.ts +152 -0
- package/src/onboarding/domain/entities/OnboardingUserData.ts +43 -0
- package/src/onboarding/hooks/useOnboardingFlow.ts +50 -0
- package/src/onboarding/index.ts +108 -0
- package/src/onboarding/infrastructure/hooks/__tests__/useOnboardingAnswers.test.ts +163 -0
- package/src/onboarding/infrastructure/hooks/__tests__/useOnboardingNavigation.test.ts +121 -0
- package/src/onboarding/infrastructure/hooks/useOnboardingAnswers.ts +69 -0
- package/src/onboarding/infrastructure/hooks/useOnboardingNavigation.ts +75 -0
- package/src/onboarding/infrastructure/services/SlideManager.ts +53 -0
- package/src/onboarding/infrastructure/services/ValidationManager.ts +127 -0
- package/src/onboarding/infrastructure/storage/OnboardingStore.ts +99 -0
- package/src/onboarding/infrastructure/storage/OnboardingStoreActions.ts +50 -0
- package/src/onboarding/infrastructure/storage/OnboardingStoreSelectors.ts +25 -0
- package/src/onboarding/infrastructure/storage/OnboardingStoreState.ts +22 -0
- package/src/onboarding/infrastructure/storage/__tests__/OnboardingStore.test.ts +85 -0
- package/src/onboarding/infrastructure/storage/actions/answerActions.ts +47 -0
- package/src/onboarding/infrastructure/storage/actions/completeAction.ts +45 -0
- package/src/onboarding/infrastructure/storage/actions/index.ts +22 -0
- package/src/onboarding/infrastructure/storage/actions/initializeAction.ts +40 -0
- package/src/onboarding/infrastructure/storage/actions/resetAction.ts +37 -0
- package/src/onboarding/infrastructure/storage/actions/skipAction.ts +46 -0
- package/src/onboarding/infrastructure/storage/actions/storageHelpers.ts +60 -0
- package/src/onboarding/infrastructure/utils/arrayUtils.ts +28 -0
- package/src/onboarding/infrastructure/utils/backgroundUtils.ts +38 -0
- package/src/onboarding/infrastructure/utils/layouts/collageLayout.ts +81 -0
- package/src/onboarding/infrastructure/utils/layouts/gridLayouts.ts +78 -0
- package/src/onboarding/infrastructure/utils/layouts/honeycombLayout.ts +36 -0
- package/src/onboarding/infrastructure/utils/layouts/index.ts +12 -0
- package/src/onboarding/infrastructure/utils/layouts/layoutTypes.ts +37 -0
- package/src/onboarding/infrastructure/utils/layouts/masonryLayout.ts +37 -0
- package/src/onboarding/infrastructure/utils/layouts/scatteredLayout.ts +34 -0
- package/src/onboarding/infrastructure/utils/layouts/screenDimensions.ts +11 -0
- package/src/onboarding/infrastructure/utils/layouts/tilesLayout.ts +34 -0
- package/src/onboarding/presentation/components/BackgroundImageCollage.tsx +90 -0
- package/src/onboarding/presentation/components/BackgroundVideo.tsx +24 -0
- package/src/onboarding/presentation/components/BaseSlide.tsx +47 -0
- package/src/onboarding/presentation/components/OnboardingBackground.tsx +91 -0
- package/src/onboarding/presentation/components/OnboardingFooter.tsx +151 -0
- package/src/onboarding/presentation/components/OnboardingHeader.tsx +92 -0
- package/src/onboarding/presentation/components/OnboardingResetSetting.tsx +70 -0
- package/src/onboarding/presentation/components/OnboardingScreenContent.tsx +146 -0
- package/src/onboarding/presentation/components/OnboardingSlide.tsx +124 -0
- package/src/onboarding/presentation/components/QuestionRenderer.tsx +60 -0
- package/src/onboarding/presentation/components/QuestionSlide.tsx +67 -0
- package/src/onboarding/presentation/components/QuestionSlideHeader.tsx +75 -0
- package/src/onboarding/presentation/components/questions/MultipleChoiceQuestion.tsx +74 -0
- package/src/onboarding/presentation/components/questions/QuestionOptionItem.tsx +115 -0
- package/src/onboarding/presentation/components/questions/RatingQuestion.tsx +66 -0
- package/src/onboarding/presentation/components/questions/SingleChoiceQuestion.tsx +117 -0
- package/src/onboarding/presentation/components/questions/TextInputQuestion.tsx +71 -0
- package/src/onboarding/presentation/hooks/__tests__/useOnboardingContainerStyle.test.ts +96 -0
- package/src/onboarding/presentation/hooks/useOnboardingContainerStyle.ts +37 -0
- package/src/onboarding/presentation/hooks/useOnboardingGestures.ts +45 -0
- package/src/onboarding/presentation/hooks/useOnboardingScreenHandlers.ts +114 -0
- package/src/onboarding/presentation/hooks/useOnboardingScreenState.ts +146 -0
- package/src/onboarding/presentation/providers/OnboardingProvider.tsx +51 -0
- package/src/onboarding/presentation/screens/OnboardingScreen.tsx +189 -0
- package/src/onboarding/presentation/types/OnboardingProps.ts +46 -0
- package/src/onboarding/presentation/types/OnboardingTheme.ts +27 -0
|
@@ -0,0 +1,163 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OnboardingAnswers Hook Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renderHook, act } from '@testing-library/react-native';
|
|
6
|
+
import { useOnboardingAnswers } from '../useOnboardingAnswers';
|
|
7
|
+
import { useOnboardingStore } from '../../storage/OnboardingStore';
|
|
8
|
+
|
|
9
|
+
// Mock the store
|
|
10
|
+
jest.mock('../../storage/OnboardingStore', () => ({
|
|
11
|
+
useOnboardingStore: jest.fn(),
|
|
12
|
+
}));
|
|
13
|
+
|
|
14
|
+
const mockUseOnboardingStore = useOnboardingStore as jest.MockedFunction<typeof useOnboardingStore>;
|
|
15
|
+
|
|
16
|
+
describe('useOnboardingAnswers', () => {
|
|
17
|
+
const mockGetAnswer = jest.fn();
|
|
18
|
+
const mockSaveAnswer = jest.fn();
|
|
19
|
+
|
|
20
|
+
beforeEach(() => {
|
|
21
|
+
jest.clearAllMocks();
|
|
22
|
+
mockUseOnboardingStore.mockReturnValue({
|
|
23
|
+
getAnswer: mockGetAnswer,
|
|
24
|
+
saveAnswer: mockSaveAnswer,
|
|
25
|
+
} as any);
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it('should initialize with undefined answer', () => {
|
|
29
|
+
const mockSlide = {
|
|
30
|
+
id: '1',
|
|
31
|
+
type: 'question',
|
|
32
|
+
question: {
|
|
33
|
+
id: 'q1',
|
|
34
|
+
type: 'single',
|
|
35
|
+
text: 'Test question',
|
|
36
|
+
},
|
|
37
|
+
};
|
|
38
|
+
|
|
39
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
40
|
+
|
|
41
|
+
expect(result.current.currentAnswer).toBeUndefined();
|
|
42
|
+
expect(mockGetAnswer).toHaveBeenCalledWith('q1');
|
|
43
|
+
});
|
|
44
|
+
|
|
45
|
+
it('should load saved answer for slide', () => {
|
|
46
|
+
const mockSlide = {
|
|
47
|
+
id: '1',
|
|
48
|
+
type: 'question',
|
|
49
|
+
question: {
|
|
50
|
+
id: 'q1',
|
|
51
|
+
type: 'single',
|
|
52
|
+
text: 'Test question',
|
|
53
|
+
},
|
|
54
|
+
};
|
|
55
|
+
|
|
56
|
+
mockGetAnswer.mockReturnValue('option1');
|
|
57
|
+
|
|
58
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
59
|
+
|
|
60
|
+
expect(result.current.currentAnswer).toBe('option1');
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
it('should use default value when no saved answer', () => {
|
|
64
|
+
const mockSlide = {
|
|
65
|
+
id: '1',
|
|
66
|
+
type: 'question',
|
|
67
|
+
question: {
|
|
68
|
+
id: 'q1',
|
|
69
|
+
type: 'single',
|
|
70
|
+
text: 'Test question',
|
|
71
|
+
defaultValue: 'default',
|
|
72
|
+
},
|
|
73
|
+
};
|
|
74
|
+
|
|
75
|
+
mockGetAnswer.mockReturnValue(undefined);
|
|
76
|
+
|
|
77
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
78
|
+
|
|
79
|
+
expect(result.current.currentAnswer).toBe('default');
|
|
80
|
+
});
|
|
81
|
+
|
|
82
|
+
it('should set current answer', () => {
|
|
83
|
+
const mockSlide = {
|
|
84
|
+
id: '1',
|
|
85
|
+
type: 'question',
|
|
86
|
+
question: {
|
|
87
|
+
id: 'q1',
|
|
88
|
+
type: 'single',
|
|
89
|
+
text: 'Test question',
|
|
90
|
+
},
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
94
|
+
|
|
95
|
+
act(() => {
|
|
96
|
+
result.current.setCurrentAnswer('newAnswer');
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
expect(result.current.currentAnswer).toBe('newAnswer');
|
|
100
|
+
});
|
|
101
|
+
|
|
102
|
+
it('should save answer for slide', async () => {
|
|
103
|
+
const mockSlide = {
|
|
104
|
+
id: '1',
|
|
105
|
+
type: 'question',
|
|
106
|
+
question: {
|
|
107
|
+
id: 'q1',
|
|
108
|
+
type: 'single',
|
|
109
|
+
text: 'Test question',
|
|
110
|
+
},
|
|
111
|
+
};
|
|
112
|
+
|
|
113
|
+
mockSaveAnswer.mockResolvedValue(undefined);
|
|
114
|
+
|
|
115
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
116
|
+
|
|
117
|
+
act(() => {
|
|
118
|
+
result.current.setCurrentAnswer('answer1');
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
await act(async () => {
|
|
122
|
+
await result.current.saveCurrentAnswer(mockSlide);
|
|
123
|
+
});
|
|
124
|
+
|
|
125
|
+
expect(mockSaveAnswer).toHaveBeenCalledWith('q1', 'answer1');
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it('should not save answer for non-question slide', async () => {
|
|
129
|
+
const mockSlide = {
|
|
130
|
+
id: '1',
|
|
131
|
+
type: 'welcome',
|
|
132
|
+
title: 'Welcome',
|
|
133
|
+
};
|
|
134
|
+
|
|
135
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
136
|
+
|
|
137
|
+
await act(async () => {
|
|
138
|
+
await result.current.saveCurrentAnswer(mockSlide);
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
expect(mockSaveAnswer).not.toHaveBeenCalled();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it('should not save undefined answer', async () => {
|
|
145
|
+
const mockSlide = {
|
|
146
|
+
id: '1',
|
|
147
|
+
type: 'question',
|
|
148
|
+
question: {
|
|
149
|
+
id: 'q1',
|
|
150
|
+
type: 'single',
|
|
151
|
+
text: 'Test question',
|
|
152
|
+
},
|
|
153
|
+
};
|
|
154
|
+
|
|
155
|
+
const { result } = renderHook(() => useOnboardingAnswers(mockSlide));
|
|
156
|
+
|
|
157
|
+
await act(async () => {
|
|
158
|
+
await result.current.saveCurrentAnswer(mockSlide);
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
expect(mockSaveAnswer).not.toHaveBeenCalled();
|
|
162
|
+
});
|
|
163
|
+
});
|
|
@@ -0,0 +1,121 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* OnboardingNavigation Hook Tests
|
|
3
|
+
*/
|
|
4
|
+
|
|
5
|
+
import { renderHook, act } from '@testing-library/react-native';
|
|
6
|
+
import { useOnboardingNavigation } from '../useOnboardingNavigation';
|
|
7
|
+
|
|
8
|
+
describe('useOnboardingNavigation', () => {
|
|
9
|
+
const mockOnComplete = jest.fn();
|
|
10
|
+
const mockOnSkip = jest.fn();
|
|
11
|
+
|
|
12
|
+
beforeEach(() => {
|
|
13
|
+
jest.clearAllMocks();
|
|
14
|
+
});
|
|
15
|
+
|
|
16
|
+
it('should initialize with first slide', () => {
|
|
17
|
+
const { result } = renderHook(() =>
|
|
18
|
+
useOnboardingNavigation(3, mockOnComplete, mockOnSkip)
|
|
19
|
+
);
|
|
20
|
+
|
|
21
|
+
expect(result.current.currentIndex).toBe(0);
|
|
22
|
+
expect(result.current.isFirstSlide).toBe(true);
|
|
23
|
+
expect(result.current.isLastSlide).toBe(false);
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it('should navigate to next slide', () => {
|
|
27
|
+
const { result } = renderHook(() =>
|
|
28
|
+
useOnboardingNavigation(3, mockOnComplete, mockOnSkip)
|
|
29
|
+
);
|
|
30
|
+
|
|
31
|
+
act(() => {
|
|
32
|
+
result.current.goToNext();
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
expect(result.current.currentIndex).toBe(1);
|
|
36
|
+
expect(result.current.isFirstSlide).toBe(false);
|
|
37
|
+
expect(result.current.isLastSlide).toBe(false);
|
|
38
|
+
});
|
|
39
|
+
|
|
40
|
+
it('should navigate to previous slide', () => {
|
|
41
|
+
const { result } = renderHook(() =>
|
|
42
|
+
useOnboardingNavigation(3, mockOnComplete, mockOnSkip)
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
act(() => {
|
|
46
|
+
result.current.goToNext();
|
|
47
|
+
});
|
|
48
|
+
act(() => {
|
|
49
|
+
result.current.goToPrevious();
|
|
50
|
+
});
|
|
51
|
+
|
|
52
|
+
expect(result.current.currentIndex).toBe(0);
|
|
53
|
+
expect(result.current.isFirstSlide).toBe(true);
|
|
54
|
+
});
|
|
55
|
+
|
|
56
|
+
it('should handle last slide correctly', () => {
|
|
57
|
+
const { result } = renderHook(() =>
|
|
58
|
+
useOnboardingNavigation(2, mockOnComplete, mockOnSkip)
|
|
59
|
+
);
|
|
60
|
+
|
|
61
|
+
act(() => {
|
|
62
|
+
result.current.goToNext();
|
|
63
|
+
});
|
|
64
|
+
|
|
65
|
+
expect(result.current.currentIndex).toBe(1);
|
|
66
|
+
expect(result.current.isLastSlide).toBe(true);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
it('should not go beyond last slide', () => {
|
|
70
|
+
const { result } = renderHook(() =>
|
|
71
|
+
useOnboardingNavigation(2, mockOnComplete, mockOnSkip)
|
|
72
|
+
);
|
|
73
|
+
|
|
74
|
+
act(() => {
|
|
75
|
+
result.current.goToNext();
|
|
76
|
+
});
|
|
77
|
+
act(() => {
|
|
78
|
+
result.current.goToNext();
|
|
79
|
+
});
|
|
80
|
+
|
|
81
|
+
expect(result.current.currentIndex).toBe(1);
|
|
82
|
+
expect(result.current.isLastSlide).toBe(true);
|
|
83
|
+
});
|
|
84
|
+
|
|
85
|
+
it('should not go before first slide', () => {
|
|
86
|
+
const { result } = renderHook(() =>
|
|
87
|
+
useOnboardingNavigation(2, mockOnComplete, mockOnSkip)
|
|
88
|
+
);
|
|
89
|
+
|
|
90
|
+
act(() => {
|
|
91
|
+
result.current.goToPrevious();
|
|
92
|
+
});
|
|
93
|
+
|
|
94
|
+
expect(result.current.currentIndex).toBe(0);
|
|
95
|
+
expect(result.current.isFirstSlide).toBe(true);
|
|
96
|
+
});
|
|
97
|
+
|
|
98
|
+
it('should call onComplete when completing', async () => {
|
|
99
|
+
const { result } = renderHook(() =>
|
|
100
|
+
useOnboardingNavigation(1, mockOnComplete, mockOnSkip)
|
|
101
|
+
);
|
|
102
|
+
|
|
103
|
+
await act(async () => {
|
|
104
|
+
await result.current.complete();
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
expect(mockOnComplete).toHaveBeenCalled();
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it('should call onSkip when skipping', async () => {
|
|
111
|
+
const { result } = renderHook(() =>
|
|
112
|
+
useOnboardingNavigation(1, mockOnComplete, mockOnSkip)
|
|
113
|
+
);
|
|
114
|
+
|
|
115
|
+
await act(async () => {
|
|
116
|
+
await result.current.skip();
|
|
117
|
+
});
|
|
118
|
+
|
|
119
|
+
expect(mockOnSkip).toHaveBeenCalled();
|
|
120
|
+
});
|
|
121
|
+
});
|
|
@@ -0,0 +1,69 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOnboardingAnswers Hook
|
|
3
|
+
*
|
|
4
|
+
* Manages answer state and operations for onboarding questions
|
|
5
|
+
* Follows Single Responsibility Principle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useState, useEffect, useCallback } from "react";
|
|
9
|
+
import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
|
|
10
|
+
import { useOnboarding } from "../storage/OnboardingStore";
|
|
11
|
+
|
|
12
|
+
export interface UseOnboardingAnswersReturn {
|
|
13
|
+
currentAnswer: any;
|
|
14
|
+
setCurrentAnswer: (answer: any) => void;
|
|
15
|
+
loadAnswerForSlide: (slide: OnboardingSlide | undefined) => void;
|
|
16
|
+
saveCurrentAnswer: (slide: OnboardingSlide | undefined) => Promise<void>;
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
/**
|
|
20
|
+
* Hook for managing onboarding question answers
|
|
21
|
+
* @param currentSlide - The current slide being displayed
|
|
22
|
+
* @returns Answer state and operations
|
|
23
|
+
*/
|
|
24
|
+
export function useOnboardingAnswers(
|
|
25
|
+
currentSlide: OnboardingSlide | undefined,
|
|
26
|
+
): UseOnboardingAnswersReturn {
|
|
27
|
+
const onboardingStore = useOnboarding();
|
|
28
|
+
const [currentAnswer, setCurrentAnswer] = useState<any>(undefined);
|
|
29
|
+
|
|
30
|
+
/**
|
|
31
|
+
* Load answer for a specific slide
|
|
32
|
+
*/
|
|
33
|
+
const loadAnswerForSlide = useCallback(
|
|
34
|
+
(slide: OnboardingSlide | undefined) => {
|
|
35
|
+
if (slide?.question) {
|
|
36
|
+
const savedAnswer = onboardingStore.getAnswer(slide.question.id);
|
|
37
|
+
setCurrentAnswer(savedAnswer ?? slide.question.defaultValue);
|
|
38
|
+
} else {
|
|
39
|
+
setCurrentAnswer(undefined);
|
|
40
|
+
}
|
|
41
|
+
},
|
|
42
|
+
[onboardingStore],
|
|
43
|
+
);
|
|
44
|
+
|
|
45
|
+
/**
|
|
46
|
+
* Save current answer for a slide
|
|
47
|
+
*/
|
|
48
|
+
const saveCurrentAnswer = useCallback(
|
|
49
|
+
async (slide: OnboardingSlide | undefined) => {
|
|
50
|
+
if (slide?.question && currentAnswer !== undefined) {
|
|
51
|
+
await onboardingStore.saveAnswer(slide.question.id, currentAnswer);
|
|
52
|
+
}
|
|
53
|
+
},
|
|
54
|
+
[currentAnswer, onboardingStore],
|
|
55
|
+
);
|
|
56
|
+
|
|
57
|
+
// Load answer when slide changes
|
|
58
|
+
useEffect(() => {
|
|
59
|
+
loadAnswerForSlide(currentSlide);
|
|
60
|
+
}, [currentSlide, loadAnswerForSlide]);
|
|
61
|
+
|
|
62
|
+
return {
|
|
63
|
+
currentAnswer,
|
|
64
|
+
setCurrentAnswer,
|
|
65
|
+
loadAnswerForSlide,
|
|
66
|
+
saveCurrentAnswer,
|
|
67
|
+
};
|
|
68
|
+
}
|
|
69
|
+
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* useOnboardingNavigation Hook
|
|
3
|
+
*
|
|
4
|
+
* Manages navigation state and callbacks for onboarding flow
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { useState, useCallback } from "react";
|
|
8
|
+
import { DeviceEventEmitter } from "react-native";
|
|
9
|
+
|
|
10
|
+
export interface UseOnboardingNavigationReturn {
|
|
11
|
+
currentIndex: number;
|
|
12
|
+
goToNext: () => void;
|
|
13
|
+
goToPrevious: () => void;
|
|
14
|
+
complete: () => Promise<void>;
|
|
15
|
+
skip: () => Promise<void>;
|
|
16
|
+
isLastSlide: boolean;
|
|
17
|
+
isFirstSlide: boolean;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Hook for managing onboarding navigation
|
|
22
|
+
*
|
|
23
|
+
* @param totalSlides - Total number of slides
|
|
24
|
+
* @param onComplete - Callback when onboarding completes
|
|
25
|
+
* @param onSkip - Callback when onboarding is skipped
|
|
26
|
+
* @returns Navigation state and handlers
|
|
27
|
+
*/
|
|
28
|
+
export const useOnboardingNavigation = (
|
|
29
|
+
totalSlides: number,
|
|
30
|
+
onComplete?: () => void | Promise<void>,
|
|
31
|
+
onSkip?: () => void | Promise<void>,
|
|
32
|
+
): UseOnboardingNavigationReturn => {
|
|
33
|
+
const [currentIndex, setCurrentIndex] = useState(0);
|
|
34
|
+
|
|
35
|
+
const goToNext = useCallback(() => {
|
|
36
|
+
if (currentIndex < totalSlides - 1) {
|
|
37
|
+
setCurrentIndex(currentIndex + 1);
|
|
38
|
+
}
|
|
39
|
+
}, [currentIndex, totalSlides]);
|
|
40
|
+
|
|
41
|
+
const goToPrevious = useCallback(() => {
|
|
42
|
+
if (currentIndex > 0) {
|
|
43
|
+
setCurrentIndex(currentIndex - 1);
|
|
44
|
+
}
|
|
45
|
+
}, [currentIndex]);
|
|
46
|
+
|
|
47
|
+
const complete = useCallback(async () => {
|
|
48
|
+
if (onComplete) {
|
|
49
|
+
await onComplete();
|
|
50
|
+
}
|
|
51
|
+
// Emit event for app-level handling
|
|
52
|
+
|
|
53
|
+
if (__DEV__) console.log("[useOnboardingNavigation] Emitting onboarding-complete event");
|
|
54
|
+
DeviceEventEmitter.emit("onboarding-complete");
|
|
55
|
+
}, [onComplete]);
|
|
56
|
+
|
|
57
|
+
const skip = useCallback(async () => {
|
|
58
|
+
if (onSkip) {
|
|
59
|
+
await onSkip();
|
|
60
|
+
}
|
|
61
|
+
// Emit event for app-level handling
|
|
62
|
+
DeviceEventEmitter.emit("onboarding-complete");
|
|
63
|
+
}, [onSkip]);
|
|
64
|
+
|
|
65
|
+
return {
|
|
66
|
+
currentIndex,
|
|
67
|
+
goToNext,
|
|
68
|
+
goToPrevious,
|
|
69
|
+
complete,
|
|
70
|
+
skip,
|
|
71
|
+
isLastSlide: currentIndex === totalSlides - 1,
|
|
72
|
+
isFirstSlide: currentIndex === 0,
|
|
73
|
+
};
|
|
74
|
+
};
|
|
75
|
+
|
|
@@ -0,0 +1,53 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding Slide Service
|
|
3
|
+
*
|
|
4
|
+
* Business logic for filtering and processing onboarding slides
|
|
5
|
+
* Follows Single Responsibility Principle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
|
|
9
|
+
import type { OnboardingUserData } from "../../domain/entities/OnboardingUserData";
|
|
10
|
+
|
|
11
|
+
/**
|
|
12
|
+
* SlideManager
|
|
13
|
+
*/
|
|
14
|
+
export class SlideManager {
|
|
15
|
+
/**
|
|
16
|
+
* Filter slides based on skipIf conditions
|
|
17
|
+
* @param slides - All available slides
|
|
18
|
+
* @param userData - User's onboarding data including answers
|
|
19
|
+
* @returns Filtered slides that should be shown
|
|
20
|
+
*/
|
|
21
|
+
static filterSlides(
|
|
22
|
+
slides: OnboardingSlide[] | undefined,
|
|
23
|
+
userData: OnboardingUserData,
|
|
24
|
+
): OnboardingSlide[] {
|
|
25
|
+
// Safety check: return empty array if slides is undefined or not an array
|
|
26
|
+
if (!slides || !Array.isArray(slides)) {
|
|
27
|
+
return [];
|
|
28
|
+
}
|
|
29
|
+
return slides.filter((slide) => {
|
|
30
|
+
if (slide.skipIf) {
|
|
31
|
+
return !slide.skipIf(userData.answers);
|
|
32
|
+
}
|
|
33
|
+
return true;
|
|
34
|
+
});
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* Get slide at specific index
|
|
39
|
+
* @param slides - Filtered slides array
|
|
40
|
+
* @param index - Slide index
|
|
41
|
+
* @returns Slide at index or undefined
|
|
42
|
+
*/
|
|
43
|
+
static getSlideAtIndex(
|
|
44
|
+
slides: OnboardingSlide[],
|
|
45
|
+
index: number,
|
|
46
|
+
): OnboardingSlide | undefined {
|
|
47
|
+
if (index < 0 || index >= slides.length) {
|
|
48
|
+
return undefined;
|
|
49
|
+
}
|
|
50
|
+
return slides[index];
|
|
51
|
+
}
|
|
52
|
+
}
|
|
53
|
+
|
|
@@ -0,0 +1,127 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding Validation Service
|
|
3
|
+
*
|
|
4
|
+
* Business logic for validating onboarding question answers
|
|
5
|
+
* Follows Single Responsibility Principle
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import type { OnboardingQuestion } from "../../domain/entities/OnboardingQuestion";
|
|
9
|
+
|
|
10
|
+
/**
|
|
11
|
+
* ValidationManager
|
|
12
|
+
*/
|
|
13
|
+
export class ValidationManager {
|
|
14
|
+
/**
|
|
15
|
+
* Validate answer against question validation rules
|
|
16
|
+
* @param question - The question to validate against
|
|
17
|
+
* @param answer - The answer to validate
|
|
18
|
+
* @returns true if valid, false otherwise
|
|
19
|
+
*/
|
|
20
|
+
static validateAnswer(
|
|
21
|
+
question: OnboardingQuestion,
|
|
22
|
+
answer: any,
|
|
23
|
+
): boolean {
|
|
24
|
+
const { validation } = question;
|
|
25
|
+
if (!validation) {
|
|
26
|
+
return true;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
// Required validation
|
|
30
|
+
if (validation.required && !answer) {
|
|
31
|
+
return false;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
// Type-specific validations
|
|
35
|
+
switch (question.type) {
|
|
36
|
+
case "multiple_choice":
|
|
37
|
+
return this.validateMultipleChoice(answer, validation);
|
|
38
|
+
case "text_input":
|
|
39
|
+
return this.validateTextInput(answer, validation);
|
|
40
|
+
case "rating":
|
|
41
|
+
return this.validateNumeric(answer, validation);
|
|
42
|
+
default:
|
|
43
|
+
break;
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
// Custom validator
|
|
47
|
+
if (validation.customValidator) {
|
|
48
|
+
const customResult = validation.customValidator(answer);
|
|
49
|
+
return customResult === true;
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
return true;
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
/**
|
|
56
|
+
* Validate multiple choice answer
|
|
57
|
+
*/
|
|
58
|
+
private static validateMultipleChoice(
|
|
59
|
+
answer: any,
|
|
60
|
+
validation: OnboardingQuestion["validation"],
|
|
61
|
+
): boolean {
|
|
62
|
+
if (!validation) return true;
|
|
63
|
+
|
|
64
|
+
if (validation.minSelections) {
|
|
65
|
+
if (!answer || !Array.isArray(answer) || answer.length < validation.minSelections) {
|
|
66
|
+
return false;
|
|
67
|
+
}
|
|
68
|
+
}
|
|
69
|
+
|
|
70
|
+
if (validation.maxSelections) {
|
|
71
|
+
if (Array.isArray(answer) && answer.length > validation.maxSelections) {
|
|
72
|
+
return false;
|
|
73
|
+
}
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
return true;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
/**
|
|
80
|
+
* Validate text input answer
|
|
81
|
+
*/
|
|
82
|
+
private static validateTextInput(
|
|
83
|
+
answer: any,
|
|
84
|
+
validation: OnboardingQuestion["validation"],
|
|
85
|
+
): boolean {
|
|
86
|
+
if (!validation) return true;
|
|
87
|
+
|
|
88
|
+
if (typeof answer !== "string") {
|
|
89
|
+
return false;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
if (validation.minLength && answer.length < validation.minLength) {
|
|
93
|
+
return false;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
if (validation.maxLength && answer.length > validation.maxLength) {
|
|
97
|
+
return false;
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
return true;
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
/**
|
|
104
|
+
* Validate numeric answer (rating)
|
|
105
|
+
*/
|
|
106
|
+
private static validateNumeric(
|
|
107
|
+
answer: any,
|
|
108
|
+
validation: OnboardingQuestion["validation"],
|
|
109
|
+
): boolean {
|
|
110
|
+
if (!validation) return true;
|
|
111
|
+
|
|
112
|
+
if (typeof answer !== "number") {
|
|
113
|
+
return false;
|
|
114
|
+
}
|
|
115
|
+
|
|
116
|
+
if (validation.min !== undefined && answer < validation.min) {
|
|
117
|
+
return false;
|
|
118
|
+
}
|
|
119
|
+
|
|
120
|
+
if (validation.max !== undefined && answer > validation.max) {
|
|
121
|
+
return false;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
return true;
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
|
|
@@ -0,0 +1,99 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Onboarding Store
|
|
3
|
+
*
|
|
4
|
+
* Zustand store for managing onboarding completion state
|
|
5
|
+
* Uses @umituz/react-native-storage for persistence
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import { useMemo } from "react";
|
|
9
|
+
import { createStore } from "@umituz/react-native-storage";
|
|
10
|
+
import type { OnboardingStoreState } from "./OnboardingStoreState";
|
|
11
|
+
import { initialOnboardingState } from "./OnboardingStoreState";
|
|
12
|
+
import { createOnboardingStoreActions } from "./OnboardingStoreActions";
|
|
13
|
+
import type { OnboardingUserData } from "../../domain/entities/OnboardingUserData";
|
|
14
|
+
import { createOnboardingStoreSelectors } from "./OnboardingStoreSelectors";
|
|
15
|
+
|
|
16
|
+
interface OnboardingActions {
|
|
17
|
+
// Simple actions
|
|
18
|
+
setCurrentStep: (step: number) => void;
|
|
19
|
+
setLoading: (loading: boolean) => void;
|
|
20
|
+
setError: (error: string | null) => void;
|
|
21
|
+
setState: (state: Partial<OnboardingStoreState>) => void;
|
|
22
|
+
getState: () => OnboardingStoreState;
|
|
23
|
+
// Async actions for initialization (match OnboardingStoreActions signatures)
|
|
24
|
+
initialize: (storageKey?: string) => Promise<void>;
|
|
25
|
+
complete: (storageKey?: string) => Promise<void>;
|
|
26
|
+
skip: (storageKey?: string) => Promise<void>;
|
|
27
|
+
reset: (storageKey?: string) => Promise<void>;
|
|
28
|
+
saveAnswer: (questionId: string, answer: unknown) => Promise<void>;
|
|
29
|
+
setUserData: (data: OnboardingUserData) => Promise<void>;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export const useOnboardingStore = createStore<
|
|
33
|
+
OnboardingStoreState,
|
|
34
|
+
OnboardingActions
|
|
35
|
+
>({
|
|
36
|
+
name: "onboarding-store",
|
|
37
|
+
initialState: initialOnboardingState,
|
|
38
|
+
persist: false,
|
|
39
|
+
actions: (
|
|
40
|
+
set: (state: Partial<OnboardingStoreState>) => void,
|
|
41
|
+
get: () => OnboardingStoreState
|
|
42
|
+
): OnboardingActions => {
|
|
43
|
+
const actions = createOnboardingStoreActions(set, get);
|
|
44
|
+
|
|
45
|
+
return {
|
|
46
|
+
setCurrentStep: (step: number) => set({ currentStep: step }),
|
|
47
|
+
setLoading: (loading: boolean) => set({ loading }),
|
|
48
|
+
setError: (error: string | null) => set({ error }),
|
|
49
|
+
setState: set,
|
|
50
|
+
getState: get,
|
|
51
|
+
|
|
52
|
+
// Async actions from actions module
|
|
53
|
+
initialize: actions.initialize,
|
|
54
|
+
complete: actions.complete,
|
|
55
|
+
skip: actions.skip,
|
|
56
|
+
reset: actions.reset,
|
|
57
|
+
saveAnswer: actions.saveAnswer,
|
|
58
|
+
setUserData: actions.setUserData,
|
|
59
|
+
};
|
|
60
|
+
},
|
|
61
|
+
});
|
|
62
|
+
|
|
63
|
+
/**
|
|
64
|
+
* Hook for accessing onboarding state
|
|
65
|
+
* Memoized to prevent unnecessary re-renders in consumer components
|
|
66
|
+
*/
|
|
67
|
+
export const useOnboarding = () => {
|
|
68
|
+
const store = useOnboardingStore();
|
|
69
|
+
const setState = store.setState;
|
|
70
|
+
const getState = store.getState;
|
|
71
|
+
|
|
72
|
+
const actions = useMemo(() => createOnboardingStoreActions(setState, getState), [setState, getState]);
|
|
73
|
+
const selectors = useMemo(() => createOnboardingStoreSelectors(getState), [getState]);
|
|
74
|
+
|
|
75
|
+
return useMemo(() => ({
|
|
76
|
+
// State
|
|
77
|
+
isOnboardingComplete: store.isOnboardingComplete,
|
|
78
|
+
currentStep: store.currentStep,
|
|
79
|
+
loading: store.loading,
|
|
80
|
+
error: store.error,
|
|
81
|
+
userData: store.userData,
|
|
82
|
+
|
|
83
|
+
// Actions
|
|
84
|
+
initialize: actions.initialize,
|
|
85
|
+
complete: actions.complete,
|
|
86
|
+
skip: actions.skip,
|
|
87
|
+
setCurrentStep: store.setCurrentStep,
|
|
88
|
+
reset: actions.reset,
|
|
89
|
+
setLoading: store.setLoading,
|
|
90
|
+
setError: store.setError,
|
|
91
|
+
saveAnswer: actions.saveAnswer,
|
|
92
|
+
setUserData: actions.setUserData,
|
|
93
|
+
|
|
94
|
+
// Selectors
|
|
95
|
+
getAnswer: selectors.getAnswer,
|
|
96
|
+
getUserData: selectors.getUserData,
|
|
97
|
+
}), [store, actions, selectors]);
|
|
98
|
+
};
|
|
99
|
+
|