@creezio/interactive-demo 0.22.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.
@@ -0,0 +1,563 @@
1
+ "use client";
2
+
3
+ /**
4
+ * Lecteur de démo interactive — exécute un `DemoScenario` en live dans la
5
+ * page : spotlight sur les cibles, cartes de narration, faux curseur qui
6
+ * clique et tape réellement (mêmes événements DOM qu'une souris).
7
+ *
8
+ * Monté par `InteractiveDemoRoot` (démo en cours) ou directement par une
9
+ * marque. Styles : `@creezio/interactive-demo/ui/interactive-demo.css`.
10
+ */
11
+
12
+ import { useCallback, useEffect, useRef, useState } from "react";
13
+ import { getWorkspaceTabNavigate } from "@creezio/shell-ui/ui";
14
+ import type {
15
+ DemoScenario,
16
+ DemoStep,
17
+ DemoTarget,
18
+ } from "@creezio/interactive-demo";
19
+ import { getDemoCursor } from "./fake-cursor";
20
+ import {
21
+ findScrollableRoot,
22
+ moveCursorToElement,
23
+ resolveDemoTarget,
24
+ sleep,
25
+ submitField,
26
+ synthClick,
27
+ typeInto,
28
+ waitForDemoTarget,
29
+ } from "./dom";
30
+
31
+ export type DemoPlayerProps = {
32
+ scenario: DemoScenario;
33
+ /** Fin normale (dernière étape jouée). */
34
+ onFinish?: () => void;
35
+ /** Sortie anticipée (bouton « Quitter »). */
36
+ onExit?: () => void;
37
+ /**
38
+ * Navigation SPA (ex. `router.push` Next). Le player préfère toujours le
39
+ * `navigate` du workspace onglets kit s'il est monté
40
+ * (`getWorkspaceTabNavigate`) : un `router.push` direct sur l'onglet
41
+ * épinglé Dashboard serait réaligné par le provider. Cette prop est le
42
+ * fallback hors workspace ; défaut final : `window.location.assign`
43
+ * (rechargement complet).
44
+ */
45
+ navigate?: (href: string) => void;
46
+ /** Libellé du badge du curseur (défaut « Démo »). */
47
+ cursorLabel?: string;
48
+ };
49
+
50
+ type Rect = { left: number; top: number; width: number; height: number };
51
+
52
+ type CardState = {
53
+ kicker: string;
54
+ title?: string;
55
+ body?: string;
56
+ centered: boolean;
57
+ /** Ancre : rect de la cible (carte positionnée autour). */
58
+ anchor?: Rect;
59
+ placement?: "top" | "bottom" | "left" | "right" | "auto";
60
+ showNext: boolean;
61
+ nextLabel?: string;
62
+ };
63
+
64
+ const CARD_W = 380;
65
+ const CARD_H_GUESS = 170;
66
+ const MARGIN = 14;
67
+
68
+ function cardPosition(anchor: Rect, placement: CardState["placement"]): {
69
+ left: number;
70
+ top: number;
71
+ } {
72
+ const vw = window.innerWidth;
73
+ const vh = window.innerHeight;
74
+ const w = Math.min(CARD_W, vw - 32);
75
+ const clamp = (v: number, min: number, max: number) =>
76
+ Math.max(min, Math.min(max, v));
77
+
78
+ const below = anchor.top + anchor.height + MARGIN;
79
+ const above = anchor.top - CARD_H_GUESS - MARGIN;
80
+ const right = anchor.left + anchor.width + MARGIN;
81
+ const left = anchor.left - w - MARGIN;
82
+
83
+ const fitsBelow = below + CARD_H_GUESS < vh - 90;
84
+ const fitsAbove = above > 12;
85
+ const fitsRight = right + w < vw - 12;
86
+ const fitsLeft = left > 12;
87
+
88
+ let p = placement && placement !== "auto" ? placement : undefined;
89
+ if (!p) {
90
+ p = fitsBelow ? "bottom" : fitsAbove ? "top" : fitsRight ? "right" : "left";
91
+ }
92
+
93
+ const centeredX = clamp(
94
+ anchor.left + anchor.width / 2 - w / 2,
95
+ 16,
96
+ vw - w - 16,
97
+ );
98
+ const centeredY = clamp(
99
+ anchor.top + anchor.height / 2 - CARD_H_GUESS / 2,
100
+ 12,
101
+ vh - CARD_H_GUESS - 100,
102
+ );
103
+
104
+ switch (p) {
105
+ case "bottom":
106
+ return { left: centeredX, top: clamp(below, 12, vh - CARD_H_GUESS - 100) };
107
+ case "top":
108
+ return { left: centeredX, top: clamp(above, 12, vh - CARD_H_GUESS - 100) };
109
+ case "right":
110
+ return { left: clamp(right, 16, vw - w - 16), top: centeredY };
111
+ case "left":
112
+ return { left: clamp(left, 16, vw - w - 16), top: centeredY };
113
+ }
114
+ }
115
+
116
+ export function DemoPlayer({
117
+ scenario,
118
+ onFinish,
119
+ onExit,
120
+ navigate,
121
+ cursorLabel,
122
+ }: DemoPlayerProps) {
123
+ const [stepIndex, setStepIndex] = useState(0);
124
+ const [spotlight, setSpotlight] = useState<Rect | null>(null);
125
+ const [veil, setVeil] = useState(false);
126
+ const [card, setCard] = useState<CardState | null>(null);
127
+ const [note, setNote] = useState<string | null>(null);
128
+
129
+ const runIdRef = useRef(0);
130
+ const advanceRef = useRef<(() => void) | null>(null);
131
+ const spotlightElRef = useRef<Element | null>(null);
132
+ const noteTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
133
+
134
+ const total = scenario.steps.length;
135
+
136
+ /** Bouton « Suivant » : résout l'attente en cours. */
137
+ const advance = useCallback(() => {
138
+ advanceRef.current?.();
139
+ advanceRef.current = null;
140
+ }, []);
141
+
142
+ const exit = useCallback(() => {
143
+ runIdRef.current += 1;
144
+ advanceRef.current?.();
145
+ advanceRef.current = null;
146
+ getDemoCursor().hide();
147
+ onExit?.();
148
+ }, [onExit]);
149
+
150
+ // Suivi du rect de la cible spotlightée (scroll / resize / re-render).
151
+ useEffect(() => {
152
+ let raf = 0;
153
+ const track = () => {
154
+ const el = spotlightElRef.current;
155
+ if (el && document.contains(el)) {
156
+ const r = el.getBoundingClientRect();
157
+ setSpotlight((prev) => {
158
+ if (
159
+ prev &&
160
+ Math.abs(prev.left - r.left) < 1 &&
161
+ Math.abs(prev.top - r.top) < 1 &&
162
+ Math.abs(prev.width - r.width) < 1 &&
163
+ Math.abs(prev.height - r.height) < 1
164
+ ) {
165
+ return prev;
166
+ }
167
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
168
+ });
169
+ }
170
+ raf = requestAnimationFrame(track);
171
+ };
172
+ raf = requestAnimationFrame(track);
173
+ return () => cancelAnimationFrame(raf);
174
+ }, []);
175
+
176
+ useEffect(() => {
177
+ const runId = ++runIdRef.current;
178
+ const cancelled = () => runIdRef.current !== runId;
179
+
180
+ const cursor = getDemoCursor();
181
+ cursor.setLabel(cursorLabel ?? "Démo");
182
+
183
+ const showNote = (text: string) => {
184
+ setNote(text);
185
+ if (noteTimerRef.current) clearTimeout(noteTimerRef.current);
186
+ noteTimerRef.current = setTimeout(() => setNote(null), 2600);
187
+ };
188
+
189
+ const clearStage = () => {
190
+ spotlightElRef.current = null;
191
+ setSpotlight(null);
192
+ setVeil(false);
193
+ setCard(null);
194
+ };
195
+
196
+ /** Attente du bouton « Suivant » (ou avance auto). */
197
+ const waitAdvance = (step: DemoStep) =>
198
+ new Promise<void>((resolve) => {
199
+ let done = false;
200
+ const finish = () => {
201
+ if (done) return;
202
+ done = true;
203
+ advanceRef.current = null;
204
+ resolve();
205
+ };
206
+ advanceRef.current = finish;
207
+ if (typeof step.autoAdvanceMs === "number" && step.autoAdvanceMs > 0) {
208
+ setTimeout(finish, step.autoAdvanceMs);
209
+ }
210
+ });
211
+
212
+ const anchorFor = (el: Element): Rect => {
213
+ const r = el.getBoundingClientRect();
214
+ return { left: r.left, top: r.top, width: r.width, height: r.height };
215
+ };
216
+
217
+ const spotlightOn = (el: Element) => {
218
+ spotlightElRef.current = el;
219
+ setVeil(false);
220
+ setSpotlight(anchorFor(el));
221
+ };
222
+
223
+ const missTarget = async (step: DemoStep): Promise<"skip" | "stop"> => {
224
+ // Une démo ne bloque jamais : les cibles introuvables sont sautées
225
+ // (permissions, page vide, UI modifiée) — note discrète.
226
+ if (step.optional === false) {
227
+ showNote("Étape indisponible — démo interrompue.");
228
+ return "stop";
229
+ }
230
+ showNote("Étape passée (élément indisponible sur cet écran).");
231
+ return "skip";
232
+ };
233
+
234
+ const execStep = async (step: DemoStep, index: number): Promise<boolean> => {
235
+ if (step.delayMs) await sleep(step.delayMs);
236
+ if (cancelled()) return false;
237
+
238
+ const kicker = `${scenario.title} — ${index + 1}/${total}`;
239
+ const timeout = step.timeoutMs ?? 6000;
240
+
241
+ switch (step.kind) {
242
+ case "say": {
243
+ clearStage();
244
+ setVeil(true);
245
+ setCard({
246
+ kicker,
247
+ title: step.title,
248
+ body: step.body,
249
+ centered: true,
250
+ showNext: true,
251
+ nextLabel: index === total - 1 ? "Terminer" : index === 0 ? "C'est parti" : "Suivant",
252
+ });
253
+ await waitAdvance(step);
254
+ setVeil(false);
255
+ setCard(null);
256
+ return !cancelled();
257
+ }
258
+
259
+ case "navigate": {
260
+ clearStage();
261
+ const go =
262
+ getWorkspaceTabNavigate() ??
263
+ navigate ??
264
+ ((href: string) => window.location.assign(href));
265
+ const before = window.location.pathname;
266
+ go(step.href);
267
+ // Laisser la navigation App Router se poser.
268
+ for (let i = 0; i < 30 && window.location.pathname === before; i++) {
269
+ if (cancelled()) return false;
270
+ await sleep(100);
271
+ }
272
+ await sleep(450);
273
+ if (cancelled()) return false;
274
+ if (step.title || step.body) {
275
+ setVeil(true);
276
+ setCard({
277
+ kicker,
278
+ title: step.title,
279
+ body: step.body,
280
+ centered: true,
281
+ showNext: true,
282
+ nextLabel: index === total - 1 ? "Terminer" : "Suivant",
283
+ });
284
+ await waitAdvance(step);
285
+ setVeil(false);
286
+ setCard(null);
287
+ }
288
+ return !cancelled();
289
+ }
290
+
291
+ case "highlight": {
292
+ const el = await waitForDemoTarget(step.target, timeout, cancelled);
293
+ if (cancelled()) return false;
294
+ if (!el) {
295
+ return (await missTarget(step)) === "skip";
296
+ }
297
+ const rect = el.getBoundingClientRect();
298
+ if (rect.top < 60 || rect.bottom > window.innerHeight - 20) {
299
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
300
+ await sleep(450);
301
+ }
302
+ spotlightOn(el);
303
+ const anchor = anchorFor(el);
304
+ await getDemoCursor().moveTo(
305
+ anchor.left + Math.min(anchor.width / 2, 180),
306
+ anchor.top + anchor.height / 2,
307
+ );
308
+ setCard({
309
+ kicker,
310
+ title: step.title,
311
+ body: step.body,
312
+ centered: false,
313
+ anchor,
314
+ placement: step.placement,
315
+ showNext: true,
316
+ nextLabel: index === total - 1 ? "Terminer" : "Suivant",
317
+ });
318
+ await waitAdvance(step);
319
+ clearStage();
320
+ getDemoCursor().hideSoon();
321
+ return !cancelled();
322
+ }
323
+
324
+ case "click": {
325
+ const el = await waitForDemoTarget(step.target, timeout, cancelled);
326
+ if (cancelled()) return false;
327
+ if (!el) {
328
+ return (await missTarget(step)) === "skip";
329
+ }
330
+ clearStage();
331
+ if (step.title || step.body) {
332
+ setCard({
333
+ kicker,
334
+ title: step.title,
335
+ body: step.body,
336
+ centered: false,
337
+ anchor: anchorFor(el),
338
+ showNext: false,
339
+ });
340
+ }
341
+ const { x, y } = await moveCursorToElement(el);
342
+ if (cancelled()) return false;
343
+ await getDemoCursor().clickEffect();
344
+ synthClick(el, x, y);
345
+ getDemoCursor().hideSoon();
346
+ await sleep(900);
347
+ setCard(null);
348
+ return !cancelled();
349
+ }
350
+
351
+ case "type": {
352
+ let el = await waitForDemoTarget(step.target, timeout, cancelled);
353
+ if (cancelled()) return false;
354
+ if (el && !(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
355
+ el = el.querySelector("input, textarea");
356
+ }
357
+ if (!el || !(el instanceof HTMLInputElement || el instanceof HTMLTextAreaElement)) {
358
+ return (await missTarget(step)) === "skip";
359
+ }
360
+ clearStage();
361
+ if (step.title || step.body) {
362
+ setCard({
363
+ kicker,
364
+ title: step.title,
365
+ body: step.body,
366
+ centered: false,
367
+ anchor: anchorFor(el),
368
+ showNext: false,
369
+ });
370
+ }
371
+ const { x, y } = await moveCursorToElement(el);
372
+ if (cancelled()) return false;
373
+ await getDemoCursor().clickEffect();
374
+ synthClick(el, x, y);
375
+ await sleep(150);
376
+ await typeInto(el, step.text, cancelled);
377
+ if (cancelled()) return false;
378
+ if (step.submit) submitField(el);
379
+ getDemoCursor().hideSoon();
380
+ await sleep(900);
381
+ setCard(null);
382
+ return !cancelled();
383
+ }
384
+
385
+ case "scroll": {
386
+ clearStage();
387
+ if (step.target) {
388
+ const el = await waitForDemoTarget(step.target, timeout, cancelled);
389
+ if (el) {
390
+ el.scrollIntoView({ block: "center", behavior: "smooth" });
391
+ await sleep(650);
392
+ return !cancelled();
393
+ }
394
+ }
395
+ const root = findScrollableRoot();
396
+ const dir = step.direction === "up" ? -1 : 1;
397
+ root.scrollBy({
398
+ top: dir * Math.round(window.innerHeight * 0.75),
399
+ behavior: "smooth",
400
+ });
401
+ await sleep(650);
402
+ return !cancelled();
403
+ }
404
+
405
+ case "wait": {
406
+ await sleep(step.ms);
407
+ return !cancelled();
408
+ }
409
+
410
+ case "waitFor": {
411
+ clearStage();
412
+ // Step silencieux par défaut : carte seulement si title/body posés.
413
+ if (step.title || step.body) {
414
+ setCard({
415
+ kicker,
416
+ title: step.title,
417
+ body: step.body,
418
+ centered: true,
419
+ showNext: false,
420
+ });
421
+ }
422
+ const conditionMet = () => {
423
+ if (step.target) {
424
+ const el = resolveDemoTarget(step.target);
425
+ if (step.absent ? el !== null : el === null) return false;
426
+ }
427
+ if (step.url && !window.location.pathname.startsWith(step.url)) {
428
+ return false;
429
+ }
430
+ return true;
431
+ };
432
+ // Même cadence de polling que la résolution de cible (150 ms).
433
+ const deadline = Date.now() + Math.max(0, step.timeoutMs ?? 8000);
434
+ let met = conditionMet();
435
+ while (!met && Date.now() < deadline) {
436
+ if (cancelled()) return false;
437
+ await sleep(150);
438
+ met = conditionMet();
439
+ }
440
+ setCard(null);
441
+ if (cancelled()) return false;
442
+ if (!met) {
443
+ return (await missTarget(step)) === "skip";
444
+ }
445
+ return true;
446
+ }
447
+ }
448
+ };
449
+
450
+ (async () => {
451
+ for (let i = 0; i < scenario.steps.length; i++) {
452
+ if (cancelled()) return;
453
+ setStepIndex(i);
454
+ const ok = await execStep(scenario.steps[i]!, i);
455
+ if (!ok) {
456
+ if (!cancelled()) {
457
+ // Étape non-optionnelle en échec : sortie propre.
458
+ clearStage();
459
+ getDemoCursor().hide();
460
+ onExit?.();
461
+ }
462
+ return;
463
+ }
464
+ }
465
+ if (cancelled()) return;
466
+ clearStage();
467
+ getDemoCursor().hide();
468
+ onFinish?.();
469
+ })();
470
+
471
+ return () => {
472
+ runIdRef.current += 1;
473
+ advanceRef.current = null;
474
+ if (noteTimerRef.current) clearTimeout(noteTimerRef.current);
475
+ getDemoCursor().hide();
476
+ };
477
+ // Le scénario est immuable pendant la lecture : relance sur changement d'id.
478
+ // eslint-disable-next-line react-hooks/exhaustive-deps
479
+ }, [scenario.id]);
480
+
481
+ const cardPos =
482
+ card && !card.centered && card.anchor
483
+ ? cardPosition(card.anchor, card.placement)
484
+ : null;
485
+
486
+ return (
487
+ <div data-creezio-demo-ui="1">
488
+ {veil ? <div className="creezio-demo-veil" /> : null}
489
+ {spotlight ? (
490
+ <div
491
+ className="creezio-demo-spotlight"
492
+ style={{
493
+ left: spotlight.left - 6,
494
+ top: spotlight.top - 6,
495
+ width: spotlight.width + 12,
496
+ height: spotlight.height + 12,
497
+ }}
498
+ />
499
+ ) : null}
500
+
501
+ {card ? (
502
+ <div
503
+ className={
504
+ card.centered
505
+ ? "creezio-demo-card creezio-demo-card--centered"
506
+ : "creezio-demo-card"
507
+ }
508
+ style={cardPos ? { left: cardPos.left, top: cardPos.top } : undefined}
509
+ role="dialog"
510
+ aria-live="polite"
511
+ >
512
+ <p className="creezio-demo-card-kicker">{card.kicker}</p>
513
+ {card.title ? (
514
+ <h3 className="creezio-demo-card-title">{card.title}</h3>
515
+ ) : null}
516
+ {card.body ? (
517
+ <p className="creezio-demo-card-body">{card.body}</p>
518
+ ) : null}
519
+ {card.showNext ? (
520
+ <div className="creezio-demo-card-actions">
521
+ <button
522
+ type="button"
523
+ className="creezio-demo-btn creezio-demo-btn--ghost"
524
+ onClick={exit}
525
+ >
526
+ Quitter
527
+ </button>
528
+ <button
529
+ type="button"
530
+ className="creezio-demo-btn creezio-demo-btn--primary"
531
+ onClick={advance}
532
+ >
533
+ {card.nextLabel ?? "Suivant"}
534
+ </button>
535
+ </div>
536
+ ) : null}
537
+ </div>
538
+ ) : null}
539
+
540
+ {note ? <div className="creezio-demo-note">{note}</div> : null}
541
+
542
+ <div className="creezio-demo-controls">
543
+ <span className="creezio-demo-controls-label">{scenario.title}</span>
544
+ <span className="creezio-demo-progress">
545
+ <span
546
+ className="creezio-demo-progress-fill"
547
+ style={{ width: `${Math.round(((stepIndex + 1) / total) * 100)}%` }}
548
+ />
549
+ </span>
550
+ <span className="creezio-demo-controls-count">
551
+ {stepIndex + 1}/{total}
552
+ </span>
553
+ <button
554
+ type="button"
555
+ className="creezio-demo-btn creezio-demo-btn--ghost"
556
+ onClick={exit}
557
+ >
558
+ Quitter la visite
559
+ </button>
560
+ </div>
561
+ </div>
562
+ );
563
+ }