@umituz/react-native-onboarding 1.0.8 → 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.
- package/README.md +369 -0
- package/package.json +9 -3
- package/src/domain/entities/OnboardingQuestion.ts +156 -0
- package/src/domain/entities/OnboardingSlide.ts +25 -0
- package/src/domain/entities/OnboardingUserData.ts +43 -0
- package/src/index.ts +24 -1
- package/src/infrastructure/storage/OnboardingStore.ts +67 -4
- package/src/presentation/components/OnboardingFooter.tsx +17 -2
- package/src/presentation/components/OnboardingSlide.tsx +1 -1
- package/src/presentation/components/QuestionSlide.tsx +177 -0
- package/src/presentation/components/questions/MultipleChoiceQuestion.tsx +143 -0
- package/src/presentation/components/questions/RatingQuestion.tsx +78 -0
- package/src/presentation/components/questions/SingleChoiceQuestion.tsx +119 -0
- package/src/presentation/components/questions/SliderQuestion.tsx +81 -0
- package/src/presentation/components/questions/TextInputQuestion.tsx +68 -0
- package/src/presentation/screens/OnboardingScreen.tsx +112 -9
|
@@ -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
|
-
|
|
44
|
-
const
|
|
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:
|
|
63
|
+
isOnboardingComplete: isComplete,
|
|
64
|
+
userData,
|
|
48
65
|
loading: false,
|
|
49
|
-
error:
|
|
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
|
|
72
|
-
|
|
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,
|
|
@@ -6,7 +6,7 @@
|
|
|
6
6
|
|
|
7
7
|
import React, { useMemo } from "react";
|
|
8
8
|
import { View, Text, StyleSheet, ScrollView } from "react-native";
|
|
9
|
-
import { AtomicIcon } from "@umituz/react-native-design-system";
|
|
9
|
+
import { AtomicIcon } from "@umituz/react-native-design-system-atoms";
|
|
10
10
|
import type { OnboardingSlide as OnboardingSlideType } from "../../domain/entities/OnboardingSlide";
|
|
11
11
|
|
|
12
12
|
export interface OnboardingSlideProps {
|
|
@@ -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
|
+
|