@umituz/react-native-onboarding 1.0.9 → 2.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.
@@ -0,0 +1,119 @@
1
+ /**
2
+ * Single Choice Question Component
3
+ *
4
+ * Radio button style question for single 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, QuestionOption } from "../../../domain/entities/OnboardingQuestion";
11
+
12
+ export interface SingleChoiceQuestionProps {
13
+ question: OnboardingQuestion;
14
+ value: string | undefined;
15
+ onChange: (value: string) => void;
16
+ }
17
+
18
+ export const SingleChoiceQuestion: React.FC<SingleChoiceQuestionProps> = ({
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 renderOption = (option: QuestionOption) => {
27
+ const isSelected = value === option.id;
28
+
29
+ return (
30
+ <TouchableOpacity
31
+ key={option.id}
32
+ style={[styles.option, isSelected && styles.optionSelected]}
33
+ onPress={() => onChange(option.id)}
34
+ activeOpacity={0.7}
35
+ >
36
+ {option.icon && (
37
+ <View style={styles.optionIcon}>
38
+ {isEmoji(option.icon) ? (
39
+ <Text style={styles.emoji}>{option.icon}</Text>
40
+ ) : (
41
+ <AtomicIcon
42
+ name={option.icon as any}
43
+ customSize={24}
44
+ customColor={isSelected ? "#FFFFFF" : "rgba(255, 255, 255, 0.8)"}
45
+ />
46
+ )}
47
+ </View>
48
+ )}
49
+ <Text style={[styles.optionLabel, isSelected && styles.optionLabelSelected]}>
50
+ {option.label}
51
+ </Text>
52
+ <View style={[styles.radio, isSelected && styles.radioSelected]}>
53
+ {isSelected && <View style={styles.radioInner} />}
54
+ </View>
55
+ </TouchableOpacity>
56
+ );
57
+ };
58
+
59
+ return (
60
+ <View style={styles.container}>
61
+ {question.options?.map(renderOption)}
62
+ </View>
63
+ );
64
+ };
65
+
66
+ const styles = StyleSheet.create({
67
+ container: {
68
+ width: "100%",
69
+ gap: 12,
70
+ },
71
+ option: {
72
+ flexDirection: "row",
73
+ alignItems: "center",
74
+ backgroundColor: "rgba(255, 255, 255, 0.1)",
75
+ borderRadius: 12,
76
+ padding: 16,
77
+ borderWidth: 2,
78
+ borderColor: "rgba(255, 255, 255, 0.2)",
79
+ },
80
+ optionSelected: {
81
+ backgroundColor: "rgba(255, 255, 255, 0.25)",
82
+ borderColor: "rgba(255, 255, 255, 0.5)",
83
+ },
84
+ optionIcon: {
85
+ marginRight: 12,
86
+ },
87
+ emoji: {
88
+ fontSize: 24,
89
+ },
90
+ optionLabel: {
91
+ flex: 1,
92
+ fontSize: 16,
93
+ color: "rgba(255, 255, 255, 0.9)",
94
+ fontWeight: "500",
95
+ },
96
+ optionLabelSelected: {
97
+ color: "#FFFFFF",
98
+ fontWeight: "600",
99
+ },
100
+ radio: {
101
+ width: 24,
102
+ height: 24,
103
+ borderRadius: 12,
104
+ borderWidth: 2,
105
+ borderColor: "rgba(255, 255, 255, 0.5)",
106
+ alignItems: "center",
107
+ justifyContent: "center",
108
+ },
109
+ radioSelected: {
110
+ borderColor: "#FFFFFF",
111
+ },
112
+ radioInner: {
113
+ width: 12,
114
+ height: 12,
115
+ borderRadius: 6,
116
+ backgroundColor: "#FFFFFF",
117
+ },
118
+ });
119
+
@@ -0,0 +1,81 @@
1
+ /**
2
+ * Slider Question Component
3
+ *
4
+ * Slider for numeric value selection
5
+ */
6
+
7
+ import React from "react";
8
+ import { View, Text, StyleSheet } from "react-native";
9
+ import Slider from "@react-native-community/slider";
10
+ import type { OnboardingQuestion } from "../../../domain/entities/OnboardingQuestion";
11
+
12
+ export interface SliderQuestionProps {
13
+ question: OnboardingQuestion;
14
+ value: number | undefined;
15
+ onChange: (value: number) => void;
16
+ }
17
+
18
+ export const SliderQuestion: React.FC<SliderQuestionProps> = ({
19
+ question,
20
+ value,
21
+ onChange,
22
+ }) => {
23
+ const { validation } = question;
24
+ const min = validation?.min ?? 0;
25
+ const max = validation?.max ?? 100;
26
+ const currentValue = value ?? min;
27
+
28
+ return (
29
+ <View style={styles.container}>
30
+ <View style={styles.valueContainer}>
31
+ <Text style={styles.valueText}>{currentValue}</Text>
32
+ </View>
33
+ <Slider
34
+ style={styles.slider}
35
+ minimumValue={min}
36
+ maximumValue={max}
37
+ value={currentValue}
38
+ onValueChange={onChange}
39
+ minimumTrackTintColor="#FFFFFF"
40
+ maximumTrackTintColor="rgba(255, 255, 255, 0.3)"
41
+ thumbTintColor="#FFFFFF"
42
+ step={1}
43
+ />
44
+ <View style={styles.labels}>
45
+ <Text style={styles.label}>{min}</Text>
46
+ <Text style={styles.label}>{max}</Text>
47
+ </View>
48
+ </View>
49
+ );
50
+ };
51
+
52
+ const styles = StyleSheet.create({
53
+ container: {
54
+ width: "100%",
55
+ paddingHorizontal: 8,
56
+ },
57
+ valueContainer: {
58
+ alignItems: "center",
59
+ marginBottom: 16,
60
+ },
61
+ valueText: {
62
+ fontSize: 48,
63
+ fontWeight: "bold",
64
+ color: "#FFFFFF",
65
+ },
66
+ slider: {
67
+ width: "100%",
68
+ height: 40,
69
+ },
70
+ labels: {
71
+ flexDirection: "row",
72
+ justifyContent: "space-between",
73
+ marginTop: 8,
74
+ },
75
+ label: {
76
+ fontSize: 14,
77
+ color: "rgba(255, 255, 255, 0.7)",
78
+ fontWeight: "500",
79
+ },
80
+ });
81
+
@@ -0,0 +1,68 @@
1
+ /**
2
+ * Text Input Question Component
3
+ *
4
+ * Text input field for free-form text answers
5
+ */
6
+
7
+ import React from "react";
8
+ import { View, TextInput, StyleSheet, Text } from "react-native";
9
+ import type { OnboardingQuestion } from "../../../domain/entities/OnboardingQuestion";
10
+
11
+ export interface TextInputQuestionProps {
12
+ question: OnboardingQuestion;
13
+ value: string | undefined;
14
+ onChange: (value: string) => void;
15
+ }
16
+
17
+ export const TextInputQuestion: React.FC<TextInputQuestionProps> = ({
18
+ question,
19
+ value = "",
20
+ onChange,
21
+ }) => {
22
+ const { validation } = question;
23
+
24
+ return (
25
+ <View style={styles.container}>
26
+ <TextInput
27
+ style={styles.input}
28
+ value={value}
29
+ onChangeText={onChange}
30
+ placeholder={question.placeholder || "Type your answer..."}
31
+ placeholderTextColor="rgba(255, 255, 255, 0.5)"
32
+ maxLength={validation?.maxLength}
33
+ multiline={validation?.maxLength ? validation.maxLength > 100 : false}
34
+ numberOfLines={validation?.maxLength && validation.maxLength > 100 ? 4 : 1}
35
+ autoCapitalize="sentences"
36
+ autoCorrect={true}
37
+ />
38
+ {validation?.maxLength && (
39
+ <Text style={styles.charCount}>
40
+ {value.length} / {validation.maxLength}
41
+ </Text>
42
+ )}
43
+ </View>
44
+ );
45
+ };
46
+
47
+ const styles = StyleSheet.create({
48
+ container: {
49
+ width: "100%",
50
+ },
51
+ input: {
52
+ backgroundColor: "rgba(255, 255, 255, 0.15)",
53
+ borderRadius: 12,
54
+ padding: 16,
55
+ fontSize: 16,
56
+ color: "#FFFFFF",
57
+ borderWidth: 2,
58
+ borderColor: "rgba(255, 255, 255, 0.3)",
59
+ minHeight: 56,
60
+ },
61
+ charCount: {
62
+ fontSize: 13,
63
+ color: "rgba(255, 255, 255, 0.6)",
64
+ textAlign: "right",
65
+ marginTop: 8,
66
+ },
67
+ });
68
+
@@ -5,7 +5,7 @@
5
5
  * Generic and reusable across hundreds of apps
