@sunboard/react 0.1.3 → 0.3.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/dist/index.mjs CHANGED
@@ -18,143 +18,15 @@ async function navigateWithFallback(url, navigate) {
18
18
  //#region src/hooks/sunboard-context.ts
19
19
  const SunboardContext = createContext(null);
20
20
 
21
- //#endregion
22
- //#region src/hooks/useSunboardPreviewSpec.ts
23
- function useSunboardPreviewSpec() {
24
- const [state, setState] = useState(() => ({
25
- error: null,
26
- loading: getPreviewSpecUrl() !== null,
27
- source: "none",
28
- spec: null
29
- }));
30
- useEffect(() => {
31
- const previewUrl = getPreviewSpecUrl();
32
- if (!previewUrl) {
33
- setState({
34
- error: null,
35
- loading: false,
36
- source: "none",
37
- spec: null
38
- });
39
- return;
40
- }
41
- const controller = new AbortController();
42
- let cancelled = false;
43
- let eventSource = null;
44
- const loadSpec = async (signal) => {
45
- const response = await fetch(previewUrl.toString(), { signal });
46
- if (!response.ok) throw new Error(`Sunboard preview failed with ${response.status}.`);
47
- const json = await response.json();
48
- if (!isSunboardSpecLike(json)) throw new Error("Sunboard preview returned an invalid spec payload.");
49
- return json;
50
- };
51
- setState({
52
- error: null,
53
- loading: true,
54
- source: "none",
55
- spec: null
56
- });
57
- loadSpec(controller.signal).then((previewSpec) => {
58
- if (cancelled) return;
59
- setState({
60
- error: null,
61
- loading: false,
62
- source: "preview",
63
- spec: previewSpec
64
- });
65
- const eventsUrl = buildEventsUrl(previewUrl);
66
- if (eventsUrl && typeof EventSource !== "undefined") {
67
- eventSource = new EventSource(eventsUrl);
68
- let firstMessage = true;
69
- eventSource.onmessage = () => {
70
- if (firstMessage) {
71
- firstMessage = false;
72
- return;
73
- }
74
- reloadSpec(previewUrl);
75
- };
76
- eventSource.onerror = () => {};
77
- }
78
- }).catch((error) => {
79
- if (controller.signal.aborted || cancelled) return;
80
- setState({
81
- error: error instanceof Error ? error : new Error(String(error)),
82
- loading: false,
83
- source: "none",
84
- spec: null
85
- });
86
- });
87
- const reloadSpec = async (url) => {
88
- try {
89
- const response = await fetch(url.toString());
90
- if (!response.ok) throw new Error(`Sunboard preview failed with ${response.status}.`);
91
- const json = await response.json();
92
- if (!isSunboardSpecLike(json)) throw new Error("Sunboard preview returned an invalid spec payload.");
93
- if (cancelled) return;
94
- setState({
95
- error: null,
96
- loading: false,
97
- source: "preview",
98
- spec: json
99
- });
100
- } catch (error) {
101
- if (cancelled) return;
102
- setState((current) => ({
103
- ...current,
104
- error: error instanceof Error ? error : new Error(String(error))
105
- }));
106
- }
107
- };
108
- return () => {
109
- cancelled = true;
110
- controller.abort();
111
- eventSource?.close();
112
- };
113
- }, []);
114
- return state;
115
- }
116
- function buildEventsUrl(specUrl) {
117
- try {
118
- const eventsUrl = new URL(specUrl.toString());
119
- eventsUrl.pathname = eventsUrl.pathname.replace(/\/spec\.json$/, "/events");
120
- if (eventsUrl.pathname === specUrl.pathname) return null;
121
- return eventsUrl.toString();
122
- } catch {
123
- return null;
124
- }
125
- }
126
- function getPreviewSpecUrl() {
127
- if (typeof window === "undefined") return null;
128
- const pageUrl = new URL(window.location.href);
129
- if (pageUrl.searchParams.get("sunboard_preview") !== "1") return null;
130
- const previewUrlParam = pageUrl.searchParams.get("sunboard_preview_url");
131
- if (!previewUrlParam) return null;
132
- try {
133
- const previewUrl = new URL(previewUrlParam);
134
- return isAllowedLocalPreviewUrl(previewUrl) ? previewUrl : null;
135
- } catch {
136
- return null;
137
- }
138
- }
139
- function isAllowedLocalPreviewUrl(url) {
140
- return url.protocol === "http:" && (url.hostname === "localhost" || url.hostname === "127.0.0.1");
141
- }
142
- function isSunboardSpecLike(value) {
143
- if (!value || typeof value !== "object") return false;
144
- const candidate = value;
145
- return typeof candidate.key === "string" && typeof candidate.name === "string" && typeof candidate.type === "string" && typeof candidate.checklist === "object" && Array.isArray(candidate.checklist?.steps);
146
- }
147
-
148
21
  //#endregion
149
22
  //#region src/version.ts
150
23
  const SDK_VERSION = "0.0.0";
151
24
 
152
25
  //#endregion
153
26
  //#region src/components/SunboardProvider.tsx
154
- function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: modeProp, route, navigate, apiUrl, publishableKey, environment = "production", onTrack, children }) {
27
+ function SunboardProvider({ spec: specProp, user, loading: loadingProp, route, navigate, apiUrl, publishableKey, onTrack, children }) {
155
28
  const currentRoute = route ?? getBrowserRoute();
156
29
  const currentOrigin = getBrowserOrigin();
157
- const preview = useSunboardPreviewSpec();
158
30
  const client = useMemo(() => {
159
31
  if (!publishableKey) return null;
160
32
  return new SunboardRuntimeClient$1({
@@ -163,31 +35,44 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
163
35
  sdkVersion: SDK_VERSION
164
36
  });
165
37
  }, [apiUrl, publishableKey]);
38
+ const [bootstrapNonce, setBootstrapNonce] = useState(0);
39
+ const refreshBootstrap = useCallback(() => {
40
+ setBootstrapNonce((nonce) => nonce + 1);
41
+ }, []);
166
42
  const bootstrap = useSunboardBootstrap(client, {
167
- enabled: !specProp && preview.source !== "preview" && !preview.loading,
168
- environment,
43
+ enabled: !specProp,
44
+ nonce: bootstrapNonce,
169
45
  origin: currentOrigin,
170
46
  route: currentRoute,
171
47
  user
172
48
  });
173
49
  const activeExperience = bootstrap.experience;
174
- const hostedSpec = activeExperience?.spec ?? null;
175
- const resolvedSpec = specProp ?? preview.spec ?? hostedSpec;
176
- const resolvedLoading = loadingProp ?? (preview.loading || bootstrap.loading);
177
- const resolvedMode = modeProp ?? (preview.source === "preview" ? "preview" : "production");
178
- const hostedMode = client !== null && !specProp && preview.source !== "preview";
50
+ const activeSurvey = bootstrap.survey;
51
+ const hostedSpec = activeSurvey?.spec ?? activeExperience?.spec ?? null;
52
+ const resolvedSpec = specProp ?? hostedSpec;
53
+ const resolvedLoading = loadingProp ?? bootstrap.loading;
54
+ const hostedMode = client !== null && !specProp;
55
+ const ownedAttributes = bootstrap.attributes;
56
+ const effectiveUser = useMemo(() => {
57
+ if (!user || !ownedAttributes || Object.keys(ownedAttributes).length === 0) return user;
58
+ return {
59
+ ...user,
60
+ properties: {
61
+ ...user.properties ?? {},
62
+ ...ownedAttributes
63
+ }
64
+ };
65
+ }, [user, ownedAttributes]);
179
66
  const [completedStepKeys, setCompletedStepKeys] = useState(() => /* @__PURE__ */ new Set());
180
67
  const [completedTourKeys, setCompletedTourKeys] = useState(() => /* @__PURE__ */ new Set());
181
68
  const [activeTourState, setActiveTourState] = useState(null);
182
- const [tourError, setTourError] = useState(null);
183
69
  const lastNavigatedTourStep = useRef(null);
184
70
  const storageKey = !hostedMode && resolvedSpec && user ? completedToursStorageKey(resolvedSpec.key, user.id) : null;
185
- const experienceKey = activeExperience?.key ?? resolvedSpec?.key ?? null;
186
- const versionId = activeExperience?.version.id ?? null;
71
+ const experienceKey = activeSurvey?.key ?? activeExperience?.key ?? resolvedSpec?.key ?? null;
72
+ const versionId = activeSurvey?.version.id ?? activeExperience?.version.id ?? null;
187
73
  useEffect(() => {
188
74
  setCompletedStepKeys(/* @__PURE__ */ new Set());
189
75
  setActiveTourState(null);
190
- setTourError(null);
191
76
  lastNavigatedTourStep.current = null;
192
77
  if (!storageKey) {
193
78
  setCompletedTourKeys(/* @__PURE__ */ new Set());
@@ -221,7 +106,7 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
221
106
  ]);
222
107
  useEffect(() => {
223
108
  if (!resolvedSpec) return;
224
- const tourCompletedStepKeys = resolvedSpec.checklist.steps.filter((step) => step.completeWhen?.tour && effectiveCompletedTourKeys.has(step.completeWhen.tour)).map((step) => step.key);
109
+ const tourCompletedStepKeys = (resolvedSpec.checklist?.steps ?? []).filter((step) => step.completeWhen?.tour && effectiveCompletedTourKeys.has(step.completeWhen.tour)).map((step) => step.key);
225
110
  if (tourCompletedStepKeys.length === 0) return;
226
111
  setCompletedStepKeys((current) => mergeKeys(current, tourCompletedStepKeys));
227
112
  }, [effectiveCompletedTourKeys, resolvedSpec]);
@@ -252,7 +137,6 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
252
137
  useEffect(() => {
253
138
  if (!hostedMode || !client || !activeTour || !user || !experienceKey) return;
254
139
  client.recordTourStep({
255
- environment,
256
140
  experienceKey,
257
141
  origin: currentOrigin,
258
142
  route: currentRoute,
@@ -270,8 +154,8 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
270
154
  hostedMode,
271
155
  user?.id
272
156
  ]);
273
- const totalSteps = resolvedSpec?.checklist.steps.length ?? 0;
274
- const completedCount = resolvedSpec?.checklist.steps.filter((step) => effectiveCompletedStepKeys.has(step.key)).length ?? 0;
157
+ const totalSteps = resolvedSpec?.checklist?.steps.length ?? 0;
158
+ const completedCount = resolvedSpec?.checklist?.steps.filter((step) => effectiveCompletedStepKeys.has(step.key)).length ?? 0;
275
159
  const completeTour = useCallback((tourKey) => {
276
160
  setCompletedTourKeys((current) => {
277
161
  const next = new Set(current);
@@ -289,12 +173,11 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
289
173
  ok: false,
290
174
  reason: "missing-user"
291
175
  };
292
- const matchingStepKeys = resolvedSpec.checklist.steps.filter((step) => step.completeWhen?.event === eventName).map((step) => step.key);
176
+ const matchingStepKeys = (resolvedSpec.checklist?.steps ?? []).filter((step) => step.completeWhen?.event === eventName).map((step) => step.key);
293
177
  if (matchingStepKeys.length > 0) setCompletedStepKeys((current) => mergeKeys(current, matchingStepKeys));
294
178
  let networkOk = true;
295
179
  if (hostedMode && client && experienceKey) try {
296
180
  applyProgress((await client.track({
297
- environment,
298
181
  eventName,
299
182
  experienceKey,
300
183
  origin: currentOrigin,
@@ -323,7 +206,6 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
323
206
  client,
324
207
  currentRoute,
325
208
  currentOrigin,
326
- environment,
327
209
  experienceKey,
328
210
  hostedMode,
329
211
  onTrack,
@@ -332,6 +214,52 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
332
214
  user,
333
215
  versionId
334
216
  ]);
217
+ const submitSurvey = useCallback(async (answers) => {
218
+ if (resolvedLoading || !resolvedSpec) return {
219
+ ok: false,
220
+ reason: "not-ready"
221
+ };
222
+ if (!user) return {
223
+ ok: false,
224
+ reason: "missing-user"
225
+ };
226
+ if (resolvedSpec.type !== "survey" || !resolvedSpec.survey) return {
227
+ ok: false,
228
+ reason: "not-survey"
229
+ };
230
+ if (!hostedMode || !client || !experienceKey) return {
231
+ ok: false,
232
+ reason: "not-ready"
233
+ };
234
+ try {
235
+ await client.submitSurvey({
236
+ answers,
237
+ experienceKey,
238
+ origin: currentOrigin,
239
+ route: currentRoute,
240
+ user: toRuntimeUser(user),
241
+ versionId: versionId ?? void 0
242
+ });
243
+ } catch {
244
+ return {
245
+ ok: false,
246
+ reason: "network-error"
247
+ };
248
+ }
249
+ refreshBootstrap();
250
+ return { ok: true };
251
+ }, [
252
+ client,
253
+ currentOrigin,
254
+ currentRoute,
255
+ experienceKey,
256
+ hostedMode,
257
+ refreshBootstrap,
258
+ resolvedLoading,
259
+ resolvedSpec,
260
+ user,
261
+ versionId
262
+ ]);
335
263
  const navigateToStep = useCallback(async (step) => {
336
264
  if (!step.route) return;
337
265
  await navigateWithFallback(step.route, navigate);
@@ -349,14 +277,12 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
349
277
  ok: false,
350
278
  reason: "missing-tour"
351
279
  };
352
- setTourError(null);
353
280
  lastNavigatedTourStep.current = null;
354
281
  setActiveTourState({
355
282
  key: tourKey,
356
283
  stepIndex: 0
357
284
  });
358
285
  if (hostedMode && client && experienceKey && user) client.startTour({
359
- environment,
360
286
  experienceKey,
361
287
  origin: currentOrigin,
362
288
  route: currentRoute,
@@ -369,7 +295,6 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
369
295
  client,
370
296
  currentRoute,
371
297
  currentOrigin,
372
- environment,
373
298
  experienceKey,
374
299
  hostedMode,
375
300
  resolvedLoading,
@@ -381,10 +306,8 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
381
306
  const dismissed = activeTourState;
382
307
  const dismissedStepKey = dismissed ? resolvedSpec?.tours[dismissed.key]?.steps[dismissed.stepIndex]?.key : void 0;
383
308
  setActiveTourState(null);
384
- setTourError(null);
385
309
  lastNavigatedTourStep.current = null;
386
310
  if (!options?.silent && hostedMode && client && experienceKey && user && dismissed && dismissedStepKey) client.dismissTour({
387
- environment,
388
311
  experienceKey,
389
312
  origin: currentOrigin,
390
313
  route: currentRoute,
@@ -399,7 +322,6 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
399
322
  client,
400
323
  currentOrigin,
401
324
  currentRoute,
402
- environment,
403
325
  experienceKey,
404
326
  hostedMode,
405
327
  resolvedSpec,
@@ -411,7 +333,6 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
411
333
  if (!hostedMode || !client || !experienceKey || !user) return;
412
334
  try {
413
335
  applyProgress((await client.completeTour({
414
- environment,
415
336
  experienceKey,
416
337
  origin: currentOrigin,
417
338
  route: currentRoute,
@@ -425,7 +346,6 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
425
346
  completeTour,
426
347
  currentRoute,
427
348
  currentOrigin,
428
- environment,
429
349
  experienceKey,
430
350
  hostedMode,
431
351
  user,
@@ -445,7 +365,6 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
445
365
  ok: false,
446
366
  reason: "missing-tour"
447
367
  };
448
- setTourError(null);
449
368
  lastNavigatedTourStep.current = null;
450
369
  if (activeTourState.stepIndex >= tour.steps.length - 1) {
451
370
  const completedKey = activeTourState.key;
@@ -481,48 +400,41 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
481
400
  currentRoute,
482
401
  resolvedSpec
483
402
  ]);
484
- const reportTourError = useCallback((error) => {
485
- setTourError(error);
486
- }, []);
487
403
  const value = useMemo(() => ({
488
404
  spec: resolvedSpec,
489
- user,
405
+ user: effectiveUser,
490
406
  loading: resolvedLoading,
491
- mode: resolvedMode,
492
407
  route: currentRoute,
493
408
  completedStepKeys: effectiveCompletedStepKeys,
494
409
  completedTourKeys: effectiveCompletedTourKeys,
495
410
  completedCount,
496
411
  totalSteps,
497
412
  activeTour,
498
- tourError,
499
413
  track,
414
+ submitSurvey,
500
415
  startTour,
501
416
  dismissTour,
502
417
  advanceTour,
503
418
  navigateToStep,
504
419
  startChecklistStep,
505
- reportTourError,
506
420
  visibleHotspots
507
421
  }), [
508
422
  resolvedSpec,
509
- user,
423
+ effectiveUser,
510
424
  resolvedLoading,
511
- resolvedMode,
512
425
  currentRoute,
513
426
  effectiveCompletedStepKeys,
514
427
  effectiveCompletedTourKeys,
515
428
  completedCount,
516
429
  totalSteps,
517
430
  activeTour,
518
- tourError,
519
431
  track,
432
+ submitSurvey,
520
433
  startTour,
521
434
  dismissTour,
522
435
  advanceTour,
523
436
  navigateToStep,
524
437
  startChecklistStep,
525
- reportTourError,
526
438
  visibleHotspots
527
439
  ]);
528
440
  return /* @__PURE__ */ jsx(SunboardContext.Provider, {
@@ -531,47 +443,56 @@ function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: mo
531
443
  });
532
444
  }
533
445
  function useSunboardBootstrap(client, options) {
534
- const { enabled, environment, origin, route, user } = options;
446
+ const { enabled, nonce, origin, route, user } = options;
535
447
  const [state, setState] = useState({
448
+ attributes: null,
536
449
  experience: null,
537
- loading: false
450
+ loading: false,
451
+ survey: null
538
452
  });
539
453
  const warnedSdkAdvisory = useRef(false);
540
454
  useEffect(() => {
541
455
  if (!client || !enabled || !user?.id) {
542
456
  setState({
457
+ attributes: null,
543
458
  experience: null,
544
- loading: false
459
+ loading: false,
460
+ survey: null
545
461
  });
546
462
  return;
547
463
  }
548
464
  const controller = new AbortController();
549
465
  setState({
466
+ attributes: null,
550
467
  experience: null,
551
- loading: true
468
+ loading: true,
469
+ survey: null
552
470
  });
553
471
  client.bootstrap({
554
- environment,
555
472
  origin,
556
473
  route,
557
474
  user: toRuntimeUser(user)
558
475
  }).then((response) => {
559
476
  if (controller.signal.aborted) return;
560
- if (response.experiences.length === 0 && isDevEnvironment()) console.warn(`[sunboard] bootstrap returned no active experiences for route "${route}" in environment "${environment}". Check the dashboard for an enabled deployment whose trigger.route matches.`);
477
+ if (response.experiences.length === 0 && !response.survey && isDevEnvironment()) console.warn(`[sunboard] bootstrap returned no active experiences for route "${route}". Check the dashboard for an enabled deployment whose trigger.route matches (the publishable key selects the environment).`);
561
478
  if (response.sdkAdvisory && isDevEnvironment() && !warnedSdkAdvisory.current) {
562
479
  warnedSdkAdvisory.current = true;
563
480
  console.warn(`[sunboard] ${response.sdkAdvisory.message}`);
564
481
  }
565
482
  setState({
483
+ attributes: response.attributes ?? null,
566
484
  experience: response.experiences[0] ?? null,
567
- loading: false
485
+ loading: false,
486
+ survey: response.survey ?? null
568
487
  });
569
488
  }).catch((error) => {
570
489
  if (controller.signal.aborted) return;
571
490
  if (isDevEnvironment()) console.warn("[sunboard] bootstrap failed", error);
572
491
  setState({
492
+ attributes: null,
573
493
  experience: null,
574
- loading: false
494
+ loading: false,
495
+ survey: null
575
496
  });
576
497
  });
577
498
  return () => {
@@ -580,7 +501,7 @@ function useSunboardBootstrap(client, options) {
580
501
  }, [
581
502
  client,
582
503
  enabled,
583
- environment,
504
+ nonce,
584
505
  origin,
585
506
  user?.id
586
507
  ]);
@@ -685,19 +606,18 @@ function useSunboard() {
685
606
 
686
607
  //#endregion
687
608
  //#region src/hooks/useChecklist.ts
688
- const SUPPORTED_CHECKLIST_TYPES = new Set(["checklist", "hybrid"]);
689
609
  function actionLabelFor(step) {
690
610
  if (step.action?.type === "startTour") return "Start";
691
611
  if (step.action || step.route) return "Open";
692
612
  }
693
- function interpolateOptional(text, user) {
613
+ function interpolateOptional$1(text, user) {
694
614
  if (!text) return void 0;
695
615
  return interpolateTokens(text, user) || void 0;
696
616
  }
697
617
  function useChecklist() {
698
618
  const { spec, user, loading, completedStepKeys, completedCount, totalSteps, startChecklistStep } = useSunboard();
699
619
  const [dismissedKey, setDismissedKey] = useState(null);
700
- const hasChecklist = Boolean(spec && SUPPORTED_CHECKLIST_TYPES.has(spec.type));
620
+ const hasChecklist = Boolean(spec && spec.checklist?.steps.length);
701
621
  const allCompleted = totalSteps > 0 && completedCount >= totalSteps;
702
622
  const ready = !loading && !!user && !!spec && hasChecklist;
703
623
  const specKey = spec?.key ?? null;
@@ -712,17 +632,17 @@ function useChecklist() {
712
632
  const sawIncompleteFirst = observationRef.current?.sawIncomplete ?? false;
713
633
  const dismissed = dismissedKey !== null && dismissedKey === observationKey;
714
634
  const rawSteps = useMemo(() => spec?.checklist?.steps ?? [], [spec]);
715
- const title = useMemo(() => interpolateOptional(spec?.title, user ?? null), [spec?.title, user]);
716
- const subtitle = useMemo(() => interpolateOptional(spec?.subtitle, user ?? null), [spec?.subtitle, user]);
635
+ const title = useMemo(() => interpolateOptional$1(spec?.title, user ?? null), [spec?.title, user]);
636
+ const subtitle = useMemo(() => interpolateOptional$1(spec?.subtitle, user ?? null), [spec?.subtitle, user]);
717
637
  const completion = useMemo(() => {
718
638
  const raw = spec?.completion;
719
639
  if (!raw) return void 0;
720
- const interpolatedTitle = interpolateOptional(raw.title, user ?? null);
640
+ const interpolatedTitle = interpolateOptional$1(raw.title, user ?? null);
721
641
  if (!interpolatedTitle) return void 0;
722
642
  return {
723
643
  title: interpolatedTitle,
724
- subtitle: interpolateOptional(raw.subtitle, user ?? null),
725
- message: interpolateOptional(raw.message, user ?? null),
644
+ subtitle: interpolateOptional$1(raw.subtitle, user ?? null),
645
+ message: interpolateOptional$1(raw.message, user ?? null),
726
646
  dismissAfterMs: raw.dismissAfterMs
727
647
  };
728
648
  }, [spec?.completion, user]);
@@ -768,6 +688,140 @@ function useChecklist() {
768
688
  };
769
689
  }
770
690
 
691
+ //#endregion
692
+ //#region src/hooks/useSurvey.ts
693
+ function interpolateOptional(text, user) {
694
+ if (!text) return void 0;
695
+ return interpolateTokens(text, user) || void 0;
696
+ }
697
+ function answered(value) {
698
+ if (value === void 0 || value === null) return false;
699
+ if (typeof value === "string") return value.trim().length > 0;
700
+ if (Array.isArray(value)) return value.length > 0;
701
+ return true;
702
+ }
703
+ function useSurvey() {
704
+ const { spec, user, loading, submitSurvey } = useSunboard();
705
+ const [answers, setAnswers] = useState({});
706
+ const [submitting, setSubmitting] = useState(false);
707
+ const [submitted, setSubmitted] = useState(false);
708
+ const [error, setError] = useState(null);
709
+ const [stepIndex, setStepIndex] = useState(0);
710
+ const isSurvey = Boolean(spec && spec.type === "survey" && spec.survey);
711
+ const ready = !loading && !!user && isSurvey;
712
+ const title = useMemo(() => interpolateOptional(spec?.title, user ?? null), [spec?.title, user]);
713
+ const subtitle = useMemo(() => interpolateOptional(spec?.subtitle, user ?? null), [spec?.subtitle, user]);
714
+ const steps = useMemo(() => {
715
+ return (spec?.survey?.steps ?? []).map((step) => ({
716
+ title: interpolateOptional(step.title, user ?? null),
717
+ subtitle: interpolateOptional(step.subtitle, user ?? null),
718
+ questions: step.questions.map((question) => ({
719
+ key: question.key,
720
+ prompt: interpolateTokens(question.prompt, user ?? null),
721
+ type: question.type,
722
+ required: question.required,
723
+ options: (question.options ?? []).map((option) => ({
724
+ value: option.value,
725
+ label: interpolateTokens(option.label, user ?? null)
726
+ }))
727
+ }))
728
+ }));
729
+ }, [spec?.survey, user]);
730
+ const questions = useMemo(() => steps.flatMap((step) => step.questions), [steps]);
731
+ const signature = questions.map((question) => question.key).join("|");
732
+ const signatureRef = useRef(signature);
733
+ if (signatureRef.current !== signature) {
734
+ signatureRef.current = signature;
735
+ if (stepIndex !== 0) setStepIndex(0);
736
+ }
737
+ const totalSteps = steps.length;
738
+ const safeIndex = totalSteps > 0 ? Math.min(stepIndex, totalSteps - 1) : 0;
739
+ const currentStep = steps[safeIndex];
740
+ const setAnswer = useCallback((key, value) => {
741
+ setAnswers((current) => ({
742
+ ...current,
743
+ [key]: value
744
+ }));
745
+ }, []);
746
+ const toggleAnswer = useCallback((key, value) => {
747
+ setAnswers((current) => {
748
+ const existing = Array.isArray(current[key]) ? current[key] : [];
749
+ const next$1 = existing.includes(value) ? existing.filter((item) => item !== value) : [...existing, value];
750
+ return {
751
+ ...current,
752
+ [key]: next$1
753
+ };
754
+ });
755
+ }, []);
756
+ const isAnswered = useCallback((key) => answered(answers[key]), [answers]);
757
+ const canSubmit = useMemo(() => {
758
+ if (!ready || submitting) return false;
759
+ return questions.filter((question) => question.required).every((question) => answered(answers[question.key]));
760
+ }, [
761
+ answers,
762
+ questions,
763
+ ready,
764
+ submitting
765
+ ]);
766
+ const canAdvance = useMemo(() => {
767
+ if (!currentStep) return false;
768
+ return currentStep.questions.filter((question) => question.required).every((question) => answered(answers[question.key]));
769
+ }, [answers, currentStep]);
770
+ const submit = useCallback(async () => {
771
+ if (!canSubmit) return;
772
+ setSubmitting(true);
773
+ setError(null);
774
+ const result = await submitSurvey(answers);
775
+ setSubmitting(false);
776
+ if (result.ok) {
777
+ setSubmitted(true);
778
+ return;
779
+ }
780
+ setError(result.reason === "network-error" ? "Something went wrong. Please try again." : "Survey is not ready.");
781
+ }, [
782
+ answers,
783
+ canSubmit,
784
+ submitSurvey
785
+ ]);
786
+ const isLastStep = totalSteps === 0 || safeIndex >= totalSteps - 1;
787
+ const isFirstStep = safeIndex === 0;
788
+ const next = useCallback(() => {
789
+ if (isLastStep) submit();
790
+ else setStepIndex(safeIndex + 1);
791
+ }, [
792
+ isLastStep,
793
+ safeIndex,
794
+ submit
795
+ ]);
796
+ const back = useCallback(() => {
797
+ setStepIndex((current) => Math.max(0, current - 1));
798
+ }, []);
799
+ return {
800
+ visible: ready && !submitted,
801
+ title,
802
+ subtitle,
803
+ questions,
804
+ steps,
805
+ stepIndex: safeIndex,
806
+ totalSteps,
807
+ currentStep,
808
+ progress: totalSteps > 0 ? (safeIndex + 1) / totalSteps * 100 : 0,
809
+ isFirstStep,
810
+ isLastStep,
811
+ canAdvance,
812
+ next,
813
+ back,
814
+ answers,
815
+ setAnswer,
816
+ toggleAnswer,
817
+ isAnswered,
818
+ canSubmit,
819
+ submitting,
820
+ error,
821
+ submit
822
+ };
823
+ }
824
+
771
825
  //#endregion
772
826
  //#region src/icons/index.tsx
773
827
  const CheckIcon = () => {
@@ -932,7 +986,7 @@ function Hotspot({ hotspot, hotspotKey }) {
932
986
  //#endregion
933
987
  //#region src/components/SunboardTour.tsx
934
988
  function SunboardTour() {
935
- const { activeTour, advanceTour, dismissTour, mode, reportTourError, route, tourError } = useSunboard();
989
+ const { activeTour, advanceTour, dismissTour, route } = useSunboard();
936
990
  const arrowRef = useRef(null);
937
991
  const [spotlightRect, setSpotlightRect] = useState(null);
938
992
  const routeReady = routeMatches(activeTour?.step.route, route ?? "/");
@@ -947,36 +1001,16 @@ function SunboardTour() {
947
1001
  ]
948
1002
  });
949
1003
  useEffect(() => {
950
- if (!activeTour) {
951
- reportTourError(null);
952
- return;
953
- }
954
- if (!routeReady) return;
955
- if (!target.timedOut) return;
956
- const error = {
957
- message: "Sunboard could not find the tour target.",
958
- route: activeTour.step.route,
959
- stepKey: activeTour.step.key,
960
- target: activeTour.step.target,
961
- tourKey: activeTour.key
962
- };
963
- if (mode === "preview") {
964
- reportTourError(error);
965
- return;
966
- }
967
- reportTourError(null);
1004
+ if (!activeTour || !routeReady || !target.timedOut) return;
968
1005
  dismissTour({ silent: true });
969
1006
  }, [
970
1007
  activeTour,
971
1008
  dismissTour,
972
- mode,
973
- reportTourError,
974
1009
  routeReady,
975
1010
  target.timedOut
976
1011
  ]);
977
1012
  useEffect(() => {
978
1013
  if (!activeTour || !target.element) return;
979
- reportTourError(null);
980
1014
  refs.setReference(target.element);
981
1015
  target.element.scrollIntoView({
982
1016
  block: "center",
@@ -986,7 +1020,6 @@ function SunboardTour() {
986
1020
  }, [
987
1021
  activeTour,
988
1022
  refs,
989
- reportTourError,
990
1023
  target.element,
991
1024
  update
992
1025
  ]);
@@ -1029,22 +1062,6 @@ function SunboardTour() {
1029
1062
  const stepTitle = useInterpolated(activeTour?.step.title);
1030
1063
  const stepBody = useInterpolated(activeTour?.step.body);
1031
1064
  if (!activeTour) return null;
1032
- if (tourError && mode === "preview") return /* @__PURE__ */ jsxs("div", {
1033
- className: "sunboard-debug",
1034
- role: "alert",
1035
- children: [
1036
- /* @__PURE__ */ jsx("strong", { children: tourError.message }),
1037
- /* @__PURE__ */ jsxs("span", { children: ["Tour: ", tourError.tourKey] }),
1038
- /* @__PURE__ */ jsxs("span", { children: ["Step: ", tourError.stepKey] }),
1039
- tourError.route ? /* @__PURE__ */ jsxs("span", { children: ["Route: ", tourError.route] }) : null,
1040
- tourError.target ? /* @__PURE__ */ jsxs("span", { children: ["Target: ", tourError.target] }) : null,
1041
- /* @__PURE__ */ jsx("button", {
1042
- onClick: () => void dismissTour(),
1043
- type: "button",
1044
- children: "Dismiss"
1045
- })
1046
- ]
1047
- });
1048
1065
  if (!routeReady || !target.element) return null;
1049
1066
  const isLastStep = activeTour.stepIndex === activeTour.totalSteps - 1;
1050
1067
  return /* @__PURE__ */ jsxs(FloatingPortal, { children: [/* @__PURE__ */ jsx(SunboardTourScrim, { rect: spotlightRect }), /* @__PURE__ */ jsxs("section", {
@@ -1254,12 +1271,130 @@ function SunboardChecklist() {
1254
1271
  ]
1255
1272
  });
1256
1273
  }
1274
+ function SunboardSurvey() {
1275
+ const { visible, title, subtitle, currentStep, stepIndex, totalSteps, progress, isFirstStep, isLastStep, canAdvance, canSubmit, next, back, answers, setAnswer, toggleAnswer, submitting, error } = useSurvey();
1276
+ if (!visible || !title || !currentStep) return null;
1277
+ const showProgress = totalSteps > 1;
1278
+ return /* @__PURE__ */ jsx("div", {
1279
+ className: "sunboard-survey-overlay",
1280
+ children: /* @__PURE__ */ jsxs("section", {
1281
+ className: "sunboard-survey-card",
1282
+ role: "dialog",
1283
+ "aria-modal": "true",
1284
+ "aria-label": `${title} survey`,
1285
+ children: [
1286
+ /* @__PURE__ */ jsxs("div", {
1287
+ className: "sunboard-survey-head",
1288
+ children: [subtitle ? /* @__PURE__ */ jsx("p", {
1289
+ className: "sunboard-eyebrow",
1290
+ children: subtitle
1291
+ }) : null, /* @__PURE__ */ jsx("h2", {
1292
+ className: "sunboard-title",
1293
+ children: title
1294
+ })]
1295
+ }),
1296
+ showProgress ? /* @__PURE__ */ jsxs("div", {
1297
+ className: "sunboard-survey-progress-row",
1298
+ children: [/* @__PURE__ */ jsxs("p", {
1299
+ className: "sunboard-survey-stepcount",
1300
+ children: [
1301
+ "Step ",
1302
+ stepIndex + 1,
1303
+ " of ",
1304
+ totalSteps
1305
+ ]
1306
+ }), /* @__PURE__ */ jsx("div", {
1307
+ className: "sunboard-progress",
1308
+ role: "progressbar",
1309
+ "aria-label": `${title} progress`,
1310
+ "aria-valuemin": 1,
1311
+ "aria-valuemax": totalSteps,
1312
+ "aria-valuenow": stepIndex + 1,
1313
+ children: /* @__PURE__ */ jsx("div", {
1314
+ className: "sunboard-progress-fill",
1315
+ style: { width: `${progress}%` }
1316
+ })
1317
+ })]
1318
+ }) : null,
1319
+ /* @__PURE__ */ jsxs("form", {
1320
+ className: "sunboard-survey-form",
1321
+ onSubmit: (event) => {
1322
+ event.preventDefault();
1323
+ next();
1324
+ },
1325
+ children: [
1326
+ currentStep.title ? /* @__PURE__ */ jsx("p", {
1327
+ className: "sunboard-survey-step-title",
1328
+ children: currentStep.title
1329
+ }) : null,
1330
+ /* @__PURE__ */ jsx("ol", {
1331
+ className: "sunboard-survey-questions",
1332
+ children: currentStep.questions.map((question) => /* @__PURE__ */ jsxs("li", {
1333
+ className: "sunboard-survey-question",
1334
+ children: [/* @__PURE__ */ jsxs("p", {
1335
+ className: "sunboard-survey-prompt",
1336
+ children: [question.prompt, question.required ? /* @__PURE__ */ jsx("span", {
1337
+ "aria-hidden": "true",
1338
+ className: "sunboard-survey-required",
1339
+ children: " *"
1340
+ }) : null]
1341
+ }), question.type === "text" ? /* @__PURE__ */ jsx("input", {
1342
+ className: "sunboard-survey-input",
1343
+ type: "text",
1344
+ value: typeof answers[question.key] === "string" ? answers[question.key] : "",
1345
+ required: question.required,
1346
+ "aria-label": question.prompt,
1347
+ onChange: (event) => setAnswer(question.key, event.target.value)
1348
+ }) : /* @__PURE__ */ jsx("div", {
1349
+ className: "sunboard-survey-options",
1350
+ role: "group",
1351
+ "aria-label": question.prompt,
1352
+ children: question.options.map((option) => {
1353
+ const selected = question.type === "multi-select" ? Array.isArray(answers[question.key]) && answers[question.key].includes(option.value) : answers[question.key] === option.value;
1354
+ return /* @__PURE__ */ jsx("button", {
1355
+ className: "sunboard-survey-option",
1356
+ type: "button",
1357
+ "aria-pressed": selected,
1358
+ "data-selected": selected ? "true" : "false",
1359
+ onClick: () => question.type === "multi-select" ? toggleAnswer(question.key, option.value) : setAnswer(question.key, option.value),
1360
+ children: option.label
1361
+ }, option.value);
1362
+ })
1363
+ })]
1364
+ }, question.key))
1365
+ }),
1366
+ error ? /* @__PURE__ */ jsx("p", {
1367
+ className: "sunboard-survey-error",
1368
+ role: "alert",
1369
+ children: error
1370
+ }) : null,
1371
+ /* @__PURE__ */ jsxs("div", {
1372
+ className: "sunboard-survey-nav",
1373
+ children: [!isFirstStep ? /* @__PURE__ */ jsx("button", {
1374
+ className: "sunboard-survey-back",
1375
+ type: "button",
1376
+ onClick: back,
1377
+ children: "Back"
1378
+ }) : /* @__PURE__ */ jsx("span", {}), /* @__PURE__ */ jsx("button", {
1379
+ className: "sunboard-survey-submit",
1380
+ type: "submit",
1381
+ disabled: isLastStep ? !canSubmit : !canAdvance,
1382
+ children: isLastStep ? submitting ? "Submitting…" : "Finish" : "Continue"
1383
+ })]
1384
+ })
1385
+ ]
1386
+ })
1387
+ ]
1388
+ })
1389
+ });
1390
+ }
1257
1391
  const Sunboard = Object.assign(SunboardChecklist, {
1258
1392
  Checklist: SunboardChecklist,
1393
+ Survey: SunboardSurvey,
1259
1394
  Hotspots: SunboardHotspots,
1260
1395
  Tour: SunboardTour
1261
1396
  });
1262
1397
 
1263
1398
  //#endregion
1264
- export { Sunboard, SunboardChecklist, SunboardHotspots, SunboardProvider, SunboardRuntimeApiError, SunboardRuntimeClient, SunboardTour, useChecklist, useSunboard, useSunboardPreviewSpec };
1399
+ export { Sunboard, SunboardChecklist, SunboardHotspots, SunboardProvider, SunboardRuntimeApiError, SunboardRuntimeClient, SunboardSurvey, SunboardTour, useChecklist, useSunboard, useSurvey };
1265
1400
  //# sourceMappingURL=index.mjs.map