@umituz/react-native-onboarding 1.0.9 → 2.0.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.
@@ -11,6 +11,7 @@ import {
11
11
  StorageKey,
12
12
  unwrap,
13
13
  } from "@umituz/react-native-storage";
14
+ import type { OnboardingUserData } from "../../domain/entities/OnboardingUserData";
14
15
 
15
16
  interface OnboardingStore {
16
17
  // State
@@ -18,6 +19,7 @@ interface OnboardingStore {
18
19
  currentStep: number;
19
20
  loading: boolean;
20
21
  error: string | null;
22
+ userData: OnboardingUserData;
21
23
 
22
24
  // Actions
23
25
  initialize: (storageKey?: string) => Promise<void>;
@@ -27,26 +29,41 @@ interface OnboardingStore {
27
29
  reset: (storageKey?: string) => Promise<void>;
28
30
  setLoading: (loading: boolean) => void;
29
31
  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>;
30
36
  }
31
37
 
32
38
  const DEFAULT_STORAGE_KEY = StorageKey.ONBOARDING_COMPLETED;
39
+ const USER_DATA_STORAGE_KEY = "@onboarding_user_data";
33
40
 
34
41
  export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
35
42
  isOnboardingComplete: false,
36
43
  currentStep: 0,
37
44
  loading: true,
38
45
  error: null,
46
+ userData: { answers: {} },
39
47
 
40
48
  initialize: async (storageKey = DEFAULT_STORAGE_KEY) => {
41
49
  set({ loading: true, error: null });
42
50
 
43
- const result = await storageRepository.getString(storageKey, "false");
44
- const data = unwrap(result, "false");
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: {} });
45
61
 
46
62
  set({
47
- isOnboardingComplete: data === "true",
63
+ isOnboardingComplete: isComplete,
64
+ userData,
48
65
  loading: false,
49
- error: result.success ? null : result.error?.message || null,
66
+ error: completionResult.success ? null : completionResult.error?.message || null,
50
67
  });
51
68
  },
52
69
 
@@ -55,8 +72,14 @@ export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
55
72
 
56
73
  const result = await storageRepository.setString(storageKey, "true");
57
74
 
75
+ // Update user data with completion timestamp
76
+ const userData = get().userData;
77
+ userData.completedAt = new Date().toISOString();
78
+ await storageRepository.setObject(USER_DATA_STORAGE_KEY, userData);
79
+
58
80
  set({
59
81
  isOnboardingComplete: result.success,
82
+ userData,
60
83
  loading: false,
61
84
  error: result.success ? null : result.error?.message || null,
62
85
  });
@@ -67,8 +90,15 @@ export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
67
90
 
68
91
  const result = await storageRepository.setString(storageKey, "true");
69
92
 
93
+ // Update user data with skipped flag
94
+ const userData = get().userData;
95
+ userData.skipped = true;
96
+ userData.completedAt = new Date().toISOString();
97
+ await storageRepository.setObject(USER_DATA_STORAGE_KEY, userData);
98
+
70
99
  set({
71
100
  isOnboardingComplete: result.success,
101
+ userData,
72
102
  loading: false,
73
103
  error: result.success ? null : result.error?.message || null,
74
104
  });
@@ -80,10 +110,12 @@ export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
80
110
  set({ loading: true, error: null });
81
111
 
82
112
  const result = await storageRepository.removeItem(storageKey);
113
+ await storageRepository.removeItem(USER_DATA_STORAGE_KEY);
83
114
 
84
115
  set({
85
116
  isOnboardingComplete: false,
86
117
  currentStep: 0,
118
+ userData: { answers: {} },
87
119
  loading: false,
88
120
  error: result.success ? null : result.error?.message || null,
89
121
  });
@@ -91,6 +123,27 @@ export const useOnboardingStore = create<OnboardingStore>((set, get) => ({
91
123
 
92
124
  setLoading: (loading) => set({ loading }),
93
125
  setError: (error) => set({ error }),
126
+
127
+ saveAnswer: async (questionId: string, answer: any) => {
128
+ const userData = get().userData;
129
+ userData.answers[questionId] = answer;
130
+
131
+ await storageRepository.setObject(USER_DATA_STORAGE_KEY, userData);
132
+ set({ userData: { ...userData } });
133
+ },
134
+
135
+ getAnswer: (questionId: string) => {
136
+ return get().userData.answers[questionId];
137
+ },
138
+
139
+ getUserData: () => {
140
+ return get().userData;
141
+ },
142
+
143
+ setUserData: async (data: OnboardingUserData) => {
144
+ await storageRepository.setObject(USER_DATA_STORAGE_KEY, data);
145
+ set({ userData: data });
146
+ },
94
147
  }));
