react-native-my-survey-sdk 2.0.6 → 2.0.8

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,184 @@
1
+ import React, { useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ ScrollView,
8
+ Animated,
9
+ } from 'react-native';
10
+ import { XeboQuestion, XeboAnswer, XeboChoice } from '../models/XeboModels';
11
+ import { getTheme } from '../theme/XeboTheme';
12
+
13
+ interface Props {
14
+ question: XeboQuestion;
15
+ isLastQuestion: boolean;
16
+ onAnswer: (answer: XeboAnswer) => void;
17
+ }
18
+
19
+ export const XeboMultipleChoiceView: React.FC<Props> = ({ question, isLastQuestion, onAnswer }) => {
20
+ const theme = getTheme();
21
+ const [selectedIds, setSelectedIds] = useState<Set<string>>(new Set());
22
+ const [error, setError] = useState('');
23
+ const shakeAnim = useRef(new Animated.Value(0)).current;
24
+
25
+ const shake = () => {
26
+ Animated.sequence([
27
+ Animated.timing(shakeAnim, { toValue: 8, duration: 60, useNativeDriver: true }),
28
+ Animated.timing(shakeAnim, { toValue: -8, duration: 60, useNativeDriver: true }),
29
+ Animated.timing(shakeAnim, { toValue: 6, duration: 60, useNativeDriver: true }),
30
+ Animated.timing(shakeAnim, { toValue: 0, duration: 60, useNativeDriver: true }),
31
+ ]).start();
32
+ };
33
+
34
+ const toggleChoice = (choice: XeboChoice) => {
35
+ setSelectedIds(prev => {
36
+ const next = new Set(prev);
37
+ if (next.has(choice.id)) {
38
+ next.delete(choice.id);
39
+ } else {
40
+ next.add(choice.id);
41
+ }
42
+ return next;
43
+ });
44
+ setError('');
45
+ };
46
+
47
+ const handleNext = () => {
48
+ if (question.isRequired && selectedIds.size === 0) {
49
+ setError('Please select at least one option.');
50
+ shake();
51
+ return;
52
+ }
53
+ onAnswer({
54
+ questionId: question.id,
55
+ value: Array.from(selectedIds).map(id => ({
56
+ rowId: id,
57
+ text: question.options?.find(o => o.id === id)?.text,
58
+ })),
59
+ });
60
+ };
61
+
62
+ return (
63
+ <View style={styles.container}>
64
+ <Text style={[styles.title, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
65
+ {question.title}
66
+ </Text>
67
+ {!!question.subtitle && (
68
+ <Text style={[styles.subtitle, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
69
+ {question.subtitle}
70
+ </Text>
71
+ )}
72
+
73
+ <ScrollView style={styles.optionsList} showsVerticalScrollIndicator={false}>
74
+ {(question.options ?? []).map(choice => {
75
+ const selected = selectedIds.has(choice.id);
76
+ return (
77
+ <TouchableOpacity
78
+ key={choice.id}
79
+ style={[
80
+ styles.optionRow,
81
+ { borderColor: selected ? theme.primaryColor : '#E5E7EB' },
82
+ ]}
83
+ onPress={() => toggleChoice(choice)}
84
+ activeOpacity={0.7}
85
+ >
86
+ <View
87
+ style={[
88
+ styles.checkbox,
89
+ {
90
+ borderColor: selected ? theme.primaryColor : '#9CA3AF',
91
+ backgroundColor: selected ? theme.primaryColor : 'transparent',
92
+ },
93
+ ]}
94
+ >
95
+ {selected && <Text style={styles.checkmark}>✓</Text>}
96
+ </View>
97
+ <Text style={[styles.optionText, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
98
+ {choice.text}
99
+ </Text>
100
+ </TouchableOpacity>
101
+ );
102
+ })}
103
+ </ScrollView>
104
+
105
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
106
+
107
+ <Animated.View style={{ transform: [{ translateX: shakeAnim }] }}>
108
+ <TouchableOpacity
109
+ style={[styles.button, { backgroundColor: theme.primaryColor, borderRadius: theme.cornerRadius }]}
110
+ onPress={handleNext}
111
+ activeOpacity={0.8}
112
+ >
113
+ <Text style={[styles.buttonText, { fontFamily: theme.fontFamily }]}>
114
+ {isLastQuestion ? 'Submit' : 'Next'}
115
+ </Text>
116
+ </TouchableOpacity>
117
+ </Animated.View>
118
+ </View>
119
+ );
120
+ };
121
+
122
+ const styles = StyleSheet.create({
123
+ container: {
124
+ flex: 1,
125
+ paddingHorizontal: 20,
126
+ paddingTop: 16,
127
+ paddingBottom: 24,
128
+ },
129
+ title: {
130
+ fontSize: 18,
131
+ fontWeight: '700',
132
+ marginBottom: 6,
133
+ },
134
+ subtitle: {
135
+ fontSize: 14,
136
+ opacity: 0.6,
137
+ marginBottom: 16,
138
+ },
139
+ optionsList: {
140
+ flex: 1,
141
+ marginBottom: 12,
142
+ },
143
+ optionRow: {
144
+ flexDirection: 'row',
145
+ alignItems: 'center',
146
+ borderWidth: 1.5,
147
+ borderRadius: 10,
148
+ padding: 14,
149
+ marginBottom: 10,
150
+ },
151
+ checkbox: {
152
+ width: 20,
153
+ height: 20,
154
+ borderRadius: 4,
155
+ borderWidth: 2,
156
+ alignItems: 'center',
157
+ justifyContent: 'center',
158
+ marginRight: 12,
159
+ },
160
+ checkmark: {
161
+ color: '#FFFFFF',
162
+ fontSize: 13,
163
+ fontWeight: '700',
164
+ },
165
+ optionText: {
166
+ fontSize: 15,
167
+ flex: 1,
168
+ },
169
+ errorText: {
170
+ color: '#EF4444',
171
+ fontSize: 13,
172
+ marginBottom: 8,
173
+ },
174
+ button: {
175
+ paddingVertical: 14,
176
+ alignItems: 'center',
177
+ marginTop: 4,
178
+ },
179
+ buttonText: {
180
+ color: '#FFFFFF',
181
+ fontSize: 16,
182
+ fontWeight: '600',
183
+ },
184
+ });
@@ -0,0 +1,292 @@
1
+ import React, { useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ ScrollView,
8
+ Animated,
9
+ TextInput,
10
+ } from 'react-native';
11
+ import { XeboQuestion, XeboAnswer, XeboConditionalFollowUp, XeboQuestionType } from '../models/XeboModels';
12
+ import { getTheme } from '../theme/XeboTheme';
13
+
14
+ interface Props {
15
+ question: XeboQuestion;
16
+ isLastQuestion: boolean;
17
+ onAnswer: (answer: XeboAnswer) => void;
18
+ }
19
+
20
+ // NPS zone colors — matches iOS SDK defaults
21
+ const DETRACTOR_COLOR = '#F24236'; // UIColor(red:0.95,green:0.26,blue:0.21)
22
+ const PASSIVE_COLOR = '#FFC208'; // UIColor(red:1.00,green:0.76,blue:0.03)
23
+ const PROMOTER_COLOR = '#2ECC70'; // UIColor(red:0.18,green:0.80,blue:0.44)
24
+
25
+ function npsColor(score: number, npsLabels?: XeboQuestion['npsLabels']): string {
26
+ if (score <= 6) return npsLabels?.detractorColor ?? DETRACTOR_COLOR;
27
+ if (score <= 8) return npsLabels?.passiveColor ?? PASSIVE_COLOR;
28
+ return npsLabels?.promoterColor ?? PROMOTER_COLOR;
29
+ }
30
+
31
+ /** Returns true if the hex color is perceived as dark (use white text on top) */
32
+ function isColorDark(hex: string): boolean {
33
+ const m = /^#?([a-f\d]{2})([a-f\d]{2})([a-f\d]{2})$/i.exec(hex);
34
+ if (!m) return false;
35
+ const r = parseInt(m[1], 16) / 255;
36
+ const g = parseInt(m[2], 16) / 255;
37
+ const b = parseInt(m[3], 16) / 255;
38
+ return 0.299 * r + 0.587 * g + 0.114 * b < 0.5;
39
+ }
40
+
41
+ export const XeboNPSView: React.FC<Props> = ({ question, isLastQuestion, onAnswer }) => {
42
+ const theme = getTheme();
43
+ const [selectedScore, setSelectedScore] = useState<number | null>(null);
44
+ const [followUpText, setFollowUpText] = useState('');
45
+ const [followUpChoice, setFollowUpChoice] = useState<string | null>(null);
46
+ const [error, setError] = useState('');
47
+ const shakeAnim = useRef(new Animated.Value(0)).current;
48
+ const scaleAnim = useRef<Animated.Value[]>(
49
+ Array.from({ length: 11 }, () => new Animated.Value(1))
50
+ ).current;
51
+
52
+ const shake = () => {
53
+ Animated.sequence([
54
+ Animated.timing(shakeAnim, { toValue: 8, duration: 60, useNativeDriver: true }),
55
+ Animated.timing(shakeAnim, { toValue: -8, duration: 60, useNativeDriver: true }),
56
+ Animated.timing(shakeAnim, { toValue: 6, duration: 60, useNativeDriver: true }),
57
+ Animated.timing(shakeAnim, { toValue: 0, duration: 60, useNativeDriver: true }),
58
+ ]).start();
59
+ };
60
+
61
+ const handleScorePress = (score: number) => {
62
+ setSelectedScore(score);
63
+ setError('');
64
+ setFollowUpText('');
65
+ setFollowUpChoice(null);
66
+ Animated.spring(scaleAnim[score], { toValue: 1.15, useNativeDriver: true }).start(() => {
67
+ Animated.spring(scaleAnim[score], { toValue: 1, useNativeDriver: true }).start();
68
+ });
69
+ };
70
+
71
+ // Find applicable conditional follow-up
72
+ const activeFollowUp: XeboConditionalFollowUp | undefined =
73
+ selectedScore !== null
74
+ ? (question.conditionalFollowUps ?? []).find(
75
+ f => selectedScore >= f.minRating && selectedScore <= f.maxRating
76
+ )
77
+ : undefined;
78
+
79
+ const hasTextFollowUp = !!question.followUpQuestion && !activeFollowUp;
80
+
81
+ const handleNext = () => {
82
+ if (question.isRequired && selectedScore === null) {
83
+ setError('Please select a score.');
84
+ shake();
85
+ return;
86
+ }
87
+
88
+ const score = selectedScore ?? 0;
89
+ const colId = question.columns?.[score]?.id ?? score.toString();
90
+ const rowId = question.options?.[0]?.id ?? score.toString();
91
+
92
+ const followUpAnswerText = activeFollowUp
93
+ ? followUpChoice
94
+ ? activeFollowUp.followUpOptions.find(o => o.id === followUpChoice)?.text
95
+ : followUpText
96
+ : hasTextFollowUp
97
+ ? followUpText
98
+ : undefined;
99
+
100
+ onAnswer({
101
+ questionId: question.id,
102
+ value: [{ rowId, colId, text: followUpAnswerText }],
103
+ });
104
+ };
105
+
106
+ return (
107
+ <ScrollView
108
+ style={styles.scroll}
109
+ contentContainerStyle={styles.container}
110
+ showsVerticalScrollIndicator={false}
111
+ keyboardShouldPersistTaps="handled"
112
+ >
113
+ <Text style={[styles.title, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
114
+ {question.title}
115
+ </Text>
116
+ {!!question.subtitle && (
117
+ <Text style={[styles.subtitle, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
118
+ {question.subtitle}
119
+ </Text>
120
+ )}
121
+
122
+ {/* NPS Scale — single row, always-visible category colors */}
123
+ <View style={styles.scaleRow}>
124
+ {Array.from({ length: 11 }, (_, i) => {
125
+ const selected = selectedScore === i;
126
+ const color = npsColor(i, question.npsLabels);
127
+ return (
128
+ <Animated.View key={i} style={[styles.scoreCell, { transform: [{ scale: scaleAnim[i] }] }]}>
129
+ <TouchableOpacity
130
+ style={[
131
+ styles.scoreButton,
132
+ {
133
+ backgroundColor: color,
134
+ borderColor: selected ? '#000000' : color,
135
+ borderWidth: selected ? 2.5 : 1.5,
136
+ },
137
+ ]}
138
+ onPress={() => handleScorePress(i)}
139
+ activeOpacity={0.8}
140
+ >
141
+ <Text style={[
142
+ styles.scoreText,
143
+ { color: isColorDark(color) ? '#FFFFFF' : selected ? '#111111' : '#444444' },
144
+ ]}>
145
+ {i}
146
+ </Text>
147
+ </TouchableOpacity>
148
+ </Animated.View>
149
+ );
150
+ })}
151
+ </View>
152
+
153
+ {/* Lower / Upper labels */}
154
+ {(question.npsLabels?.lower || question.npsLabels?.upper) && (
155
+ <View style={styles.labelsRow}>
156
+ <Text style={[styles.label, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
157
+ {question.npsLabels?.lower ?? ''}
158
+ </Text>
159
+ <Text style={[styles.label, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
160
+ {question.npsLabels?.upper ?? ''}
161
+ </Text>
162
+ </View>
163
+ )}
164
+
165
+ {/* Conditional follow-up (NPS Pro) */}
166
+ {selectedScore !== null && activeFollowUp && (
167
+ <View style={[styles.followUpCard, { borderColor: '#E5E7EB', borderRadius: theme.cornerRadius }]}>
168
+ <Text style={[styles.followUpTitle, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
169
+ {activeFollowUp.followUpQuestion}
170
+ </Text>
171
+ {activeFollowUp.followUpType === XeboQuestionType.singleChoice && activeFollowUp.followUpOptions.length > 0 ? (
172
+ activeFollowUp.followUpOptions.map(opt => (
173
+ <TouchableOpacity
174
+ key={opt.id}
175
+ style={[
176
+ styles.followUpOption,
177
+ followUpChoice === opt.id && { borderColor: theme.primaryColor },
178
+ ]}
179
+ onPress={() => setFollowUpChoice(opt.id)}
180
+ activeOpacity={0.7}
181
+ >
182
+ <Text style={[styles.followUpOptionText, { color: theme.textColor }]}>{opt.text}</Text>
183
+ </TouchableOpacity>
184
+ ))
185
+ ) : (
186
+ <TextInput
187
+ style={[styles.followUpInput, { borderColor: '#E5E7EB', color: theme.textColor, borderRadius: theme.cornerRadius }]}
188
+ placeholder="Your comment..."
189
+ placeholderTextColor="#9CA3AF"
190
+ value={followUpText}
191
+ onChangeText={setFollowUpText}
192
+ multiline
193
+ numberOfLines={3}
194
+ textAlignVertical="top"
195
+ />
196
+ )}
197
+ </View>
198
+ )}
199
+
200
+ {/* Simple text follow-up */}
201
+ {selectedScore !== null && hasTextFollowUp && (
202
+ <View style={[styles.followUpCard, { borderColor: '#E5E7EB', borderRadius: theme.cornerRadius }]}>
203
+ <Text style={[styles.followUpTitle, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
204
+ {question.followUpQuestion}
205
+ </Text>
206
+ <TextInput
207
+ style={[styles.followUpInput, { borderColor: '#E5E7EB', color: theme.textColor, borderRadius: theme.cornerRadius }]}
208
+ placeholder="Your comment..."
209
+ placeholderTextColor="#9CA3AF"
210
+ value={followUpText}
211
+ onChangeText={setFollowUpText}
212
+ multiline
213
+ numberOfLines={3}
214
+ textAlignVertical="top"
215
+ />
216
+ </View>
217
+ )}
218
+
219
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
220
+
221
+ {selectedScore !== null && (
222
+ <Animated.View style={{ transform: [{ translateX: shakeAnim }] }}>
223
+ <TouchableOpacity
224
+ style={[styles.button, { backgroundColor: theme.primaryColor, borderRadius: theme.cornerRadius }]}
225
+ onPress={handleNext}
226
+ activeOpacity={0.8}
227
+ >
228
+ <Text style={[styles.buttonText, { fontFamily: theme.fontFamily }]}>
229
+ {isLastQuestion ? 'Submit' : 'Next'}
230
+ </Text>
231
+ </TouchableOpacity>
232
+ </Animated.View>
233
+ )}
234
+ </ScrollView>
235
+ );
236
+ };
237
+
238
+ const styles = StyleSheet.create({
239
+ scroll: { flex: 1 },
240
+ container: {
241
+ paddingHorizontal: 20,
242
+ paddingTop: 16,
243
+ paddingBottom: 24,
244
+ flexGrow: 1,
245
+ },
246
+ title: { fontSize: 18, fontWeight: '700', marginBottom: 6 },
247
+ subtitle: { fontSize: 14, opacity: 0.6, marginBottom: 16 },
248
+ scaleRow: {
249
+ flexDirection: 'row',
250
+ gap: 3,
251
+ marginBottom: 8,
252
+ },
253
+ scoreCell: {
254
+ flex: 1,
255
+ },
256
+ scoreButton: {
257
+ height: 44,
258
+ borderRadius: 6,
259
+ alignItems: 'center',
260
+ justifyContent: 'center',
261
+ },
262
+ scoreText: { fontSize: 13, fontWeight: '700' },
263
+ labelsRow: {
264
+ flexDirection: 'row',
265
+ justifyContent: 'space-between',
266
+ marginBottom: 20,
267
+ },
268
+ label: { fontSize: 12, opacity: 0.6 },
269
+ followUpCard: {
270
+ borderWidth: 1.5,
271
+ padding: 14,
272
+ marginBottom: 16,
273
+ },
274
+ followUpTitle: { fontSize: 15, fontWeight: '600', marginBottom: 12 },
275
+ followUpOption: {
276
+ borderWidth: 1.5,
277
+ borderColor: '#E5E7EB',
278
+ borderRadius: 8,
279
+ padding: 10,
280
+ marginBottom: 8,
281
+ },
282
+ followUpOptionText: { fontSize: 14 },
283
+ followUpInput: {
284
+ borderWidth: 1.5,
285
+ padding: 10,
286
+ fontSize: 14,
287
+ minHeight: 80,
288
+ },
289
+ errorText: { color: '#EF4444', fontSize: 13, marginBottom: 8 },
290
+ button: { paddingVertical: 14, alignItems: 'center', marginTop: 4 },
291
+ buttonText: { color: '#FFFFFF', fontSize: 16, fontWeight: '600' },
292
+ });