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,356 @@
1
+ import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TouchableOpacity,
6
+ StyleSheet,
7
+ Animated,
8
+ Dimensions,
9
+ Platform,
10
+ KeyboardAvoidingView,
11
+ Modal,
12
+ TouchableWithoutFeedback,
13
+ SafeAreaView,
14
+ } from 'react-native';
15
+ import { XeboSurveyManager, XEBO_EVENTS } from '../core/XeboSurveyManager';
16
+ import {
17
+ XeboSurvey,
18
+ XeboAnswer,
19
+ XeboQuestion,
20
+ XeboQuestionType,
21
+ } from '../models/XeboModels';
22
+ import { getTheme } from '../theme/XeboTheme';
23
+
24
+ import { XeboIntroView } from './XeboIntroView';
25
+ import { XeboThankYouView } from './XeboThankYouView';
26
+ import { XeboSingleChoiceView } from './XeboSingleChoiceView';
27
+ import { XeboMultipleChoiceView } from './XeboMultipleChoiceView';
28
+ import { XeboDropdownView } from './XeboDropdownView';
29
+ import { XeboTextBoxView } from './XeboTextBoxView';
30
+ import { XeboNPSView } from './XeboNPSView';
31
+ import { XeboMultiNPSView } from './XeboMultiNPSView';
32
+ import { XeboRatingView } from './XeboRatingView';
33
+ import { XeboMultiRatingView } from './XeboMultiRatingView';
34
+
35
+ const SCREEN_HEIGHT = Dimensions.get('window').height;
36
+ const MODAL_MAX_HEIGHT = SCREEN_HEIGHT * 0.78; // used as initial off-screen translateY value
37
+
38
+ function computeSheetHeight(question: XeboQuestion | null, screen: ModalScreen): number {
39
+ if (screen === 'thankYou') return SCREEN_HEIGHT * 0.36;
40
+ if (screen === 'intro') return SCREEN_HEIGHT * 0.42;
41
+ if (!question) return SCREEN_HEIGHT * 0.48;
42
+ switch (question.type) {
43
+ case XeboQuestionType.multiNps:
44
+ case XeboQuestionType.multiRating: {
45
+ const rowCount = question.options?.length ?? 3;
46
+ // base ~25% + ~15% per row, capped at 78%
47
+ return Math.min(SCREEN_HEIGHT * 0.78, SCREEN_HEIGHT * (0.25 + rowCount * 0.15));
48
+ }
49
+ case XeboQuestionType.nps:
50
+ return (question.conditionalFollowUps?.length ?? 0) > 0
51
+ ? SCREEN_HEIGHT * 0.62 // NPS Pro — space for follow-up card
52
+ : SCREEN_HEIGHT * 0.46; // simple NPS
53
+ case XeboQuestionType.rating:
54
+ return question.followUpQuestion ? SCREEN_HEIGHT * 0.56 : SCREEN_HEIGHT * 0.46;
55
+ case XeboQuestionType.singleTextBox:
56
+ return SCREEN_HEIGHT * 0.44;
57
+ case XeboQuestionType.multipleTextBox: {
58
+ const fieldCount = question.options?.length ?? 1;
59
+ return Math.min(SCREEN_HEIGHT * 0.70, SCREEN_HEIGHT * (0.28 + fieldCount * 0.10));
60
+ }
61
+ case XeboQuestionType.singleChoice:
62
+ case XeboQuestionType.multipleChoice:
63
+ case XeboQuestionType.dropdown: {
64
+ const optCount = question.options?.length ?? 0;
65
+ return Math.min(SCREEN_HEIGHT * 0.72, SCREEN_HEIGHT * (0.30 + optCount * 0.058));
66
+ }
67
+ default:
68
+ return SCREEN_HEIGHT * 0.48;
69
+ }
70
+ }
71
+
72
+ // Which screen we show inside the modal
73
+ type ModalScreen = 'intro' | 'question' | 'thankYou';
74
+
75
+ export const XeboSurveyModal: React.FC = () => {
76
+ const theme = getTheme();
77
+
78
+ const [visible, setVisible] = useState(false);
79
+ const [survey, setSurvey] = useState<XeboSurvey | null>(null);
80
+ const [screen, setScreen] = useState<ModalScreen>('intro');
81
+ const [questionIndex, setQuestionIndex] = useState(0);
82
+
83
+ const sheetHeight = useMemo(
84
+ () => computeSheetHeight(survey?.questions[questionIndex] ?? null, screen),
85
+ [survey, questionIndex, screen]
86
+ );
87
+
88
+ // Spring slide-up animation
89
+ const slideAnim = useRef(new Animated.Value(MODAL_MAX_HEIGHT)).current;
90
+ const backdropAnim = useRef(new Animated.Value(0)).current;
91
+
92
+ // ─── Subscribe to manager events ─────────────────────────────────────────
93
+
94
+ useEffect(() => {
95
+ const onLoaded = (s: XeboSurvey) => {
96
+ setSurvey(s);
97
+ setQuestionIndex(0);
98
+ };
99
+
100
+ const onVisible = (v: boolean) => {
101
+ if (v) {
102
+ openModal();
103
+ } else {
104
+ closeModal();
105
+ }
106
+ };
107
+
108
+ const onQuestionChanged = (idx: number) => {
109
+ setQuestionIndex(idx);
110
+ };
111
+
112
+ XeboSurveyManager.on(XEBO_EVENTS.SURVEY_LOADED, onLoaded);
113
+ XeboSurveyManager.on(XEBO_EVENTS.SURVEY_VISIBLE, onVisible);
114
+ XeboSurveyManager.on(XEBO_EVENTS.QUESTION_CHANGED, onQuestionChanged);
115
+
116
+ return () => {
117
+ XeboSurveyManager.off(XEBO_EVENTS.SURVEY_LOADED, onLoaded);
118
+ XeboSurveyManager.off(XEBO_EVENTS.SURVEY_VISIBLE, onVisible);
119
+ XeboSurveyManager.off(XEBO_EVENTS.QUESTION_CHANGED, onQuestionChanged);
120
+ };
121
+ }, []);
122
+
123
+ // ─── Animations ───────────────────────────────────────────────────────────
124
+
125
+ const openModal = useCallback(() => {
126
+ // Sync survey into React state NOW so the modal never renders blank on first frame
127
+ const s = XeboSurveyManager.survey;
128
+ setSurvey(s);
129
+ setVisible(true);
130
+ slideAnim.setValue(MODAL_MAX_HEIGHT);
131
+ backdropAnim.setValue(0);
132
+
133
+ // Determine initial screen
134
+ if (s?.introPage?.enabled) {
135
+ setScreen('intro');
136
+ } else {
137
+ setScreen('question');
138
+ setQuestionIndex(XeboSurveyManager.currentQuestionIndex);
139
+ }
140
+
141
+ Animated.parallel([
142
+ Animated.spring(slideAnim, {
143
+ toValue: 0,
144
+ tension: 180,
145
+ friction: 20,
146
+ useNativeDriver: true,
147
+ }),
148
+ Animated.timing(backdropAnim, {
149
+ toValue: 1,
150
+ duration: 200,
151
+ useNativeDriver: true,
152
+ }),
153
+ ]).start();
154
+ }, []);
155
+
156
+ const closeModal = useCallback(() => {
157
+ Animated.parallel([
158
+ Animated.timing(slideAnim, {
159
+ toValue: MODAL_MAX_HEIGHT,
160
+ duration: 300,
161
+ useNativeDriver: true,
162
+ }),
163
+ Animated.timing(backdropAnim, {
164
+ toValue: 0,
165
+ duration: 300,
166
+ useNativeDriver: true,
167
+ }),
168
+ ]).start(() => {
169
+ setVisible(false);
170
+ setSurvey(null);
171
+ });
172
+ }, []);
173
+
174
+ // ─── User actions ─────────────────────────────────────────────────────────
175
+
176
+ const handleSkip = () => {
177
+ XeboSurveyManager.dismissSurvey();
178
+ };
179
+
180
+ const handleIntroStart = () => {
181
+ setScreen('question');
182
+ setQuestionIndex(0);
183
+ };
184
+
185
+ const handleAnswer = useCallback((answer: XeboAnswer) => {
186
+ XeboSurveyManager.recordAnswer(answer);
187
+ XeboSurveyManager.nextQuestion();
188
+ // questionIndex will be updated via QUESTION_CHANGED event
189
+ // But also check if we're now at the thank-you question
190
+ const s = XeboSurveyManager.survey;
191
+ const newIdx = XeboSurveyManager.currentQuestionIndex;
192
+ if (s && s.questions[newIdx]?.id === 'thank_you_auto') {
193
+ setScreen('thankYou');
194
+ }
195
+ }, []);
196
+
197
+ // ─── Question renderer ────────────────────────────────────────────────────
198
+
199
+ const renderQuestion = (question: XeboQuestion) => {
200
+ const questions = survey?.questions ?? [];
201
+ // The last "real" question before thank-you
202
+ const lastRealIndex = questions.findIndex(q => q.id === 'thank_you_auto') - 1;
203
+ const isLast = questionIndex === lastRealIndex;
204
+
205
+ const commonProps = { question, isLastQuestion: isLast, onAnswer: handleAnswer };
206
+
207
+ switch (question.type) {
208
+ case XeboQuestionType.singleChoice:
209
+ return <XeboSingleChoiceView {...commonProps} />;
210
+ case XeboQuestionType.multipleChoice:
211
+ return <XeboMultipleChoiceView {...commonProps} />;
212
+ case XeboQuestionType.dropdown:
213
+ return <XeboDropdownView {...commonProps} />;
214
+ case XeboQuestionType.singleTextBox:
215
+ case XeboQuestionType.multipleTextBox:
216
+ return <XeboTextBoxView {...commonProps} />;
217
+ case XeboQuestionType.nps:
218
+ return <XeboNPSView {...commonProps} />;
219
+ case XeboQuestionType.multiNps:
220
+ return <XeboMultiNPSView {...commonProps} />;
221
+ case XeboQuestionType.rating:
222
+ return <XeboRatingView {...commonProps} />;
223
+ case XeboQuestionType.multiRating:
224
+ return <XeboMultiRatingView {...commonProps} />;
225
+ default:
226
+ return null;
227
+ }
228
+ };
229
+
230
+ // ─── Content ──────────────────────────────────────────────────────────────
231
+
232
+ const renderContent = () => {
233
+ if (!survey) return null;
234
+
235
+ if (screen === 'intro' && survey.introPage) {
236
+ return <XeboIntroView introPage={survey.introPage} onStart={handleIntroStart} />;
237
+ }
238
+
239
+ if (screen === 'thankYou') {
240
+ const tyQuestion = survey.questions.find(q => q.id === 'thank_you_auto');
241
+ if (tyQuestion) {
242
+ return <XeboThankYouView question={tyQuestion} onDismiss={handleSkip} />;
243
+ }
244
+ }
245
+
246
+ if (screen === 'question') {
247
+ const question = survey.questions[questionIndex];
248
+ if (!question) return null;
249
+ return renderQuestion(question);
250
+ }
251
+
252
+ return null;
253
+ };
254
+
255
+ if (!visible) return null;
256
+
257
+ return (
258
+ <Modal
259
+ transparent
260
+ visible={visible}
261
+ animationType="none"
262
+ statusBarTranslucent
263
+ onRequestClose={handleSkip}
264
+ >
265
+ {/* Backdrop — no dismiss on tap */}
266
+ <Animated.View style={[styles.backdrop, { opacity: backdropAnim }]} />
267
+
268
+ <KeyboardAvoidingView
269
+ style={styles.kvContainer}
270
+ behavior={Platform.OS === 'ios' ? 'padding' : 'height'}
271
+ keyboardVerticalOffset={0}
272
+ >
273
+ <Animated.View
274
+ style={[
275
+ styles.sheet,
276
+ {
277
+ backgroundColor: theme.backgroundColor,
278
+ transform: [{ translateY: slideAnim }],
279
+ height: sheetHeight,
280
+ },
281
+ ]}
282
+ >
283
+ {/* Handle bar */}
284
+ <View style={styles.handleWrap}>
285
+ <View style={styles.handle} />
286
+ </View>
287
+
288
+ {/* Skip button — right aligned */}
289
+ {screen !== 'thankYou' && (
290
+ <View style={styles.skipRow}>
291
+ <TouchableOpacity onPress={handleSkip} style={styles.skipButton} activeOpacity={0.7}>
292
+ <Text style={[styles.skipText, { color: theme.textColor }]}>Skip</Text>
293
+ </TouchableOpacity>
294
+ </View>
295
+ )}
296
+
297
+ {/* Main content */}
298
+ <View style={styles.content}>{renderContent()}</View>
299
+
300
+ {/* Safe area bottom padding */}
301
+ <SafeAreaView edges={['bottom'] as any} />
302
+ </Animated.View>
303
+ </KeyboardAvoidingView>
304
+ </Modal>
305
+ );
306
+ };
307
+
308
+ const styles = StyleSheet.create({
309
+ backdrop: {
310
+ ...StyleSheet.absoluteFillObject,
311
+ backgroundColor: 'rgba(0,0,0,0.5)',
312
+ },
313
+ kvContainer: {
314
+ flex: 1,
315
+ justifyContent: 'flex-end',
316
+ },
317
+ sheet: {
318
+ borderTopLeftRadius: 16,
319
+ borderTopRightRadius: 16,
320
+ overflow: 'hidden',
321
+ elevation: 24,
322
+ shadowColor: '#000',
323
+ shadowOffset: { width: 0, height: -4 },
324
+ shadowOpacity: 0.15,
325
+ shadowRadius: 12,
326
+ },
327
+ handleWrap: {
328
+ alignItems: 'center',
329
+ paddingTop: 10,
330
+ paddingBottom: 4,
331
+ },
332
+ handle: {
333
+ width: 36,
334
+ height: 4,
335
+ borderRadius: 2,
336
+ backgroundColor: '#D1D5DB',
337
+ },
338
+ skipRow: {
339
+ flexDirection: 'row',
340
+ justifyContent: 'flex-end',
341
+ paddingHorizontal: 16,
342
+ paddingBottom: 4,
343
+ },
344
+ skipButton: {
345
+ paddingHorizontal: 12,
346
+ paddingVertical: 6,
347
+ },
348
+ skipText: {
349
+ fontSize: 14,
350
+ fontWeight: '500',
351
+ opacity: 0.6,
352
+ },
353
+ content: {
354
+ flex: 1,
355
+ },
356
+ });
@@ -0,0 +1,183 @@
1
+ import React, { useRef, useState } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ TextInput,
6
+ TouchableOpacity,
7
+ StyleSheet,
8
+ ScrollView,
9
+ Animated,
10
+ KeyboardAvoidingView,
11
+ Platform,
12
+ } from 'react-native';
13
+ import { XeboQuestion, XeboAnswer } from '../models/XeboModels';
14
+ import { XeboQuestionType } from '../models/XeboModels';
15
+ import { getTheme } from '../theme/XeboTheme';
16
+
17
+ interface Props {
18
+ question: XeboQuestion;
19
+ isLastQuestion: boolean;
20
+ onAnswer: (answer: XeboAnswer) => void;
21
+ }
22
+
23
+ export const XeboTextBoxView: React.FC<Props> = ({ question, isLastQuestion, onAnswer }) => {
24
+ const theme = getTheme();
25
+ const isMulti = question.type === XeboQuestionType.multipleTextBox;
26
+ const fields = question.options && question.options.length > 0 ? question.options : [{ id: 'default', text: '', value: '' }];
27
+
28
+ const [values, setValues] = useState<Record<string, string>>({});
29
+ const [error, setError] = useState('');
30
+ const shakeAnim = useRef(new Animated.Value(0)).current;
31
+
32
+ const shake = () => {
33
+ Animated.sequence([
34
+ Animated.timing(shakeAnim, { toValue: 8, duration: 60, useNativeDriver: true }),
35
+ Animated.timing(shakeAnim, { toValue: -8, duration: 60, useNativeDriver: true }),
36
+ Animated.timing(shakeAnim, { toValue: 6, duration: 60, useNativeDriver: true }),
37
+ Animated.timing(shakeAnim, { toValue: 0, duration: 60, useNativeDriver: true }),
38
+ ]).start();
39
+ };
40
+
41
+ const handleChange = (fieldId: string, text: string) => {
42
+ setValues(prev => ({ ...prev, [fieldId]: text }));
43
+ setError('');
44
+ };
45
+
46
+ const handleSubmit = () => {
47
+ if (question.isRequired) {
48
+ const empty = fields.some(f => !(values[f.id] ?? '').trim());
49
+ if (empty) {
50
+ setError('Please fill in all fields.');
51
+ shake();
52
+ return;
53
+ }
54
+ }
55
+
56
+ onAnswer({
57
+ questionId: question.id,
58
+ value: fields
59
+ .filter(f => (values[f.id] ?? '').trim())
60
+ .map(f => ({ rowId: f.id, text: values[f.id] ?? '' })),
61
+ });
62
+ };
63
+
64
+ return (
65
+ <KeyboardAvoidingView
66
+ style={styles.kvContainer}
67
+ behavior={Platform.OS === 'ios' ? 'padding' : undefined}
68
+ >
69
+ <ScrollView
70
+ style={styles.scroll}
71
+ contentContainerStyle={styles.container}
72
+ keyboardShouldPersistTaps="handled"
73
+ showsVerticalScrollIndicator={false}
74
+ >
75
+ <Text style={[styles.title, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
76
+ {question.title}
77
+ </Text>
78
+ {!!question.subtitle && (
79
+ <Text style={[styles.subtitle, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
80
+ {question.subtitle}
81
+ </Text>
82
+ )}
83
+
84
+ {fields.map((field, idx) => (
85
+ <View key={field.id} style={styles.fieldWrap}>
86
+ {isMulti && !!field.text && (
87
+ <Text style={[styles.fieldLabel, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
88
+ {field.text}
89
+ </Text>
90
+ )}
91
+ <TextInput
92
+ style={[
93
+ styles.input,
94
+ {
95
+ borderColor: '#E5E7EB',
96
+ color: theme.textColor,
97
+ fontFamily: theme.fontFamily,
98
+ borderRadius: theme.cornerRadius,
99
+ },
100
+ ]}
101
+ placeholder={isMulti ? '' : (question.placeholder || 'Type your answer here...')}
102
+ placeholderTextColor="#9CA3AF"
103
+ value={values[field.id] ?? ''}
104
+ onChangeText={text => handleChange(field.id, text)}
105
+ multiline={!isMulti}
106
+ numberOfLines={isMulti ? 1 : 4}
107
+ textAlignVertical={isMulti ? 'center' : 'top'}
108
+ returnKeyType={idx === fields.length - 1 ? 'done' : 'next'}
109
+ />
110
+ </View>
111
+ ))}
112
+
113
+ {!!error && <Text style={styles.errorText}>{error}</Text>}
114
+
115
+ <Animated.View style={{ transform: [{ translateX: shakeAnim }] }}>
116
+ <TouchableOpacity
117
+ style={[styles.button, { backgroundColor: theme.primaryColor, borderRadius: theme.cornerRadius }]}
118
+ onPress={handleSubmit}
119
+ activeOpacity={0.8}
120
+ >
121
+ <Text style={[styles.buttonText, { fontFamily: theme.fontFamily }]}>
122
+ {isLastQuestion ? 'Submit' : 'Next'}
123
+ </Text>
124
+ </TouchableOpacity>
125
+ </Animated.View>
126
+ </ScrollView>
127
+ </KeyboardAvoidingView>
128
+ );
129
+ };
130
+
131
+ const styles = StyleSheet.create({
132
+ kvContainer: {
133
+ flex: 1,
134
+ },
135
+ scroll: {
136
+ flex: 1,
137
+ },
138
+ container: {
139
+ paddingHorizontal: 20,
140
+ paddingTop: 16,
141
+ paddingBottom: 24,
142
+ flexGrow: 1,
143
+ },
144
+ title: {
145
+ fontSize: 18,
146
+ fontWeight: '700',
147
+ marginBottom: 6,
148
+ },
149
+ subtitle: {
150
+ fontSize: 14,
151
+ opacity: 0.6,
152
+ marginBottom: 16,
153
+ },
154
+ fieldWrap: {
155
+ marginBottom: 14,
156
+ },
157
+ fieldLabel: {
158
+ fontSize: 14,
159
+ fontWeight: '500',
160
+ marginBottom: 6,
161
+ },
162
+ input: {
163
+ borderWidth: 1.5,
164
+ padding: 12,
165
+ fontSize: 15,
166
+ minHeight: 48,
167
+ },
168
+ errorText: {
169
+ color: '#EF4444',
170
+ fontSize: 13,
171
+ marginBottom: 8,
172
+ },
173
+ button: {
174
+ paddingVertical: 14,
175
+ alignItems: 'center',
176
+ marginTop: 8,
177
+ },
178
+ buttonText: {
179
+ color: '#FFFFFF',
180
+ fontSize: 16,
181
+ fontWeight: '600',
182
+ },
183
+ });
@@ -0,0 +1,100 @@
1
+ import React, { useEffect, useRef } from 'react';
2
+ import {
3
+ View,
4
+ Text,
5
+ StyleSheet,
6
+ Animated,
7
+ Linking,
8
+ } from 'react-native';
9
+ import { XeboQuestion } from '../models/XeboModels';
10
+ import { getTheme } from '../theme/XeboTheme';
11
+
12
+ interface Props {
13
+ question: XeboQuestion;
14
+ onDismiss: () => void;
15
+ }
16
+
17
+ export const XeboThankYouView: React.FC<Props> = ({ question, onDismiss }) => {
18
+ const theme = getTheme();
19
+ const fadeAnim = useRef(new Animated.Value(0)).current;
20
+ const scaleAnim = useRef(new Animated.Value(0.8)).current;
21
+
22
+ useEffect(() => {
23
+ Animated.parallel([
24
+ Animated.timing(fadeAnim, { toValue: 1, duration: 400, useNativeDriver: true }),
25
+ Animated.spring(scaleAnim, { toValue: 1, tension: 60, friction: 7, useNativeDriver: true }),
26
+ ]).start();
27
+
28
+ if (question.redirectURL) {
29
+ const timer = setTimeout(async () => {
30
+ try {
31
+ await Linking.openURL(question.redirectURL!);
32
+ } catch {
33
+ // ignore
34
+ }
35
+ onDismiss();
36
+ }, 2500);
37
+ return () => clearTimeout(timer);
38
+ }
39
+ }, []);
40
+
41
+ return (
42
+ <Animated.View
43
+ style={[
44
+ styles.container,
45
+ { opacity: fadeAnim, transform: [{ scale: scaleAnim }] },
46
+ ]}
47
+ >
48
+ {/* Green checkmark circle */}
49
+ <View style={styles.iconCircle}>
50
+ <Text style={styles.checkmark}>✓</Text>
51
+ </View>
52
+
53
+ <Text style={[styles.title, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
54
+ {question.title}
55
+ </Text>
56
+
57
+ {!!question.subtitle && (
58
+ <Text style={[styles.subtitle, { color: theme.textColor, fontFamily: theme.fontFamily }]}>
59
+ {question.subtitle}
60
+ </Text>
61
+ )}
62
+ </Animated.View>
63
+ );
64
+ };
65
+
66
+ const styles = StyleSheet.create({
67
+ container: {
68
+ flex: 1,
69
+ paddingHorizontal: 20,
70
+ paddingVertical: 40,
71
+ alignItems: 'center',
72
+ justifyContent: 'center',
73
+ },
74
+ iconCircle: {
75
+ width: 80,
76
+ height: 80,
77
+ borderRadius: 40,
78
+ backgroundColor: '#22C55E',
79
+ alignItems: 'center',
80
+ justifyContent: 'center',
81
+ marginBottom: 24,
82
+ },
83
+ checkmark: {
84
+ color: '#FFFFFF',
85
+ fontSize: 40,
86
+ fontWeight: '700',
87
+ },
88
+ title: {
89
+ fontSize: 22,
90
+ fontWeight: '700',
91
+ textAlign: 'center',
92
+ marginBottom: 12,
93
+ },
94
+ subtitle: {
95
+ fontSize: 15,
96
+ textAlign: 'center',
97
+ opacity: 0.7,
98
+ lineHeight: 22,
99
+ },
100
+ });