reflet-sdk 0.6.4 → 0.6.6
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/CHANGELOG.md +23 -0
- package/README.md +100 -2
- package/dist/{client-DAdWdIFY.js → client-oFFfRxAM.js} +135 -59
- package/dist/client-oFFfRxAM.js.map +1 -0
- package/dist/client-transport-B7BdVU7J.d.ts +150 -0
- package/dist/client-transport-B7BdVU7J.d.ts.map +1 -0
- package/dist/{devtools-layer-DgmM3RhW.js → devtools-layer-B91EVgqg.js} +417 -916
- package/dist/devtools-layer-B91EVgqg.js.map +1 -0
- package/dist/devtools-next.js +1 -1
- package/dist/devtools-vite.js +2 -2
- package/dist/feedback.d.ts +7 -1
- package/dist/feedback.d.ts.map +1 -1
- package/dist/feedback.js +105 -57
- package/dist/feedback.js.map +1 -1
- package/dist/{handler-BSNmdHXv.js → handler-yA_knIf3.js} +4 -4
- package/dist/{handler-BSNmdHXv.js.map → handler-yA_knIf3.js.map} +1 -1
- package/dist/index-Buum9tIY.d.ts +230 -0
- package/dist/index-Buum9tIY.d.ts.map +1 -0
- package/dist/index.d.ts +3 -3
- package/dist/index.js +1 -1
- package/dist/options-CIBazDPp.d.ts.map +1 -1
- package/dist/protocol-vJhQHTcB.js +390 -0
- package/dist/protocol-vJhQHTcB.js.map +1 -0
- package/dist/{react-context-Caq1fodf.js → react-context-Kazo_JCz.js} +3 -14
- package/dist/react-context-Kazo_JCz.js.map +1 -0
- package/dist/react.d.ts +43 -3
- package/dist/react.d.ts.map +1 -1
- package/dist/react.js +389 -3
- package/dist/react.js.map +1 -1
- package/dist/selection-outline-NzAAAZXR.js +51 -0
- package/dist/selection-outline-NzAAAZXR.js.map +1 -0
- package/dist/server.js +1 -1
- package/dist/server.js.map +1 -1
- package/dist/sessions-panel-DVyWzZ3C.js +6011 -0
- package/dist/sessions-panel-DVyWzZ3C.js.map +1 -0
- package/dist/{submit-Cd-g1Dtg.js → submit-DYGZQVdG.js} +49 -74
- package/dist/submit-DYGZQVdG.js.map +1 -0
- package/dist/surveys.d.ts +3 -0
- package/dist/surveys.js +2 -0
- package/dist/team-activation-DCqh71bl.js +24 -0
- package/dist/team-activation-DCqh71bl.js.map +1 -0
- package/dist/team-layer-F-9BE4hc.js +423 -0
- package/dist/team-layer-F-9BE4hc.js.map +1 -0
- package/dist/{types-DnaDEN9Y.d.ts → types-B3XiHPqy.d.ts} +4 -9
- package/dist/types-B3XiHPqy.d.ts.map +1 -0
- package/dist/use-survey-session-LK6sN1Fg.js +1247 -0
- package/dist/use-survey-session-LK6sN1Fg.js.map +1 -0
- package/dist/z-index-CwokqVq_.js +15 -0
- package/dist/z-index-CwokqVq_.js.map +1 -0
- package/package.json +6 -2
- package/dist/client-DAdWdIFY.js.map +0 -1
- package/dist/client-DF1fdsSC.d.ts +0 -131
- package/dist/client-DF1fdsSC.d.ts.map +0 -1
- package/dist/devtools-layer-DgmM3RhW.js.map +0 -1
- package/dist/protocol-ClqTFzwR.js +0 -35
- package/dist/protocol-ClqTFzwR.js.map +0 -1
- package/dist/react-context-Caq1fodf.js.map +0 -1
- package/dist/submit-Cd-g1Dtg.js.map +0 -1
- package/dist/types-DnaDEN9Y.d.ts.map +0 -1
|
@@ -0,0 +1,1247 @@
|
|
|
1
|
+
import { t as RefletContext } from "./react-context-Kazo_JCz.js";
|
|
2
|
+
import { useContext, useEffect, useId, useMemo, useRef, useState, useSyncExternalStore } from "react";
|
|
3
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
4
|
+
//#region ../survey-core/src/defaults.ts
|
|
5
|
+
const DEFAULT_ENDING = {
|
|
6
|
+
description: "Your answers have been recorded.",
|
|
7
|
+
id: "default",
|
|
8
|
+
title: "Thank you!"
|
|
9
|
+
};
|
|
10
|
+
const DEFAULT_DISPLAY = {
|
|
11
|
+
frequency: "once",
|
|
12
|
+
position: "bottom_right"
|
|
13
|
+
};
|
|
14
|
+
const DEFAULT_TIME_DELAY_MS = 5e3;
|
|
15
|
+
const DEFAULT_RATING_MIN = 1;
|
|
16
|
+
const DEFAULT_RATING_MAX = 5;
|
|
17
|
+
/** Every survey has at least one ending: stored endings, or the default thank-you. */
|
|
18
|
+
const endingsOf = (endings) => endings && endings.length > 0 ? [...endings] : [DEFAULT_ENDING];
|
|
19
|
+
const displayOf = (display) => display ?? DEFAULT_DISPLAY;
|
|
20
|
+
const ratingRange = (config) => ({
|
|
21
|
+
max: config?.maxValue ?? DEFAULT_RATING_MAX,
|
|
22
|
+
min: config?.minValue ?? DEFAULT_RATING_MIN
|
|
23
|
+
});
|
|
24
|
+
const textMaxChars = (config) => config?.maxLength ?? 1e3;
|
|
25
|
+
/** Statements show a message and a button; they never collect an answer. */
|
|
26
|
+
const takesAnswer = (type) => type !== "statement";
|
|
27
|
+
//#endregion
|
|
28
|
+
//#region ../survey-core/src/answers.ts
|
|
29
|
+
const isIntegerBetween = (value, min, max) => typeof value === "number" && Number.isInteger(value) && value >= min && value <= max;
|
|
30
|
+
/** A choice outside the list is accepted only as the respondent's "Other" text. */
|
|
31
|
+
const choiceIssue = (choice, config) => {
|
|
32
|
+
if (typeof choice !== "string" || choice.trim() === "") return "Pick one of the options.";
|
|
33
|
+
if (config?.choices?.includes(choice)) return null;
|
|
34
|
+
if (!config?.allowOther) return "Pick one of the options.";
|
|
35
|
+
return choice.length > 200 ? `Keep “Other” under 200 characters.` : null;
|
|
36
|
+
};
|
|
37
|
+
const multipleChoiceIssue = (value, config) => {
|
|
38
|
+
if (!Array.isArray(value)) return "Pick at least one option.";
|
|
39
|
+
if (new Set(value).size !== value.length) return "Each option can only be picked once.";
|
|
40
|
+
const listed = config?.choices ?? [];
|
|
41
|
+
const others = value.filter((choice) => !listed.includes(choice));
|
|
42
|
+
if (others.length > 1) return "Only one “Other” answer is allowed.";
|
|
43
|
+
return others.length === 1 ? choiceIssue(others[0], config) : null;
|
|
44
|
+
};
|
|
45
|
+
/** Why `value` can't be stored for `question`, or null when it's a valid answer. */
|
|
46
|
+
const answerIssue = (question, value) => {
|
|
47
|
+
switch (question.type) {
|
|
48
|
+
case "rating": {
|
|
49
|
+
const { max, min } = ratingRange(question.config);
|
|
50
|
+
return isIntegerBetween(value, min, max) ? null : `Pick a rating from ${min} to ${max}.`;
|
|
51
|
+
}
|
|
52
|
+
case "nps": return isIntegerBetween(value, 0, 10) ? null : `Pick a score from 0 to 10.`;
|
|
53
|
+
case "boolean": return typeof value === "boolean" ? null : "Answer yes or no.";
|
|
54
|
+
case "text": {
|
|
55
|
+
if (typeof value !== "string") return "Type an answer.";
|
|
56
|
+
const max = textMaxChars(question.config);
|
|
57
|
+
return value.length > max ? `Keep your answer under ${max} characters.` : null;
|
|
58
|
+
}
|
|
59
|
+
case "single_choice": return choiceIssue(value, question.config);
|
|
60
|
+
case "multiple_choice": return multipleChoiceIssue(value, question.config);
|
|
61
|
+
case "statement": return "This step doesn’t take an answer.";
|
|
62
|
+
default: return "Unknown question type.";
|
|
63
|
+
}
|
|
64
|
+
};
|
|
65
|
+
//#endregion
|
|
66
|
+
//#region ../survey-core/src/flow.ts
|
|
67
|
+
const isEmptyAnswer = (value) => {
|
|
68
|
+
if (value === void 0) return true;
|
|
69
|
+
if (typeof value === "string") return value.trim() === "";
|
|
70
|
+
if (Array.isArray(value)) return value.length === 0;
|
|
71
|
+
return false;
|
|
72
|
+
};
|
|
73
|
+
const includesValue = (answer, expected) => {
|
|
74
|
+
if (typeof expected !== "string") return false;
|
|
75
|
+
if (Array.isArray(answer)) return answer.includes(expected);
|
|
76
|
+
return typeof answer === "string" && answer.toLowerCase().includes(expected.toLowerCase());
|
|
77
|
+
};
|
|
78
|
+
const compareNumbers = (answer, expected, compare) => typeof answer === "number" && typeof expected === "number" && compare(answer, expected);
|
|
79
|
+
const evaluateRule = (rule, answer) => {
|
|
80
|
+
const empty = isEmptyAnswer(answer);
|
|
81
|
+
if (rule.operator === "answered") return !empty;
|
|
82
|
+
if (rule.operator === "skipped") return empty;
|
|
83
|
+
if (answer === void 0 || empty) return false;
|
|
84
|
+
switch (rule.operator) {
|
|
85
|
+
case "equals": return answer === rule.value;
|
|
86
|
+
case "not_equals": return answer !== rule.value;
|
|
87
|
+
case "greater_than": return compareNumbers(answer, rule.value, (a, b) => a > b);
|
|
88
|
+
case "less_than": return compareNumbers(answer, rule.value, (a, b) => a < b);
|
|
89
|
+
case "includes": return includesValue(answer, rule.value);
|
|
90
|
+
default: return false;
|
|
91
|
+
}
|
|
92
|
+
};
|
|
93
|
+
const sortByOrder = (questions) => [...questions].sort((a, b) => a.order - b.order);
|
|
94
|
+
/** `endingsOf` never returns an empty list, so the first ending always exists. */
|
|
95
|
+
const firstEnding = (endings) => endings[0];
|
|
96
|
+
/**
|
|
97
|
+
* Jumps only go forward in question order, so every flow ends. A target that
|
|
98
|
+
* points backward or at a deleted question is ignored and the default path wins.
|
|
99
|
+
*/
|
|
100
|
+
const stepForTarget = (sorted, from, target, endings) => {
|
|
101
|
+
if (target.kind === "ending") return {
|
|
102
|
+
ending: endings.find((candidate) => candidate.id === target.endingId) ?? firstEnding(endings),
|
|
103
|
+
kind: "ending"
|
|
104
|
+
};
|
|
105
|
+
const question = sorted.find((q) => q._id === target.questionId);
|
|
106
|
+
return question && question.order > from.order ? {
|
|
107
|
+
kind: "question",
|
|
108
|
+
question
|
|
109
|
+
} : null;
|
|
110
|
+
};
|
|
111
|
+
const defaultStepAfter = (sorted, from, endings) => {
|
|
112
|
+
const following = sorted.find((q) => q.order > from.order);
|
|
113
|
+
return following ? {
|
|
114
|
+
kind: "question",
|
|
115
|
+
question: following
|
|
116
|
+
} : {
|
|
117
|
+
ending: firstEnding(endings),
|
|
118
|
+
kind: "ending"
|
|
119
|
+
};
|
|
120
|
+
};
|
|
121
|
+
const firstStep = (questions, storedEndings) => {
|
|
122
|
+
const [first] = sortByOrder(questions);
|
|
123
|
+
return first ? {
|
|
124
|
+
kind: "question",
|
|
125
|
+
question: first
|
|
126
|
+
} : {
|
|
127
|
+
ending: firstEnding(endingsOf(storedEndings)),
|
|
128
|
+
kind: "ending"
|
|
129
|
+
};
|
|
130
|
+
};
|
|
131
|
+
const resolveNextStep = (questions, current, answer, storedEndings) => {
|
|
132
|
+
const sorted = sortByOrder(questions);
|
|
133
|
+
const endings = endingsOf(storedEndings);
|
|
134
|
+
const target = (takesAnswer(current.type) ? current.logic?.find((rule) => evaluateRule(rule, answer)) : void 0)?.target ?? current.next;
|
|
135
|
+
return (target ? stepForTarget(sorted, current, target, endings) : null) ?? defaultStepAfter(sorted, current, endings);
|
|
136
|
+
};
|
|
137
|
+
/** Follows the flow from `start` using known answers; unanswered questions take their skipped path. */
|
|
138
|
+
const walkFrom = (questions, start, answers, storedEndings) => {
|
|
139
|
+
const visited = [];
|
|
140
|
+
let step = start;
|
|
141
|
+
while (step.kind === "question" && visited.length <= questions.length) {
|
|
142
|
+
visited.push(step.question);
|
|
143
|
+
step = resolveNextStep(questions, step.question, answers.get(step.question._id), storedEndings);
|
|
144
|
+
}
|
|
145
|
+
return {
|
|
146
|
+
ending: step.kind === "ending" ? step.ending : firstEnding(endingsOf(storedEndings)),
|
|
147
|
+
questions: visited
|
|
148
|
+
};
|
|
149
|
+
};
|
|
150
|
+
//#endregion
|
|
151
|
+
//#region ../survey-core/src/session.ts
|
|
152
|
+
const REQUIRED_ANSWER_MESSAGE = "This question needs an answer.";
|
|
153
|
+
const SAVE_FAILED_MESSAGE = "Couldn’t save your answer. Try again.";
|
|
154
|
+
const START_FAILED_MESSAGE = "Couldn’t load this survey. Try again.";
|
|
155
|
+
const previewTransport = {
|
|
156
|
+
answer: () => Promise.resolve(),
|
|
157
|
+
complete: () => Promise.resolve(),
|
|
158
|
+
dismiss: () => Promise.resolve(),
|
|
159
|
+
start: () => Promise.resolve({ responseId: "preview" })
|
|
160
|
+
};
|
|
161
|
+
var SurveySession = class {
|
|
162
|
+
survey;
|
|
163
|
+
transport;
|
|
164
|
+
callbacks;
|
|
165
|
+
endings;
|
|
166
|
+
listeners = /* @__PURE__ */ new Set();
|
|
167
|
+
submitted = /* @__PURE__ */ new Set();
|
|
168
|
+
history = [];
|
|
169
|
+
answers = /* @__PURE__ */ new Map();
|
|
170
|
+
current = null;
|
|
171
|
+
ending = null;
|
|
172
|
+
error = null;
|
|
173
|
+
isSubmitting = false;
|
|
174
|
+
phase = "starting";
|
|
175
|
+
responseId = null;
|
|
176
|
+
snapshot;
|
|
177
|
+
constructor(options) {
|
|
178
|
+
this.survey = options.survey;
|
|
179
|
+
this.transport = options.transport;
|
|
180
|
+
this.callbacks = options.callbacks ?? {};
|
|
181
|
+
this.endings = endingsOf(options.survey.endings);
|
|
182
|
+
this.snapshot = this.buildSnapshot();
|
|
183
|
+
}
|
|
184
|
+
subscribe = (listener) => {
|
|
185
|
+
this.listeners.add(listener);
|
|
186
|
+
return () => this.listeners.delete(listener);
|
|
187
|
+
};
|
|
188
|
+
getSnapshot = () => this.snapshot;
|
|
189
|
+
/** A method, not a field read, so TS doesn't narrow `phase` across awaits. */
|
|
190
|
+
isDismissed() {
|
|
191
|
+
return this.phase === "dismissed";
|
|
192
|
+
}
|
|
193
|
+
async start() {
|
|
194
|
+
if (this.phase !== "starting" && this.phase !== "start_failed") return;
|
|
195
|
+
this.update({
|
|
196
|
+
error: null,
|
|
197
|
+
phase: "starting"
|
|
198
|
+
});
|
|
199
|
+
try {
|
|
200
|
+
const { responseId } = await this.transport.start({ surveyId: this.survey._id });
|
|
201
|
+
this.responseId = responseId;
|
|
202
|
+
if (this.isDismissed()) {
|
|
203
|
+
this.transport.dismiss({ responseId }).catch(() => void 0);
|
|
204
|
+
return;
|
|
205
|
+
}
|
|
206
|
+
this.callbacks.onStart?.({
|
|
207
|
+
responseId,
|
|
208
|
+
surveyId: this.survey._id
|
|
209
|
+
});
|
|
210
|
+
const step = firstStep(this.survey.questions, this.endings);
|
|
211
|
+
if (step.kind === "question") {
|
|
212
|
+
this.current = step.question;
|
|
213
|
+
this.update({ phase: "question" });
|
|
214
|
+
} else await this.finish(step.ending);
|
|
215
|
+
} catch {
|
|
216
|
+
if (!this.isDismissed()) this.update({
|
|
217
|
+
error: START_FAILED_MESSAGE,
|
|
218
|
+
phase: "start_failed"
|
|
219
|
+
});
|
|
220
|
+
}
|
|
221
|
+
}
|
|
222
|
+
setAnswer(value) {
|
|
223
|
+
if (!this.current || this.phase !== "question") return;
|
|
224
|
+
const answers = new Map(this.answers);
|
|
225
|
+
if (value === void 0) answers.delete(this.current._id);
|
|
226
|
+
else answers.set(this.current._id, value);
|
|
227
|
+
this.answers = answers;
|
|
228
|
+
this.update({ error: null });
|
|
229
|
+
}
|
|
230
|
+
async next() {
|
|
231
|
+
const question = this.current;
|
|
232
|
+
const responseId = this.responseId;
|
|
233
|
+
if (!(question && responseId) || this.isSubmitting) return;
|
|
234
|
+
const value = this.answers.get(question._id);
|
|
235
|
+
const validationError = this.validate(question, value);
|
|
236
|
+
if (validationError) {
|
|
237
|
+
this.update({ error: validationError });
|
|
238
|
+
return;
|
|
239
|
+
}
|
|
240
|
+
this.update({
|
|
241
|
+
error: null,
|
|
242
|
+
isSubmitting: true
|
|
243
|
+
});
|
|
244
|
+
try {
|
|
245
|
+
await this.saveAnswer(question, responseId, value);
|
|
246
|
+
if (this.isDismissed()) return;
|
|
247
|
+
const step = resolveNextStep(this.survey.questions, question, value, this.endings);
|
|
248
|
+
if (step.kind === "question") {
|
|
249
|
+
this.history = [...this.history, question];
|
|
250
|
+
this.current = step.question;
|
|
251
|
+
this.update({ isSubmitting: false });
|
|
252
|
+
} else await this.finish(step.ending);
|
|
253
|
+
} catch {
|
|
254
|
+
if (!this.isDismissed()) this.update({
|
|
255
|
+
error: SAVE_FAILED_MESSAGE,
|
|
256
|
+
isSubmitting: false
|
|
257
|
+
});
|
|
258
|
+
}
|
|
259
|
+
}
|
|
260
|
+
back() {
|
|
261
|
+
const previous = this.history.at(-1);
|
|
262
|
+
if (!previous || this.isSubmitting || this.phase !== "question") return;
|
|
263
|
+
this.history = this.history.slice(0, -1);
|
|
264
|
+
this.current = previous;
|
|
265
|
+
this.update({ error: null });
|
|
266
|
+
}
|
|
267
|
+
dismiss() {
|
|
268
|
+
if (this.phase === "dismissed") return;
|
|
269
|
+
const closesAfterEnding = this.phase === "ending";
|
|
270
|
+
if (this.phase === "question" && this.responseId) this.transport.dismiss({ responseId: this.responseId }).catch(() => void 0);
|
|
271
|
+
if (!closesAfterEnding) this.callbacks.onDismiss?.({
|
|
272
|
+
answeredCount: this.submitted.size,
|
|
273
|
+
responseId: this.responseId,
|
|
274
|
+
surveyId: this.survey._id
|
|
275
|
+
});
|
|
276
|
+
this.update({ phase: "dismissed" });
|
|
277
|
+
}
|
|
278
|
+
validate(question, value) {
|
|
279
|
+
if (!takesAnswer(question.type)) return null;
|
|
280
|
+
if (value === void 0 || isEmptyAnswer(value)) return question.required ? REQUIRED_ANSWER_MESSAGE : null;
|
|
281
|
+
return answerIssue(question, value);
|
|
282
|
+
}
|
|
283
|
+
async saveAnswer(question, responseId, value) {
|
|
284
|
+
if (!takesAnswer(question.type)) return;
|
|
285
|
+
if (value !== void 0 && !isEmptyAnswer(value)) {
|
|
286
|
+
await this.transport.answer({
|
|
287
|
+
questionId: question._id,
|
|
288
|
+
responseId,
|
|
289
|
+
value
|
|
290
|
+
});
|
|
291
|
+
this.submitted.add(question._id);
|
|
292
|
+
this.callbacks.onAnswer?.({
|
|
293
|
+
questionId: question._id,
|
|
294
|
+
surveyId: this.survey._id,
|
|
295
|
+
value
|
|
296
|
+
});
|
|
297
|
+
return;
|
|
298
|
+
}
|
|
299
|
+
if (this.submitted.has(question._id)) {
|
|
300
|
+
await this.transport.answer({
|
|
301
|
+
questionId: question._id,
|
|
302
|
+
responseId,
|
|
303
|
+
value: null
|
|
304
|
+
});
|
|
305
|
+
this.submitted.delete(question._id);
|
|
306
|
+
}
|
|
307
|
+
}
|
|
308
|
+
async finish(ending) {
|
|
309
|
+
const responseId = this.responseId;
|
|
310
|
+
if (!responseId) return;
|
|
311
|
+
await this.transport.complete({ responseId });
|
|
312
|
+
if (this.isDismissed()) return;
|
|
313
|
+
this.ending = ending;
|
|
314
|
+
this.current = null;
|
|
315
|
+
this.update({
|
|
316
|
+
isSubmitting: false,
|
|
317
|
+
phase: "ending"
|
|
318
|
+
});
|
|
319
|
+
this.callbacks.onComplete?.({
|
|
320
|
+
endingId: ending.id,
|
|
321
|
+
responseId,
|
|
322
|
+
surveyId: this.survey._id
|
|
323
|
+
});
|
|
324
|
+
}
|
|
325
|
+
update(patch) {
|
|
326
|
+
if (patch.error !== void 0) this.error = patch.error;
|
|
327
|
+
if (patch.isSubmitting !== void 0) this.isSubmitting = patch.isSubmitting;
|
|
328
|
+
if (patch.phase !== void 0) this.phase = patch.phase;
|
|
329
|
+
this.snapshot = this.buildSnapshot();
|
|
330
|
+
for (const listener of this.listeners) listener();
|
|
331
|
+
}
|
|
332
|
+
buildSnapshot() {
|
|
333
|
+
const question = this.phase === "question" ? this.current : null;
|
|
334
|
+
const answered = this.history.length;
|
|
335
|
+
const remaining = question ? walkFrom(this.survey.questions, {
|
|
336
|
+
kind: "question",
|
|
337
|
+
question
|
|
338
|
+
}, this.answers, this.endings).questions.length : 0;
|
|
339
|
+
const isLastStep = question ? resolveNextStep(this.survey.questions, question, this.answers.get(question._id), this.endings).kind === "ending" : false;
|
|
340
|
+
return {
|
|
341
|
+
answers: this.answers,
|
|
342
|
+
canGoBack: question !== null && answered > 0,
|
|
343
|
+
ending: this.phase === "ending" ? this.ending : null,
|
|
344
|
+
error: this.error,
|
|
345
|
+
isLastStep,
|
|
346
|
+
isSubmitting: this.isSubmitting,
|
|
347
|
+
phase: this.phase,
|
|
348
|
+
progress: this.phase === "ending" ? 1 : answered / (answered + remaining || 1),
|
|
349
|
+
question
|
|
350
|
+
};
|
|
351
|
+
}
|
|
352
|
+
};
|
|
353
|
+
//#endregion
|
|
354
|
+
//#region ../survey-core/src/links.ts
|
|
355
|
+
const RELATIVE_LINK_BASE = "https://relative.invalid";
|
|
356
|
+
/** Only http(s) or relative links reach a button href: survey content is rendered on customer sites. */
|
|
357
|
+
const safeLinkUrl = (url) => {
|
|
358
|
+
if (!url) return null;
|
|
359
|
+
try {
|
|
360
|
+
const { protocol } = new URL(url, RELATIVE_LINK_BASE);
|
|
361
|
+
return protocol === "http:" || protocol === "https:" ? url : null;
|
|
362
|
+
} catch {
|
|
363
|
+
return null;
|
|
364
|
+
}
|
|
365
|
+
};
|
|
366
|
+
//#endregion
|
|
367
|
+
//#region src/surveys/card/card-keyboard.ts
|
|
368
|
+
const DIGIT = /^\d$/;
|
|
369
|
+
const pickByKey = (key, question) => {
|
|
370
|
+
if (question.type === "boolean") {
|
|
371
|
+
const lower = key.toLowerCase();
|
|
372
|
+
if (lower === "y") return {
|
|
373
|
+
kind: "pick",
|
|
374
|
+
value: true
|
|
375
|
+
};
|
|
376
|
+
return lower === "n" ? {
|
|
377
|
+
kind: "pick",
|
|
378
|
+
value: false
|
|
379
|
+
} : null;
|
|
380
|
+
}
|
|
381
|
+
if (!DIGIT.test(key)) return null;
|
|
382
|
+
const digit = Number(key);
|
|
383
|
+
if (question.type === "nps") return {
|
|
384
|
+
kind: "pick",
|
|
385
|
+
value: digit
|
|
386
|
+
};
|
|
387
|
+
if (question.type === "rating") {
|
|
388
|
+
const { max, min } = ratingRange(question.config);
|
|
389
|
+
return digit >= min && digit <= max ? {
|
|
390
|
+
kind: "pick",
|
|
391
|
+
value: digit
|
|
392
|
+
} : null;
|
|
393
|
+
}
|
|
394
|
+
if (question.type === "single_choice") {
|
|
395
|
+
const choice = question.config?.choices?.[digit - 1];
|
|
396
|
+
return choice === void 0 ? null : {
|
|
397
|
+
kind: "pick",
|
|
398
|
+
value: choice
|
|
399
|
+
};
|
|
400
|
+
}
|
|
401
|
+
return null;
|
|
402
|
+
};
|
|
403
|
+
/**
|
|
404
|
+
* Enter moves on, Escape closes, and digits (or Y/N) answer single-click
|
|
405
|
+
* questions — except while typing, where keys belong to the field.
|
|
406
|
+
*/
|
|
407
|
+
const cardKeyAction = (event, question) => {
|
|
408
|
+
const { target } = event;
|
|
409
|
+
if (event.key === "Escape") return { kind: "close" };
|
|
410
|
+
const isTextArea = target instanceof HTMLTextAreaElement;
|
|
411
|
+
const isTextInput = target instanceof HTMLInputElement;
|
|
412
|
+
if (event.key === "Enter") {
|
|
413
|
+
if (isTextArea) return event.metaKey || event.ctrlKey ? { kind: "next" } : null;
|
|
414
|
+
return target instanceof HTMLButtonElement || target instanceof HTMLAnchorElement || !question ? null : { kind: "next" };
|
|
415
|
+
}
|
|
416
|
+
if (isTextArea || isTextInput || !question) return null;
|
|
417
|
+
return pickByKey(event.key, question);
|
|
418
|
+
};
|
|
419
|
+
//#endregion
|
|
420
|
+
//#region src/surveys/card/card-styles.ts
|
|
421
|
+
const LIGHT_TOKENS = `
|
|
422
|
+
--rfs-bg: #ffffff;
|
|
423
|
+
--rfs-text: #18181b;
|
|
424
|
+
--rfs-muted: #71717a;
|
|
425
|
+
--rfs-border: rgb(24 24 27 / 10%);
|
|
426
|
+
--rfs-subtle: rgb(24 24 27 / 4%);
|
|
427
|
+
--rfs-hover: rgb(24 24 27 / 7%);
|
|
428
|
+
--rfs-danger: #dc2626;
|
|
429
|
+
--rfs-success: #16a34a;
|
|
430
|
+
--rfs-primary: var(--rf-survey-primary, #18181b);
|
|
431
|
+
--rfs-primary-text: var(--rf-survey-primary-text, #ffffff);
|
|
432
|
+
--rfs-shadow: 0 0 0 1px var(--rfs-border), 0 2px 6px -1px rgb(15 15 15 / 8%), 0 18px 44px -14px rgb(15 15 15 / 24%);
|
|
433
|
+
color-scheme: light;
|
|
434
|
+
`;
|
|
435
|
+
const DARK_TOKENS = `
|
|
436
|
+
--rfs-bg: #1c1c1f;
|
|
437
|
+
--rfs-text: #f4f4f5;
|
|
438
|
+
--rfs-muted: #a1a1aa;
|
|
439
|
+
--rfs-border: rgb(255 255 255 / 10%);
|
|
440
|
+
--rfs-subtle: rgb(255 255 255 / 5%);
|
|
441
|
+
--rfs-hover: rgb(255 255 255 / 9%);
|
|
442
|
+
--rfs-danger: #f87171;
|
|
443
|
+
--rfs-success: #4ade80;
|
|
444
|
+
--rfs-primary: var(--rf-survey-primary, #fafafa);
|
|
445
|
+
--rfs-primary-text: var(--rf-survey-primary-text, #18181b);
|
|
446
|
+
--rfs-shadow: 0 0 0 1px var(--rfs-border), 0 2px 6px -1px rgb(0 0 0 / 30%), 0 18px 44px -14px rgb(0 0 0 / 60%);
|
|
447
|
+
color-scheme: dark;
|
|
448
|
+
`;
|
|
449
|
+
/** Every selector hangs off `.rf-survey`, so the card looks the same in a shadow root and in a page. */
|
|
450
|
+
const SURVEY_CARD_STYLES = `
|
|
451
|
+
.rf-survey {
|
|
452
|
+
${LIGHT_TOKENS}
|
|
453
|
+
--rfs-radius: 14px;
|
|
454
|
+
--rfs-control-radius: 9px;
|
|
455
|
+
--rfs-ease: cubic-bezier(0.16, 1, 0.3, 1);
|
|
456
|
+
position: relative;
|
|
457
|
+
box-sizing: border-box;
|
|
458
|
+
width: 100%;
|
|
459
|
+
padding: 20px;
|
|
460
|
+
border-radius: var(--rfs-radius);
|
|
461
|
+
background: var(--rfs-bg);
|
|
462
|
+
color: var(--rfs-text);
|
|
463
|
+
font-family: ui-sans-serif, system-ui, -apple-system, "Segoe UI", Roboto, sans-serif;
|
|
464
|
+
font-size: 14px;
|
|
465
|
+
line-height: 1.45;
|
|
466
|
+
text-align: left;
|
|
467
|
+
-webkit-font-smoothing: antialiased;
|
|
468
|
+
overflow: hidden;
|
|
469
|
+
}
|
|
470
|
+
.rf-survey[data-theme="dark"] { ${DARK_TOKENS} }
|
|
471
|
+
@media (prefers-color-scheme: dark) {
|
|
472
|
+
.rf-survey[data-theme="auto"] { ${DARK_TOKENS} }
|
|
473
|
+
}
|
|
474
|
+
.rf-survey *, .rf-survey *::before, .rf-survey *::after { box-sizing: border-box; }
|
|
475
|
+
.rf-survey :where(h2, p) { margin: 0; }
|
|
476
|
+
.rf-survey :where(fieldset) { min-width: 0; margin: 0; padding: 0; border: 0; }
|
|
477
|
+
.rf-survey :where(button, input, textarea, a) {
|
|
478
|
+
margin: 0;
|
|
479
|
+
font: inherit;
|
|
480
|
+
color: inherit;
|
|
481
|
+
letter-spacing: inherit;
|
|
482
|
+
}
|
|
483
|
+
.rf-survey :where(button) { cursor: pointer; background: none; border: 0; padding: 0; }
|
|
484
|
+
.rf-survey :where(button:disabled) { cursor: default; }
|
|
485
|
+
.rf-survey :where(button, a, input, textarea):focus-visible {
|
|
486
|
+
outline: 2px solid var(--rfs-primary);
|
|
487
|
+
outline-offset: 2px;
|
|
488
|
+
}
|
|
489
|
+
.rf-survey[data-variant="floating"] { box-shadow: var(--rfs-shadow); animation: rfs-enter 360ms var(--rfs-ease); }
|
|
490
|
+
.rf-survey[data-variant="inline"] { box-shadow: 0 0 0 1px var(--rfs-border); }
|
|
491
|
+
.rf-survey[data-variant="page"] {
|
|
492
|
+
max-width: 560px;
|
|
493
|
+
margin: 0 auto;
|
|
494
|
+
padding: 32px;
|
|
495
|
+
box-shadow: var(--rfs-shadow);
|
|
496
|
+
font-size: 15px;
|
|
497
|
+
}
|
|
498
|
+
.rfs-progress { position: absolute; inset: 0 0 auto; height: 3px; background: var(--rfs-subtle); }
|
|
499
|
+
.rfs-progress-fill {
|
|
500
|
+
height: 100%;
|
|
501
|
+
background: var(--rfs-primary);
|
|
502
|
+
transform-origin: left;
|
|
503
|
+
transition: transform 400ms var(--rfs-ease);
|
|
504
|
+
}
|
|
505
|
+
.rfs-close {
|
|
506
|
+
position: absolute;
|
|
507
|
+
top: 12px;
|
|
508
|
+
right: 12px;
|
|
509
|
+
display: grid;
|
|
510
|
+
place-items: center;
|
|
511
|
+
width: 28px;
|
|
512
|
+
height: 28px;
|
|
513
|
+
border-radius: 8px;
|
|
514
|
+
color: var(--rfs-muted);
|
|
515
|
+
transition: background-color 150ms ease, color 150ms ease;
|
|
516
|
+
}
|
|
517
|
+
.rfs-close:hover { background: var(--rfs-hover); color: var(--rfs-text); }
|
|
518
|
+
.rfs-step { display: flex; flex-direction: column; gap: 14px; animation: rfs-step-in 280ms var(--rfs-ease); }
|
|
519
|
+
.rfs-heading { display: flex; flex-direction: column; gap: 4px; padding-right: 28px; }
|
|
520
|
+
.rfs-title { font-size: 1.07em; font-weight: 600; line-height: 1.35; text-wrap: balance; outline: none; }
|
|
521
|
+
.rfs-description { color: var(--rfs-muted); text-wrap: pretty; white-space: pre-line; }
|
|
522
|
+
.rfs-scale { display: flex; gap: 6px; }
|
|
523
|
+
.rfs-scale[data-kind="nps"] { display: grid; grid-template-columns: repeat(11, minmax(0, 1fr)); gap: 4px; }
|
|
524
|
+
.rfs-scale-option {
|
|
525
|
+
flex: 1;
|
|
526
|
+
min-width: 0;
|
|
527
|
+
height: 40px;
|
|
528
|
+
border-radius: var(--rfs-control-radius);
|
|
529
|
+
background: var(--rfs-subtle);
|
|
530
|
+
box-shadow: inset 0 0 0 1px var(--rfs-border);
|
|
531
|
+
font-weight: 500;
|
|
532
|
+
font-variant-numeric: tabular-nums;
|
|
533
|
+
transition: background-color 150ms ease, color 150ms ease, transform 150ms var(--rfs-ease);
|
|
534
|
+
}
|
|
535
|
+
.rfs-scale[data-kind="nps"] .rfs-scale-option { height: 36px; font-size: 13px; }
|
|
536
|
+
.rfs-scale-option:hover { background: var(--rfs-hover); }
|
|
537
|
+
.rfs-scale-option:active { transform: scale(0.96); }
|
|
538
|
+
.rfs-scale-option[aria-pressed="true"] { background: var(--rfs-primary); color: var(--rfs-primary-text); box-shadow: none; }
|
|
539
|
+
.rfs-scale[data-style="star"], .rfs-scale[data-style="emoji"] { gap: 2px; justify-content: center; }
|
|
540
|
+
.rfs-scale[data-style="star"] .rfs-scale-option,
|
|
541
|
+
.rfs-scale[data-style="emoji"] .rfs-scale-option {
|
|
542
|
+
flex: 0 1 44px;
|
|
543
|
+
height: 44px;
|
|
544
|
+
background: none;
|
|
545
|
+
box-shadow: none;
|
|
546
|
+
color: var(--rfs-border);
|
|
547
|
+
}
|
|
548
|
+
.rfs-scale[data-style="star"] .rfs-scale-option[data-lit="true"] { color: #f5a524; }
|
|
549
|
+
.rfs-scale[data-style="emoji"] .rfs-scale-option { font-size: 26px; filter: grayscale(1); opacity: 0.55; }
|
|
550
|
+
.rfs-scale[data-style="emoji"] .rfs-scale-option:hover,
|
|
551
|
+
.rfs-scale[data-style="emoji"] .rfs-scale-option[aria-pressed="true"] { filter: none; opacity: 1; transform: scale(1.12); background: none; }
|
|
552
|
+
.rfs-scale-labels { display: flex; justify-content: space-between; gap: 12px; margin-top: -6px; color: var(--rfs-muted); font-size: 12px; }
|
|
553
|
+
.rfs-choices { display: flex; flex-direction: column; gap: 6px; }
|
|
554
|
+
.rfs-choice {
|
|
555
|
+
display: flex;
|
|
556
|
+
align-items: center;
|
|
557
|
+
gap: 10px;
|
|
558
|
+
width: 100%;
|
|
559
|
+
min-height: 40px;
|
|
560
|
+
padding: 8px 12px;
|
|
561
|
+
border-radius: var(--rfs-control-radius);
|
|
562
|
+
background: var(--rfs-subtle);
|
|
563
|
+
box-shadow: inset 0 0 0 1px var(--rfs-border);
|
|
564
|
+
text-align: left;
|
|
565
|
+
transition: background-color 150ms ease, box-shadow 150ms ease;
|
|
566
|
+
}
|
|
567
|
+
.rfs-choice:hover { background: var(--rfs-hover); }
|
|
568
|
+
.rfs-choice[aria-pressed="true"] { box-shadow: inset 0 0 0 1.5px var(--rfs-primary); background: var(--rfs-bg); }
|
|
569
|
+
.rfs-indicator {
|
|
570
|
+
flex: none;
|
|
571
|
+
display: grid;
|
|
572
|
+
place-items: center;
|
|
573
|
+
width: 16px;
|
|
574
|
+
height: 16px;
|
|
575
|
+
border-radius: 999px;
|
|
576
|
+
box-shadow: inset 0 0 0 1.5px var(--rfs-muted);
|
|
577
|
+
color: var(--rfs-primary-text);
|
|
578
|
+
transition: background-color 150ms ease, box-shadow 150ms ease;
|
|
579
|
+
}
|
|
580
|
+
.rfs-indicator[data-shape="square"] { border-radius: 5px; }
|
|
581
|
+
.rfs-choice[aria-pressed="true"] .rfs-indicator { background: var(--rfs-primary); box-shadow: none; }
|
|
582
|
+
.rfs-boolean { display: grid; grid-template-columns: 1fr 1fr; gap: 8px; }
|
|
583
|
+
.rfs-boolean .rfs-choice { justify-content: center; font-weight: 500; }
|
|
584
|
+
.rfs-input {
|
|
585
|
+
display: block;
|
|
586
|
+
width: 100%;
|
|
587
|
+
padding: 10px 12px;
|
|
588
|
+
border: 0;
|
|
589
|
+
border-radius: var(--rfs-control-radius);
|
|
590
|
+
background: var(--rfs-subtle);
|
|
591
|
+
box-shadow: inset 0 0 0 1px var(--rfs-border);
|
|
592
|
+
resize: vertical;
|
|
593
|
+
transition: box-shadow 150ms ease;
|
|
594
|
+
}
|
|
595
|
+
.rfs-input::placeholder { color: var(--rfs-muted); }
|
|
596
|
+
.rfs-input:focus { outline: none; box-shadow: inset 0 0 0 1.5px var(--rfs-primary); }
|
|
597
|
+
.rfs-input-wrap { display: flex; flex-direction: column; gap: 4px; }
|
|
598
|
+
.rfs-counter { align-self: flex-end; color: var(--rfs-muted); font-size: 12px; font-variant-numeric: tabular-nums; }
|
|
599
|
+
.rfs-counter[data-full="true"] { color: var(--rfs-danger); }
|
|
600
|
+
.rfs-error { color: var(--rfs-danger); font-size: 13px; }
|
|
601
|
+
.rfs-footer { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-top: 2px; }
|
|
602
|
+
.rfs-button {
|
|
603
|
+
display: inline-flex;
|
|
604
|
+
align-items: center;
|
|
605
|
+
justify-content: center;
|
|
606
|
+
height: 36px;
|
|
607
|
+
padding: 0 16px;
|
|
608
|
+
border-radius: var(--rfs-control-radius);
|
|
609
|
+
font-weight: 500;
|
|
610
|
+
text-decoration: none;
|
|
611
|
+
transition: background-color 150ms ease, opacity 150ms ease, transform 150ms var(--rfs-ease);
|
|
612
|
+
}
|
|
613
|
+
.rfs-button:active:not(:disabled) { transform: scale(0.97); }
|
|
614
|
+
.rfs-button[data-tone="primary"] { background: var(--rfs-primary); color: var(--rfs-primary-text); }
|
|
615
|
+
.rfs-button[data-tone="primary"]:hover:not(:disabled) { opacity: 0.88; }
|
|
616
|
+
.rfs-button[data-tone="primary"]:disabled { opacity: 0.55; }
|
|
617
|
+
.rfs-button[data-tone="ghost"] { margin-right: auto; color: var(--rfs-muted); }
|
|
618
|
+
.rfs-button[data-tone="ghost"]:hover { background: var(--rfs-hover); color: var(--rfs-text); }
|
|
619
|
+
.rfs-ending { align-items: center; padding: 8px 0 4px; text-align: center; }
|
|
620
|
+
.rfs-ending .rfs-heading { padding-right: 0; align-items: center; }
|
|
621
|
+
.rfs-ending-mark {
|
|
622
|
+
display: grid;
|
|
623
|
+
place-items: center;
|
|
624
|
+
width: 40px;
|
|
625
|
+
height: 40px;
|
|
626
|
+
border-radius: 999px;
|
|
627
|
+
background: var(--rfs-subtle);
|
|
628
|
+
color: var(--rfs-success);
|
|
629
|
+
animation: rfs-pop 420ms var(--rfs-ease);
|
|
630
|
+
}
|
|
631
|
+
.rfs-skeleton { display: flex; flex-direction: column; gap: 10px; }
|
|
632
|
+
.rfs-skeleton-line { height: 12px; border-radius: 6px; background: var(--rfs-subtle); animation: rfs-pulse 1.4s ease-in-out infinite; }
|
|
633
|
+
.rfs-skeleton-line:first-child { width: 70%; height: 16px; }
|
|
634
|
+
@keyframes rfs-enter { from { opacity: 0; transform: translateY(12px) scale(0.98); } }
|
|
635
|
+
@keyframes rfs-step-in { from { opacity: 0; transform: translateX(8px); } }
|
|
636
|
+
@keyframes rfs-pop { from { opacity: 0; transform: scale(0.6); } }
|
|
637
|
+
@keyframes rfs-pulse { 50% { opacity: 0.5; } }
|
|
638
|
+
@media (prefers-reduced-motion: reduce) {
|
|
639
|
+
.rf-survey, .rf-survey * { animation: none !important; transition: none !important; }
|
|
640
|
+
}
|
|
641
|
+
`;
|
|
642
|
+
//#endregion
|
|
643
|
+
//#region src/surveys/card/choice-fields.tsx
|
|
644
|
+
const OTHER_LABEL = "Other";
|
|
645
|
+
function CheckIcon() {
|
|
646
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
647
|
+
"aria-hidden": "true",
|
|
648
|
+
fill: "none",
|
|
649
|
+
height: "10",
|
|
650
|
+
stroke: "currentColor",
|
|
651
|
+
strokeLinecap: "round",
|
|
652
|
+
strokeLinejoin: "round",
|
|
653
|
+
strokeWidth: "3",
|
|
654
|
+
viewBox: "0 0 24 24",
|
|
655
|
+
width: "10",
|
|
656
|
+
children: /* @__PURE__ */ jsx("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
|
|
657
|
+
});
|
|
658
|
+
}
|
|
659
|
+
function ChoiceOption({ checked, label, multiple, onSelect }) {
|
|
660
|
+
return /* @__PURE__ */ jsxs("button", {
|
|
661
|
+
"aria-pressed": checked,
|
|
662
|
+
className: "rfs-choice",
|
|
663
|
+
onClick: onSelect,
|
|
664
|
+
type: "button",
|
|
665
|
+
children: [/* @__PURE__ */ jsx("span", {
|
|
666
|
+
className: "rfs-indicator",
|
|
667
|
+
"data-shape": multiple ? "square" : "round",
|
|
668
|
+
children: checked && /* @__PURE__ */ jsx(CheckIcon, {})
|
|
669
|
+
}), label]
|
|
670
|
+
});
|
|
671
|
+
}
|
|
672
|
+
function OtherInput({ onChange, value }) {
|
|
673
|
+
const inputRef = useRef(null);
|
|
674
|
+
useEffect(() => {
|
|
675
|
+
inputRef.current?.focus({ preventScroll: true });
|
|
676
|
+
}, []);
|
|
677
|
+
return /* @__PURE__ */ jsx("input", {
|
|
678
|
+
"aria-label": "Other answer",
|
|
679
|
+
className: "rfs-input",
|
|
680
|
+
maxLength: 200,
|
|
681
|
+
onChange: (event) => onChange(event.target.value),
|
|
682
|
+
placeholder: "Type your answer…",
|
|
683
|
+
ref: inputRef,
|
|
684
|
+
type: "text",
|
|
685
|
+
value
|
|
686
|
+
});
|
|
687
|
+
}
|
|
688
|
+
function SingleChoiceField({ labelledBy, onChange, onPick, question, value }) {
|
|
689
|
+
const choices = question.config?.choices ?? [];
|
|
690
|
+
const typedOther = typeof value === "string" && !choices.includes(value) ? value : "";
|
|
691
|
+
const [otherOpen, setOtherOpen] = useState(typedOther !== "");
|
|
692
|
+
return /* @__PURE__ */ jsxs("fieldset", {
|
|
693
|
+
"aria-labelledby": labelledBy,
|
|
694
|
+
className: "rfs-choices",
|
|
695
|
+
children: [
|
|
696
|
+
choices.map((choice) => /* @__PURE__ */ jsx(ChoiceOption, {
|
|
697
|
+
checked: !otherOpen && value === choice,
|
|
698
|
+
label: choice,
|
|
699
|
+
multiple: false,
|
|
700
|
+
onSelect: () => {
|
|
701
|
+
setOtherOpen(false);
|
|
702
|
+
onPick(choice);
|
|
703
|
+
}
|
|
704
|
+
}, choice)),
|
|
705
|
+
question.config?.allowOther && /* @__PURE__ */ jsx(ChoiceOption, {
|
|
706
|
+
checked: otherOpen,
|
|
707
|
+
label: OTHER_LABEL,
|
|
708
|
+
multiple: false,
|
|
709
|
+
onSelect: () => {
|
|
710
|
+
setOtherOpen(true);
|
|
711
|
+
onChange(typedOther || void 0);
|
|
712
|
+
}
|
|
713
|
+
}),
|
|
714
|
+
otherOpen && /* @__PURE__ */ jsx(OtherInput, {
|
|
715
|
+
onChange: (text) => onChange(text || void 0),
|
|
716
|
+
value: typedOther
|
|
717
|
+
})
|
|
718
|
+
]
|
|
719
|
+
});
|
|
720
|
+
}
|
|
721
|
+
function MultipleChoiceField({ labelledBy, onChange, question, value }) {
|
|
722
|
+
const choices = question.config?.choices ?? [];
|
|
723
|
+
const picked = Array.isArray(value) ? value : [];
|
|
724
|
+
const listed = picked.filter((choice) => choices.includes(choice));
|
|
725
|
+
const typedOther = picked.find((choice) => !choices.includes(choice)) ?? "";
|
|
726
|
+
const [otherOpen, setOtherOpen] = useState(typedOther !== "");
|
|
727
|
+
const commit = (nextListed, other) => {
|
|
728
|
+
const next = other ? [...nextListed, other] : nextListed;
|
|
729
|
+
onChange(next.length > 0 ? next : void 0);
|
|
730
|
+
};
|
|
731
|
+
return /* @__PURE__ */ jsxs("fieldset", {
|
|
732
|
+
"aria-labelledby": labelledBy,
|
|
733
|
+
className: "rfs-choices",
|
|
734
|
+
children: [
|
|
735
|
+
choices.map((choice) => {
|
|
736
|
+
const checked = listed.includes(choice);
|
|
737
|
+
return /* @__PURE__ */ jsx(ChoiceOption, {
|
|
738
|
+
checked,
|
|
739
|
+
label: choice,
|
|
740
|
+
multiple: true,
|
|
741
|
+
onSelect: () => commit(checked ? listed.filter((item) => item !== choice) : choices.filter((item) => item === choice || listed.includes(item)), typedOther)
|
|
742
|
+
}, choice);
|
|
743
|
+
}),
|
|
744
|
+
question.config?.allowOther && /* @__PURE__ */ jsx(ChoiceOption, {
|
|
745
|
+
checked: otherOpen,
|
|
746
|
+
label: OTHER_LABEL,
|
|
747
|
+
multiple: true,
|
|
748
|
+
onSelect: () => {
|
|
749
|
+
setOtherOpen(!otherOpen);
|
|
750
|
+
commit(listed, otherOpen ? "" : typedOther);
|
|
751
|
+
}
|
|
752
|
+
}),
|
|
753
|
+
otherOpen && /* @__PURE__ */ jsx(OtherInput, {
|
|
754
|
+
onChange: (text) => commit(listed, text),
|
|
755
|
+
value: typedOther
|
|
756
|
+
})
|
|
757
|
+
]
|
|
758
|
+
});
|
|
759
|
+
}
|
|
760
|
+
function BooleanField({ labelledBy, onPick, value }) {
|
|
761
|
+
return /* @__PURE__ */ jsx("fieldset", {
|
|
762
|
+
"aria-labelledby": labelledBy,
|
|
763
|
+
className: "rfs-boolean",
|
|
764
|
+
children: [{
|
|
765
|
+
answer: true,
|
|
766
|
+
label: "Yes"
|
|
767
|
+
}, {
|
|
768
|
+
answer: false,
|
|
769
|
+
label: "No"
|
|
770
|
+
}].map(({ answer, label }) => /* @__PURE__ */ jsx("button", {
|
|
771
|
+
"aria-pressed": value === answer,
|
|
772
|
+
className: "rfs-choice",
|
|
773
|
+
onClick: () => onPick(answer),
|
|
774
|
+
type: "button",
|
|
775
|
+
children: label
|
|
776
|
+
}, label))
|
|
777
|
+
});
|
|
778
|
+
}
|
|
779
|
+
//#endregion
|
|
780
|
+
//#region src/surveys/card/scale-fields.tsx
|
|
781
|
+
const EMOJI_FACES = [
|
|
782
|
+
"😞",
|
|
783
|
+
"🙁",
|
|
784
|
+
"😐",
|
|
785
|
+
"🙂",
|
|
786
|
+
"😄"
|
|
787
|
+
];
|
|
788
|
+
const DEFAULT_NPS_MIN_LABEL = "Not likely";
|
|
789
|
+
const DEFAULT_NPS_MAX_LABEL = "Very likely";
|
|
790
|
+
const valuesBetween = (min, max) => Array.from({ length: max - min + 1 }, (_, index) => min + index);
|
|
791
|
+
function StarIcon() {
|
|
792
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
793
|
+
"aria-hidden": "true",
|
|
794
|
+
fill: "currentColor",
|
|
795
|
+
height: "28",
|
|
796
|
+
viewBox: "0 0 24 24",
|
|
797
|
+
width: "28",
|
|
798
|
+
children: /* @__PURE__ */ jsx("path", { d: "M12 2.8l2.83 5.74 6.33.92-4.58 4.46 1.08 6.3L12 17.25l-5.66 2.97 1.08-6.3L2.84 9.46l6.33-.92z" })
|
|
799
|
+
});
|
|
800
|
+
}
|
|
801
|
+
function ScaleLabels({ max, min }) {
|
|
802
|
+
if (!(min || max)) return null;
|
|
803
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
804
|
+
"aria-hidden": "true",
|
|
805
|
+
className: "rfs-scale-labels",
|
|
806
|
+
children: [/* @__PURE__ */ jsx("span", { children: min }), /* @__PURE__ */ jsx("span", { children: max })]
|
|
807
|
+
});
|
|
808
|
+
}
|
|
809
|
+
function RatingField({ labelledBy, onPick, question, value }) {
|
|
810
|
+
const [hovered, setHovered] = useState(null);
|
|
811
|
+
const { max, min } = ratingRange(question.config);
|
|
812
|
+
const style = question.config?.ratingStyle ?? "number";
|
|
813
|
+
const values = valuesBetween(min, max);
|
|
814
|
+
const lit = hovered ?? value ?? Number.NEGATIVE_INFINITY;
|
|
815
|
+
const faceFor = (option) => {
|
|
816
|
+
const step = values.length > 1 ? (option - min) / (max - min) : 1;
|
|
817
|
+
return EMOJI_FACES[Math.round(step * (EMOJI_FACES.length - 1))];
|
|
818
|
+
};
|
|
819
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("fieldset", {
|
|
820
|
+
"aria-labelledby": labelledBy,
|
|
821
|
+
className: "rfs-scale",
|
|
822
|
+
"data-style": style,
|
|
823
|
+
children: values.map((option) => /* @__PURE__ */ jsxs("button", {
|
|
824
|
+
"aria-label": `${option} of ${max}`,
|
|
825
|
+
"aria-pressed": value === option,
|
|
826
|
+
className: "rfs-scale-option",
|
|
827
|
+
"data-lit": option <= lit,
|
|
828
|
+
onClick: () => onPick(option),
|
|
829
|
+
onPointerEnter: () => setHovered(option),
|
|
830
|
+
onPointerLeave: () => setHovered(null),
|
|
831
|
+
type: "button",
|
|
832
|
+
children: [
|
|
833
|
+
style === "number" && option,
|
|
834
|
+
style === "star" && /* @__PURE__ */ jsx(StarIcon, {}),
|
|
835
|
+
style === "emoji" && faceFor(option)
|
|
836
|
+
]
|
|
837
|
+
}, option))
|
|
838
|
+
}), /* @__PURE__ */ jsx(ScaleLabels, {
|
|
839
|
+
max: question.config?.maxLabel,
|
|
840
|
+
min: question.config?.minLabel
|
|
841
|
+
})] });
|
|
842
|
+
}
|
|
843
|
+
function NpsField({ labelledBy, onPick, question, value }) {
|
|
844
|
+
return /* @__PURE__ */ jsxs(Fragment, { children: [/* @__PURE__ */ jsx("fieldset", {
|
|
845
|
+
"aria-labelledby": labelledBy,
|
|
846
|
+
className: "rfs-scale",
|
|
847
|
+
"data-kind": "nps",
|
|
848
|
+
children: valuesBetween(0, 10).map((option) => /* @__PURE__ */ jsx("button", {
|
|
849
|
+
"aria-pressed": value === option,
|
|
850
|
+
className: "rfs-scale-option",
|
|
851
|
+
onClick: () => onPick(option),
|
|
852
|
+
type: "button",
|
|
853
|
+
children: option
|
|
854
|
+
}, option))
|
|
855
|
+
}), /* @__PURE__ */ jsx(ScaleLabels, {
|
|
856
|
+
max: question.config?.maxLabel ?? DEFAULT_NPS_MAX_LABEL,
|
|
857
|
+
min: question.config?.minLabel ?? DEFAULT_NPS_MIN_LABEL
|
|
858
|
+
})] });
|
|
859
|
+
}
|
|
860
|
+
//#endregion
|
|
861
|
+
//#region src/surveys/card/question-body.tsx
|
|
862
|
+
function TextField({ labelledBy, onChange, question, value }) {
|
|
863
|
+
const max = textMaxChars(question.config);
|
|
864
|
+
const text = typeof value === "string" ? value : "";
|
|
865
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
866
|
+
className: "rfs-input-wrap",
|
|
867
|
+
children: [/* @__PURE__ */ jsx("textarea", {
|
|
868
|
+
"aria-labelledby": labelledBy,
|
|
869
|
+
className: "rfs-input",
|
|
870
|
+
maxLength: max,
|
|
871
|
+
onChange: (event) => onChange(event.target.value || void 0),
|
|
872
|
+
placeholder: question.config?.placeholder ?? "Type your answer…",
|
|
873
|
+
rows: 3,
|
|
874
|
+
value: text
|
|
875
|
+
}), /* @__PURE__ */ jsxs("span", {
|
|
876
|
+
className: "rfs-counter",
|
|
877
|
+
"data-full": text.length >= max,
|
|
878
|
+
children: [
|
|
879
|
+
text.length,
|
|
880
|
+
"/",
|
|
881
|
+
max
|
|
882
|
+
]
|
|
883
|
+
})]
|
|
884
|
+
});
|
|
885
|
+
}
|
|
886
|
+
function QuestionBody(props) {
|
|
887
|
+
const { labelledBy, onPick, question, value } = props;
|
|
888
|
+
const numericValue = typeof value === "number" ? value : void 0;
|
|
889
|
+
switch (question.type) {
|
|
890
|
+
case "rating": return /* @__PURE__ */ jsx(RatingField, {
|
|
891
|
+
labelledBy,
|
|
892
|
+
onPick,
|
|
893
|
+
question,
|
|
894
|
+
value: numericValue
|
|
895
|
+
});
|
|
896
|
+
case "nps": return /* @__PURE__ */ jsx(NpsField, {
|
|
897
|
+
labelledBy,
|
|
898
|
+
onPick,
|
|
899
|
+
question,
|
|
900
|
+
value: numericValue
|
|
901
|
+
});
|
|
902
|
+
case "single_choice": return /* @__PURE__ */ jsx(SingleChoiceField, { ...props });
|
|
903
|
+
case "multiple_choice": return /* @__PURE__ */ jsx(MultipleChoiceField, { ...props });
|
|
904
|
+
case "boolean": return /* @__PURE__ */ jsx(BooleanField, { ...props });
|
|
905
|
+
case "text": return /* @__PURE__ */ jsx(TextField, { ...props });
|
|
906
|
+
default: return null;
|
|
907
|
+
}
|
|
908
|
+
}
|
|
909
|
+
function StepHeading({ description, headingId, headingRef, title }) {
|
|
910
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
911
|
+
className: "rfs-heading",
|
|
912
|
+
children: [/* @__PURE__ */ jsx("h2", {
|
|
913
|
+
className: "rfs-title",
|
|
914
|
+
id: headingId,
|
|
915
|
+
ref: headingRef,
|
|
916
|
+
tabIndex: -1,
|
|
917
|
+
children: title
|
|
918
|
+
}), description && /* @__PURE__ */ jsx("p", {
|
|
919
|
+
className: "rfs-description",
|
|
920
|
+
children: description
|
|
921
|
+
})]
|
|
922
|
+
});
|
|
923
|
+
}
|
|
924
|
+
function ActionButton({ children, disabled, onClick, url }) {
|
|
925
|
+
const safeUrl = safeLinkUrl(url);
|
|
926
|
+
if (safeUrl) return /* @__PURE__ */ jsx("a", {
|
|
927
|
+
className: "rfs-button",
|
|
928
|
+
"data-tone": "primary",
|
|
929
|
+
href: safeUrl,
|
|
930
|
+
onClick,
|
|
931
|
+
rel: "noopener noreferrer",
|
|
932
|
+
target: "_blank",
|
|
933
|
+
children
|
|
934
|
+
});
|
|
935
|
+
return /* @__PURE__ */ jsx("button", {
|
|
936
|
+
className: "rfs-button",
|
|
937
|
+
"data-tone": "primary",
|
|
938
|
+
disabled,
|
|
939
|
+
onClick,
|
|
940
|
+
type: "button",
|
|
941
|
+
children
|
|
942
|
+
});
|
|
943
|
+
}
|
|
944
|
+
function SuccessMark() {
|
|
945
|
+
return /* @__PURE__ */ jsx("span", {
|
|
946
|
+
className: "rfs-ending-mark",
|
|
947
|
+
children: /* @__PURE__ */ jsx("svg", {
|
|
948
|
+
"aria-hidden": "true",
|
|
949
|
+
fill: "none",
|
|
950
|
+
height: "20",
|
|
951
|
+
stroke: "currentColor",
|
|
952
|
+
strokeLinecap: "round",
|
|
953
|
+
strokeLinejoin: "round",
|
|
954
|
+
strokeWidth: "2.5",
|
|
955
|
+
viewBox: "0 0 24 24",
|
|
956
|
+
width: "20",
|
|
957
|
+
children: /* @__PURE__ */ jsx("path", { d: "M5 12.5l4.5 4.5L19 7.5" })
|
|
958
|
+
})
|
|
959
|
+
});
|
|
960
|
+
}
|
|
961
|
+
function EndingStep({ ending, headingId, headingRef, onClose }) {
|
|
962
|
+
const hasButton = Boolean(ending.buttonUrl || ending.buttonLabel);
|
|
963
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
964
|
+
className: "rfs-step rfs-ending",
|
|
965
|
+
children: [
|
|
966
|
+
/* @__PURE__ */ jsx(SuccessMark, {}),
|
|
967
|
+
/* @__PURE__ */ jsx(StepHeading, {
|
|
968
|
+
description: ending.description,
|
|
969
|
+
headingId,
|
|
970
|
+
headingRef,
|
|
971
|
+
title: ending.title
|
|
972
|
+
}),
|
|
973
|
+
hasButton && /* @__PURE__ */ jsx(ActionButton, {
|
|
974
|
+
onClick: onClose,
|
|
975
|
+
url: ending.buttonUrl,
|
|
976
|
+
children: ending.buttonLabel ?? "Continue"
|
|
977
|
+
})
|
|
978
|
+
]
|
|
979
|
+
});
|
|
980
|
+
}
|
|
981
|
+
function LoadingStep() {
|
|
982
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
983
|
+
"aria-busy": "true",
|
|
984
|
+
"aria-label": "Loading survey",
|
|
985
|
+
className: "rfs-skeleton",
|
|
986
|
+
role: "status",
|
|
987
|
+
children: [
|
|
988
|
+
/* @__PURE__ */ jsx("span", { className: "rfs-skeleton-line" }),
|
|
989
|
+
/* @__PURE__ */ jsx("span", { className: "rfs-skeleton-line" }),
|
|
990
|
+
/* @__PURE__ */ jsx("span", { className: "rfs-skeleton-line" })
|
|
991
|
+
]
|
|
992
|
+
});
|
|
993
|
+
}
|
|
994
|
+
function StartFailedStep({ message, onRetry }) {
|
|
995
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
996
|
+
className: "rfs-step",
|
|
997
|
+
children: [/* @__PURE__ */ jsx("p", {
|
|
998
|
+
className: "rfs-error",
|
|
999
|
+
role: "alert",
|
|
1000
|
+
children: message
|
|
1001
|
+
}), /* @__PURE__ */ jsx("div", {
|
|
1002
|
+
className: "rfs-footer",
|
|
1003
|
+
children: /* @__PURE__ */ jsx(ActionButton, {
|
|
1004
|
+
onClick: onRetry,
|
|
1005
|
+
children: "Try again"
|
|
1006
|
+
})
|
|
1007
|
+
})]
|
|
1008
|
+
});
|
|
1009
|
+
}
|
|
1010
|
+
//#endregion
|
|
1011
|
+
//#region src/surveys/card/question-step.tsx
|
|
1012
|
+
function QuestionStep({ headingId, headingRef, onPick, question, session }) {
|
|
1013
|
+
const { snapshot } = session;
|
|
1014
|
+
const isStatement = question.type === "statement";
|
|
1015
|
+
const nextLabel = snapshot.isLastStep ? "Submit" : "Next";
|
|
1016
|
+
return /* @__PURE__ */ jsxs("div", {
|
|
1017
|
+
className: "rfs-step",
|
|
1018
|
+
children: [
|
|
1019
|
+
/* @__PURE__ */ jsx(StepHeading, {
|
|
1020
|
+
description: question.description,
|
|
1021
|
+
headingId,
|
|
1022
|
+
headingRef,
|
|
1023
|
+
title: question.title
|
|
1024
|
+
}),
|
|
1025
|
+
/* @__PURE__ */ jsx(QuestionBody, {
|
|
1026
|
+
labelledBy: headingId,
|
|
1027
|
+
onChange: session.setAnswer,
|
|
1028
|
+
onPick,
|
|
1029
|
+
question,
|
|
1030
|
+
value: snapshot.answers.get(question._id)
|
|
1031
|
+
}),
|
|
1032
|
+
snapshot.error && /* @__PURE__ */ jsx("p", {
|
|
1033
|
+
className: "rfs-error",
|
|
1034
|
+
role: "alert",
|
|
1035
|
+
children: snapshot.error
|
|
1036
|
+
}),
|
|
1037
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1038
|
+
className: "rfs-footer",
|
|
1039
|
+
children: [snapshot.canGoBack && /* @__PURE__ */ jsx("button", {
|
|
1040
|
+
className: "rfs-button",
|
|
1041
|
+
"data-tone": "ghost",
|
|
1042
|
+
disabled: snapshot.isSubmitting,
|
|
1043
|
+
onClick: session.back,
|
|
1044
|
+
type: "button",
|
|
1045
|
+
children: "Back"
|
|
1046
|
+
}), /* @__PURE__ */ jsx(ActionButton, {
|
|
1047
|
+
disabled: snapshot.isSubmitting,
|
|
1048
|
+
onClick: () => session.next(),
|
|
1049
|
+
url: isStatement ? question.config?.buttonUrl : void 0,
|
|
1050
|
+
children: isStatement ? question.config?.buttonLabel ?? "Continue" : nextLabel
|
|
1051
|
+
})]
|
|
1052
|
+
})
|
|
1053
|
+
]
|
|
1054
|
+
});
|
|
1055
|
+
}
|
|
1056
|
+
//#endregion
|
|
1057
|
+
//#region src/surveys/card/survey-card.tsx
|
|
1058
|
+
const HEX_COLOR = /^#([\da-f]{3}|[\da-f]{6})$/i;
|
|
1059
|
+
const LIGHT_LUMINANCE_THRESHOLD = .6;
|
|
1060
|
+
const DARK_TEXT = "#18181b";
|
|
1061
|
+
const LIGHT_TEXT = "#ffffff";
|
|
1062
|
+
/** Dark text on light brand colors, white text otherwise. */
|
|
1063
|
+
const readableTextOn = (color) => {
|
|
1064
|
+
const match = HEX_COLOR.exec(color.trim());
|
|
1065
|
+
if (!match?.[1]) return LIGHT_TEXT;
|
|
1066
|
+
const hex = match[1].length === 3 ? [...match[1]].map((digit) => digit + digit).join("") : match[1];
|
|
1067
|
+
const [red = 0, green = 0, blue = 0] = [
|
|
1068
|
+
0,
|
|
1069
|
+
2,
|
|
1070
|
+
4
|
|
1071
|
+
].map((offset) => Number.parseInt(hex.slice(offset, offset + 2), 16) / 255);
|
|
1072
|
+
return .2126 * red + .7152 * green + .0722 * blue > LIGHT_LUMINANCE_THRESHOLD ? DARK_TEXT : LIGHT_TEXT;
|
|
1073
|
+
};
|
|
1074
|
+
function CloseIcon() {
|
|
1075
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
1076
|
+
"aria-hidden": "true",
|
|
1077
|
+
fill: "none",
|
|
1078
|
+
height: "16",
|
|
1079
|
+
stroke: "currentColor",
|
|
1080
|
+
strokeLinecap: "round",
|
|
1081
|
+
strokeWidth: "2",
|
|
1082
|
+
viewBox: "0 0 24 24",
|
|
1083
|
+
width: "16",
|
|
1084
|
+
children: /* @__PURE__ */ jsx("path", { d: "M6 6l12 12M18 6L6 18" })
|
|
1085
|
+
});
|
|
1086
|
+
}
|
|
1087
|
+
/**
|
|
1088
|
+
* Renders any survey session: questions, branching, endings and errors.
|
|
1089
|
+
* Ships its own scoped styles, so it works in a shadow root or a page.
|
|
1090
|
+
*/
|
|
1091
|
+
function SurveyCard({ className, onClose, primaryColor, session, theme = "auto", variant = "inline" }) {
|
|
1092
|
+
const { snapshot } = session;
|
|
1093
|
+
const { ending, phase, question } = snapshot;
|
|
1094
|
+
const headingId = useId();
|
|
1095
|
+
const headingRef = useRef(null);
|
|
1096
|
+
const hasInteracted = useRef(false);
|
|
1097
|
+
const stepKey = question?._id ?? phase;
|
|
1098
|
+
useEffect(() => {
|
|
1099
|
+
if (stepKey && hasInteracted.current) headingRef.current?.focus({ preventScroll: true });
|
|
1100
|
+
}, [stepKey]);
|
|
1101
|
+
if (phase === "dismissed") return null;
|
|
1102
|
+
const closable = onClose !== void 0 || variant === "floating";
|
|
1103
|
+
const close = () => {
|
|
1104
|
+
session.dismiss();
|
|
1105
|
+
onClose?.();
|
|
1106
|
+
};
|
|
1107
|
+
const pick = (value) => {
|
|
1108
|
+
session.setAnswer(value);
|
|
1109
|
+
session.next();
|
|
1110
|
+
};
|
|
1111
|
+
const onKeyDown = (event) => {
|
|
1112
|
+
hasInteracted.current = true;
|
|
1113
|
+
if (event.nativeEvent.isComposing) return;
|
|
1114
|
+
const action = cardKeyAction({
|
|
1115
|
+
ctrlKey: event.ctrlKey,
|
|
1116
|
+
key: event.key,
|
|
1117
|
+
metaKey: event.metaKey,
|
|
1118
|
+
target: event.target
|
|
1119
|
+
}, question);
|
|
1120
|
+
if (!action || action.kind === "close" && !closable) return;
|
|
1121
|
+
event.preventDefault();
|
|
1122
|
+
if (action.kind === "close") close();
|
|
1123
|
+
else if (action.kind === "next") session.next();
|
|
1124
|
+
else pick(action.value);
|
|
1125
|
+
};
|
|
1126
|
+
const themeStyle = primaryColor ? {
|
|
1127
|
+
"--rf-survey-primary": primaryColor,
|
|
1128
|
+
"--rf-survey-primary-text": readableTextOn(primaryColor)
|
|
1129
|
+
} : {};
|
|
1130
|
+
const showsProgress = phase === "question" || phase === "ending";
|
|
1131
|
+
return /* @__PURE__ */ jsxs("section", {
|
|
1132
|
+
"aria-label": question || ending ? void 0 : session.survey.title,
|
|
1133
|
+
"aria-labelledby": question || ending ? headingId : void 0,
|
|
1134
|
+
className: className ? `rf-survey ${className}` : "rf-survey",
|
|
1135
|
+
"data-theme": theme,
|
|
1136
|
+
"data-variant": variant,
|
|
1137
|
+
onKeyDown,
|
|
1138
|
+
onPointerDown: () => {
|
|
1139
|
+
hasInteracted.current = true;
|
|
1140
|
+
},
|
|
1141
|
+
style: themeStyle,
|
|
1142
|
+
children: [
|
|
1143
|
+
/* @__PURE__ */ jsx("style", { children: SURVEY_CARD_STYLES }),
|
|
1144
|
+
showsProgress && /* @__PURE__ */ jsx("div", {
|
|
1145
|
+
"aria-label": "Survey progress",
|
|
1146
|
+
"aria-valuemax": 100,
|
|
1147
|
+
"aria-valuemin": 0,
|
|
1148
|
+
"aria-valuenow": Math.round(snapshot.progress * 100),
|
|
1149
|
+
className: "rfs-progress",
|
|
1150
|
+
role: "progressbar",
|
|
1151
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1152
|
+
className: "rfs-progress-fill",
|
|
1153
|
+
style: { transform: `scaleX(${snapshot.progress})` }
|
|
1154
|
+
})
|
|
1155
|
+
}),
|
|
1156
|
+
closable && /* @__PURE__ */ jsx("button", {
|
|
1157
|
+
"aria-label": "Close survey",
|
|
1158
|
+
className: "rfs-close",
|
|
1159
|
+
onClick: close,
|
|
1160
|
+
type: "button",
|
|
1161
|
+
children: /* @__PURE__ */ jsx(CloseIcon, {})
|
|
1162
|
+
}),
|
|
1163
|
+
phase === "starting" && /* @__PURE__ */ jsx(LoadingStep, {}),
|
|
1164
|
+
phase === "start_failed" && /* @__PURE__ */ jsx(StartFailedStep, {
|
|
1165
|
+
message: snapshot.error,
|
|
1166
|
+
onRetry: () => session.start()
|
|
1167
|
+
}),
|
|
1168
|
+
ending && /* @__PURE__ */ jsx(EndingStep, {
|
|
1169
|
+
ending,
|
|
1170
|
+
headingId,
|
|
1171
|
+
headingRef,
|
|
1172
|
+
onClose: close
|
|
1173
|
+
}, ending.id),
|
|
1174
|
+
question && /* @__PURE__ */ jsx(QuestionStep, {
|
|
1175
|
+
headingId,
|
|
1176
|
+
headingRef,
|
|
1177
|
+
onPick: pick,
|
|
1178
|
+
question,
|
|
1179
|
+
session
|
|
1180
|
+
}, question._id)
|
|
1181
|
+
]
|
|
1182
|
+
});
|
|
1183
|
+
}
|
|
1184
|
+
//#endregion
|
|
1185
|
+
//#region src/surveys/client-transport.ts
|
|
1186
|
+
/** Runs a survey session against the Reflet REST API with this client's key and user. */
|
|
1187
|
+
const createRefletSurveyTransport = (client, options = {}) => ({
|
|
1188
|
+
answer: (input) => client.submitSurveyAnswer(input),
|
|
1189
|
+
complete: ({ responseId }) => client.completeSurveyResponse(responseId),
|
|
1190
|
+
dismiss: ({ responseId }) => client.dismissSurveyResponse(responseId),
|
|
1191
|
+
start: ({ surveyId }) => client.startSurveyResponse({
|
|
1192
|
+
pageUrl: options.pageUrl,
|
|
1193
|
+
surveyId
|
|
1194
|
+
})
|
|
1195
|
+
});
|
|
1196
|
+
//#endregion
|
|
1197
|
+
//#region src/surveys/use-survey-session.ts
|
|
1198
|
+
const startedSessions = /* @__PURE__ */ new WeakSet();
|
|
1199
|
+
/** Subscribes a component to an existing session. */
|
|
1200
|
+
function useSessionHandle(session) {
|
|
1201
|
+
const snapshot = useSyncExternalStore(session.subscribe, session.getSnapshot, session.getSnapshot);
|
|
1202
|
+
return useMemo(() => ({
|
|
1203
|
+
back: () => session.back(),
|
|
1204
|
+
dismiss: () => session.dismiss(),
|
|
1205
|
+
next: () => session.next(),
|
|
1206
|
+
setAnswer: (value) => session.setAnswer(value),
|
|
1207
|
+
snapshot,
|
|
1208
|
+
start: () => session.start(),
|
|
1209
|
+
survey: session.survey
|
|
1210
|
+
}), [session, snapshot]);
|
|
1211
|
+
}
|
|
1212
|
+
/**
|
|
1213
|
+
* Headless survey runner for custom UIs. A new session begins whenever
|
|
1214
|
+
* `survey` or `transport` changes identity, so memoize them.
|
|
1215
|
+
*/
|
|
1216
|
+
function useSurveySession(survey, options = {}) {
|
|
1217
|
+
const { autoStart = true, callbacks, transport } = options;
|
|
1218
|
+
const client = useContext(RefletContext)?.client;
|
|
1219
|
+
const callbacksRef = useRef(callbacks);
|
|
1220
|
+
useEffect(() => {
|
|
1221
|
+
callbacksRef.current = callbacks;
|
|
1222
|
+
});
|
|
1223
|
+
const resolvedTransport = useMemo(() => {
|
|
1224
|
+
if (transport) return transport;
|
|
1225
|
+
return client ? createRefletSurveyTransport(client) : previewTransport;
|
|
1226
|
+
}, [client, transport]);
|
|
1227
|
+
const session = useMemo(() => new SurveySession({
|
|
1228
|
+
callbacks: {
|
|
1229
|
+
onAnswer: (event) => callbacksRef.current?.onAnswer?.(event),
|
|
1230
|
+
onComplete: (event) => callbacksRef.current?.onComplete?.(event),
|
|
1231
|
+
onDismiss: (event) => callbacksRef.current?.onDismiss?.(event),
|
|
1232
|
+
onStart: (event) => callbacksRef.current?.onStart?.(event)
|
|
1233
|
+
},
|
|
1234
|
+
survey,
|
|
1235
|
+
transport: resolvedTransport
|
|
1236
|
+
}), [survey, resolvedTransport]);
|
|
1237
|
+
useEffect(() => {
|
|
1238
|
+
if (!autoStart || startedSessions.has(session)) return;
|
|
1239
|
+
startedSessions.add(session);
|
|
1240
|
+
session.start();
|
|
1241
|
+
}, [autoStart, session]);
|
|
1242
|
+
return useSessionHandle(session);
|
|
1243
|
+
}
|
|
1244
|
+
//#endregion
|
|
1245
|
+
export { SurveySession as a, displayOf as c, SurveyCard as i, useSurveySession as n, previewTransport as o, createRefletSurveyTransport as r, DEFAULT_TIME_DELAY_MS as s, useSessionHandle as t };
|
|
1246
|
+
|
|
1247
|
+
//# sourceMappingURL=use-survey-session-LK6sN1Fg.js.map
|