95
148
 
96
149
  /**
@@ -102,6 +155,7 @@ export const useOnboarding = () => {
102
155
  currentStep,
103
156
  loading,
104
157
  error,
158
+ userData,
105
159
  initialize,
106
160
  complete,
107
161
  skip,
@@ -109,6 +163,10 @@ export const useOnboarding = () => {
109
163
  reset,
110
164
  setLoading,
111
165
  setError,
166
+ saveAnswer,
167
+ getAnswer,
168
+ getUserData,
169
+ setUserData,
112
170
  } = useOnboardingStore();
113
171
 
114
172
  return {
@@ -116,6 +174,7 @@ export const useOnboarding = () => {
116
174
  currentStep,
117
175
  loading,
118
176
  error,
177
+ userData,
119
178
  initialize,
120
179
  complete,
121
180
  skip,
@@ -123,6 +182,10 @@ export const useOnboarding = () => {
123
182
  reset,
124
183
  setLoading,
125
184
  setError,
185
+ saveAnswer,
186
+ getAnswer,
187
+ getUserData,
188
+ setUserData,
126
189
  };
127
190
  };
128
191
 
@@ -20,6 +20,7 @@ export interface OnboardingFooterProps {
20
20
  showProgressText?: boolean;
21
21
  nextButtonText?: string;
22
22
  getStartedButtonText?: string;
23
+ disabled?: boolean;
23
24
  }
24
25
 
25
26
  export const OnboardingFooter: React.FC<OnboardingFooterProps> = ({
@@ -32,6 +33,7 @@ export const OnboardingFooter: React.FC<OnboardingFooterProps> = ({
32
33
  showProgressText = true,
33
34
  nextButtonText,
34
35
  getStartedButtonText,
36
+ disabled = false,
35
37
  }) => {
36
38
  const insets = useSafeAreaInsets();
37
39
  const { t } = useLocalization();
@@ -68,8 +70,15 @@ export const OnboardingFooter: React.FC<OnboardingFooterProps> = ({
68
70
  </View>
69
71
  )}
70
72
 
71
- <TouchableOpacity style={styles.button} onPress={onNext}>
72
- <Text style={styles.buttonText}>{buttonText}</Text>
73
+ <TouchableOpacity
74
+ style={[styles.button, disabled && styles.buttonDisabled]}
75
+ onPress={onNext}
76
+ disabled={disabled}
77
+ activeOpacity={disabled ? 1 : 0.7}
78
+ >
79
+ <Text style={[styles.buttonText, disabled && styles.buttonTextDisabled]}>
80
+ {buttonText}
81
+ </Text>
73
82
  </TouchableOpacity>
74
83
 
75
84
  {showProgressText && (
@@ -128,11 +137,17 @@ const getStyles = (
128
137
  alignItems: "center",
129
138
  marginBottom: 12,
130
139
  },
140
+ buttonDisabled: {
141
+ backgroundColor: "rgba(255, 255, 255, 0.4)",
142
+ },
131
143
  buttonText: {
132
144
  color: tokens.colors.primary,
133
145
  fontSize: 16,
134
146
  fontWeight: "bold",
135
147
  },
148
+ buttonTextDisabled: {
149
+ color: "rgba(255, 255, 255, 0.6)",
150
+ },
136
151
  progressText: {
137
152
  color: "rgba(255, 255, 255, 0.75)",
138
153
  fontSize: 12,
@@ -0,0 +1,177 @@
1
+ /**
2
+ * Question Slide Component
3
+ *
4
+ * Displays a personalization question slide
5
+ */
6
+
7
+ import React, { useMemo } from "react";
8
+ import { View, Text, StyleSheet, ScrollView } from "react-native";
9
+ import { AtomicIcon } from "@umituz/react-native-design-system-atoms";
10
+ import type { OnboardingSlide } from "../../domain/entities/OnboardingSlide";
11
+ import { SingleChoiceQuestion } from "./questions/SingleChoiceQuestion";
12
+ import { MultipleChoiceQuestion } from "./questions/MultipleChoiceQuestion";
13
+ import { TextInputQuestion } from "./questions/TextInputQuestion";
14
+ import { SliderQuestion } from "./questions/SliderQuestion";
15
+ import { RatingQuestion } from "./questions/RatingQuestion";
16
+
17
+ export interface QuestionSlideProps {
18
+ slide: OnboardingSlide;
19
+ value: any;
20
+ onChange: (value: any) => void;
21
+ }
22
+
23
+ export const QuestionSlide: React.FC<QuestionSlideProps> = ({
24
+ slide,
25
+ value,
26
+ onChange,
27
+ }) => {
28
+ const styles = useMemo(() => getStyles(), []);
29
+ const { question } = slide;
30
+
31
+ if (!question) {
32
+ return null;
33
+ }
34
+
35
+ // Check if icon is an emoji or Lucide icon name
36
+ const isEmoji = /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(slide.icon);
37
+
38
+ const renderQuestion = () => {
39
+ switch (question.type) {
40
+ case "single_choice":
41
+ return (
42
+ <SingleChoiceQuestion
43
+ question={question}
44
+ value={value}
45
+ onChange={onChange}
46
+ />
47
+ );
48
+ case "multiple_choice":
49
+ return (
50
+ <MultipleChoiceQuestion
51
+ question={question}
52
+ value={value}
53
+ onChange={onChange}
54
+ />
55
+ );
56
+ case "text_input":
57
+ return (
58
+ <TextInputQuestion
59
+ question={question}
60
+ value={value}
61
+ onChange={onChange}
62
+ />
63
+ );
64
+ case "slider":
65
+ return (
66
+ <SliderQuestion
67
+ question={question}
68
+ value={value}
69
+ onChange={onChange}
70
+ />
71
+ );
72
+ case "rating":
73
+ return (
74
+ <RatingQuestion
75
+ question={question}
76
+ value={value}
77
+ onChange={onChange}
78
+ />
79
+ );
80
+ default:
81
+ return null;
82
+ }
83
+ };
84
+
85
+ return (
86
+ <ScrollView
87
+ contentContainerStyle={styles.content}
88
+ showsVerticalScrollIndicator={false}
89
+ >
90
+ <View style={styles.slideContent}>
91
+ {/* Icon */}
92
+ <View style={styles.iconContainer}>
93
+ {isEmoji ? (
94
+ <Text style={styles.icon}>{slide.icon}</Text>
95
+ ) : (
96
+ <AtomicIcon
97
+ name={slide.icon as any}
98
+ customSize={48}
99
+ customColor="#FFFFFF"
100
+ />
101
+ )}
102
+ </View>
103
+
104
+ {/* Title */}
105
+ <Text style={styles.title}>{slide.title}</Text>
106
+
107
+ {/* Description */}
108
+ {slide.description && (
109
+ <Text style={styles.description}>{slide.description}</Text>
110
+ )}
111
+
112
+ {/* Question */}
113
+ <View style={styles.questionContainer}>{renderQuestion()}</View>
114
+
115
+ {/* Validation hint */}
116
+ {question.validation?.required && !value && (
117
+ <Text style={styles.requiredHint}>* This field is required</Text>
118
+ )}
119
+ </View>
120
+ </ScrollView>
121
+ );
122
+ };
123
+
124
+ const getStyles = () =>
125
+ StyleSheet.create({
126
+ content: {
127
+ flexGrow: 1,
128
+ justifyContent: "center",
129
+ alignItems: "center",
130
+ paddingHorizontal: 30,
131
+ paddingVertical: 20,
132
+ },
133
+ slideContent: {
134
+ alignItems: "center",
135
+ maxWidth: 500,
136
+ width: "100%",
137
+ },
138
+ iconContainer: {
139
+ width: 96,
140
+ height: 96,
141
+ borderRadius: 48,
142
+ backgroundColor: "rgba(255, 255, 255, 0.2)",
143
+ alignItems: "center",
144
+ justifyContent: "center",
145
+ marginBottom: 24,
146
+ borderWidth: 2,
147
+ borderColor: "rgba(255, 255, 255, 0.3)",
148
+ },
149
+ icon: {
150
+ fontSize: 48,
151
+ },
152
+ title: {
153
+ fontSize: 24,
154
+ fontWeight: "bold",
155
+ color: "#FFFFFF",
156
+ textAlign: "center",
157
+ marginBottom: 12,
158
+ },
159
+ description: {
160
+ fontSize: 15,
161
+ color: "rgba(255, 255, 255, 0.9)",
162
+ textAlign: "center",
163
+ lineHeight: 22,
164
+ marginBottom: 24,
165
+ },
166
+ questionContainer: {
167
+ width: "100%",
168
+ marginTop: 8,
169
+ },
170
+ requiredHint: {
171
+ fontSize: 13,
172
+ color: "rgba(255, 255, 255, 0.7)",
173
+ fontStyle: "italic",
174
+ marginTop: 12,
175
+ },
176
+ });
177
+
@@ -0,0 +1,143 @@
1
+ /**
2
+ * Multiple Choice Question Component
3
+ *
4
+ * Checkbox style question for multiple selections
5
+ */
6
+
7
+ import React from "react";
8
+ import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
9
+ import { AtomicIcon } from "@umituz/react-native-design-system-atoms";
10
+ import type { OnboardingQuestion, QuestionOption } from "../../../domain/entities/OnboardingQuestion";
11
+
12
+ export interface MultipleChoiceQuestionProps {
13
+ question: OnboardingQuestion;
14
+ value: string[] | undefined;
15
+ onChange: (value: string[]) => void;
16
+ }
17
+
18
+ export const MultipleChoiceQuestion: React.FC<MultipleChoiceQuestionProps> = ({
19
+ question,
20
+ value = [],
21
+ onChange,
22
+ }) => {
23
+ const isEmoji = (icon: string) =>
24
+ /[\u{1F300}-\u{1F9FF}]|[\u{2600}-\u{26FF}]|[\u{2700}-\u{27BF}]/u.test(icon);
25
+
26
+ const handleToggle = (optionId: string) => {
27
+ const newValue = value.includes(optionId)
28
+ ? value.filter((id) => id !== optionId)
29
+ : [...value, optionId];
30
+
31
+ // Check max selections
32
+ if (
33
+ question.validation?.maxSelections &&
34
+ newValue.length > question.validation.maxSelections
35
+ ) {
36
+ return;
37
+ }
38
+
39
+ onChange(newValue);
40
+ };
41
+
42
+ const renderOption = (option: QuestionOption) => {
43
+ const isSelected = value.includes(option.id);
44
+
45
+ return (
46
+ <TouchableOpacity
47
+ key={option.id}
48
+ style={[styles.option, isSelected && styles.optionSelected]}
49
+ onPress={() => handleToggle(option.id)}
50
+ activeOpacity={0.7}
51
+ >
52
+ {option.icon && (
53
+ <View style={styles.optionIcon}>
54
+ {isEmoji(option.icon) ? (
55
+ <Text style={styles.emoji}>{option.icon}</Text>
56
+ ) : (
57
+ <AtomicIcon
58
+ name={option.icon as any}
59
+ customSize={24}
60
+ customColor={isSelected ? "#FFFFFF" : "rgba(255, 255, 255, 0.8)"}
61
+ />
62
+ )}
63
+ </View>
64
+ )}
65
+ <Text style={[styles.optionLabel, isSelected && styles.optionLabelSelected]}>
66
+ {option.label}
67
+ </Text>
68
+ <View style={[styles.checkbox, isSelected && styles.checkboxSelected]}>
69
+ {isSelected && (
70
+ <AtomicIcon name="Check" customSize={16} customColor="#FFFFFF" />
71
+ )}
72
+ </View>
73
+ </TouchableOpacity>
74
+ );
75
+ };
76
+
77
+ return (
78
+ <View style={styles.container}>
79
+ {question.options?.map(renderOption)}
80
+ {question.validation?.maxSelections && (
81
+ <Text style={styles.hint}>
82
+ Select up to {question.validation.maxSelections} options
83
+ </Text>
84
+ )}
85
+ </View>
86
+ );
87
+ };
88
+
89
+ const styles = StyleSheet.create({
90
+ container: {
91
+ width: "100%",
92
+ gap: 12,
93
+ },
94
+ option: {
95
+ flexDirection: "row",
96
+ alignItems: "center",
97
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
98
+ borderRadius: 12,
99
+ padding: 16,
100
+ borderWidth: 2,
101
+ borderColor: "rgba(255, 255, 255, 0.2)",
102
+ },
103
+ optionSelected: {
104
+ backgroundColor: "rgba(255, 255, 255, 0.25)",
105
+ borderColor: "rgba(255, 255, 255, 0.5)",
106
+ },
107
+ optionIcon: {
108
+ marginRight: 12,
109
+ },
110
+ emoji: {
111
+ fontSize: 24,
112
+ },
113
+ optionLabel: {
114
+ flex: 1,
115
+ fontSize: 16,
116
+ color: "rgba(255, 255, 255, 0.9)",
117
+ fontWeight: "500",
118
+ },
119
+ optionLabelSelected: {
120
+ color: "#FFFFFF",
121
+ fontWeight: "600",
122
+ },
123
+ checkbox: {
124
+ width: 24,
125
+ height: 24,
126
+ borderRadius: 6,
127
+ borderWidth: 2,
128
+ borderColor: "rgba(255, 255, 255, 0.5)",
129
+ alignItems: "center",
130
+ justifyContent: "center",
131
+ },
132
+ checkboxSelected: {
133
+ borderColor: "#FFFFFF",
134
+ backgroundColor: "rgba(255, 255, 255, 0.3)",
135
+ },
136
+ hint: {
137
+ fontSize: 13,
138
+ color: "rgba(255, 255, 255, 0.7)",
139
+ textAlign: "center",
140
+ marginTop: 4,
141
+ },
142
+ });
143
+
@@ -0,0 +1,78 @@
1
+ /**
2
+ * Rating Question Component
3
+ *
4
+ * Star rating or numeric rating selection
5
+ */
6
+
7
+ import React from "react";
8
+ import { View, Text, TouchableOpacity, StyleSheet } from "react-native";
9
+ import { AtomicIcon } from "@umituz/react-native-design-system-atoms";
10
+ import type { OnboardingQuestion } from "../../../domain/entities/OnboardingQuestion";
11
+
12
+ export interface RatingQuestionProps {
13
+ question: OnboardingQuestion;
14
+ value: number | undefined;
15
+ onChange: (value: number) => void;
16
+ }
17
+
18
+ export const RatingQuestion: React.FC<RatingQuestionProps> = ({
19
+ question,
20
+ value = 0,
21
+ onChange,
22
+ }) => {
23
+ const { validation } = question;
24
+ const max = validation?.max ?? 5;
25
+
26
+ const renderStar = (index: number) => {
27
+ const isFilled = index < value;
28
+
29
+ return (
30
+ <TouchableOpacity
31
+ key={index}
32
+ onPress={() => onChange(index + 1)}
33
+ activeOpacity={0.7}
34
+ style={styles.star}
35
+ >
36
+ <AtomicIcon
37
+ name={isFilled ? "Star" : "Star"}
38
+ customSize={48}
39
+ customColor={isFilled ? "#FFD700" : "rgba(255, 255, 255, 0.3)"}
40
+ />
41
+ </TouchableOpacity>
42
+ );
43
+ };
44
+
45
+ return (
46
+ <View style={styles.container}>
47
+ <View style={styles.stars}>
48
+ {Array.from({ length: max }, (_, i) => renderStar(i))}
49
+ </View>
50
+ {value > 0 && (
51
+ <Text style={styles.valueText}>
52
+ {value} / {max}
53
+ </Text>
54
+ )}
55
+ </View>
56
+ );
57
+ };
58
+
59
+ const styles = StyleSheet.create({
60
+ container: {
61
+ width: "100%",
62
+ alignItems: "center",
63
+ },
64
+ stars: {
65
+ flexDirection: "row",
66
+ gap: 8,
67
+ marginBottom: 16,
68
+ },
69
+ star: {
70
+ padding: 4,
71
+ },
72
+ valueText: {
73
+ fontSize: 18,
74
+ color: "rgba(255, 255, 255, 0.9)",
75
+ fontWeight: "600",
76
+ },
77
+ });
78
+