@umituz/react-native-onboarding 2.10.0 → 3.0.1

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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@umituz/react-native-onboarding",
3
- "version": "2.10.0",
3
+ "version": "3.0.1",
4
4
  "description": "Advanced onboarding flow for React Native apps with personalization questions, theme-aware colors, animations, and customizable slides. SOLID, DRY, KISS principles applied.",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -36,8 +36,8 @@
36
36
  "@umituz/react-native-design-system-theme": "latest",
37
37
  "@umituz/react-native-design-system": "latest",
38
38
  "@umituz/react-native-design-system-atoms": "latest",
39
+ "@expo/vector-icons": ">=14.0.0",
39
40
  "expo-linear-gradient": "^15.0.0",
40
- "lucide-react-native": "^0.344.0",
41
41
  "react": ">=18.2.0",
42
42
  "react-native": ">=0.74.0",
43
43
  "react-native-safe-area-context": "^5.0.0",
@@ -50,8 +50,8 @@
50
50
  "@umituz/react-native-localization": "latest",
51
51
  "@umituz/react-native-design-system-theme": "latest",
52
52
  "@umituz/react-native-design-system-atoms": "latest",
53
+ "@expo/vector-icons": ">=14.0.0",
53
54
  "expo-linear-gradient": "^15.0.7",
54
- "lucide-react-native": "^0.344.0",
55
55
  "react": "^18.2.0",
56
56
  "react-native": "^0.74.0",
57
57
  "react-native-safe-area-context": "^5.6.0",
@@ -66,4 +66,4 @@
66
66
  "README.md",
67
67
  "LICENSE"
68
68
  ]
69
- }
69
+ }
package/src/index.ts CHANGED
@@ -48,10 +48,18 @@ export {
48
48
  useOnboardingStore,
49
49
  useOnboarding,
50
50
  } from "./infrastructure/storage/OnboardingStore";
51
+ export type { OnboardingStoreState } from "./infrastructure/storage/OnboardingStoreState";
52
+ export type { OnboardingStoreActions } from "./infrastructure/storage/OnboardingStoreActions";
53
+ export type { OnboardingStoreSelectors } from "./infrastructure/storage/OnboardingStoreSelectors";
51
54
  export {
52
55
  useOnboardingNavigation,
53
56
  type UseOnboardingNavigationReturn,
54
57
  } from "./infrastructure/hooks/useOnboardingNavigation";
58
+ export {
59
+ useOnboardingContainerStyle,
60
+ type UseOnboardingContainerStyleProps,
61
+ type UseOnboardingContainerStyleReturn,
62
+ } from "./presentation/hooks/useOnboardingContainerStyle";
55
63
 
56
64
  // =============================================================================
57
65
  // PRESENTATION LAYER - Components and Screens
@@ -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
+ });
@@ -7,7 +7,7 @@
7
7
 
8
8
  import { useState, useEffect, useCallback } from "react";
9
9
  import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
10
- import { useOnboardingStore } from "../storage/OnboardingStore";
10
+ import { useOnboarding } from "../storage/OnboardingStore";
11
11
 