6
6
  */
7
7
 
8
- import React, { useMemo } from "react";
8
+ import React, { useMemo, useState, useEffect } from "react";
9
9
  import { View, StyleSheet, StatusBar } from "react-native";
10
10
  import { LinearGradient } from "expo-linear-gradient";
11
11
  import { useSafeAreaInsets } from "react-native-safe-area-context";
@@ -14,6 +14,7 @@ import { useOnboardingNavigation } from "../../infrastructure/hooks/useOnboardin
14
14
  import { useOnboardingStore } from "../../infrastructure/storage/OnboardingStore";
15
15
  import { OnboardingHeader } from "../components/OnboardingHeader";
16
16
  import { OnboardingSlide as OnboardingSlideComponent } from "../components/OnboardingSlide";
17
+ import { QuestionSlide } from "../components/QuestionSlide";
17
18
  import { OnboardingFooter } from "../components/OnboardingFooter";
18
19
 
19
20
  export interface OnboardingScreenProps extends OnboardingOptions {
@@ -83,8 +84,25 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
83
84
  }) => {
84
85
  const insets = useSafeAreaInsets();
85
86
  const onboardingStore = useOnboardingStore();
87
+ const [currentAnswer, setCurrentAnswer] = useState<any>(undefined);
88
+
89
+ // Filter slides based on skipIf conditions
90
+ const filteredSlides = useMemo(() => {
91
+ const userData = onboardingStore.getUserData();
92
+ return slides.filter((slide) => {
93
+ if (slide.skipIf) {
94
+ return !slide.skipIf(userData.answers);
95
+ }
96
+ return true;
97
+ });
98
+ }, [slides, onboardingStore]);
86
99
 
87
100
  const handleComplete = async () => {
101
+ // Save current answer if exists
102
+ if (currentSlide.question && currentAnswer !== undefined) {
103
+ await onboardingStore.saveAnswer(currentSlide.question.id, currentAnswer);
104
+ }
105
+
88
106
  await onboardingStore.complete(storageKey);
89
107
  if (onComplete) {
90
108
  await onComplete();
@@ -104,9 +122,14 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
104
122
  goToPrevious,
105
123
  isLastSlide,
106
124
  isFirstSlide,
107
- } = useOnboardingNavigation(slides.length, handleComplete, handleSkip);
125
+ } = useOnboardingNavigation(filteredSlides.length, handleComplete, handleSkip);
126
+
127
+ const handleNext = async () => {
128
+ // Save current answer if exists
129
+ if (currentSlide.question && currentAnswer !== undefined) {
130
+ await onboardingStore.saveAnswer(currentSlide.question.id, currentAnswer);
131
+ }
108
132
 
109
- const handleNext = () => {
110
133
  if (isLastSlide) {
111
134
  if (autoComplete) {
112
135
  handleComplete();
@@ -115,12 +138,85 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
115
138
  }
116
139
  } else {
117
140
  goToNext();
141
+ // Load next slide's answer
142
+ const nextSlide = filteredSlides[currentIndex + 1];
143
+ if (nextSlide?.question) {
144
+ const savedAnswer = onboardingStore.getAnswer(nextSlide.question.id);
145
+ setCurrentAnswer(savedAnswer);
146
+ } else {
147
+ setCurrentAnswer(undefined);
148
+ }
149
+ }
150
+ };
151
+
152
+ const handlePrevious = () => {
153
+ goToPrevious();
154
+ // Load previous slide's answer
155
+ if (currentIndex > 0) {
156
+ const prevSlide = filteredSlides[currentIndex - 1];
157
+ if (prevSlide?.question) {
158
+ const savedAnswer = onboardingStore.getAnswer(prevSlide.question.id);
159
+ setCurrentAnswer(savedAnswer);
160
+ } else {
161
+ setCurrentAnswer(undefined);
162
+ }
118
163
  }
119
164
  };
120
165
 
121
- const currentSlide = slides[currentIndex];
166
+ const currentSlide = filteredSlides[currentIndex];
122
167
  const styles = useMemo(() => getStyles(insets), [insets]);
123
168
 
169
+ // Load current slide's answer on mount and when slide changes
170
+ useEffect(() => {
171
+ if (currentSlide?.question) {
172
+ const savedAnswer = onboardingStore.getAnswer(currentSlide.question.id);
173
+ setCurrentAnswer(savedAnswer ?? currentSlide.question.defaultValue);
174
+ } else {
175
+ setCurrentAnswer(undefined);
176
+ }
177
+ }, [currentIndex, currentSlide, onboardingStore]);
178
+
179
+ // Validate current answer
180
+ const isAnswerValid = useMemo(() => {
181
+ if (!currentSlide?.question) return true;
182
+
183
+ const { validation } = currentSlide.question;
184
+ if (!validation) return true;
185
+
186
+ // Required validation
187
+ if (validation.required && !currentAnswer) return false;
188
+
189
+ // Type-specific validations
190
+ switch (currentSlide.question.type) {
191
+ case "multiple_choice":
192
+ if (validation.minSelections && (!currentAnswer || currentAnswer.length < validation.minSelections)) {
193
+ return false;
194
+ }
195
+ break;
196
+ case "text_input":
197
+ if (validation.minLength && (!currentAnswer || currentAnswer.length < validation.minLength)) {
198
+ return false;
199
+ }
200
+ break;
201
+ case "slider":
202
+ case "rating":
203
+ if (validation.min !== undefined && currentAnswer < validation.min) {
204
+ return false;
205
+ }
206
+ if (validation.max !== undefined && currentAnswer > validation.max) {
207
+ return false;
208
+ }
209
+ break;
210
+ }
211
+
212
+ // Custom validator
213
+ if (validation.customValidator) {
214
+ return validation.customValidator(currentAnswer) === true;
215
+ }
216
+
217
+ return true;
218
+ }, [currentSlide, currentAnswer]);
219
+
124
220
  return (
125
221
  <View style={styles.container}>
126
222
  <StatusBar barStyle="light-content" />
@@ -133,13 +229,13 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
133
229
  {renderHeader ? (
134
230
  renderHeader({
135
231
  isFirstSlide,
136
- onBack: goToPrevious,
232
+ onBack: handlePrevious,
137
233
  onSkip: handleSkip,
138
234
  })
139
235
  ) : (
140
236
  <OnboardingHeader
141
237
  isFirstSlide={isFirstSlide}
142
- onBack={goToPrevious}
238
+ onBack={handlePrevious}
143
239
  onSkip={handleSkip}
144
240
  showBackButton={showBackButton}
145
241
  showSkipButton={showSkipButton}
@@ -148,13 +244,19 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
148
244
  )}
149
245
  {renderSlide ? (
150
246
  renderSlide(currentSlide)
247
+ ) : currentSlide.type === "question" && currentSlide.question ? (
248
+ <QuestionSlide
249
+ slide={currentSlide}
250
+ value={currentAnswer}
251
+ onChange={setCurrentAnswer}
252
+ />
151
253
  ) : (
152
254
  <OnboardingSlideComponent slide={currentSlide} />
153
255
  )}
154
256
  {renderFooter ? (
155
257
  renderFooter({
156
258
  currentIndex,
157
- totalSlides: slides.length,
259
+ totalSlides: filteredSlides.length,
158
260
  isLastSlide,
159
261
  onNext: handleNext,
160
262
  onUpgrade,
@@ -163,7 +265,7 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
163
265
  ) : (
164
266
  <OnboardingFooter
165
267
  currentIndex={currentIndex}
166
- totalSlides={slides.length}
268
+ totalSlides={filteredSlides.length}
167
269
  isLastSlide={isLastSlide}
168
270
  onNext={handleNext}
169
271
  showProgressBar={showProgressBar}
@@ -171,6 +273,7 @@ export const OnboardingScreen: React.FC<OnboardingScreenProps> = ({
171
273
  showProgressText={showProgressText}
172
274
  nextButtonText={nextButtonText}
173
275
  getStartedButtonText={getStartedButtonText}
276
+ disabled={!isAnswerValid}
174
277
  />
175
278
  )}
176
279
  </View>