@sunboard/react 0.1.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/LICENSE +21 -0
- package/README.md +59 -0
- package/dist/index.d.mts +156 -0
- package/dist/index.d.mts.map +1 -0
- package/dist/index.mjs +1250 -0
- package/dist/index.mjs.map +1 -0
- package/dist/styles.css +560 -0
- package/package.json +59 -0
package/dist/index.mjs
ADDED
|
@@ -0,0 +1,1250 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
|
|
3
|
+
import { createContext, useCallback, useContext, useEffect, useMemo, useRef, useState } from "react";
|
|
4
|
+
import { SunboardRuntimeApiError, SunboardRuntimeClient, SunboardRuntimeClient as SunboardRuntimeClient$1, routeMatches } from "@sunboard/core/runtime-api";
|
|
5
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
6
|
+
import { FloatingArrow, FloatingPortal, arrow, autoUpdate, flip, offset, shift, useFloating } from "@floating-ui/react";
|
|
7
|
+
|
|
8
|
+
//#region src/adapters/navigation.ts
|
|
9
|
+
async function navigateWithFallback(url, navigate) {
|
|
10
|
+
if (navigate) {
|
|
11
|
+
await navigate(url);
|
|
12
|
+
return;
|
|
13
|
+
}
|
|
14
|
+
if (typeof window !== "undefined") window.location.assign(url);
|
|
15
|
+
}
|
|
16
|
+
|
|
17
|
+
//#endregion
|
|
18
|
+
//#region src/hooks/sunboard-context.ts
|
|
19
|
+
const SunboardContext = createContext(null);
|
|
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
|
+
//#endregion
|
|
149
|
+
//#region src/version.ts
|
|
150
|
+
const SDK_VERSION = "0.0.0";
|
|
151
|
+
|
|
152
|
+
//#endregion
|
|
153
|
+
//#region src/components/SunboardProvider.tsx
|
|
154
|
+
function SunboardProvider({ spec: specProp, user, loading: loadingProp, mode: modeProp, route, navigate, apiUrl, publishableKey, environment = "production", onTrack, children }) {
|
|
155
|
+
const currentRoute = route ?? getBrowserRoute();
|
|
156
|
+
const currentOrigin = getBrowserOrigin();
|
|
157
|
+
const preview = useSunboardPreviewSpec();
|
|
158
|
+
const client = useMemo(() => {
|
|
159
|
+
if (!apiUrl || !publishableKey) return null;
|
|
160
|
+
return new SunboardRuntimeClient$1({
|
|
161
|
+
apiUrl,
|
|
162
|
+
publishableKey,
|
|
163
|
+
sdkVersion: SDK_VERSION
|
|
164
|
+
});
|
|
165
|
+
}, [apiUrl, publishableKey]);
|
|
166
|
+
const bootstrap = useSunboardBootstrap(client, {
|
|
167
|
+
enabled: !specProp && preview.source !== "preview" && !preview.loading,
|
|
168
|
+
environment,
|
|
169
|
+
origin: currentOrigin,
|
|
170
|
+
route: currentRoute,
|
|
171
|
+
user
|
|
172
|
+
});
|
|
173
|
+
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";
|
|
179
|
+
const [completedStepKeys, setCompletedStepKeys] = useState(() => /* @__PURE__ */ new Set());
|
|
180
|
+
const [completedTourKeys, setCompletedTourKeys] = useState(() => /* @__PURE__ */ new Set());
|
|
181
|
+
const [activeTourState, setActiveTourState] = useState(null);
|
|
182
|
+
const [tourError, setTourError] = useState(null);
|
|
183
|
+
const lastNavigatedTourStep = useRef(null);
|
|
184
|
+
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;
|
|
187
|
+
useEffect(() => {
|
|
188
|
+
setCompletedStepKeys(/* @__PURE__ */ new Set());
|
|
189
|
+
setActiveTourState(null);
|
|
190
|
+
setTourError(null);
|
|
191
|
+
lastNavigatedTourStep.current = null;
|
|
192
|
+
if (!storageKey) {
|
|
193
|
+
setCompletedTourKeys(/* @__PURE__ */ new Set());
|
|
194
|
+
return;
|
|
195
|
+
}
|
|
196
|
+
setCompletedTourKeys(readCompletedTours(storageKey));
|
|
197
|
+
}, [
|
|
198
|
+
resolvedSpec?.key,
|
|
199
|
+
storageKey,
|
|
200
|
+
user?.id
|
|
201
|
+
]);
|
|
202
|
+
useEffect(() => {
|
|
203
|
+
if (!hostedMode || !activeExperience) return;
|
|
204
|
+
applyProgress(activeExperience.progress, setCompletedStepKeys, setCompletedTourKeys);
|
|
205
|
+
}, [activeExperience, hostedMode]);
|
|
206
|
+
const effectiveCompletedStepKeys = useMemo(() => {
|
|
207
|
+
if (!hostedMode || !activeExperience) return completedStepKeys;
|
|
208
|
+
return mergeKeys(completedStepKeys, activeExperience.progress.completedStepKeys);
|
|
209
|
+
}, [
|
|
210
|
+
activeExperience,
|
|
211
|
+
completedStepKeys,
|
|
212
|
+
hostedMode
|
|
213
|
+
]);
|
|
214
|
+
const effectiveCompletedTourKeys = useMemo(() => {
|
|
215
|
+
if (!hostedMode || !activeExperience) return completedTourKeys;
|
|
216
|
+
return mergeKeys(completedTourKeys, activeExperience.progress.completedTourKeys);
|
|
217
|
+
}, [
|
|
218
|
+
activeExperience,
|
|
219
|
+
completedTourKeys,
|
|
220
|
+
hostedMode
|
|
221
|
+
]);
|
|
222
|
+
useEffect(() => {
|
|
223
|
+
if (!resolvedSpec) return;
|
|
224
|
+
const tourCompletedStepKeys = resolvedSpec.checklist.steps.filter((step) => step.completeWhen?.tour && effectiveCompletedTourKeys.has(step.completeWhen.tour)).map((step) => step.key);
|
|
225
|
+
if (tourCompletedStepKeys.length === 0) return;
|
|
226
|
+
setCompletedStepKeys((current) => {
|
|
227
|
+
const next = new Set(current);
|
|
228
|
+
for (const stepKey of tourCompletedStepKeys) next.add(stepKey);
|
|
229
|
+
return next;
|
|
230
|
+
});
|
|
231
|
+
}, [effectiveCompletedTourKeys, resolvedSpec]);
|
|
232
|
+
const activeTour = useMemo(() => {
|
|
233
|
+
if (!resolvedSpec || !activeTourState) return null;
|
|
234
|
+
const tour = resolvedSpec.tours[activeTourState.key];
|
|
235
|
+
const step = tour?.steps[activeTourState.stepIndex];
|
|
236
|
+
if (!tour || !step) return null;
|
|
237
|
+
return {
|
|
238
|
+
key: activeTourState.key,
|
|
239
|
+
name: tour.name,
|
|
240
|
+
step,
|
|
241
|
+
stepIndex: activeTourState.stepIndex,
|
|
242
|
+
totalSteps: tour.steps.length
|
|
243
|
+
};
|
|
244
|
+
}, [activeTourState, resolvedSpec]);
|
|
245
|
+
useEffect(() => {
|
|
246
|
+
if (!activeTour?.step.route || routeMatches(activeTour.step.route, currentRoute)) return;
|
|
247
|
+
const navigationKey = `${activeTour.key}:${activeTour.step.key}:${activeTour.step.route}`;
|
|
248
|
+
if (lastNavigatedTourStep.current === navigationKey) return;
|
|
249
|
+
lastNavigatedTourStep.current = navigationKey;
|
|
250
|
+
navigateWithFallback(activeTour.step.route, navigate);
|
|
251
|
+
}, [
|
|
252
|
+
activeTour,
|
|
253
|
+
currentRoute,
|
|
254
|
+
navigate
|
|
255
|
+
]);
|
|
256
|
+
useEffect(() => {
|
|
257
|
+
if (!hostedMode || !client || !activeTour || !user || !experienceKey) return;
|
|
258
|
+
client.recordTourStep({
|
|
259
|
+
environment,
|
|
260
|
+
experienceKey,
|
|
261
|
+
origin: currentOrigin,
|
|
262
|
+
route: currentRoute,
|
|
263
|
+
stepKey: activeTour.step.key,
|
|
264
|
+
tourKey: activeTour.key,
|
|
265
|
+
user: toRuntimeUser(user),
|
|
266
|
+
versionId: versionId ?? void 0
|
|
267
|
+
}).catch(() => {});
|
|
268
|
+
}, [
|
|
269
|
+
activeTour?.key,
|
|
270
|
+
activeTour?.step.key,
|
|
271
|
+
client,
|
|
272
|
+
currentOrigin,
|
|
273
|
+
experienceKey,
|
|
274
|
+
hostedMode,
|
|
275
|
+
user?.id
|
|
276
|
+
]);
|
|
277
|
+
const totalSteps = resolvedSpec?.checklist.steps.length ?? 0;
|
|
278
|
+
const completedCount = resolvedSpec?.checklist.steps.filter((step) => effectiveCompletedStepKeys.has(step.key)).length ?? 0;
|
|
279
|
+
const completeTour = useCallback((tourKey) => {
|
|
280
|
+
setCompletedTourKeys((current) => {
|
|
281
|
+
const next = new Set(current);
|
|
282
|
+
next.add(tourKey);
|
|
283
|
+
if (storageKey) writeCompletedTours(storageKey, next);
|
|
284
|
+
return next;
|
|
285
|
+
});
|
|
286
|
+
}, [storageKey]);
|
|
287
|
+
const track = useCallback(async (eventName, properties) => {
|
|
288
|
+
if (resolvedLoading || !resolvedSpec) return {
|
|
289
|
+
ok: false,
|
|
290
|
+
reason: "not-ready"
|
|
291
|
+
};
|
|
292
|
+
if (!user) return {
|
|
293
|
+
ok: false,
|
|
294
|
+
reason: "missing-user"
|
|
295
|
+
};
|
|
296
|
+
const matchingStepKeys = resolvedSpec.checklist.steps.filter((step) => step.completeWhen?.event === eventName).map((step) => step.key);
|
|
297
|
+
if (matchingStepKeys.length > 0) setCompletedStepKeys((current) => {
|
|
298
|
+
const next = new Set(current);
|
|
299
|
+
for (const stepKey of matchingStepKeys) next.add(stepKey);
|
|
300
|
+
return next;
|
|
301
|
+
});
|
|
302
|
+
let networkOk = true;
|
|
303
|
+
if (hostedMode && client && experienceKey) try {
|
|
304
|
+
applyProgress((await client.track({
|
|
305
|
+
environment,
|
|
306
|
+
eventName,
|
|
307
|
+
experienceKey,
|
|
308
|
+
origin: currentOrigin,
|
|
309
|
+
properties,
|
|
310
|
+
route: currentRoute,
|
|
311
|
+
user: toRuntimeUser(user),
|
|
312
|
+
versionId: versionId ?? void 0
|
|
313
|
+
})).progress, setCompletedStepKeys, setCompletedTourKeys);
|
|
314
|
+
} catch {
|
|
315
|
+
networkOk = false;
|
|
316
|
+
}
|
|
317
|
+
try {
|
|
318
|
+
await onTrack?.(eventName, properties);
|
|
319
|
+
} catch {
|
|
320
|
+
return {
|
|
321
|
+
ok: false,
|
|
322
|
+
reason: "network-error"
|
|
323
|
+
};
|
|
324
|
+
}
|
|
325
|
+
if (!networkOk) return {
|
|
326
|
+
ok: false,
|
|
327
|
+
reason: "network-error"
|
|
328
|
+
};
|
|
329
|
+
return { ok: true };
|
|
330
|
+
}, [
|
|
331
|
+
client,
|
|
332
|
+
currentRoute,
|
|
333
|
+
currentOrigin,
|
|
334
|
+
environment,
|
|
335
|
+
experienceKey,
|
|
336
|
+
hostedMode,
|
|
337
|
+
onTrack,
|
|
338
|
+
resolvedLoading,
|
|
339
|
+
resolvedSpec,
|
|
340
|
+
user,
|
|
341
|
+
versionId
|
|
342
|
+
]);
|
|
343
|
+
const navigateToStep = useCallback(async (step) => {
|
|
344
|
+
if (!step.route) return;
|
|
345
|
+
await navigateWithFallback(step.route, navigate);
|
|
346
|
+
}, [navigate]);
|
|
347
|
+
const startTour = useCallback(async (tourKey) => {
|
|
348
|
+
if (resolvedLoading || !resolvedSpec) return {
|
|
349
|
+
ok: false,
|
|
350
|
+
reason: "not-ready"
|
|
351
|
+
};
|
|
352
|
+
if (!user) return {
|
|
353
|
+
ok: false,
|
|
354
|
+
reason: "missing-user"
|
|
355
|
+
};
|
|
356
|
+
if (!resolvedSpec.tours[tourKey]) return {
|
|
357
|
+
ok: false,
|
|
358
|
+
reason: "missing-tour"
|
|
359
|
+
};
|
|
360
|
+
setTourError(null);
|
|
361
|
+
lastNavigatedTourStep.current = null;
|
|
362
|
+
setActiveTourState({
|
|
363
|
+
key: tourKey,
|
|
364
|
+
stepIndex: 0
|
|
365
|
+
});
|
|
366
|
+
if (hostedMode && client && experienceKey && user) client.startTour({
|
|
367
|
+
environment,
|
|
368
|
+
experienceKey,
|
|
369
|
+
origin: currentOrigin,
|
|
370
|
+
route: currentRoute,
|
|
371
|
+
tourKey,
|
|
372
|
+
user: toRuntimeUser(user),
|
|
373
|
+
versionId: versionId ?? void 0
|
|
374
|
+
}).catch(() => {});
|
|
375
|
+
return { ok: true };
|
|
376
|
+
}, [
|
|
377
|
+
client,
|
|
378
|
+
currentRoute,
|
|
379
|
+
currentOrigin,
|
|
380
|
+
environment,
|
|
381
|
+
experienceKey,
|
|
382
|
+
hostedMode,
|
|
383
|
+
resolvedLoading,
|
|
384
|
+
resolvedSpec,
|
|
385
|
+
user,
|
|
386
|
+
versionId
|
|
387
|
+
]);
|
|
388
|
+
const dismissTour = useCallback(async () => {
|
|
389
|
+
setActiveTourState(null);
|
|
390
|
+
setTourError(null);
|
|
391
|
+
lastNavigatedTourStep.current = null;
|
|
392
|
+
return { ok: true };
|
|
393
|
+
}, []);
|
|
394
|
+
const finishActiveTour = useCallback(async (tourKey) => {
|
|
395
|
+
completeTour(tourKey);
|
|
396
|
+
if (!hostedMode || !client || !experienceKey || !user) return;
|
|
397
|
+
try {
|
|
398
|
+
applyProgress((await client.completeTour({
|
|
399
|
+
environment,
|
|
400
|
+
experienceKey,
|
|
401
|
+
origin: currentOrigin,
|
|
402
|
+
route: currentRoute,
|
|
403
|
+
tourKey,
|
|
404
|
+
user: toRuntimeUser(user),
|
|
405
|
+
versionId: versionId ?? void 0
|
|
406
|
+
})).progress, setCompletedStepKeys, setCompletedTourKeys);
|
|
407
|
+
} catch {}
|
|
408
|
+
}, [
|
|
409
|
+
client,
|
|
410
|
+
completeTour,
|
|
411
|
+
currentRoute,
|
|
412
|
+
currentOrigin,
|
|
413
|
+
environment,
|
|
414
|
+
experienceKey,
|
|
415
|
+
hostedMode,
|
|
416
|
+
user,
|
|
417
|
+
versionId
|
|
418
|
+
]);
|
|
419
|
+
const advanceTour = useCallback(async () => {
|
|
420
|
+
if (resolvedLoading || !resolvedSpec || !activeTourState) return {
|
|
421
|
+
ok: false,
|
|
422
|
+
reason: "not-ready"
|
|
423
|
+
};
|
|
424
|
+
if (!user) return {
|
|
425
|
+
ok: false,
|
|
426
|
+
reason: "missing-user"
|
|
427
|
+
};
|
|
428
|
+
const tour = resolvedSpec.tours[activeTourState.key];
|
|
429
|
+
if (!tour) return {
|
|
430
|
+
ok: false,
|
|
431
|
+
reason: "missing-tour"
|
|
432
|
+
};
|
|
433
|
+
setTourError(null);
|
|
434
|
+
lastNavigatedTourStep.current = null;
|
|
435
|
+
if (activeTourState.stepIndex >= tour.steps.length - 1) {
|
|
436
|
+
const completedKey = activeTourState.key;
|
|
437
|
+
setActiveTourState(null);
|
|
438
|
+
await finishActiveTour(completedKey);
|
|
439
|
+
return { ok: true };
|
|
440
|
+
}
|
|
441
|
+
setActiveTourState({
|
|
442
|
+
key: activeTourState.key,
|
|
443
|
+
stepIndex: activeTourState.stepIndex + 1
|
|
444
|
+
});
|
|
445
|
+
return { ok: true };
|
|
446
|
+
}, [
|
|
447
|
+
activeTourState,
|
|
448
|
+
finishActiveTour,
|
|
449
|
+
resolvedLoading,
|
|
450
|
+
resolvedSpec,
|
|
451
|
+
user
|
|
452
|
+
]);
|
|
453
|
+
const startChecklistStep = useCallback(async (step) => {
|
|
454
|
+
if (step.action?.type === "startTour") return startTour(step.action.tour);
|
|
455
|
+
await navigateToStep(step);
|
|
456
|
+
}, [navigateToStep, startTour]);
|
|
457
|
+
const visibleHotspots = useMemo(() => {
|
|
458
|
+
if (!resolvedSpec || activeTour) return [];
|
|
459
|
+
return Object.entries(resolvedSpec.hotspots).filter(([, hotspot]) => routeMatches(hotspot.route, currentRoute)).filter(([, hotspot]) => hotspot.repeatable || !effectiveCompletedTourKeys.has(hotspot.action.tour)).map(([key, hotspot]) => ({
|
|
460
|
+
key,
|
|
461
|
+
hotspot
|
|
462
|
+
}));
|
|
463
|
+
}, [
|
|
464
|
+
activeTour,
|
|
465
|
+
effectiveCompletedTourKeys,
|
|
466
|
+
currentRoute,
|
|
467
|
+
resolvedSpec
|
|
468
|
+
]);
|
|
469
|
+
const reportTourError = useCallback((error) => {
|
|
470
|
+
setTourError(error);
|
|
471
|
+
}, []);
|
|
472
|
+
const value = useMemo(() => ({
|
|
473
|
+
spec: resolvedSpec,
|
|
474
|
+
user,
|
|
475
|
+
loading: resolvedLoading,
|
|
476
|
+
mode: resolvedMode,
|
|
477
|
+
route: currentRoute,
|
|
478
|
+
completedStepKeys: effectiveCompletedStepKeys,
|
|
479
|
+
completedTourKeys: effectiveCompletedTourKeys,
|
|
480
|
+
completedCount,
|
|
481
|
+
totalSteps,
|
|
482
|
+
activeTour,
|
|
483
|
+
tourError,
|
|
484
|
+
track,
|
|
485
|
+
startTour,
|
|
486
|
+
dismissTour,
|
|
487
|
+
advanceTour,
|
|
488
|
+
navigateToStep,
|
|
489
|
+
startChecklistStep,
|
|
490
|
+
reportTourError,
|
|
491
|
+
visibleHotspots
|
|
492
|
+
}), [
|
|
493
|
+
resolvedSpec,
|
|
494
|
+
user,
|
|
495
|
+
resolvedLoading,
|
|
496
|
+
resolvedMode,
|
|
497
|
+
currentRoute,
|
|
498
|
+
effectiveCompletedStepKeys,
|
|
499
|
+
effectiveCompletedTourKeys,
|
|
500
|
+
completedCount,
|
|
501
|
+
totalSteps,
|
|
502
|
+
activeTour,
|
|
503
|
+
tourError,
|
|
504
|
+
track,
|
|
505
|
+
startTour,
|
|
506
|
+
dismissTour,
|
|
507
|
+
advanceTour,
|
|
508
|
+
navigateToStep,
|
|
509
|
+
startChecklistStep,
|
|
510
|
+
reportTourError,
|
|
511
|
+
visibleHotspots
|
|
512
|
+
]);
|
|
513
|
+
return /* @__PURE__ */ jsx(SunboardContext.Provider, {
|
|
514
|
+
value,
|
|
515
|
+
children
|
|
516
|
+
});
|
|
517
|
+
}
|
|
518
|
+
function useSunboardBootstrap(client, options) {
|
|
519
|
+
const { enabled, environment, origin, route, user } = options;
|
|
520
|
+
const [state, setState] = useState({
|
|
521
|
+
experience: null,
|
|
522
|
+
loading: false
|
|
523
|
+
});
|
|
524
|
+
const warnedSdkAdvisory = useRef(false);
|
|
525
|
+
useEffect(() => {
|
|
526
|
+
if (!client || !enabled || !user?.id) {
|
|
527
|
+
setState({
|
|
528
|
+
experience: null,
|
|
529
|
+
loading: false
|
|
530
|
+
});
|
|
531
|
+
return;
|
|
532
|
+
}
|
|
533
|
+
const controller = new AbortController();
|
|
534
|
+
setState({
|
|
535
|
+
experience: null,
|
|
536
|
+
loading: true
|
|
537
|
+
});
|
|
538
|
+
client.bootstrap({
|
|
539
|
+
environment,
|
|
540
|
+
origin,
|
|
541
|
+
route,
|
|
542
|
+
user: toRuntimeUser(user)
|
|
543
|
+
}).then((response) => {
|
|
544
|
+
if (controller.signal.aborted) return;
|
|
545
|
+
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.`);
|
|
546
|
+
if (response.sdkAdvisory && isDevEnvironment() && !warnedSdkAdvisory.current) {
|
|
547
|
+
warnedSdkAdvisory.current = true;
|
|
548
|
+
console.warn(`[sunboard] ${response.sdkAdvisory.message}`);
|
|
549
|
+
}
|
|
550
|
+
setState({
|
|
551
|
+
experience: response.experiences[0] ?? null,
|
|
552
|
+
loading: false
|
|
553
|
+
});
|
|
554
|
+
}).catch((error) => {
|
|
555
|
+
if (controller.signal.aborted) return;
|
|
556
|
+
if (isDevEnvironment()) console.warn("[sunboard] bootstrap failed", error);
|
|
557
|
+
setState({
|
|
558
|
+
experience: null,
|
|
559
|
+
loading: false
|
|
560
|
+
});
|
|
561
|
+
});
|
|
562
|
+
return () => {
|
|
563
|
+
controller.abort();
|
|
564
|
+
};
|
|
565
|
+
}, [
|
|
566
|
+
client,
|
|
567
|
+
enabled,
|
|
568
|
+
environment,
|
|
569
|
+
origin,
|
|
570
|
+
user?.id
|
|
571
|
+
]);
|
|
572
|
+
return state;
|
|
573
|
+
}
|
|
574
|
+
function applyProgress(progress, setSteps, setTours) {
|
|
575
|
+
setSteps((current) => mergeKeys(current, progress.completedStepKeys));
|
|
576
|
+
setTours((current) => mergeKeys(current, progress.completedTourKeys));
|
|
577
|
+
}
|
|
578
|
+
function mergeKeys(current, incoming) {
|
|
579
|
+
if (incoming.length === 0) return current;
|
|
580
|
+
const next = new Set(current);
|
|
581
|
+
for (const key of incoming) next.add(key);
|
|
582
|
+
if (next.size === current.size) return current;
|
|
583
|
+
return next;
|
|
584
|
+
}
|
|
585
|
+
function isDevEnvironment() {
|
|
586
|
+
if (typeof process === "undefined" || !process.env) return false;
|
|
587
|
+
return process.env.NODE_ENV !== "production";
|
|
588
|
+
}
|
|
589
|
+
function toRuntimeUser(user) {
|
|
590
|
+
return {
|
|
591
|
+
email: user.email ?? void 0,
|
|
592
|
+
group: user.group ?? void 0,
|
|
593
|
+
id: user.id,
|
|
594
|
+
properties: user.properties
|
|
595
|
+
};
|
|
596
|
+
}
|
|
597
|
+
function completedToursStorageKey(specKey, userId) {
|
|
598
|
+
return `sunboard:${specKey}:${userId}:completed-tours`;
|
|
599
|
+
}
|
|
600
|
+
function getBrowserRoute() {
|
|
601
|
+
if (typeof window === "undefined") return "/";
|
|
602
|
+
return `${window.location.pathname}${window.location.search}`;
|
|
603
|
+
}
|
|
604
|
+
function getBrowserOrigin() {
|
|
605
|
+
if (typeof window === "undefined") return;
|
|
606
|
+
return window.location.origin;
|
|
607
|
+
}
|
|
608
|
+
function readCompletedTours(storageKey) {
|
|
609
|
+
if (typeof window === "undefined") return /* @__PURE__ */ new Set();
|
|
610
|
+
try {
|
|
611
|
+
const rawValue = window.localStorage.getItem(storageKey);
|
|
612
|
+
const parsed = rawValue ? JSON.parse(rawValue) : [];
|
|
613
|
+
if (!Array.isArray(parsed)) return /* @__PURE__ */ new Set();
|
|
614
|
+
return new Set(parsed.filter((value) => typeof value === "string"));
|
|
615
|
+
} catch {
|
|
616
|
+
return /* @__PURE__ */ new Set();
|
|
617
|
+
}
|
|
618
|
+
}
|
|
619
|
+
function writeCompletedTours(storageKey, tourKeys) {
|
|
620
|
+
if (typeof window === "undefined") return;
|
|
621
|
+
try {
|
|
622
|
+
window.localStorage.setItem(storageKey, JSON.stringify([...tourKeys].sort()));
|
|
623
|
+
} catch {}
|
|
624
|
+
}
|
|
625
|
+
|
|
626
|
+
//#endregion
|
|
627
|
+
//#region src/lib/interpolate-tokens.ts
|
|
628
|
+
const TOKEN_PATTERN = /\{\{\s*user\.([\w][\w.]*)(?:\s*\|\s*(?:"([^"]*)"|'([^']*)'))?\s*\}\}/g;
|
|
629
|
+
function flattenUser(user) {
|
|
630
|
+
if (!user) return {};
|
|
631
|
+
const flat = { ...user.properties ?? {} };
|
|
632
|
+
flat.id = user.id;
|
|
633
|
+
if (user.email !== void 0) flat.email = user.email;
|
|
634
|
+
if (user.group !== void 0) flat.group = user.group;
|
|
635
|
+
return flat;
|
|
636
|
+
}
|
|
637
|
+
function lookupPath(root, path) {
|
|
638
|
+
const segments = path.split(".");
|
|
639
|
+
let current = root;
|
|
640
|
+
for (const segment of segments) {
|
|
641
|
+
if (current === null || current === void 0) return void 0;
|
|
642
|
+
if (typeof current !== "object") return void 0;
|
|
643
|
+
current = current[segment];
|
|
644
|
+
}
|
|
645
|
+
return current;
|
|
646
|
+
}
|
|
647
|
+
function coerceValue(value) {
|
|
648
|
+
if (value === null || value === void 0) return null;
|
|
649
|
+
if (typeof value === "string") return value.length > 0 ? value : null;
|
|
650
|
+
if (typeof value === "number" || typeof value === "boolean") return String(value);
|
|
651
|
+
return null;
|
|
652
|
+
}
|
|
653
|
+
function interpolateTokens(template, user) {
|
|
654
|
+
if (!template || !template.includes("{{")) return template;
|
|
655
|
+
const flat = flattenUser(user);
|
|
656
|
+
return template.replace(TOKEN_PATTERN, (_match, path, doubleQuoted, singleQuoted) => {
|
|
657
|
+
const resolved = coerceValue(lookupPath(flat, path));
|
|
658
|
+
if (resolved !== null) return resolved;
|
|
659
|
+
return doubleQuoted ?? singleQuoted ?? "";
|
|
660
|
+
});
|
|
661
|
+
}
|
|
662
|
+
|
|
663
|
+
//#endregion
|
|
664
|
+
//#region src/hooks/useSunboard.ts
|
|
665
|
+
function useSunboard() {
|
|
666
|
+
const context = useContext(SunboardContext);
|
|
667
|
+
if (!context) throw new Error("useSunboard must be used within SunboardProvider.");
|
|
668
|
+
return context;
|
|
669
|
+
}
|
|
670
|
+
|
|
671
|
+
//#endregion
|
|
672
|
+
//#region src/hooks/useChecklist.ts
|
|
673
|
+
const SUPPORTED_CHECKLIST_TYPES = new Set(["checklist", "hybrid"]);
|
|
674
|
+
function actionLabelFor(step) {
|
|
675
|
+
if (step.action?.type === "startTour") return "Start";
|
|
676
|
+
if (step.action || step.route) return "Open";
|
|
677
|
+
}
|
|
678
|
+
function interpolateOptional(text, user) {
|
|
679
|
+
if (!text) return void 0;
|
|
680
|
+
return interpolateTokens(text, user) || void 0;
|
|
681
|
+
}
|
|
682
|
+
function useChecklist() {
|
|
683
|
+
const { spec, user, loading, completedStepKeys, completedCount, totalSteps, startChecklistStep } = useSunboard();
|
|
684
|
+
const [dismissedKey, setDismissedKey] = useState(null);
|
|
685
|
+
const hasChecklist = Boolean(spec && SUPPORTED_CHECKLIST_TYPES.has(spec.type));
|
|
686
|
+
const allCompleted = totalSteps > 0 && completedCount >= totalSteps;
|
|
687
|
+
const ready = !loading && !!user && !!spec && hasChecklist;
|
|
688
|
+
const specKey = spec?.key ?? null;
|
|
689
|
+
const userId = user?.id ?? null;
|
|
690
|
+
const observationKey = ready && specKey && userId ? `${specKey}:${userId}` : null;
|
|
691
|
+
const observationRef = useRef(null);
|
|
692
|
+
if (observationKey && observationRef.current?.key !== observationKey) observationRef.current = {
|
|
693
|
+
key: observationKey,
|
|
694
|
+
sawIncomplete: !allCompleted
|
|
695
|
+
};
|
|
696
|
+
else if (observationKey && observationRef.current && !allCompleted) observationRef.current.sawIncomplete = true;
|
|
697
|
+
const sawIncompleteFirst = observationRef.current?.sawIncomplete ?? false;
|
|
698
|
+
const dismissed = dismissedKey !== null && dismissedKey === observationKey;
|
|
699
|
+
const rawSteps = useMemo(() => spec?.checklist?.steps ?? [], [spec]);
|
|
700
|
+
const title = useMemo(() => interpolateOptional(spec?.title, user ?? null), [spec?.title, user]);
|
|
701
|
+
const subtitle = useMemo(() => interpolateOptional(spec?.subtitle, user ?? null), [spec?.subtitle, user]);
|
|
702
|
+
const completion = useMemo(() => {
|
|
703
|
+
const raw = spec?.completion;
|
|
704
|
+
if (!raw) return void 0;
|
|
705
|
+
const interpolatedTitle = interpolateOptional(raw.title, user ?? null);
|
|
706
|
+
if (!interpolatedTitle) return void 0;
|
|
707
|
+
return {
|
|
708
|
+
title: interpolatedTitle,
|
|
709
|
+
subtitle: interpolateOptional(raw.subtitle, user ?? null),
|
|
710
|
+
message: interpolateOptional(raw.message, user ?? null),
|
|
711
|
+
dismissAfterMs: raw.dismissAfterMs
|
|
712
|
+
};
|
|
713
|
+
}, [spec?.completion, user]);
|
|
714
|
+
const showingCompletion = ready && allCompleted && !!completion && sawIncompleteFirst && !dismissed;
|
|
715
|
+
const visible = ready && !allCompleted || showingCompletion;
|
|
716
|
+
const steps = useMemo(() => {
|
|
717
|
+
return rawSteps.map((step) => ({
|
|
718
|
+
key: step.key,
|
|
719
|
+
title: interpolateTokens(step.title, user ?? null),
|
|
720
|
+
description: step.description ? interpolateTokens(step.description, user ?? null) : void 0,
|
|
721
|
+
badge: step.badge ? interpolateTokens(step.badge, user ?? null) : void 0,
|
|
722
|
+
actionLabel: actionLabelFor(step)
|
|
723
|
+
}));
|
|
724
|
+
}, [rawSteps, user]);
|
|
725
|
+
const stepsByKey = useMemo(() => {
|
|
726
|
+
const map = /* @__PURE__ */ new Map();
|
|
727
|
+
for (const step of rawSteps) map.set(step.key, step);
|
|
728
|
+
return map;
|
|
729
|
+
}, [rawSteps]);
|
|
730
|
+
const remainingCount = Math.max(totalSteps - completedCount, 0);
|
|
731
|
+
const progress = totalSteps > 0 ? Math.round(completedCount / totalSteps * 100) : 0;
|
|
732
|
+
const dismissCompletion = useCallback(() => {
|
|
733
|
+
if (observationKey) setDismissedKey(observationKey);
|
|
734
|
+
}, [observationKey]);
|
|
735
|
+
return {
|
|
736
|
+
visible,
|
|
737
|
+
title,
|
|
738
|
+
subtitle,
|
|
739
|
+
steps,
|
|
740
|
+
completedStepKeys,
|
|
741
|
+
completedCount,
|
|
742
|
+
totalSteps,
|
|
743
|
+
remainingCount,
|
|
744
|
+
progress,
|
|
745
|
+
isCompleted: (key) => completedStepKeys.has(key),
|
|
746
|
+
hasAction: (step) => step.actionLabel !== void 0,
|
|
747
|
+
startStep: (step) => {
|
|
748
|
+
const raw = stepsByKey.get(step.key);
|
|
749
|
+
if (raw) startChecklistStep(raw);
|
|
750
|
+
},
|
|
751
|
+
completion: showingCompletion ? completion : void 0,
|
|
752
|
+
dismissCompletion
|
|
753
|
+
};
|
|
754
|
+
}
|
|
755
|
+
|
|
756
|
+
//#endregion
|
|
757
|
+
//#region src/icons/index.tsx
|
|
758
|
+
const CheckIcon = () => {
|
|
759
|
+
return /* @__PURE__ */ jsx("svg", {
|
|
760
|
+
xmlns: "http://www.w3.org/2000/svg",
|
|
761
|
+
width: "24",
|
|
762
|
+
height: "24",
|
|
763
|
+
viewBox: "0 0 24 24",
|
|
764
|
+
fill: "none",
|
|
765
|
+
stroke: "currentColor",
|
|
766
|
+
strokeWidth: "2",
|
|
767
|
+
strokeLinecap: "round",
|
|
768
|
+
strokeLinejoin: "round",
|
|
769
|
+
className: "lucide lucide-check-icon lucide-check",
|
|
770
|
+
children: /* @__PURE__ */ jsx("path", { d: "M20 6 9 17l-5-5" })
|
|
771
|
+
});
|
|
772
|
+
};
|
|
773
|
+
|
|
774
|
+
//#endregion
|
|
775
|
+
//#region src/components/ChecklistStep.tsx
|
|
776
|
+
function ChecklistStep({ completed, onStart, step }) {
|
|
777
|
+
const hasAction = step.actionLabel !== void 0;
|
|
778
|
+
const status = /* @__PURE__ */ jsx("span", {
|
|
779
|
+
className: "sunboard-step-status",
|
|
780
|
+
"aria-label": completed ? "Completed" : "Incomplete",
|
|
781
|
+
children: completed ? /* @__PURE__ */ jsx(CheckIcon, {}) : null
|
|
782
|
+
});
|
|
783
|
+
const content = /* @__PURE__ */ jsxs("div", {
|
|
784
|
+
className: "sunboard-step-content",
|
|
785
|
+
children: [/* @__PURE__ */ jsxs("div", {
|
|
786
|
+
className: "sunboard-step-heading",
|
|
787
|
+
children: [/* @__PURE__ */ jsx("h3", {
|
|
788
|
+
className: "sunboard-step-title",
|
|
789
|
+
children: step.title
|
|
790
|
+
}), step.badge ? /* @__PURE__ */ jsx("span", {
|
|
791
|
+
className: "sunboard-step-badge",
|
|
792
|
+
children: step.badge
|
|
793
|
+
}) : null]
|
|
794
|
+
}), step.description ? /* @__PURE__ */ jsx("p", {
|
|
795
|
+
className: "sunboard-step-description",
|
|
796
|
+
children: step.description
|
|
797
|
+
}) : null]
|
|
798
|
+
});
|
|
799
|
+
return /* @__PURE__ */ jsx("li", {
|
|
800
|
+
className: "sunboard-step",
|
|
801
|
+
"data-completed": completed,
|
|
802
|
+
children: hasAction ? /* @__PURE__ */ jsxs("button", {
|
|
803
|
+
className: "sunboard-step-row",
|
|
804
|
+
type: "button",
|
|
805
|
+
onClick: onStart,
|
|
806
|
+
"aria-label": `${step.actionLabel} ${step.title}`,
|
|
807
|
+
children: [status, content]
|
|
808
|
+
}) : /* @__PURE__ */ jsxs("div", {
|
|
809
|
+
className: "sunboard-step-row",
|
|
810
|
+
children: [status, content]
|
|
811
|
+
})
|
|
812
|
+
});
|
|
813
|
+
}
|
|
814
|
+
|
|
815
|
+
//#endregion
|
|
816
|
+
//#region src/hooks/useInterpolated.ts
|
|
817
|
+
function useInterpolated(text) {
|
|
818
|
+
const { user } = useSunboard();
|
|
819
|
+
return useMemo(() => {
|
|
820
|
+
if (text === void 0) return void 0;
|
|
821
|
+
return interpolateTokens(text, user);
|
|
822
|
+
}, [text, user]);
|
|
823
|
+
}
|
|
824
|
+
|
|
825
|
+
//#endregion
|
|
826
|
+
//#region src/hooks/useTargetElement.ts
|
|
827
|
+
function useTargetElement(selector, enabled, timeoutMs = 5e3) {
|
|
828
|
+
const [state, setState] = useState({
|
|
829
|
+
element: null,
|
|
830
|
+
timedOut: false
|
|
831
|
+
});
|
|
832
|
+
useEffect(() => {
|
|
833
|
+
if (!enabled || !selector) {
|
|
834
|
+
setState({
|
|
835
|
+
element: null,
|
|
836
|
+
timedOut: false
|
|
837
|
+
});
|
|
838
|
+
return;
|
|
839
|
+
}
|
|
840
|
+
let cancelled = false;
|
|
841
|
+
const startedAt = Date.now();
|
|
842
|
+
const findTarget = () => {
|
|
843
|
+
if (cancelled) return;
|
|
844
|
+
const element = document.querySelector(selector);
|
|
845
|
+
if (element instanceof HTMLElement) {
|
|
846
|
+
setState({
|
|
847
|
+
element,
|
|
848
|
+
timedOut: false
|
|
849
|
+
});
|
|
850
|
+
return;
|
|
851
|
+
}
|
|
852
|
+
if (Date.now() - startedAt >= timeoutMs) {
|
|
853
|
+
setState({
|
|
854
|
+
element: null,
|
|
855
|
+
timedOut: true
|
|
856
|
+
});
|
|
857
|
+
return;
|
|
858
|
+
}
|
|
859
|
+
window.setTimeout(findTarget, 100);
|
|
860
|
+
};
|
|
861
|
+
setState({
|
|
862
|
+
element: null,
|
|
863
|
+
timedOut: false
|
|
864
|
+
});
|
|
865
|
+
findTarget();
|
|
866
|
+
return () => {
|
|
867
|
+
cancelled = true;
|
|
868
|
+
};
|
|
869
|
+
}, [
|
|
870
|
+
enabled,
|
|
871
|
+
selector,
|
|
872
|
+
timeoutMs
|
|
873
|
+
]);
|
|
874
|
+
return state;
|
|
875
|
+
}
|
|
876
|
+
|
|
877
|
+
//#endregion
|
|
878
|
+
//#region src/components/SunboardHotspots.tsx
|
|
879
|
+
const HOTSPOT_RADIUS_PX = 9;
|
|
880
|
+
function SunboardHotspots() {
|
|
881
|
+
const { visibleHotspots } = useSunboard();
|
|
882
|
+
return /* @__PURE__ */ jsx(Fragment, { children: visibleHotspots.map(({ key, hotspot }) => /* @__PURE__ */ jsx(Hotspot, {
|
|
883
|
+
hotspot,
|
|
884
|
+
hotspotKey: key
|
|
885
|
+
}, key)) });
|
|
886
|
+
}
|
|
887
|
+
function Hotspot({ hotspot, hotspotKey }) {
|
|
888
|
+
const { startTour } = useSunboard();
|
|
889
|
+
const label = useInterpolated(hotspot.label);
|
|
890
|
+
const target = useTargetElement(hotspot.target, true);
|
|
891
|
+
const { refs, floatingStyles } = useFloating({
|
|
892
|
+
placement: hotspot.placement,
|
|
893
|
+
strategy: "fixed",
|
|
894
|
+
middleware: [offset(-HOTSPOT_RADIUS_PX)],
|
|
895
|
+
whileElementsMounted: autoUpdate
|
|
896
|
+
});
|
|
897
|
+
useEffect(() => {
|
|
898
|
+
if (!target.element) return;
|
|
899
|
+
refs.setReference(target.element);
|
|
900
|
+
}, [refs, target.element]);
|
|
901
|
+
if (!target.element) return null;
|
|
902
|
+
return /* @__PURE__ */ jsx(FloatingPortal, { children: /* @__PURE__ */ jsx("button", {
|
|
903
|
+
"aria-label": label ?? `Start ${hotspot.action.tour} tour`,
|
|
904
|
+
className: "sunboard-hotspot",
|
|
905
|
+
"data-pulse": hotspot.pulse,
|
|
906
|
+
"data-sunboard-hotspot": hotspotKey,
|
|
907
|
+
onClick: () => {
|
|
908
|
+
startTour(hotspot.action.tour);
|
|
909
|
+
},
|
|
910
|
+
ref: refs.setFloating,
|
|
911
|
+
style: floatingStyles,
|
|
912
|
+
type: "button",
|
|
913
|
+
children: /* @__PURE__ */ jsx("span", { className: "sunboard-hotspot-dot" })
|
|
914
|
+
}) });
|
|
915
|
+
}
|
|
916
|
+
|
|
917
|
+
//#endregion
|
|
918
|
+
//#region src/components/SunboardTour.tsx
|
|
919
|
+
function SunboardTour() {
|
|
920
|
+
const { activeTour, advanceTour, dismissTour, mode, reportTourError, route, tourError } = useSunboard();
|
|
921
|
+
const arrowRef = useRef(null);
|
|
922
|
+
const [spotlightRect, setSpotlightRect] = useState(null);
|
|
923
|
+
const routeReady = routeMatches(activeTour?.step.route, route ?? "/");
|
|
924
|
+
const target = useTargetElement(activeTour?.step.target ?? null, Boolean(activeTour) && routeReady, 1e3);
|
|
925
|
+
const { context, refs, floatingStyles, update } = useFloating({
|
|
926
|
+
placement: activeTour?.step.placement ?? "bottom",
|
|
927
|
+
middleware: [
|
|
928
|
+
offset(16),
|
|
929
|
+
flip(),
|
|
930
|
+
shift({ padding: 12 }),
|
|
931
|
+
arrow({ element: arrowRef })
|
|
932
|
+
]
|
|
933
|
+
});
|
|
934
|
+
useEffect(() => {
|
|
935
|
+
if (!activeTour) {
|
|
936
|
+
reportTourError(null);
|
|
937
|
+
return;
|
|
938
|
+
}
|
|
939
|
+
if (!routeReady) return;
|
|
940
|
+
if (!target.timedOut) return;
|
|
941
|
+
const error = {
|
|
942
|
+
message: "Sunboard could not find the tour target.",
|
|
943
|
+
route: activeTour.step.route,
|
|
944
|
+
stepKey: activeTour.step.key,
|
|
945
|
+
target: activeTour.step.target,
|
|
946
|
+
tourKey: activeTour.key
|
|
947
|
+
};
|
|
948
|
+
if (mode === "preview") {
|
|
949
|
+
reportTourError(error);
|
|
950
|
+
return;
|
|
951
|
+
}
|
|
952
|
+
reportTourError(null);
|
|
953
|
+
dismissTour();
|
|
954
|
+
}, [
|
|
955
|
+
activeTour,
|
|
956
|
+
dismissTour,
|
|
957
|
+
mode,
|
|
958
|
+
reportTourError,
|
|
959
|
+
routeReady,
|
|
960
|
+
target.timedOut
|
|
961
|
+
]);
|
|
962
|
+
useEffect(() => {
|
|
963
|
+
if (!activeTour || !target.element) return;
|
|
964
|
+
reportTourError(null);
|
|
965
|
+
refs.setReference(target.element);
|
|
966
|
+
target.element.scrollIntoView({
|
|
967
|
+
block: "center",
|
|
968
|
+
inline: "center"
|
|
969
|
+
});
|
|
970
|
+
update();
|
|
971
|
+
}, [
|
|
972
|
+
activeTour,
|
|
973
|
+
refs,
|
|
974
|
+
reportTourError,
|
|
975
|
+
target.element,
|
|
976
|
+
update
|
|
977
|
+
]);
|
|
978
|
+
useEffect(() => {
|
|
979
|
+
if (!activeTour || !target.element) {
|
|
980
|
+
setSpotlightRect(null);
|
|
981
|
+
return;
|
|
982
|
+
}
|
|
983
|
+
const updateSpotlight = () => {
|
|
984
|
+
setSpotlightRect(target.element?.getBoundingClientRect() ?? null);
|
|
985
|
+
};
|
|
986
|
+
updateSpotlight();
|
|
987
|
+
window.addEventListener("resize", updateSpotlight);
|
|
988
|
+
window.addEventListener("scroll", updateSpotlight, true);
|
|
989
|
+
target.element.setAttribute("data-sunboard-tour-target", "true");
|
|
990
|
+
return () => {
|
|
991
|
+
window.removeEventListener("resize", updateSpotlight);
|
|
992
|
+
window.removeEventListener("scroll", updateSpotlight, true);
|
|
993
|
+
target.element?.removeAttribute("data-sunboard-tour-target");
|
|
994
|
+
setSpotlightRect(null);
|
|
995
|
+
};
|
|
996
|
+
}, [activeTour, target.element]);
|
|
997
|
+
useEffect(() => {
|
|
998
|
+
if (!activeTour || !target.element) return;
|
|
999
|
+
const advanceOnTargetClick = () => {
|
|
1000
|
+
window.setTimeout(() => {
|
|
1001
|
+
advanceTour();
|
|
1002
|
+
}, 0);
|
|
1003
|
+
};
|
|
1004
|
+
target.element.addEventListener("click", advanceOnTargetClick);
|
|
1005
|
+
return () => {
|
|
1006
|
+
target.element?.removeEventListener("click", advanceOnTargetClick);
|
|
1007
|
+
};
|
|
1008
|
+
}, [
|
|
1009
|
+
activeTour,
|
|
1010
|
+
advanceTour,
|
|
1011
|
+
target.element
|
|
1012
|
+
]);
|
|
1013
|
+
const tourName = useInterpolated(activeTour?.name);
|
|
1014
|
+
const stepTitle = useInterpolated(activeTour?.step.title);
|
|
1015
|
+
const stepBody = useInterpolated(activeTour?.step.body);
|
|
1016
|
+
if (!activeTour) return null;
|
|
1017
|
+
if (tourError && mode === "preview") return /* @__PURE__ */ jsxs("div", {
|
|
1018
|
+
className: "sunboard-debug",
|
|
1019
|
+
role: "alert",
|
|
1020
|
+
children: [
|
|
1021
|
+
/* @__PURE__ */ jsx("strong", { children: tourError.message }),
|
|
1022
|
+
/* @__PURE__ */ jsxs("span", { children: ["Tour: ", tourError.tourKey] }),
|
|
1023
|
+
/* @__PURE__ */ jsxs("span", { children: ["Step: ", tourError.stepKey] }),
|
|
1024
|
+
tourError.route ? /* @__PURE__ */ jsxs("span", { children: ["Route: ", tourError.route] }) : null,
|
|
1025
|
+
tourError.target ? /* @__PURE__ */ jsxs("span", { children: ["Target: ", tourError.target] }) : null,
|
|
1026
|
+
/* @__PURE__ */ jsx("button", {
|
|
1027
|
+
onClick: () => void dismissTour(),
|
|
1028
|
+
type: "button",
|
|
1029
|
+
children: "Dismiss"
|
|
1030
|
+
})
|
|
1031
|
+
]
|
|
1032
|
+
});
|
|
1033
|
+
if (!routeReady || !target.element) return null;
|
|
1034
|
+
const isLastStep = activeTour.stepIndex === activeTour.totalSteps - 1;
|
|
1035
|
+
return /* @__PURE__ */ jsxs(FloatingPortal, { children: [/* @__PURE__ */ jsx(SunboardTourScrim, { rect: spotlightRect }), /* @__PURE__ */ jsxs("section", {
|
|
1036
|
+
"aria-label": `${tourName} tour step`,
|
|
1037
|
+
className: "sunboard-tour",
|
|
1038
|
+
ref: refs.setFloating,
|
|
1039
|
+
role: "dialog",
|
|
1040
|
+
style: floatingStyles,
|
|
1041
|
+
children: [
|
|
1042
|
+
/* @__PURE__ */ jsx(FloatingArrow, {
|
|
1043
|
+
className: "sunboard-tour-arrow",
|
|
1044
|
+
context,
|
|
1045
|
+
fill: "var(--sunboard-tour-background)",
|
|
1046
|
+
ref: arrowRef,
|
|
1047
|
+
stroke: "var(--sunboard-tour-border)",
|
|
1048
|
+
strokeWidth: 1,
|
|
1049
|
+
tipRadius: 2
|
|
1050
|
+
}),
|
|
1051
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1052
|
+
className: "sunboard-tour-meta",
|
|
1053
|
+
children: [
|
|
1054
|
+
activeTour.stepIndex + 1,
|
|
1055
|
+
" / ",
|
|
1056
|
+
activeTour.totalSteps
|
|
1057
|
+
]
|
|
1058
|
+
}),
|
|
1059
|
+
/* @__PURE__ */ jsx("h2", {
|
|
1060
|
+
className: "sunboard-tour-title",
|
|
1061
|
+
children: stepTitle
|
|
1062
|
+
}),
|
|
1063
|
+
stepBody ? /* @__PURE__ */ jsx("p", {
|
|
1064
|
+
className: "sunboard-tour-body",
|
|
1065
|
+
children: stepBody
|
|
1066
|
+
}) : null,
|
|
1067
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1068
|
+
className: "sunboard-tour-actions",
|
|
1069
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
1070
|
+
className: "sunboard-tour-secondary",
|
|
1071
|
+
onClick: () => void dismissTour(),
|
|
1072
|
+
type: "button",
|
|
1073
|
+
children: "Dismiss"
|
|
1074
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
1075
|
+
className: "sunboard-tour-primary",
|
|
1076
|
+
onClick: () => void advanceTour(),
|
|
1077
|
+
type: "button",
|
|
1078
|
+
children: isLastStep ? "Finish" : "Next"
|
|
1079
|
+
})]
|
|
1080
|
+
})
|
|
1081
|
+
]
|
|
1082
|
+
})] });
|
|
1083
|
+
}
|
|
1084
|
+
function SunboardTourScrim({ rect }) {
|
|
1085
|
+
if (!rect) return /* @__PURE__ */ jsx("div", { className: "sunboard-tour-scrim" });
|
|
1086
|
+
const padding = 8;
|
|
1087
|
+
const radius = 12;
|
|
1088
|
+
const left = Math.max(rect.left - padding, 0);
|
|
1089
|
+
const top = Math.max(rect.top - padding, 0);
|
|
1090
|
+
const width = rect.width + padding * 2;
|
|
1091
|
+
const height = rect.height + padding * 2;
|
|
1092
|
+
const maskId = "sunboard-tour-spotlight-mask";
|
|
1093
|
+
return /* @__PURE__ */ jsxs("svg", {
|
|
1094
|
+
"aria-hidden": "true",
|
|
1095
|
+
className: "sunboard-tour-scrim sunboard-tour-spotlight",
|
|
1096
|
+
focusable: "false",
|
|
1097
|
+
children: [/* @__PURE__ */ jsx("defs", { children: /* @__PURE__ */ jsxs("mask", {
|
|
1098
|
+
id: maskId,
|
|
1099
|
+
children: [/* @__PURE__ */ jsx("rect", {
|
|
1100
|
+
fill: "white",
|
|
1101
|
+
height: "100%",
|
|
1102
|
+
width: "100%",
|
|
1103
|
+
x: "0",
|
|
1104
|
+
y: "0"
|
|
1105
|
+
}), /* @__PURE__ */ jsx("rect", {
|
|
1106
|
+
fill: "black",
|
|
1107
|
+
height,
|
|
1108
|
+
rx: radius,
|
|
1109
|
+
ry: radius,
|
|
1110
|
+
width,
|
|
1111
|
+
x: left,
|
|
1112
|
+
y: top
|
|
1113
|
+
})]
|
|
1114
|
+
}) }), /* @__PURE__ */ jsx("rect", {
|
|
1115
|
+
fill: "var(--sunboard-overlay-color)",
|
|
1116
|
+
height: "100%",
|
|
1117
|
+
mask: `url(#${maskId})`,
|
|
1118
|
+
width: "100%",
|
|
1119
|
+
x: "0",
|
|
1120
|
+
y: "0"
|
|
1121
|
+
})]
|
|
1122
|
+
});
|
|
1123
|
+
}
|
|
1124
|
+
|
|
1125
|
+
//#endregion
|
|
1126
|
+
//#region src/components/Sunboard.tsx
|
|
1127
|
+
function SunboardChecklist() {
|
|
1128
|
+
const [open, setOpen] = useState(true);
|
|
1129
|
+
const { visible, title, subtitle, steps, completedCount, totalSteps, remainingCount, progress, isCompleted, startStep, completion, dismissCompletion } = useChecklist();
|
|
1130
|
+
useEffect(() => {
|
|
1131
|
+
if (!completion?.dismissAfterMs) return;
|
|
1132
|
+
const handle = window.setTimeout(dismissCompletion, completion.dismissAfterMs);
|
|
1133
|
+
return () => window.clearTimeout(handle);
|
|
1134
|
+
}, [completion, dismissCompletion]);
|
|
1135
|
+
if (!visible || !title) return null;
|
|
1136
|
+
if (completion) return /* @__PURE__ */ jsxs("aside", {
|
|
1137
|
+
className: "sunboard-root sunboard-root-completion",
|
|
1138
|
+
"aria-label": `${title} complete`,
|
|
1139
|
+
role: "status",
|
|
1140
|
+
"aria-live": "polite",
|
|
1141
|
+
children: [/* @__PURE__ */ jsx("button", {
|
|
1142
|
+
className: "sunboard-close sunboard-completion-close",
|
|
1143
|
+
type: "button",
|
|
1144
|
+
onClick: dismissCompletion,
|
|
1145
|
+
"aria-label": "Dismiss completion message",
|
|
1146
|
+
children: "x"
|
|
1147
|
+
}), /* @__PURE__ */ jsxs("div", {
|
|
1148
|
+
className: "sunboard-completion",
|
|
1149
|
+
children: [
|
|
1150
|
+
/* @__PURE__ */ jsx("div", {
|
|
1151
|
+
className: "sunboard-completion-check",
|
|
1152
|
+
"aria-hidden": "true",
|
|
1153
|
+
children: /* @__PURE__ */ jsx("svg", {
|
|
1154
|
+
viewBox: "0 0 24 24",
|
|
1155
|
+
fill: "none",
|
|
1156
|
+
stroke: "currentColor",
|
|
1157
|
+
strokeWidth: "3",
|
|
1158
|
+
strokeLinecap: "round",
|
|
1159
|
+
strokeLinejoin: "round",
|
|
1160
|
+
children: /* @__PURE__ */ jsx("polyline", { points: "5 12 10 17 19 8" })
|
|
1161
|
+
})
|
|
1162
|
+
}),
|
|
1163
|
+
completion.subtitle ? /* @__PURE__ */ jsx("p", {
|
|
1164
|
+
className: "sunboard-completion-subtitle",
|
|
1165
|
+
children: completion.subtitle
|
|
1166
|
+
}) : null,
|
|
1167
|
+
/* @__PURE__ */ jsx("h2", {
|
|
1168
|
+
className: "sunboard-completion-title",
|
|
1169
|
+
children: completion.title
|
|
1170
|
+
}),
|
|
1171
|
+
completion.message ? /* @__PURE__ */ jsx("p", {
|
|
1172
|
+
className: "sunboard-completion-message",
|
|
1173
|
+
children: completion.message
|
|
1174
|
+
}) : null
|
|
1175
|
+
]
|
|
1176
|
+
})]
|
|
1177
|
+
});
|
|
1178
|
+
if (!open) return /* @__PURE__ */ jsx("button", {
|
|
1179
|
+
className: "sunboard-launcher",
|
|
1180
|
+
type: "button",
|
|
1181
|
+
onClick: () => setOpen(true),
|
|
1182
|
+
"aria-label": `Open ${title} checklist, ${remainingCount} tasks remaining`,
|
|
1183
|
+
children: /* @__PURE__ */ jsx("span", {
|
|
1184
|
+
className: "sunboard-launcher-count",
|
|
1185
|
+
children: remainingCount
|
|
1186
|
+
})
|
|
1187
|
+
});
|
|
1188
|
+
return /* @__PURE__ */ jsxs("aside", {
|
|
1189
|
+
className: "sunboard-root",
|
|
1190
|
+
"aria-label": `${title} checklist`,
|
|
1191
|
+
children: [
|
|
1192
|
+
/* @__PURE__ */ jsxs("div", {
|
|
1193
|
+
className: "sunboard-header",
|
|
1194
|
+
children: [/* @__PURE__ */ jsxs("div", { children: [subtitle ? /* @__PURE__ */ jsx("p", {
|
|
1195
|
+
className: "sunboard-eyebrow",
|
|
1196
|
+
children: subtitle
|
|
1197
|
+
}) : null, /* @__PURE__ */ jsx("h2", {
|
|
1198
|
+
className: "sunboard-title",
|
|
1199
|
+
children: title
|
|
1200
|
+
})] }), /* @__PURE__ */ jsxs("div", {
|
|
1201
|
+
className: "sunboard-header-actions",
|
|
1202
|
+
children: [/* @__PURE__ */ jsxs("p", {
|
|
1203
|
+
className: "sunboard-progress-count",
|
|
1204
|
+
"aria-label": "Checklist progress",
|
|
1205
|
+
children: [
|
|
1206
|
+
completedCount,
|
|
1207
|
+
" / ",
|
|
1208
|
+
totalSteps
|
|
1209
|
+
]
|
|
1210
|
+
}), /* @__PURE__ */ jsx("button", {
|
|
1211
|
+
className: "sunboard-close",
|
|
1212
|
+
type: "button",
|
|
1213
|
+
onClick: () => setOpen(false),
|
|
1214
|
+
"aria-label": `Close ${title} checklist`,
|
|
1215
|
+
children: "x"
|
|
1216
|
+
})]
|
|
1217
|
+
})]
|
|
1218
|
+
}),
|
|
1219
|
+
/* @__PURE__ */ jsx("div", {
|
|
1220
|
+
className: "sunboard-progress",
|
|
1221
|
+
role: "progressbar",
|
|
1222
|
+
"aria-label": `${title} progress`,
|
|
1223
|
+
"aria-valuemin": 0,
|
|
1224
|
+
"aria-valuemax": totalSteps,
|
|
1225
|
+
"aria-valuenow": completedCount,
|
|
1226
|
+
children: /* @__PURE__ */ jsx("div", {
|
|
1227
|
+
className: "sunboard-progress-fill",
|
|
1228
|
+
style: { width: `${progress}%` }
|
|
1229
|
+
})
|
|
1230
|
+
}),
|
|
1231
|
+
/* @__PURE__ */ jsx("ol", {
|
|
1232
|
+
className: "sunboard-step-list",
|
|
1233
|
+
children: steps.map((step) => /* @__PURE__ */ jsx(ChecklistStep, {
|
|
1234
|
+
completed: isCompleted(step.key),
|
|
1235
|
+
onStart: () => startStep(step),
|
|
1236
|
+
step
|
|
1237
|
+
}, step.key))
|
|
1238
|
+
})
|
|
1239
|
+
]
|
|
1240
|
+
});
|
|
1241
|
+
}
|
|
1242
|
+
const Sunboard = Object.assign(SunboardChecklist, {
|
|
1243
|
+
Checklist: SunboardChecklist,
|
|
1244
|
+
Hotspots: SunboardHotspots,
|
|
1245
|
+
Tour: SunboardTour
|
|
1246
|
+
});
|
|
1247
|
+
|
|
1248
|
+
//#endregion
|
|
1249
|
+
export { Sunboard, SunboardChecklist, SunboardHotspots, SunboardProvider, SunboardRuntimeApiError, SunboardRuntimeClient, SunboardTour, useChecklist, useSunboard, useSunboardPreviewSpec };
|
|
1250
|
+
//# sourceMappingURL=index.mjs.map
|