12
12
  export interface UseOnboardingAnswersReturn {
13
13
  currentAnswer: any;
@@ -24,7 +24,7 @@ export interface UseOnboardingAnswersReturn {
24
24
  export function useOnboardingAnswers(
25
25
  currentSlide: OnboardingSlide | undefined,
26
26
  ): UseOnboardingAnswersReturn {
27
- const onboardingStore = useOnboardingStore();
27
+ const onboardingStore = useOnboarding();
28
28
  const [currentAnswer, setCurrentAnswer] = useState<any>(undefined);
29
29
 
30
30
  /**
@@ -6,187 +6,62 @@
6
6
  */
7
7
 
8
8
  import { create } from "zustand";
9
- import {
10
- storageRepository,
11
- StorageKey,
12
- unwrap,
13
- } from "@umituz/react-native-storage";
14
- import type { OnboardingUserData } from "../../domain/entities/OnboardingUserData";
9
+ import type { OnboardingStoreState } from "./OnboardingStoreState";
10
+ import { initialOnboardingState } from "./OnboardingStoreState";
11
+ import { createOnboardingStoreActions } from "./OnboardingStoreActions";
12
+ import { createOnboardingStoreSelectors } from "./OnboardingStoreSelectors";
15
13
 
16
- interface OnboardingStore {
17
- // State
18
- isOnboardingComplete: boolean;
19
- currentStep: number;
20
- loading: boolean;
21
- error: string | null;
22
- userData: OnboardingUserData;
23
-
24
- // Actions
25
- initialize: (storageKey?: string) => Promise<void>;
26
- complete: (storageKey?: string) => Promise<void>;
27
- skip: (storageKey?: string) => Promise<void>;
14
+ interface OnboardingStore extends OnboardingStoreState {
15
+ // Simple actions
28
16
  setCurrentStep: (step: number) => void;
29
- reset: (storageKey?: string) => Promise<void>;
30
17
  setLoading: (loading: boolean) => void;
31
18
  setError: (error: string | null) => void;
32
- saveAnswer: (questionId: string, answer: any) => Promise<void>;
33
- getAnswer: (questionId: string) => any;
34
- getUserData: () => OnboardingUserData;
35
- setUserData: (data: OnboardingUserData) => Promise<void>;
19
+ setState: (state: Partial<OnboardingStoreState>) => void;
20
+ getState: () => OnboardingStoreState;
36
21
  }
37
22
 
38
- const DEFAULT_STORAGE_KEY = StorageKey.ONBOARDING_COMPLETED;
39
- const USER_DATA_STORAGE_KEY = "@onboarding_user_data";
40
-
41
23
  export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
42
- isOnboardingComplete: false,
43
- currentStep: 0,
44
- loading: true,
45
- error: null,
46
- userData: { answers: {} },
47
-
48
- initialize: async (storageKey = DEFAULT_STORAGE_KEY) => {
49
- set({ loading: true, error: null });
50
-
51
- // Load completion status
52
- const completionResult = await storageRepository.getString(storageKey, "false");
53
- const isComplete = unwrap(completionResult, "false") === "true";
54
-
55
- // Load user data
56
- const userDataResult = await storageRepository.getObject<OnboardingUserData>(
57
- USER_DATA_STORAGE_KEY,
58
- { answers: {} }
59
- );
60
- const userData = unwrap(userDataResult, { answers: {} });
61
-
62
- set({
63
- isOnboardingComplete: isComplete,
64
- userData,
65
- loading: false,
66
- error: completionResult.success ? null : "Failed to load onboarding status",
67
- });
68
- },
69
-
70
- complete: async (storageKey = DEFAULT_STORAGE_KEY) => {
71
- set({ loading: true, error: null });
72
-
73
- const result = await storageRepository.setString(storageKey, "true");
74
-
75
- // Update user data with completion timestamp
76
- const userData = get().userData;
77
- userData.completedAt = new Date().toISOString();
78
-
79
- await storageRepository.setObject(USER_DATA_STORAGE_KEY, userData);
80
-
81
- set({
82
- isOnboardingComplete: result.success,
83
- userData,
84
- loading: false,
85
- error: result.success ? null : "Failed to complete onboarding",
86
- });
87
- },
88
-
89
- skip: async (storageKey = DEFAULT_STORAGE_KEY) => {
90
- set({ loading: true, error: null });
91
-
92
- const result = await storageRepository.setString(storageKey, "true");
93
-
94
- // Update user data with skipped flag
95
- const userData = get().userData;
96
- userData.skipped = true;
97
- userData.completedAt = new Date().toISOString();
98
- await storageRepository.setObject(USER_DATA_STORAGE_KEY, userData);
99
-
100
- set({
101
- isOnboardingComplete: result.success,
102
- userData,
103
- loading: false,
104
- error: result.success ? null : "Failed to skip onboarding",
105
- });
106
- },
24
+ ...initialOnboardingState,
107
25
 
108
26
  setCurrentStep: (step) => set({ currentStep: step }),
109
-
110
- reset: async (storageKey = DEFAULT_STORAGE_KEY) => {
111
- set({ loading: true, error: null });
112
-
113
- const result = await storageRepository.removeItem(storageKey);
114
- await storageRepository.removeItem(USER_DATA_STORAGE_KEY);
115
-
116
- set({
117
- isOnboardingComplete: false,
118
- currentStep: 0,
119
- userData: { answers: {} },
120
- loading: false,
121
- error: result.success ? null : "Failed to reset onboarding",
122
- });
123
- },
124
-
125
27
  setLoading: (loading) => set({ loading }),
126
28
  setError: (error) => set({ error }),
127
-
128
- saveAnswer: async (questionId: string, answer: any) => {
129
- const userData = get().userData;
130
- userData.answers[questionId] = answer;
131
-
132
- await storageRepository.setObject(USER_DATA_STORAGE_KEY, userData);
133
- set({ userData: { ...userData } });
134
- },
135
-
136
- getAnswer: (questionId: string) => {
137
- return get().userData.answers[questionId];
138
- },
139
-
140
- getUserData: () => {
141
- return get().userData;
142
- },
143
-
144
- setUserData: async (data: OnboardingUserData) => {
145
- await storageRepository.setObject(USER_DATA_STORAGE_KEY, data);
146
- set({ userData: data });
147
- },
29
+ setState: set,
30
+ getState: get,
148
31
  }));
149
32
 
150
33
  /**
151
34
  * Hook for accessing onboarding state
152
35
  */
153
36
  export const useOnboarding = () => {
154
- const {
155
- isOnboardingComplete,
156
- currentStep,
157
- loading,
158
- error,
159
- userData,
160
- initialize,
161
- complete,
162
- skip,
163
- setCurrentStep,
164
- reset,
165
- setLoading,
166
- setError,
167
- saveAnswer,
168
- getAnswer,
169
- getUserData,
170
- setUserData,
171
- } = useOnboardingStore();
37
+ const store = useOnboardingStore();
38
+ const setState = store.setState as any;
39
+ const getState = () => store;
40
+ const actions = createOnboardingStoreActions(setState, getState);
41
+ const selectors = createOnboardingStoreSelectors(getState);
172
42
 
173
43
  return {
174
- isOnboardingComplete,
175
- currentStep,
176
- loading,
177
- error,
178
- userData,
179
- initialize,
180
- complete,
181
- skip,
182
- setCurrentStep,
183
- reset,
184
- setLoading,
185
- setError,
186
- saveAnswer,
187
- getAnswer,
188
- getUserData,
189
- setUserData,
44
+ // State
45
+ isOnboardingComplete: store.isOnboardingComplete,
46
+ currentStep: store.currentStep,
47
+ loading: store.loading,
48
+ error: store.error,
49
+ userData: store.userData,
50
+
51
+ // Actions
52
+ initialize: actions.initialize,
53
+ complete: actions.complete,
54
+ skip: actions.skip,
55
+ setCurrentStep: store.setCurrentStep,
56
+ reset: actions.reset,
57
+ setLoading: store.setLoading,
58
+ setError: store.setError,
59
+ saveAnswer: actions.saveAnswer,
60
+ setUserData: actions.setUserData,
61
+
62
+ // Selectors
63
+ getAnswer: selectors.getAnswer,
64
+ getUserData: selectors.getUserData,
190
65
  };
191
66
  };
192
67