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.
- package/babel.config.js +4 -0
- package/package.json +18 -10
- package/src/components/XeboDropdownView.tsx +197 -0
- package/src/components/XeboIntroView.tsx +58 -0
- package/src/components/XeboMultiNPSView.tsx +166 -0
- package/src/components/XeboMultiRatingView.tsx +165 -0
- package/src/components/XeboMultipleChoiceView.tsx +184 -0
- package/src/components/XeboNPSView.tsx +292 -0
- package/src/components/XeboRatingView.tsx +262 -0
- package/src/components/XeboSingleChoiceView.tsx +169 -0
- package/src/components/XeboSurveyModal.tsx +356 -0
- package/src/components/XeboTextBoxView.tsx +183 -0
- package/src/components/XeboThankYouView.tsx +100 -0
- package/src/core/XeboNetworkService.ts +375 -0
- package/src/core/XeboOfflineQueue.ts +33 -0
- package/src/core/XeboSurveyManager.ts +296 -0
- package/src/index.ts +23 -0
- package/src/models/XeboAPIModels.ts +80 -0
- package/src/models/XeboModels.ts +124 -0
- package/src/theme/XeboTheme.ts +33 -0
- package/tsconfig.json +17 -0
- package/ReadMe.md +0 -1
- package/index.js +0 -4
- package/src/SurveyWebView.js +0 -16
- package/src/useFaqSurvey.js +0 -28
- package/src/useSurveyAlert.js +0 -25
- package/src/useSurveyTimer.js +0 -39
|
@@ -0,0 +1,375 @@
|
|
|
1
|
+
import {
|
|
2
|
+
XeboSurvey,
|
|
3
|
+
XeboQuestion,
|
|
4
|
+
XeboQuestionType,
|
|
5
|
+
XeboChoice,
|
|
6
|
+
XeboConditionalFollowUp,
|
|
7
|
+
XeboAnswer,
|
|
8
|
+
XeboEnvironment,
|
|
9
|
+
} from '../models/XeboModels';
|
|
10
|
+
import {
|
|
11
|
+
APICollectorResponse,
|
|
12
|
+
APISurveyResponse,
|
|
13
|
+
APIQuestion,
|
|
14
|
+
APIConfigScale,
|
|
15
|
+
} from '../models/XeboAPIModels';
|
|
16
|
+
|
|
17
|
+
// ─── URL helpers ──────────────────────────────────────────────────────────────
|
|
18
|
+
|
|
19
|
+
export function resolveZoneAndEnv(zone: string, explicitEnv?: XeboEnvironment): { resolvedZone: string; environment: XeboEnvironment } {
|
|
20
|
+
if (zone.startsWith('uat-')) {
|
|
21
|
+
return { resolvedZone: zone.slice(4), environment: 'uat' };
|
|
22
|
+
}
|
|
23
|
+
return { resolvedZone: zone, environment: explicitEnv ?? 'production' };
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function buildBaseURL(resolvedZone: string, environment: XeboEnvironment): string {
|
|
27
|
+
return environment === 'uat'
|
|
28
|
+
? `https://uat-${resolvedZone}-api.xebo.ai`
|
|
29
|
+
: `https://${resolvedZone}-api.xebo.ai`;
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
export function buildFeedbackBaseURL(resolvedZone: string, environment: XeboEnvironment): string {
|
|
33
|
+
return environment === 'uat'
|
|
34
|
+
? `https://uat-${resolvedZone}-feedback-api.xebo.ai`
|
|
35
|
+
: `https://${resolvedZone}-feedback-api.xebo.ai`;
|
|
36
|
+
}
|
|
37
|
+
|
|
38
|
+
// ─── HTML stripping ───────────────────────────────────────────────────────────
|
|
39
|
+
|
|
40
|
+
export function stripHTML(raw: string | undefined): string {
|
|
41
|
+
if (!raw) return '';
|
|
42
|
+
return raw
|
|
43
|
+
.replace(/<[^>]*>/g, '')
|
|
44
|
+
.replace(/&/g, '&')
|
|
45
|
+
.replace(/</g, '<')
|
|
46
|
+
.replace(/>/g, '>')
|
|
47
|
+
.replace(/ /g, ' ')
|
|
48
|
+
.replace(/"/g, '"')
|
|
49
|
+
.replace(/'/g, "'")
|
|
50
|
+
.trim();
|
|
51
|
+
}
|
|
52
|
+
|
|
53
|
+
// ─── Question type mapping ────────────────────────────────────────────────────
|
|
54
|
+
|
|
55
|
+
function mapRatingStyle(apiType?: string): string {
|
|
56
|
+
switch (apiType?.toLowerCase()) {
|
|
57
|
+
case 'star':
|
|
58
|
+
case 'stars': return 'star';
|
|
59
|
+
case 'heart':
|
|
60
|
+
case 'hearts': return 'heart';
|
|
61
|
+
case 'circle':
|
|
62
|
+
case 'circles': return 'circle';
|
|
63
|
+
case 'tile':
|
|
64
|
+
case 'tiles': return 'tile';
|
|
65
|
+
case 'emoji':
|
|
66
|
+
case 'emojis': return 'emoji';
|
|
67
|
+
default: return 'star';
|
|
68
|
+
}
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function mapQuestionType(apiType: string): XeboQuestionType {
|
|
72
|
+
switch (apiType) {
|
|
73
|
+
case 'single_choice': return XeboQuestionType.singleChoice;
|
|
74
|
+
case 'multi_choice': return XeboQuestionType.multipleChoice;
|
|
75
|
+
case 'dropdown': return XeboQuestionType.dropdown;
|
|
76
|
+
case 'single_text': return XeboQuestionType.singleTextBox;
|
|
77
|
+
case 'multi_text': return XeboQuestionType.multipleTextBox;
|
|
78
|
+
case 'nps':
|
|
79
|
+
case 'nps_pro': return XeboQuestionType.nps;
|
|
80
|
+
case 'multi_nps': return XeboQuestionType.multiNps;
|
|
81
|
+
case 'rating':
|
|
82
|
+
case 'rating_pro': return XeboQuestionType.rating;
|
|
83
|
+
case 'multi_rating': return XeboQuestionType.multiRating;
|
|
84
|
+
default: return XeboQuestionType.singleChoice;
|
|
85
|
+
}
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
// ─── Survey parsing ───────────────────────────────────────────────────────────
|
|
89
|
+
|
|
90
|
+
export function parseSurvey(data: APISurveyResponse['data']): XeboSurvey {
|
|
91
|
+
// Collect all follow-up question IDs (from NPS Pro configScale)
|
|
92
|
+
const followUpQuestionIds = new Set<string>();
|
|
93
|
+
for (const block of data.blocks) {
|
|
94
|
+
for (const item of block.questions) {
|
|
95
|
+
const q = item.question;
|
|
96
|
+
const scales = q.settings?.configScale ?? [];
|
|
97
|
+
for (const scale of scales) {
|
|
98
|
+
for (const qid of scale.qId ?? []) {
|
|
99
|
+
followUpQuestionIds.add(qid);
|
|
100
|
+
}
|
|
101
|
+
}
|
|
102
|
+
}
|
|
103
|
+
}
|
|
104
|
+
|
|
105
|
+
const questions: XeboQuestion[] = [];
|
|
106
|
+
|
|
107
|
+
for (const block of data.blocks) {
|
|
108
|
+
for (const item of block.questions) {
|
|
109
|
+
const q = item.question;
|
|
110
|
+
|
|
111
|
+
// Skip follow-up questions (they're embedded inline)
|
|
112
|
+
if (followUpQuestionIds.has(q.uuid)) continue;
|
|
113
|
+
|
|
114
|
+
const type = mapQuestionType(q.quesType);
|
|
115
|
+
if (type === XeboQuestionType.dropdown) {
|
|
116
|
+
console.log('[Xebo] Dropdown raw options keys:', JSON.stringify(Object.keys(q.options ?? {})));
|
|
117
|
+
console.log('[Xebo] Dropdown options.row:', JSON.stringify(q.options?.row));
|
|
118
|
+
console.log('[Xebo] Full dropdown q.options:', JSON.stringify(q.options));
|
|
119
|
+
}
|
|
120
|
+
const rows: XeboChoice[] = (q.options?.row ?? []).map(r => ({
|
|
121
|
+
id: r.uuid,
|
|
122
|
+
text: stripHTML(r.text),
|
|
123
|
+
value: r.uuid,
|
|
124
|
+
}));
|
|
125
|
+
const cols: XeboChoice[] = (q.options?.col ?? []).map(c => ({
|
|
126
|
+
id: c.uuid,
|
|
127
|
+
text: stripHTML(c.text),
|
|
128
|
+
value: c.uuid,
|
|
129
|
+
}));
|
|
130
|
+
|
|
131
|
+
// Build conditional follow-ups for NPS Pro
|
|
132
|
+
const conditionalFollowUps: XeboConditionalFollowUp[] = [];
|
|
133
|
+
const scales: APIConfigScale[] = q.settings?.configScale ?? [];
|
|
134
|
+
for (const scale of scales) {
|
|
135
|
+
if (scale.enabled === false) continue;
|
|
136
|
+
const followUpQId = scale.qId?.[0];
|
|
137
|
+
if (!followUpQId) continue;
|
|
138
|
+
|
|
139
|
+
// Find the follow-up question in all blocks
|
|
140
|
+
let followUpApiQ: APIQuestion | undefined;
|
|
141
|
+
for (const b of data.blocks) {
|
|
142
|
+
for (const bi of b.questions) {
|
|
143
|
+
if (bi.question.uuid === followUpQId) {
|
|
144
|
+
followUpApiQ = bi.question;
|
|
145
|
+
break;
|
|
146
|
+
}
|
|
147
|
+
}
|
|
148
|
+
if (followUpApiQ) break;
|
|
149
|
+
}
|
|
150
|
+
|
|
151
|
+
const followUpOptions: XeboChoice[] = (followUpApiQ?.options?.row ?? []).map(r => ({
|
|
152
|
+
id: r.uuid,
|
|
153
|
+
text: stripHTML(r.text),
|
|
154
|
+
value: r.uuid,
|
|
155
|
+
}));
|
|
156
|
+
|
|
157
|
+
conditionalFollowUps.push({
|
|
158
|
+
minRating: Number(scale.scale.min),
|
|
159
|
+
maxRating: Number(scale.scale.max),
|
|
160
|
+
followUpQuestion: stripHTML(followUpApiQ?.title ?? ''),
|
|
161
|
+
followUpType: followUpApiQ ? mapQuestionType(followUpApiQ.quesType) : XeboQuestionType.singleTextBox,
|
|
162
|
+
followUpOptions,
|
|
163
|
+
questionId: followUpQId,
|
|
164
|
+
});
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
const npsSettings = q.settings?.nps;
|
|
168
|
+
const ratingSettings = q.settings?.rating;
|
|
169
|
+
|
|
170
|
+
const parsed: XeboQuestion = {
|
|
171
|
+
id: q.uuid,
|
|
172
|
+
type,
|
|
173
|
+
title: stripHTML(q.title),
|
|
174
|
+
subtitle: stripHTML(q.helperText?.text),
|
|
175
|
+
placeholder: stripHTML(q.placeholder?.text),
|
|
176
|
+
isRequired: q.required?.enabled ?? false,
|
|
177
|
+
options: rows.length > 0 ? rows : undefined,
|
|
178
|
+
columns: cols.length > 0 ? cols : undefined,
|
|
179
|
+
npsLabels: npsSettings
|
|
180
|
+
? {
|
|
181
|
+
lower: npsSettings.leftLabel,
|
|
182
|
+
upper: npsSettings.rightLabel,
|
|
183
|
+
detractorColor: npsSettings.colors?.detractor,
|
|
184
|
+
passiveColor: npsSettings.colors?.passive,
|
|
185
|
+
promoterColor: npsSettings.colors?.promoter,
|
|
186
|
+
}
|
|
187
|
+
: undefined,
|
|
188
|
+
followUpQuestion: undefined,
|
|
189
|
+
conditionalFollowUps: conditionalFollowUps.length > 0 ? conditionalFollowUps : undefined,
|
|
190
|
+
ratingStyle: mapRatingStyle(ratingSettings?.type),
|
|
191
|
+
showColLabels: ratingSettings?.colLabel,
|
|
192
|
+
ratingColor: ratingSettings?.color,
|
|
193
|
+
};
|
|
194
|
+
|
|
195
|
+
questions.push(parsed);
|
|
196
|
+
}
|
|
197
|
+
}
|
|
198
|
+
|
|
199
|
+
// Append auto thank-you question
|
|
200
|
+
const tyPage = data.thankYouPage;
|
|
201
|
+
const tyTitle = tyPage?.enabled && tyPage.content
|
|
202
|
+
? stripHTML(tyPage.content)
|
|
203
|
+
: 'Thank You!';
|
|
204
|
+
const tySubtitle = tyPage?.enabled && tyPage.content
|
|
205
|
+
? undefined
|
|
206
|
+
: 'Your feedback is greatly appreciated.';
|
|
207
|
+
|
|
208
|
+
questions.push({
|
|
209
|
+
id: 'thank_you_auto',
|
|
210
|
+
type: XeboQuestionType.thankYou,
|
|
211
|
+
title: tyTitle,
|
|
212
|
+
subtitle: tySubtitle,
|
|
213
|
+
isRequired: false,
|
|
214
|
+
redirectURL: tyPage?.links,
|
|
215
|
+
});
|
|
216
|
+
|
|
217
|
+
const introPageRaw = data.introPage;
|
|
218
|
+
const thankYouPageRaw = data.thankYouPage;
|
|
219
|
+
|
|
220
|
+
const survey = {
|
|
221
|
+
id: data.uuid,
|
|
222
|
+
name: data.displayTitle,
|
|
223
|
+
projectId: data.projectId,
|
|
224
|
+
questions,
|
|
225
|
+
introPage: introPageRaw
|
|
226
|
+
? {
|
|
227
|
+
enabled: introPageRaw.enabled ?? false,
|
|
228
|
+
content: stripHTML(introPageRaw.content),
|
|
229
|
+
buttonText: introPageRaw.buttonText,
|
|
230
|
+
}
|
|
231
|
+
: undefined,
|
|
232
|
+
thankYouPage: thankYouPageRaw
|
|
233
|
+
? {
|
|
234
|
+
enabled: thankYouPageRaw.enabled ?? false,
|
|
235
|
+
content: stripHTML(thankYouPageRaw.content),
|
|
236
|
+
links: thankYouPageRaw.links,
|
|
237
|
+
}
|
|
238
|
+
: undefined,
|
|
239
|
+
};
|
|
240
|
+
|
|
241
|
+
console.log('[Xebo] Full parsed survey:', JSON.stringify(survey, null, 2));
|
|
242
|
+
return survey;
|
|
243
|
+
}
|
|
244
|
+
|
|
245
|
+
// ─── API calls ────────────────────────────────────────────────────────────────
|
|
246
|
+
|
|
247
|
+
export interface CollectorInfo {
|
|
248
|
+
surveyId: string;
|
|
249
|
+
uuid: string;
|
|
250
|
+
mongoId: string;
|
|
251
|
+
}
|
|
252
|
+
|
|
253
|
+
export async function fetchCollector(
|
|
254
|
+
baseURL: string,
|
|
255
|
+
collectorId: string,
|
|
256
|
+
apiKey: string
|
|
257
|
+
): Promise<CollectorInfo> {
|
|
258
|
+
const url = `${baseURL}/v3/collectors/collect/${collectorId}`;
|
|
259
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
260
|
+
console.log('[Xebo Step 1] Fetching collector');
|
|
261
|
+
console.log('URL:', url);
|
|
262
|
+
console.log('Collector ID:', collectorId);
|
|
263
|
+
|
|
264
|
+
const res = await fetch(url, { headers: { 'x-api-key': apiKey } });
|
|
265
|
+
const json: APICollectorResponse = await res.json();
|
|
266
|
+
|
|
267
|
+
// The API may return the collector object directly at the root OR wrapped in a `data` key
|
|
268
|
+
const payload = json?.data ?? (json as unknown as APICollectorResponse['data']);
|
|
269
|
+
|
|
270
|
+
if (!res.ok || !payload?.surveyId) {
|
|
271
|
+
throw new Error(
|
|
272
|
+
`[Xebo] fetchCollector failed — HTTP ${res.status}. ` +
|
|
273
|
+
`Check your apiKey, collectorId and zone. Response: ${JSON.stringify(json)}`
|
|
274
|
+
);
|
|
275
|
+
}
|
|
276
|
+
|
|
277
|
+
const info: CollectorInfo = {
|
|
278
|
+
surveyId: payload.surveyId,
|
|
279
|
+
uuid: payload.uuid,
|
|
280
|
+
mongoId: payload._id,
|
|
281
|
+
};
|
|
282
|
+
|
|
283
|
+
console.log('Collector UUID:', info.uuid);
|
|
284
|
+
console.log('Collector MongoId:', info.mongoId);
|
|
285
|
+
console.log('Survey ID:', info.surveyId);
|
|
286
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
287
|
+
|
|
288
|
+
return info;
|
|
289
|
+
}
|
|
290
|
+
|
|
291
|
+
export async function fetchSurvey(
|
|
292
|
+
baseURL: string,
|
|
293
|
+
surveyId: string,
|
|
294
|
+
apiKey: string
|
|
295
|
+
): Promise<XeboSurvey> {
|
|
296
|
+
const url = `${baseURL}/v3/survey-management/surveys/${surveyId}`;
|
|
297
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
298
|
+
console.log('[Xebo Step 2] Fetching survey');
|
|
299
|
+
console.log('URL:', url);
|
|
300
|
+
|
|
301
|
+
const res = await fetch(url, { headers: { 'x-api-key': apiKey } });
|
|
302
|
+
const json: APISurveyResponse = await res.json();
|
|
303
|
+
|
|
304
|
+
console.log('[Xebo] Survey HTTP status:', res.status);
|
|
305
|
+
console.log('[Xebo] Survey data keys:', Object.keys(json?.data ?? {}));
|
|
306
|
+
console.log('[Xebo] blocks:', JSON.stringify(json?.data?.blocks?.length));
|
|
307
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
308
|
+
|
|
309
|
+
if (!json?.data) {
|
|
310
|
+
throw new Error(`[Xebo] fetchSurvey — no data in response. HTTP ${res.status}`);
|
|
311
|
+
}
|
|
312
|
+
if (!Array.isArray(json.data.blocks)) {
|
|
313
|
+
throw new Error(`[Xebo] fetchSurvey — 'blocks' missing. Keys: ${Object.keys(json.data)}`);
|
|
314
|
+
}
|
|
315
|
+
|
|
316
|
+
return parseSurvey(json.data);
|
|
317
|
+
}
|
|
318
|
+
|
|
319
|
+
export interface SubmitResult {
|
|
320
|
+
ok: boolean;
|
|
321
|
+
status: number;
|
|
322
|
+
responseJson: unknown;
|
|
323
|
+
}
|
|
324
|
+
|
|
325
|
+
export async function submitResponse(
|
|
326
|
+
feedbackBaseURL: string,
|
|
327
|
+
surveyId: string,
|
|
328
|
+
collectorUUID: string,
|
|
329
|
+
apiKey: string,
|
|
330
|
+
answers: XeboAnswer[]
|
|
331
|
+
): Promise<SubmitResult> {
|
|
332
|
+
const url = `${feedbackBaseURL}/v3/survey-participation/${surveyId}/response/create-response`;
|
|
333
|
+
|
|
334
|
+
// Filter out auto thank-you answer
|
|
335
|
+
const filteredAnswers = answers.filter(a => a.questionId !== 'thank_you_auto');
|
|
336
|
+
|
|
337
|
+
const payload = {
|
|
338
|
+
collector_id: collectorUUID,
|
|
339
|
+
responses: [
|
|
340
|
+
{
|
|
341
|
+
response_status: 'complete',
|
|
342
|
+
questions: filteredAnswers.map(a => ({
|
|
343
|
+
uuid: a.questionId,
|
|
344
|
+
answer: a.value.map(v => ({
|
|
345
|
+
row_id: v.rowId,
|
|
346
|
+
col_id: v.colId ?? '',
|
|
347
|
+
text: v.text ?? '',
|
|
348
|
+
})),
|
|
349
|
+
})),
|
|
350
|
+
},
|
|
351
|
+
],
|
|
352
|
+
};
|
|
353
|
+
|
|
354
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
355
|
+
console.log('[Xebo Step 3] Submitting response');
|
|
356
|
+
console.log('URL:', url);
|
|
357
|
+
console.log('Survey ID:', surveyId);
|
|
358
|
+
console.log('Collector ID:', collectorUUID);
|
|
359
|
+
console.log('Question count:', filteredAnswers.length);
|
|
360
|
+
console.log('Payload:', JSON.stringify(payload, null, 2));
|
|
361
|
+
|
|
362
|
+
const res = await fetch(url, {
|
|
363
|
+
method: 'POST',
|
|
364
|
+
headers: { 'x-api-key': apiKey, 'Content-Type': 'application/json' },
|
|
365
|
+
body: JSON.stringify(payload),
|
|
366
|
+
});
|
|
367
|
+
|
|
368
|
+
const responseJson = await res.json();
|
|
369
|
+
|
|
370
|
+
console.log('HTTP Status:', res.status);
|
|
371
|
+
console.log('Response JSON:', JSON.stringify(responseJson, null, 2));
|
|
372
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
373
|
+
|
|
374
|
+
return { ok: res.ok, status: res.status, responseJson };
|
|
375
|
+
}
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import AsyncStorage from '@react-native-async-storage/async-storage';
|
|
2
|
+
import { XeboQueuedResponse } from '../models/XeboModels';
|
|
3
|
+
|
|
4
|
+
const STORAGE_KEY = 'com.xebo.offline_responses';
|
|
5
|
+
|
|
6
|
+
export const XeboOfflineQueue = {
|
|
7
|
+
async enqueue(response: XeboQueuedResponse): Promise<void> {
|
|
8
|
+
const existing = await this.getAll();
|
|
9
|
+
existing.push(response);
|
|
10
|
+
await this.saveAll(existing);
|
|
11
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
12
|
+
console.log('[Xebo Offline Queue] Enqueued response:', response.responseId);
|
|
13
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
14
|
+
},
|
|
15
|
+
|
|
16
|
+
async getAll(): Promise<XeboQueuedResponse[]> {
|
|
17
|
+
try {
|
|
18
|
+
const raw = await AsyncStorage.getItem(STORAGE_KEY);
|
|
19
|
+
if (!raw) return [];
|
|
20
|
+
return JSON.parse(raw) as XeboQueuedResponse[];
|
|
21
|
+
} catch {
|
|
22
|
+
return [];
|
|
23
|
+
}
|
|
24
|
+
},
|
|
25
|
+
|
|
26
|
+
async saveAll(responses: XeboQueuedResponse[]): Promise<void> {
|
|
27
|
+
await AsyncStorage.setItem(STORAGE_KEY, JSON.stringify(responses));
|
|
28
|
+
},
|
|
29
|
+
|
|
30
|
+
async clear(): Promise<void> {
|
|
31
|
+
await AsyncStorage.removeItem(STORAGE_KEY);
|
|
32
|
+
},
|
|
33
|
+
};
|
|
@@ -0,0 +1,296 @@
|
|
|
1
|
+
import NetInfo from '@react-native-community/netinfo';
|
|
2
|
+
|
|
3
|
+
// Minimal inline event emitter — no external 'events' package needed
|
|
4
|
+
type Listener = (...args: any[]) => void;
|
|
5
|
+
class SimpleEventEmitter {
|
|
6
|
+
private _listeners: Map<string, Listener[]> = new Map();
|
|
7
|
+
emit(event: string, ...args: any[]): boolean {
|
|
8
|
+
const fns = this._listeners.get(event) ?? [];
|
|
9
|
+
fns.forEach(fn => fn(...args));
|
|
10
|
+
return fns.length > 0;
|
|
11
|
+
}
|
|
12
|
+
on(event: string, listener: Listener): this {
|
|
13
|
+
this._listeners.set(event, [...(this._listeners.get(event) ?? []), listener]);
|
|
14
|
+
return this;
|
|
15
|
+
}
|
|
16
|
+
off(event: string, listener: Listener): this {
|
|
17
|
+
this._listeners.set(event, (this._listeners.get(event) ?? []).filter(l => l !== listener));
|
|
18
|
+
return this;
|
|
19
|
+
}
|
|
20
|
+
removeAllListeners(event?: string): this {
|
|
21
|
+
event ? this._listeners.delete(event) : this._listeners.clear();
|
|
22
|
+
return this;
|
|
23
|
+
}
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
const uuidv4 = () =>
|
|
27
|
+
'xxxxxxxx-xxxx-4xxx-yxxx-xxxxxxxxxxxx'.replace(/[xy]/g, c => {
|
|
28
|
+
const r = (Math.random() * 16) | 0;
|
|
29
|
+
return (c === 'x' ? r : (r & 0x3) | 0x8).toString(16);
|
|
30
|
+
});
|
|
31
|
+
import {
|
|
32
|
+
XeboSurvey,
|
|
33
|
+
XeboAnswer,
|
|
34
|
+
XeboConfig,
|
|
35
|
+
XeboThemeConfig,
|
|
36
|
+
XeboEnvironment,
|
|
37
|
+
XeboQueuedResponse,
|
|
38
|
+
} from '../models/XeboModels';
|
|
39
|
+
import {
|
|
40
|
+
resolveZoneAndEnv,
|
|
41
|
+
buildBaseURL,
|
|
42
|
+
buildFeedbackBaseURL,
|
|
43
|
+
fetchCollector,
|
|
44
|
+
fetchSurvey,
|
|
45
|
+
submitResponse,
|
|
46
|
+
} from './XeboNetworkService';
|
|
47
|
+
import { XeboOfflineQueue } from './XeboOfflineQueue';
|
|
48
|
+
import { configureTheme } from '../theme/XeboTheme';
|
|
49
|
+
|
|
50
|
+
// ─── Event names ──────────────────────────────────────────────────────────────
|
|
51
|
+
|
|
52
|
+
export const XEBO_EVENTS = {
|
|
53
|
+
SURVEY_LOADED: 'survey_loaded',
|
|
54
|
+
QUESTION_CHANGED: 'question_changed',
|
|
55
|
+
SURVEY_VISIBLE: 'survey_visible',
|
|
56
|
+
SURVEY_DISMISSED: 'survey_dismissed',
|
|
57
|
+
} as const;
|
|
58
|
+
|
|
59
|
+
// ─── Singleton class ──────────────────────────────────────────────────────────
|
|
60
|
+
|
|
61
|
+
class XeboSurveyManagerClass extends SimpleEventEmitter {
|
|
62
|
+
// Config
|
|
63
|
+
private apiKey = '';
|
|
64
|
+
private collectorId = '';
|
|
65
|
+
private resolvedZone = '';
|
|
66
|
+
private environment: XeboEnvironment = 'production';
|
|
67
|
+
private baseURL = '';
|
|
68
|
+
private feedbackBaseURL = '';
|
|
69
|
+
|
|
70
|
+
// State
|
|
71
|
+
survey: XeboSurvey | null = null;
|
|
72
|
+
currentAnswers: XeboAnswer[] = [];
|
|
73
|
+
currentQuestionIndex = 0;
|
|
74
|
+
surveyStartTime = '';
|
|
75
|
+
resolvedCollectorUUID = '';
|
|
76
|
+
resolvedCollectorMongoId = '';
|
|
77
|
+
surveyVisible = false;
|
|
78
|
+
|
|
79
|
+
// Network monitoring unsubscribe handle
|
|
80
|
+
private netInfoUnsubscribe: (() => void) | null = null;
|
|
81
|
+
|
|
82
|
+
// ─── Configure ─────────────────────────────────────────────────────────────
|
|
83
|
+
|
|
84
|
+
configure(config: XeboConfig): void {
|
|
85
|
+
this.apiKey = config.apiKey;
|
|
86
|
+
this.collectorId = config.collectorId;
|
|
87
|
+
|
|
88
|
+
const { resolvedZone, environment } = resolveZoneAndEnv(config.zone, config.environment);
|
|
89
|
+
this.resolvedZone = resolvedZone;
|
|
90
|
+
this.environment = environment;
|
|
91
|
+
this.baseURL = buildBaseURL(resolvedZone, environment);
|
|
92
|
+
this.feedbackBaseURL = buildFeedbackBaseURL(resolvedZone, environment);
|
|
93
|
+
|
|
94
|
+
this._startNetworkMonitoring();
|
|
95
|
+
// Pre-fetch survey in background so it's ready instantly when button is tapped
|
|
96
|
+
this._prefetchSurvey();
|
|
97
|
+
}
|
|
98
|
+
|
|
99
|
+
configureTheme(config: XeboThemeConfig): void {
|
|
100
|
+
configureTheme(config);
|
|
101
|
+
}
|
|
102
|
+
|
|
103
|
+
// ─── Network monitoring ────────────────────────────────────────────────────
|
|
104
|
+
|
|
105
|
+
private _startNetworkMonitoring(): void {
|
|
106
|
+
if (this.netInfoUnsubscribe) {
|
|
107
|
+
this.netInfoUnsubscribe();
|
|
108
|
+
}
|
|
109
|
+
|
|
110
|
+
let wasOffline = false;
|
|
111
|
+
|
|
112
|
+
this.netInfoUnsubscribe = NetInfo.addEventListener(state => {
|
|
113
|
+
const isConnected = state.isConnected ?? false;
|
|
114
|
+
if (wasOffline && isConnected) {
|
|
115
|
+
console.log('[Xebo] Network restored — flushing offline queue');
|
|
116
|
+
this.flushQueue();
|
|
117
|
+
}
|
|
118
|
+
wasOffline = !isConnected;
|
|
119
|
+
});
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
// ─── Background pre-fetch ──────────────────────────────────────────────────
|
|
123
|
+
|
|
124
|
+
private async _prefetchSurvey(): Promise<void> {
|
|
125
|
+
try {
|
|
126
|
+
console.log('[Xebo] Pre-fetching survey in background...');
|
|
127
|
+
const collector = await fetchCollector(this.baseURL, this.collectorId, this.apiKey);
|
|
128
|
+
this.resolvedCollectorUUID = collector.uuid;
|
|
129
|
+
this.resolvedCollectorMongoId = collector.mongoId;
|
|
130
|
+
const survey = await fetchSurvey(this.baseURL, collector.surveyId, this.apiKey);
|
|
131
|
+
this.survey = survey;
|
|
132
|
+
this.emit(XEBO_EVENTS.SURVEY_LOADED, survey);
|
|
133
|
+
console.log('[Xebo] Pre-fetch done —', survey.questions.length, 'questions ready');
|
|
134
|
+
} catch (err) {
|
|
135
|
+
console.warn('[Xebo] Pre-fetch failed (will retry on fetchAndPresentSurvey):', err);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
|
|
139
|
+
// ─── Main flow ─────────────────────────────────────────────────────────────
|
|
140
|
+
|
|
141
|
+
async fetchAndPresentSurvey(): Promise<void> {
|
|
142
|
+
try {
|
|
143
|
+
// If already pre-fetched, show instantly
|
|
144
|
+
if (this.survey && this.resolvedCollectorUUID) {
|
|
145
|
+
console.log('[Xebo] Survey already cached — showing instantly');
|
|
146
|
+
this.currentAnswers = [];
|
|
147
|
+
this.currentQuestionIndex = 0;
|
|
148
|
+
this.surveyStartTime = new Date().toISOString();
|
|
149
|
+
this.emit(XEBO_EVENTS.SURVEY_LOADED, this.survey);
|
|
150
|
+
this._setVisible(true);
|
|
151
|
+
return;
|
|
152
|
+
}
|
|
153
|
+
|
|
154
|
+
// Not cached yet — fetch now
|
|
155
|
+
console.log('[Xebo] Step 1: fetchCollector');
|
|
156
|
+
const collector = await fetchCollector(this.baseURL, this.collectorId, this.apiKey);
|
|
157
|
+
this.resolvedCollectorUUID = collector.uuid;
|
|
158
|
+
this.resolvedCollectorMongoId = collector.mongoId;
|
|
159
|
+
|
|
160
|
+
console.log('[Xebo] Step 2: fetchSurvey');
|
|
161
|
+
const survey = await fetchSurvey(this.baseURL, collector.surveyId, this.apiKey);
|
|
162
|
+
this.survey = survey;
|
|
163
|
+
this.currentAnswers = [];
|
|
164
|
+
this.currentQuestionIndex = 0;
|
|
165
|
+
this.surveyStartTime = new Date().toISOString();
|
|
166
|
+
|
|
167
|
+
this.emit(XEBO_EVENTS.SURVEY_LOADED, survey);
|
|
168
|
+
this._setVisible(true);
|
|
169
|
+
} catch (err) {
|
|
170
|
+
console.error('[Xebo] fetchAndPresentSurvey failed:', err);
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
174
|
+
// ─── Answer recording ──────────────────────────────────────────────────────
|
|
175
|
+
|
|
176
|
+
recordAnswer(answer: XeboAnswer): void {
|
|
177
|
+
const idx = this.currentAnswers.findIndex(a => a.questionId === answer.questionId);
|
|
178
|
+
if (idx >= 0) {
|
|
179
|
+
this.currentAnswers[idx] = answer;
|
|
180
|
+
} else {
|
|
181
|
+
this.currentAnswers.push(answer);
|
|
182
|
+
}
|
|
183
|
+
}
|
|
184
|
+
|
|
185
|
+
// ─── Navigation ────────────────────────────────────────────────────────────
|
|
186
|
+
|
|
187
|
+
nextQuestion(): void {
|
|
188
|
+
if (!this.survey) return;
|
|
189
|
+
const next = this.currentQuestionIndex + 1;
|
|
190
|
+
if (next < this.survey.questions.length) {
|
|
191
|
+
this.currentQuestionIndex = next;
|
|
192
|
+
this.emit(XEBO_EVENTS.QUESTION_CHANGED, this.currentQuestionIndex);
|
|
193
|
+
|
|
194
|
+
// If this is the thank-you question, submit in the background
|
|
195
|
+
const q = this.survey.questions[this.currentQuestionIndex];
|
|
196
|
+
if (q?.id === 'thank_you_auto') {
|
|
197
|
+
this._submitCurrentSession();
|
|
198
|
+
}
|
|
199
|
+
}
|
|
200
|
+
}
|
|
201
|
+
|
|
202
|
+
// ─── Submission ────────────────────────────────────────────────────────────
|
|
203
|
+
|
|
204
|
+
private async _submitCurrentSession(): Promise<void> {
|
|
205
|
+
if (!this.survey) return;
|
|
206
|
+
|
|
207
|
+
const endTime = new Date().toISOString();
|
|
208
|
+
|
|
209
|
+
try {
|
|
210
|
+
const result = await submitResponse(
|
|
211
|
+
this.feedbackBaseURL,
|
|
212
|
+
this.survey.id,
|
|
213
|
+
this.resolvedCollectorUUID,
|
|
214
|
+
this.apiKey,
|
|
215
|
+
this.currentAnswers
|
|
216
|
+
);
|
|
217
|
+
|
|
218
|
+
if (!result.ok) {
|
|
219
|
+
throw new Error(`HTTP ${result.status}`);
|
|
220
|
+
}
|
|
221
|
+
|
|
222
|
+
const respData = result.responseJson as { data?: { responseId?: string; created?: string } };
|
|
223
|
+
console.log('[Xebo] Response data:', {
|
|
224
|
+
responseId: respData?.data?.responseId,
|
|
225
|
+
created: respData?.data?.created,
|
|
226
|
+
});
|
|
227
|
+
} catch (err) {
|
|
228
|
+
console.error('[Xebo] Submission failed — queuing offline:', err);
|
|
229
|
+
await this._enqueueOffline(endTime);
|
|
230
|
+
}
|
|
231
|
+
}
|
|
232
|
+
|
|
233
|
+
private async _enqueueOffline(endTime: string): Promise<void> {
|
|
234
|
+
if (!this.survey) return;
|
|
235
|
+
|
|
236
|
+
const queued: XeboQueuedResponse = {
|
|
237
|
+
responseId: uuidv4(),
|
|
238
|
+
surveyId: this.survey.id,
|
|
239
|
+
collectorUUID: this.resolvedCollectorUUID,
|
|
240
|
+
mode: 'React Native SDK',
|
|
241
|
+
status: 'complete',
|
|
242
|
+
startTime: this.surveyStartTime,
|
|
243
|
+
endTime,
|
|
244
|
+
answers: [...this.currentAnswers],
|
|
245
|
+
};
|
|
246
|
+
|
|
247
|
+
await XeboOfflineQueue.enqueue(queued);
|
|
248
|
+
}
|
|
249
|
+
|
|
250
|
+
// ─── Flush queue ───────────────────────────────────────────────────────────
|
|
251
|
+
|
|
252
|
+
async flushQueue(): Promise<void> {
|
|
253
|
+
const all = await XeboOfflineQueue.getAll();
|
|
254
|
+
if (all.length === 0) return;
|
|
255
|
+
|
|
256
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
257
|
+
console.log(`[Xebo Offline] Flushing ${all.length} queued response(s)`);
|
|
258
|
+
|
|
259
|
+
const failed: XeboQueuedResponse[] = [];
|
|
260
|
+
|
|
261
|
+
for (const queued of all) {
|
|
262
|
+
try {
|
|
263
|
+
const result = await submitResponse(
|
|
264
|
+
this.feedbackBaseURL,
|
|
265
|
+
queued.surveyId,
|
|
266
|
+
queued.collectorUUID,
|
|
267
|
+
this.apiKey,
|
|
268
|
+
queued.answers
|
|
269
|
+
);
|
|
270
|
+
if (!result.ok) throw new Error(`HTTP ${result.status}`);
|
|
271
|
+
console.log('[Xebo Offline] Flushed response:', queued.responseId);
|
|
272
|
+
} catch (err) {
|
|
273
|
+
console.error('[Xebo Offline] Retry failed for:', queued.responseId, err);
|
|
274
|
+
failed.push(queued);
|
|
275
|
+
}
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
await XeboOfflineQueue.saveAll(failed);
|
|
279
|
+
console.log(`[Xebo Offline] Flush complete — ${failed.length} still pending`);
|
|
280
|
+
console.log('━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━');
|
|
281
|
+
}
|
|
282
|
+
|
|
283
|
+
// ─── Modal visibility ──────────────────────────────────────────────────────
|
|
284
|
+
|
|
285
|
+
private _setVisible(visible: boolean): void {
|
|
286
|
+
this.surveyVisible = visible;
|
|
287
|
+
this.emit(XEBO_EVENTS.SURVEY_VISIBLE, visible);
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
dismissSurvey(): void {
|
|
291
|
+
this._setVisible(false);
|
|
292
|
+
this.emit(XEBO_EVENTS.SURVEY_DISMISSED);
|
|
293
|
+
}
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
export const XeboSurveyManager = new XeboSurveyManagerClass();
|