@eintrek/erp-shell 0.1.72 → 0.1.74
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/README.md +54 -0
- package/dist/chunks/{guide-runtime-B-3myqxe.esm.js → guide-runtime-Dl8V1iy9.esm.js} +963 -52
- package/dist/chunks/guide-runtime-Dl8V1iy9.esm.js.map +1 -0
- package/dist/chunks/{guide-runtime-DfnNdrjZ.cjs → guide-runtime-YS2nUhwS.cjs} +967 -50
- package/dist/chunks/guide-runtime-YS2nUhwS.cjs.map +1 -0
- package/dist/guide/core.d.ts +3 -3
- package/dist/guide/guide-store.d.ts +41 -0
- package/dist/guide/guide-styles.d.ts +1 -1
- package/dist/guide/guide-ui.d.ts +40 -0
- package/dist/guide/machine.d.ts +118 -2
- package/dist/guide/pages.d.ts +56 -0
- package/dist/guide/progress-sync.d.ts +9 -1
- package/dist/guide/progress.d.ts +25 -0
- package/dist/guide/types.d.ts +72 -0
- package/dist/guide-core.cjs +7 -1
- package/dist/guide-core.cjs.map +1 -1
- package/dist/guide-core.esm.js +1 -1
- package/dist/help/help-button.d.ts +5 -3
- package/dist/index.cjs +22 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.esm.js +19 -13
- package/dist/index.esm.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/dist/chunks/guide-runtime-B-3myqxe.esm.js.map +0 -1
- package/dist/chunks/guide-runtime-DfnNdrjZ.cjs.map +0 -1
|
@@ -357,6 +357,44 @@ function publishGuideEntry(next, openPanel) {
|
|
|
357
357
|
emit();
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
|
+
const NO_PAGES = { page: null, listed: 0 };
|
|
361
|
+
let pages = NO_PAGES;
|
|
362
|
+
let openPageImpl = null;
|
|
363
|
+
let openPagesImpl = null;
|
|
364
|
+
/** Called by the host: this page's guide and the listed count, and how to open them. */
|
|
365
|
+
function publishPageGuides(next, openPage, openPages) {
|
|
366
|
+
const n = next ?? NO_PAGES;
|
|
367
|
+
openPageImpl = openPage;
|
|
368
|
+
openPagesImpl = openPages;
|
|
369
|
+
const same = n.listed === pages.listed && (n.page?.title ?? null) === (pages.page?.title ?? null);
|
|
370
|
+
if (!same) {
|
|
371
|
+
pages = n.page || n.listed ? { page: n.page ? { title: n.page.title } : null, listed: n.listed } : NO_PAGES;
|
|
372
|
+
emit();
|
|
373
|
+
}
|
|
374
|
+
}
|
|
375
|
+
function guideMenuItems(e, p = NO_PAGES) {
|
|
376
|
+
const items = [];
|
|
377
|
+
if (p.page)
|
|
378
|
+
items.push("page");
|
|
379
|
+
if (e && e.total > 0)
|
|
380
|
+
items.push("missions");
|
|
381
|
+
if (p.listed > 0)
|
|
382
|
+
items.push("pages");
|
|
383
|
+
if (items.length)
|
|
384
|
+
items.push("help");
|
|
385
|
+
return items;
|
|
386
|
+
}
|
|
387
|
+
/**
|
|
388
|
+
* Whether the "?" press opens a menu rather than the help dialog: a tour
|
|
389
|
+
* with missions shown, a guide for this page, or page guides to list. A tour
|
|
390
|
+
* whose missions are all hidden for this user (`visible`, e.g. Plannex
|
|
391
|
+
* without an employee record) has nothing to open, and "0/0" led to an
|
|
392
|
+
* empty panel. The entry itself stays published: apps wait on it before
|
|
393
|
+
* they load the facts that make missions visible.
|
|
394
|
+
*/
|
|
395
|
+
function guideMenuShown(e, p = NO_PAGES) {
|
|
396
|
+
return guideMenuItems(e, p).length > 0;
|
|
397
|
+
}
|
|
360
398
|
/** Opens the tour's quest panel, when this user has a tour here. */
|
|
361
399
|
function openGuidePanel() {
|
|
362
400
|
openPanelImpl?.();
|
|
@@ -365,6 +403,206 @@ function openGuidePanel() {
|
|
|
365
403
|
function useGuideEntry() {
|
|
366
404
|
return React.useSyncExternalStore(subscribe, () => entry, () => null);
|
|
367
405
|
}
|
|
406
|
+
/** Opens this page's guide ("คู่มือหน้านี้"), when there is one. */
|
|
407
|
+
function openPageGuide() {
|
|
408
|
+
openPageImpl?.();
|
|
409
|
+
}
|
|
410
|
+
/** Opens the panel on the page guides alone ("คู่มือทุกหน้า"). */
|
|
411
|
+
function openPagesPanel() {
|
|
412
|
+
openPagesImpl?.();
|
|
413
|
+
}
|
|
414
|
+
/** Page guides here (this page's, and the listed count); stable until they change. */
|
|
415
|
+
function usePageGuides() {
|
|
416
|
+
return React.useSyncExternalStore(subscribe, () => pages, () => NO_PAGES);
|
|
417
|
+
}
|
|
418
|
+
/**
|
|
419
|
+
* This page's guide: `{ title, open }`, or null when the page (or the
|
|
420
|
+
* sub-screen, `when`) has none for this person. For an app that draws its
|
|
421
|
+
* own "?" (LIFF's page head): draw it only when this is not null.
|
|
422
|
+
*/
|
|
423
|
+
function usePageGuideEntry() {
|
|
424
|
+
const p = usePageGuides();
|
|
425
|
+
const title = p.page?.title ?? null;
|
|
426
|
+
return React.useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/** The synthetic mission key of a page guide: `page:<key>`. */
|
|
430
|
+
const PAGE_PREFIX = "page:";
|
|
431
|
+
/** A page guide's key: lower case, digits and hyphens. */
|
|
432
|
+
const PAGE_KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
433
|
+
/** The server's row key limit (`<app>.page.<key>`). */
|
|
434
|
+
const ROW_KEY_MAX = 64;
|
|
435
|
+
function isPageMissionKey(key) {
|
|
436
|
+
return !!key && key.startsWith(PAGE_PREFIX);
|
|
437
|
+
}
|
|
438
|
+
function pageKeyOf(missionKey) {
|
|
439
|
+
return isPageMissionKey(missionKey) ? missionKey.slice(PAGE_PREFIX.length) : undefined;
|
|
440
|
+
}
|
|
441
|
+
function devWarn(message) {
|
|
442
|
+
try {
|
|
443
|
+
// Written out in full so the app's bundler replaces it.
|
|
444
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV === "production")
|
|
445
|
+
return;
|
|
446
|
+
if (typeof console !== "undefined")
|
|
447
|
+
console.warn(`[erp-shell guide] ${message}`);
|
|
448
|
+
}
|
|
449
|
+
catch {
|
|
450
|
+
/* never breaks the app */
|
|
451
|
+
}
|
|
452
|
+
}
|
|
453
|
+
/** Why a page guide cannot run, or null when it can. */
|
|
454
|
+
function pageProblem(app, page) {
|
|
455
|
+
if (!page || typeof page.key !== "string" || !PAGE_KEY_RE.test(page.key))
|
|
456
|
+
return "its key is not [a-z0-9-]";
|
|
457
|
+
if (`${app}.page.${page.key}`.length > ROW_KEY_MAX)
|
|
458
|
+
return `its row key is over ${ROW_KEY_MAX} characters`;
|
|
459
|
+
if (!Number.isInteger(page.version) || page.version < 1)
|
|
460
|
+
return "its version is not a whole number ≥ 1";
|
|
461
|
+
const routes = Array.isArray(page.route) ? page.route : [page.route];
|
|
462
|
+
if (!routes.length || routes.some(r => typeof r !== "string" || !r.startsWith("/")))
|
|
463
|
+
return "it has no route";
|
|
464
|
+
if (!Array.isArray(page.steps) || page.steps.length === 0)
|
|
465
|
+
return "it has no steps";
|
|
466
|
+
for (const st of page.steps) {
|
|
467
|
+
const kind = st.kind ?? "click";
|
|
468
|
+
if (kind !== "view" && kind !== "click")
|
|
469
|
+
return `step "${st.key}" is a ${kind} step (a page guide never saves)`;
|
|
470
|
+
}
|
|
471
|
+
return null;
|
|
472
|
+
}
|
|
473
|
+
const validCache = new WeakMap();
|
|
474
|
+
const warned = new Set();
|
|
475
|
+
/**
|
|
476
|
+
* The suite's page guides that can run. A page guide with a commit, input or
|
|
477
|
+
* select step (it would save), a bad key or no route is dropped, with a
|
|
478
|
+
* warning in development; so is a second one with the same key.
|
|
479
|
+
*/
|
|
480
|
+
function validPages(suite) {
|
|
481
|
+
const hit = validCache.get(suite);
|
|
482
|
+
if (hit)
|
|
483
|
+
return hit;
|
|
484
|
+
const out = [];
|
|
485
|
+
const seen = new Set();
|
|
486
|
+
for (const page of suite.pages ?? []) {
|
|
487
|
+
let problem = pageProblem(suite.app, page);
|
|
488
|
+
if (!problem && seen.has(page.key))
|
|
489
|
+
problem = "another page guide has the same key";
|
|
490
|
+
if (problem) {
|
|
491
|
+
const id = `${suite.key}:${page?.key}:${problem}`;
|
|
492
|
+
if (!warned.has(id)) {
|
|
493
|
+
warned.add(id);
|
|
494
|
+
devWarn(`page guide "${page?.key}" dropped: ${problem}`);
|
|
495
|
+
}
|
|
496
|
+
continue;
|
|
497
|
+
}
|
|
498
|
+
seen.add(page.key);
|
|
499
|
+
out.push(page);
|
|
500
|
+
}
|
|
501
|
+
validCache.set(suite, out);
|
|
502
|
+
return out;
|
|
503
|
+
}
|
|
504
|
+
function findPage(suite, key) {
|
|
505
|
+
return key ? validPages(suite).find(p => p.key === key) : undefined;
|
|
506
|
+
}
|
|
507
|
+
const missionCache = new WeakMap();
|
|
508
|
+
/**
|
|
509
|
+
* The page guide as a mission the machine plays: `page:<key>`. Steps lose
|
|
510
|
+
* any `route` (the guide is the page's, and ends when it is left) and are
|
|
511
|
+
* checkpoints unless they say otherwise.
|
|
512
|
+
*/
|
|
513
|
+
function pageMission(page) {
|
|
514
|
+
const hit = missionCache.get(page);
|
|
515
|
+
if (hit)
|
|
516
|
+
return hit;
|
|
517
|
+
const steps = page.steps.map(st => {
|
|
518
|
+
const { route: _route, resumeUrl: _resume, ...rest } = st;
|
|
519
|
+
return { ...rest, checkpoint: st.checkpoint ?? true };
|
|
520
|
+
});
|
|
521
|
+
const m = { key: PAGE_PREFIX + page.key, title: page.title, minutes: 1, steps };
|
|
522
|
+
missionCache.set(page, m);
|
|
523
|
+
return m;
|
|
524
|
+
}
|
|
525
|
+
function routesOf(page) {
|
|
526
|
+
return Array.isArray(page.route) ? page.route : [page.route];
|
|
527
|
+
}
|
|
528
|
+
/** How specific a route pattern is: its `?k=v` constraints, then its static segments. */
|
|
529
|
+
function routeSpecificity(pattern) {
|
|
530
|
+
const q = pattern.indexOf("?");
|
|
531
|
+
const path = q < 0 ? pattern : pattern.slice(0, q);
|
|
532
|
+
const query = q < 0 ? 0 : [...new URLSearchParams(pattern.slice(q + 1)).keys()].length;
|
|
533
|
+
const statics = path.split("/").filter(seg => seg && !/^\[.*\]$/.test(seg) && !seg.startsWith(":")).length;
|
|
534
|
+
return [query, statics];
|
|
535
|
+
}
|
|
536
|
+
/**
|
|
537
|
+
* Whether the page guide applies here: one of its routes matches and its
|
|
538
|
+
* `when` anchor (if any) is on screen. The score ranks matches: `when`
|
|
539
|
+
* first, then query constraints, then static segments. Null when it does
|
|
540
|
+
* not apply.
|
|
541
|
+
*/
|
|
542
|
+
function pageMatchScore(page, where) {
|
|
543
|
+
let best = null;
|
|
544
|
+
for (const r of routesOf(page)) {
|
|
545
|
+
if (!matchRoute(r, where.pathname, where.search, where.code))
|
|
546
|
+
continue;
|
|
547
|
+
const s = routeSpecificity(r);
|
|
548
|
+
if (!best || s[0] > best[0] || (s[0] === best[0] && s[1] > best[1]))
|
|
549
|
+
best = s;
|
|
550
|
+
}
|
|
551
|
+
if (!best)
|
|
552
|
+
return null;
|
|
553
|
+
if (page.when) {
|
|
554
|
+
let on = false;
|
|
555
|
+
try {
|
|
556
|
+
on = where.isVisible(page.when);
|
|
557
|
+
}
|
|
558
|
+
catch {
|
|
559
|
+
on = false;
|
|
560
|
+
}
|
|
561
|
+
if (!on)
|
|
562
|
+
return null;
|
|
563
|
+
}
|
|
564
|
+
return [page.when ? 1 : 0, best[0], best[1]];
|
|
565
|
+
}
|
|
566
|
+
const after = (a, b) => a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2];
|
|
567
|
+
/** The most specific page guide that applies here, whoever it is for (first in the list on a tie). */
|
|
568
|
+
function matchingPage(suite, where) {
|
|
569
|
+
let best = null;
|
|
570
|
+
let bestScore = null;
|
|
571
|
+
for (const p of validPages(suite)) {
|
|
572
|
+
const s = pageMatchScore(p, where);
|
|
573
|
+
if (s && (!bestScore || after(s, bestScore))) {
|
|
574
|
+
best = p;
|
|
575
|
+
bestScore = s;
|
|
576
|
+
}
|
|
577
|
+
}
|
|
578
|
+
return best;
|
|
579
|
+
}
|
|
580
|
+
/** Whether this person has the page guide: `visible` (only true) and `requires`. */
|
|
581
|
+
function pageShown(page, ctx) {
|
|
582
|
+
try {
|
|
583
|
+
if (page.visible && page.visible(ctx) !== true)
|
|
584
|
+
return false;
|
|
585
|
+
if (page.requires && !ctx.can(page.requires))
|
|
586
|
+
return false;
|
|
587
|
+
return true;
|
|
588
|
+
}
|
|
589
|
+
catch {
|
|
590
|
+
return false;
|
|
591
|
+
}
|
|
592
|
+
}
|
|
593
|
+
/**
|
|
594
|
+
* This page's guide for this person: the most specific match, when it is
|
|
595
|
+
* theirs. A more specific guide they do not have is not replaced by a less
|
|
596
|
+
* specific one (it would describe what is not on screen).
|
|
597
|
+
*/
|
|
598
|
+
function currentPage(suite, where, ctx) {
|
|
599
|
+
const p = matchingPage(suite, where);
|
|
600
|
+
return p && pageShown(p, ctx) ? p : null;
|
|
601
|
+
}
|
|
602
|
+
/** The page guides this person has, for the panel's list (those with an `href`). */
|
|
603
|
+
function listedPages(suite, ctx) {
|
|
604
|
+
return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
|
|
605
|
+
}
|
|
368
606
|
|
|
369
607
|
/* ------------------------------------------------------------------ */
|
|
370
608
|
/* Copy */
|
|
@@ -378,6 +616,7 @@ const COPY = {
|
|
|
378
616
|
replayEnded: "จบการทบทวนแล้ว",
|
|
379
617
|
hiddenToast: "ซ่อนแล้ว",
|
|
380
618
|
aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
|
|
619
|
+
pageDone: "จบคู่มือหน้านี้",
|
|
381
620
|
bridgeTitle: "กดปุ่มเมนู",
|
|
382
621
|
bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
|
|
383
622
|
};
|
|
@@ -385,8 +624,13 @@ const COPY = {
|
|
|
385
624
|
/* Helpers */
|
|
386
625
|
/* ------------------------------------------------------------------ */
|
|
387
626
|
const BRIDGE_TARGET = "shell.menu-toggle";
|
|
388
|
-
function initialGuideState(audience, progress) {
|
|
627
|
+
function initialGuideState(audience, progress, pages) {
|
|
389
628
|
return {
|
|
629
|
+
pageAudience: pages?.pageAudience ?? "none",
|
|
630
|
+
pagesMode: pages?.pagesMode ?? "off",
|
|
631
|
+
pages: pages?.pages ?? {},
|
|
632
|
+
pageRun: null,
|
|
633
|
+
panelSection: "all",
|
|
390
634
|
audience,
|
|
391
635
|
progress,
|
|
392
636
|
screen: { name: "idle" },
|
|
@@ -400,7 +644,13 @@ function initialGuideState(audience, progress) {
|
|
|
400
644
|
};
|
|
401
645
|
}
|
|
402
646
|
function findMission(suite, key) {
|
|
403
|
-
|
|
647
|
+
if (!key)
|
|
648
|
+
return undefined;
|
|
649
|
+
if (isPageMissionKey(key)) {
|
|
650
|
+
const page = findPage(suite, pageKeyOf(key));
|
|
651
|
+
return page ? pageMission(page) : undefined;
|
|
652
|
+
}
|
|
653
|
+
return suite.missions.find(m => m.key === key);
|
|
404
654
|
}
|
|
405
655
|
/**
|
|
406
656
|
* Whether the user has this mission (GuideMission.visible). Without the
|
|
@@ -563,8 +813,10 @@ function placeRoute(steps, index) {
|
|
|
563
813
|
return step.route;
|
|
564
814
|
return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
|
|
565
815
|
}
|
|
566
|
-
/** The user is not on the page the step happens on. */
|
|
567
|
-
function offRoute(steps, index, env) {
|
|
816
|
+
/** The user is not on the page the step happens on (a page guide's step never is: leaving the page ends it). */
|
|
817
|
+
function offRoute(steps, index, env, page) {
|
|
818
|
+
if (page)
|
|
819
|
+
return false;
|
|
568
820
|
const home = stepHomeRoute(steps, index);
|
|
569
821
|
return !!home && !matchRoute(home, env.pathname, env.search, env.code);
|
|
570
822
|
}
|
|
@@ -707,7 +959,8 @@ function welcomeDue(state, suite, ctx) {
|
|
|
707
959
|
state.progress.welcome === "not_seen" &&
|
|
708
960
|
state.progress.status === "in_progress" &&
|
|
709
961
|
!state.progress.mission_key &&
|
|
710
|
-
|
|
962
|
+
// The missions' welcome always wins over a page guide's offer.
|
|
963
|
+
(state.screen.name === "idle" || state.screen.name === "pageOffer") &&
|
|
711
964
|
(ctx ? shownMissions(suite, ctx).length : suite.missions.length) > 0);
|
|
712
965
|
}
|
|
713
966
|
/** Whether the floating "ภารกิจ n/N" chip shows. */
|
|
@@ -820,9 +1073,12 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
820
1073
|
}
|
|
821
1074
|
const step = steps[i];
|
|
822
1075
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1076
|
+
const page = pageKeyOf(mission.key);
|
|
823
1077
|
const where = placeRoute(steps, i);
|
|
824
|
-
|
|
1078
|
+
// A page guide is on its page: leaving it ends the guide (never "ทำต่อ").
|
|
1079
|
+
const away = !page && !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
825
1080
|
const bridge = !away && needsBridge(step, env, ctx);
|
|
1081
|
+
const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
|
|
826
1082
|
setScreen(d, {
|
|
827
1083
|
name: "step",
|
|
828
1084
|
mission: mission.key,
|
|
@@ -834,7 +1090,10 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
834
1090
|
confirmExit: false,
|
|
835
1091
|
misses: 0,
|
|
836
1092
|
armed: hiddenAnchorShown(step, env),
|
|
837
|
-
nothingToSave:
|
|
1093
|
+
nothingToSave: nts,
|
|
1094
|
+
status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
|
|
1095
|
+
replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
|
|
1096
|
+
...(page ? { page } : {}),
|
|
838
1097
|
});
|
|
839
1098
|
if (!opts.replay) {
|
|
840
1099
|
const cp = checkpointAtOrBefore(steps, i);
|
|
@@ -877,6 +1136,54 @@ function setNothingToSave(d, suite, env, on) {
|
|
|
877
1136
|
if (stepTargetVisible(step, env, ctx, s.bridge))
|
|
878
1137
|
announceStep(d, suite, env);
|
|
879
1138
|
}
|
|
1139
|
+
/** The step's status line (GuideStep.status): trimmed text, or null. */
|
|
1140
|
+
function stepStatus(step, ctx) {
|
|
1141
|
+
if (!step.status)
|
|
1142
|
+
return null;
|
|
1143
|
+
const text = safe(() => step.status(ctx), undefined);
|
|
1144
|
+
return typeof text === "string" && text.trim() ? text.trim() : null;
|
|
1145
|
+
}
|
|
1146
|
+
/**
|
|
1147
|
+
* In a replay, whether a "select" step may go on with ถัดไป: the next step
|
|
1148
|
+
* this person sees has its target on the page already (or there is none).
|
|
1149
|
+
* A pick that makes the next step's target (a save shown only once the
|
|
1150
|
+
* right value is chosen) is still needed.
|
|
1151
|
+
*/
|
|
1152
|
+
function replayCanGoOn(mission, index, env, context, nothingToSave = false) {
|
|
1153
|
+
if (stepKind(shownStep(mission.steps[index], nothingToSave)) !== "select")
|
|
1154
|
+
return false;
|
|
1155
|
+
for (let i = index + 1; i < mission.steps.length; i++) {
|
|
1156
|
+
const next = mission.steps[i];
|
|
1157
|
+
const ctx = guideContext(env, context, routeParamsFor(next, env));
|
|
1158
|
+
if (next.requires && !safe(() => env.can(next.requires), true))
|
|
1159
|
+
continue;
|
|
1160
|
+
if (next.skipIf && safe(() => next.skipIf(ctx), false))
|
|
1161
|
+
continue;
|
|
1162
|
+
if (!next.target)
|
|
1163
|
+
return true;
|
|
1164
|
+
if (stepTargetVisible(next, env, ctx, false))
|
|
1165
|
+
return true;
|
|
1166
|
+
// A commit step with nothing to save is read on its saved state.
|
|
1167
|
+
return nothingToSaveHolds(next, env, ctx);
|
|
1168
|
+
}
|
|
1169
|
+
return true;
|
|
1170
|
+
}
|
|
1171
|
+
/** Keep the status line and a replay's ถัดไป current (the page changed). */
|
|
1172
|
+
function syncStatus(d, suite, env) {
|
|
1173
|
+
const s = d.state.screen;
|
|
1174
|
+
if (s.name !== "step")
|
|
1175
|
+
return;
|
|
1176
|
+
const mission = findMission(suite, s.mission);
|
|
1177
|
+
if (!mission)
|
|
1178
|
+
return;
|
|
1179
|
+
const step = shownStep(mission.steps[s.index], s.nothingToSave);
|
|
1180
|
+
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1181
|
+
const off = s.phase === "away" || s.bridge;
|
|
1182
|
+
const status = off ? null : stepStatus(step, ctx);
|
|
1183
|
+
const replayNext = !off && s.replay && replayCanGoOn(mission, s.index, env, d.state.progress.context, s.nothingToSave);
|
|
1184
|
+
if (status !== s.status || replayNext !== s.replayNext)
|
|
1185
|
+
setScreen(d, { ...s, status, replayNext });
|
|
1186
|
+
}
|
|
880
1187
|
function markReadyIfVisible(d, suite, env) {
|
|
881
1188
|
const s = d.state.screen;
|
|
882
1189
|
if (s.name !== "step" || s.phase !== "waiting")
|
|
@@ -951,6 +1258,11 @@ function advance(d, suite, env, capture) {
|
|
|
951
1258
|
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
952
1259
|
}
|
|
953
1260
|
function finishMission(d, suite, env, mission, replay) {
|
|
1261
|
+
if (isPageMissionKey(mission.key)) {
|
|
1262
|
+
endPage(d, suite, "completed");
|
|
1263
|
+
d.effects.push({ type: "toast", text: COPY.pageDone });
|
|
1264
|
+
return;
|
|
1265
|
+
}
|
|
954
1266
|
if (replay) {
|
|
955
1267
|
setScreen(d, { name: "idle" });
|
|
956
1268
|
d.effects.push({ type: "toast", text: COPY.replayDone });
|
|
@@ -1144,6 +1456,134 @@ function pushAbort(d, suite, s) {
|
|
|
1144
1456
|
if (actions.length)
|
|
1145
1457
|
d.effects.push({ type: "abort", actions });
|
|
1146
1458
|
}
|
|
1459
|
+
/* ------------------------------------------------------------------ */
|
|
1460
|
+
/* Page guides */
|
|
1461
|
+
/* ------------------------------------------------------------------ */
|
|
1462
|
+
function whereOf(env) {
|
|
1463
|
+
return { code: env.code, pathname: env.pathname, search: env.search, isVisible: a => env.isVisible(a) };
|
|
1464
|
+
}
|
|
1465
|
+
/** This page's guide for this person (the most specific match, if theirs). */
|
|
1466
|
+
function pageHere(suite, env) {
|
|
1467
|
+
return currentPage(suite, whereOf(env), guideContext(env, {}));
|
|
1468
|
+
}
|
|
1469
|
+
/** The page guide's row says this person has ended this version of it. */
|
|
1470
|
+
function pageRecorded(state, page) {
|
|
1471
|
+
const row = state.pages[page.key];
|
|
1472
|
+
return !!row && row.version >= page.version;
|
|
1473
|
+
}
|
|
1474
|
+
/** Whether the first step this person sees has its target on screen (or none). */
|
|
1475
|
+
function firstStepReady(suite, pageKey, env) {
|
|
1476
|
+
const page = findPage(suite, pageKey);
|
|
1477
|
+
if (!page)
|
|
1478
|
+
return false;
|
|
1479
|
+
const mission = pageMission(page);
|
|
1480
|
+
for (const step of mission.steps) {
|
|
1481
|
+
const ctx = guideContext(env, {});
|
|
1482
|
+
if (step.requires && !safe(() => env.can(step.requires), true))
|
|
1483
|
+
continue;
|
|
1484
|
+
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
1485
|
+
continue;
|
|
1486
|
+
return stepTargetVisible(step, env, ctx, false);
|
|
1487
|
+
}
|
|
1488
|
+
return true;
|
|
1489
|
+
}
|
|
1490
|
+
/**
|
|
1491
|
+
* The page guide to offer now, or null. Due when: pages mode is "offer" and
|
|
1492
|
+
* this person has page guides; nothing else is up (no step, card, panel or
|
|
1493
|
+
* celebration waiting); this page's guide is theirs, may be offered, and has
|
|
1494
|
+
* no row of this version; its first step's target is on screen; no app
|
|
1495
|
+
* dialog is open. The host adds the rest: the welcome not due on this
|
|
1496
|
+
* route, a moment of nothing loading, the primary tab, not hidden.
|
|
1497
|
+
*/
|
|
1498
|
+
function pageOfferDue(state, suite, env) {
|
|
1499
|
+
if (state.pagesMode !== "offer" || state.pageAudience === "none")
|
|
1500
|
+
return null;
|
|
1501
|
+
if (state.screen.name !== "idle" || state.panelOpen || (state.pendingCelebrate?.length ?? 0) > 0)
|
|
1502
|
+
return null;
|
|
1503
|
+
const page = pageHere(suite, env);
|
|
1504
|
+
if (!page || page.offer === false || pageRecorded(state, page))
|
|
1505
|
+
return null;
|
|
1506
|
+
if (!firstStepReady(suite, page.key, env))
|
|
1507
|
+
return null;
|
|
1508
|
+
if (safe(() => env.dialogOpen?.() === true, false))
|
|
1509
|
+
return null;
|
|
1510
|
+
return page.key;
|
|
1511
|
+
}
|
|
1512
|
+
/** Whether the page guide still applies where the person is (its route, its `when`). */
|
|
1513
|
+
function pageStillHere(suite, key, env) {
|
|
1514
|
+
const page = findPage(suite, key);
|
|
1515
|
+
return !!page && pageMatchScore(page, whereOf(env)) !== null;
|
|
1516
|
+
}
|
|
1517
|
+
/** Write a page guide's row (once per page and version): recorded here at once, saved by the host. */
|
|
1518
|
+
function recordPage(d, suite, key, status) {
|
|
1519
|
+
const page = findPage(suite, key);
|
|
1520
|
+
if (!page || pageRecorded(d.state, page))
|
|
1521
|
+
return;
|
|
1522
|
+
const prev = d.state.pages[key];
|
|
1523
|
+
d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
|
|
1524
|
+
d.effects.push({ type: "savePage", key, version: page.version, status });
|
|
1525
|
+
}
|
|
1526
|
+
/** The page guide ends: its row written when it is due one; back to idle. */
|
|
1527
|
+
function endPage(d, suite, how) {
|
|
1528
|
+
const run = d.state.pageRun;
|
|
1529
|
+
if (run?.write)
|
|
1530
|
+
recordPage(d, suite, run.key, how);
|
|
1531
|
+
d.state = { ...d.state, pageRun: null, replayPage: null };
|
|
1532
|
+
setScreen(d, { name: "idle" });
|
|
1533
|
+
}
|
|
1534
|
+
function startPage(d, suite, env, key, write) {
|
|
1535
|
+
const page = findPage(suite, key);
|
|
1536
|
+
if (!page)
|
|
1537
|
+
return;
|
|
1538
|
+
const mission = pageMission(page);
|
|
1539
|
+
d.state = {
|
|
1540
|
+
...d.state,
|
|
1541
|
+
panelOpen: false,
|
|
1542
|
+
justDone: null,
|
|
1543
|
+
pageRun: { key, write },
|
|
1544
|
+
// What is open now is the person's: ending the guide leaves it alone.
|
|
1545
|
+
replayPage: {
|
|
1546
|
+
mission: mission.key,
|
|
1547
|
+
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
1548
|
+
dialog: safe(() => env.dialogOpen?.() === true, false),
|
|
1549
|
+
},
|
|
1550
|
+
};
|
|
1551
|
+
enterStep(d, suite, env, mission, 0, { replay: true });
|
|
1552
|
+
}
|
|
1553
|
+
/** The step before this one that this person sees, or -1. */
|
|
1554
|
+
function previousShownIndex(mission, index, env, context) {
|
|
1555
|
+
for (let j = index - 1; j >= 0; j--) {
|
|
1556
|
+
const st = mission.steps[j];
|
|
1557
|
+
const ctx = guideContext(env, context, routeParamsFor(st, env));
|
|
1558
|
+
if (st.requires && !safe(() => env.can(st.requires), true))
|
|
1559
|
+
continue;
|
|
1560
|
+
if (st.skipIf && safe(() => st.skipIf(ctx), false))
|
|
1561
|
+
continue;
|
|
1562
|
+
return j;
|
|
1563
|
+
}
|
|
1564
|
+
return -1;
|
|
1565
|
+
}
|
|
1566
|
+
/**
|
|
1567
|
+
* Where ย้อนกลับ goes from a page guide's step, or null. The step before it
|
|
1568
|
+
* that this person sees; from inside what a click opened (not a
|
|
1569
|
+
* checkpoint) back to that click, closing it first (the step's abort,
|
|
1570
|
+
* Escape by default). A click step whose result is still on screen (a tab
|
|
1571
|
+
* switched) is passed over: entering it again would pass it at once.
|
|
1572
|
+
*/
|
|
1573
|
+
function backTarget(mission, index, env, context) {
|
|
1574
|
+
const cur = mission.steps[index];
|
|
1575
|
+
let prev = previousShownIndex(mission, index, env, context);
|
|
1576
|
+
if (prev < 0 || !cur)
|
|
1577
|
+
return null;
|
|
1578
|
+
if (cur.checkpoint === false) {
|
|
1579
|
+
const to = mission.steps[prev];
|
|
1580
|
+
return { index: prev, close: to.checkpoint !== false };
|
|
1581
|
+
}
|
|
1582
|
+
while (prev >= 0 && stepKind(mission.steps[prev]) !== "view" && stateTriggerHolds(mission.steps[prev], env)) {
|
|
1583
|
+
prev = previousShownIndex(mission, prev, env, context);
|
|
1584
|
+
}
|
|
1585
|
+
return prev >= 0 ? { index: prev, close: false } : null;
|
|
1586
|
+
}
|
|
1147
1587
|
function reduceGuide(state, action, suite, env) {
|
|
1148
1588
|
const d = { state, effects: [] };
|
|
1149
1589
|
const s = state.screen;
|
|
@@ -1155,6 +1595,9 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1155
1595
|
const gate = gateState(suite, ctxNow());
|
|
1156
1596
|
if (gate === undefined)
|
|
1157
1597
|
break;
|
|
1598
|
+
// The welcome wins: the page offer goes, unrecorded (it comes back on a later visit).
|
|
1599
|
+
if (s.name === "pageOffer")
|
|
1600
|
+
setScreen(d, { name: "idle" });
|
|
1158
1601
|
if (gate === false) {
|
|
1159
1602
|
if (!state.gateDismissed)
|
|
1160
1603
|
setScreen(d, { name: "gate" });
|
|
@@ -1195,6 +1638,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1195
1638
|
case "start":
|
|
1196
1639
|
if (state.audience === "none")
|
|
1197
1640
|
break;
|
|
1641
|
+
// A mission always wins over a page guide: a running one ends (its row
|
|
1642
|
+
// written if due), an offer goes unrecorded.
|
|
1643
|
+
if (s.name === "step" && s.page)
|
|
1644
|
+
endPage(d, suite, "skipped");
|
|
1645
|
+
else if (s.name === "pageOffer")
|
|
1646
|
+
setScreen(d, { name: "idle" });
|
|
1198
1647
|
if (s.name === "step" && !s.replay && s.mission !== action.mission) {
|
|
1199
1648
|
// Leaving one mission for another: the first is set aside.
|
|
1200
1649
|
exitMission(d);
|
|
@@ -1216,8 +1665,21 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1216
1665
|
break;
|
|
1217
1666
|
}
|
|
1218
1667
|
case "sync": {
|
|
1668
|
+
if (s.name === "pageOffer") {
|
|
1669
|
+
// Ignored: the person left the page (or its sub-screen). Recorded.
|
|
1670
|
+
if (!pageStillHere(suite, s.page, env)) {
|
|
1671
|
+
recordPage(d, suite, s.page, "skipped");
|
|
1672
|
+
setScreen(d, { name: "idle" });
|
|
1673
|
+
}
|
|
1674
|
+
break;
|
|
1675
|
+
}
|
|
1219
1676
|
if (s.name !== "step")
|
|
1220
1677
|
break;
|
|
1678
|
+
if (s.page && !pageStillHere(suite, s.page, env)) {
|
|
1679
|
+
// Leaving the page ends its guide, silently.
|
|
1680
|
+
endPage(d, suite, "skipped");
|
|
1681
|
+
break;
|
|
1682
|
+
}
|
|
1221
1683
|
const mission = findMission(suite, s.mission);
|
|
1222
1684
|
if (!mission)
|
|
1223
1685
|
break;
|
|
@@ -1247,7 +1709,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1247
1709
|
break;
|
|
1248
1710
|
}
|
|
1249
1711
|
}
|
|
1250
|
-
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env)) {
|
|
1712
|
+
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page)) {
|
|
1251
1713
|
// The target is missing because the page is another one.
|
|
1252
1714
|
recoverOffRoute(d, suite, env, false);
|
|
1253
1715
|
break;
|
|
@@ -1262,6 +1724,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1262
1724
|
else if (!cur.bridge && needsBridge(step, env, ctx)) {
|
|
1263
1725
|
setScreen(d, { ...cur, bridge: true, phase: "waiting" });
|
|
1264
1726
|
}
|
|
1727
|
+
syncStatus(d, suite, env);
|
|
1265
1728
|
markReadyIfVisible(d, suite, env);
|
|
1266
1729
|
break;
|
|
1267
1730
|
}
|
|
@@ -1324,6 +1787,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1324
1787
|
advance(d, suite, env);
|
|
1325
1788
|
break;
|
|
1326
1789
|
}
|
|
1790
|
+
if (kind === "select") {
|
|
1791
|
+
// A replay goes on without a pick when the next step is on the page.
|
|
1792
|
+
if (s.replay && replayCanGoOn(mission, s.index, env, d.state.progress.context, s.nothingToSave))
|
|
1793
|
+
advance(d, suite, env);
|
|
1794
|
+
break;
|
|
1795
|
+
}
|
|
1327
1796
|
if (kind !== "input")
|
|
1328
1797
|
break;
|
|
1329
1798
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
@@ -1356,7 +1825,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1356
1825
|
const cur = d.state.screen;
|
|
1357
1826
|
if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
|
|
1358
1827
|
break;
|
|
1359
|
-
if (!cur.bridge && offRoute(mission.steps, cur.index, env)) {
|
|
1828
|
+
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
|
|
1360
1829
|
recoverOffRoute(d, suite, env, false);
|
|
1361
1830
|
break;
|
|
1362
1831
|
}
|
|
@@ -1377,7 +1846,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1377
1846
|
if (!mission)
|
|
1378
1847
|
break;
|
|
1379
1848
|
const step = mission.steps[s.index];
|
|
1380
|
-
if (!s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
1849
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
1381
1850
|
recoverOffRoute(d, suite, env, false);
|
|
1382
1851
|
break;
|
|
1383
1852
|
}
|
|
@@ -1394,7 +1863,10 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1394
1863
|
advance(d, suite, env);
|
|
1395
1864
|
}
|
|
1396
1865
|
else if (policy === "abort") {
|
|
1397
|
-
|
|
1866
|
+
if (s.page)
|
|
1867
|
+
endPage(d, suite, "skipped");
|
|
1868
|
+
else
|
|
1869
|
+
exitMission(d);
|
|
1398
1870
|
d.effects.push({ type: "toast", text: COPY.aborted });
|
|
1399
1871
|
}
|
|
1400
1872
|
else if ((policy === "rewind" || (policy === "retry" && misses >= 2)) && cp < s.index) {
|
|
@@ -1417,7 +1889,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1417
1889
|
if (s.name !== "step" || s.phase !== "missing")
|
|
1418
1890
|
break;
|
|
1419
1891
|
const mission = findMission(suite, s.mission);
|
|
1420
|
-
if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
1892
|
+
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
1421
1893
|
// Polling again cannot help on another page: go back to the step's.
|
|
1422
1894
|
recoverOffRoute(d, suite, env, true);
|
|
1423
1895
|
break;
|
|
@@ -1433,6 +1905,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1433
1905
|
case "exit":
|
|
1434
1906
|
if (s.name !== "step")
|
|
1435
1907
|
break;
|
|
1908
|
+
if (s.page) {
|
|
1909
|
+
// ✕, Escape or ปิดคู่มือ: ends silently.
|
|
1910
|
+
pushAbort(d, suite, s);
|
|
1911
|
+
endPage(d, suite, "skipped");
|
|
1912
|
+
break;
|
|
1913
|
+
}
|
|
1436
1914
|
if (s.replay) {
|
|
1437
1915
|
pushAbort(d, suite, s);
|
|
1438
1916
|
setScreen(d, { name: "idle" });
|
|
@@ -1501,11 +1979,17 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1501
1979
|
break;
|
|
1502
1980
|
setScreen(d, { name: "idle" });
|
|
1503
1981
|
break;
|
|
1504
|
-
case "openPanel":
|
|
1505
|
-
|
|
1506
|
-
|
|
1507
|
-
|
|
1982
|
+
case "openPanel": {
|
|
1983
|
+
const section = action.section ?? "all";
|
|
1984
|
+
const allowed = section === "pages" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
|
|
1985
|
+
if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
|
|
1986
|
+
break;
|
|
1987
|
+
// The offer gives way to the panel, unrecorded.
|
|
1988
|
+
if (s.name === "pageOffer")
|
|
1989
|
+
setScreen(d, { name: "idle" });
|
|
1990
|
+
d.state = { ...d.state, panelOpen: true, panelSection: section };
|
|
1508
1991
|
break;
|
|
1992
|
+
}
|
|
1509
1993
|
case "closePanel":
|
|
1510
1994
|
d.state = { ...d.state, panelOpen: false, justDone: null };
|
|
1511
1995
|
break;
|
|
@@ -1549,6 +2033,58 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1549
2033
|
passiveDone(d, suite, env, m);
|
|
1550
2034
|
break;
|
|
1551
2035
|
}
|
|
2036
|
+
case "pageOffer": {
|
|
2037
|
+
if (pageOfferDue(state, suite, env) !== action.page)
|
|
2038
|
+
break;
|
|
2039
|
+
setScreen(d, { name: "pageOffer", page: action.page });
|
|
2040
|
+
d.effects.push({ type: "announce", text: `คู่มือหน้านี้ ${findPage(suite, action.page)?.title ?? ""}` });
|
|
2041
|
+
break;
|
|
2042
|
+
}
|
|
2043
|
+
case "pageOfferStart":
|
|
2044
|
+
if (s.name !== "pageOffer" || state.pageAudience === "none")
|
|
2045
|
+
break;
|
|
2046
|
+
startPage(d, suite, env, s.page, true);
|
|
2047
|
+
break;
|
|
2048
|
+
case "pageOfferLater":
|
|
2049
|
+
if (s.name !== "pageOffer")
|
|
2050
|
+
break;
|
|
2051
|
+
recordPage(d, suite, s.page, "skipped");
|
|
2052
|
+
setScreen(d, { name: "idle" });
|
|
2053
|
+
break;
|
|
2054
|
+
case "startPage": {
|
|
2055
|
+
// Never over a mission step, a welcome, a gate or a celebration.
|
|
2056
|
+
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
2057
|
+
break;
|
|
2058
|
+
if (s.name !== "idle" && s.name !== "pageOffer")
|
|
2059
|
+
break;
|
|
2060
|
+
const page = pageHere(suite, env);
|
|
2061
|
+
if (!page || page.key !== action.page)
|
|
2062
|
+
break;
|
|
2063
|
+
startPage(d, suite, env, page.key, !pageRecorded(state, page));
|
|
2064
|
+
break;
|
|
2065
|
+
}
|
|
2066
|
+
case "back": {
|
|
2067
|
+
if (s.name !== "step" || !s.page || s.phase !== "ready" || s.confirmExit)
|
|
2068
|
+
break;
|
|
2069
|
+
const mission = findMission(suite, s.mission);
|
|
2070
|
+
if (!mission)
|
|
2071
|
+
break;
|
|
2072
|
+
const to = backTarget(mission, s.index, env, d.state.progress.context);
|
|
2073
|
+
if (!to)
|
|
2074
|
+
break;
|
|
2075
|
+
// Close what the click opened, then show the click again.
|
|
2076
|
+
if (to.close)
|
|
2077
|
+
pushAbort(d, suite, s);
|
|
2078
|
+
enterStep(d, suite, env, mission, to.index, { replay: true, noAutoPass: true });
|
|
2079
|
+
break;
|
|
2080
|
+
}
|
|
2081
|
+
case "pageRow": {
|
|
2082
|
+
const cur = d.state.pages[action.key];
|
|
2083
|
+
if (cur && cur.version > action.row.version)
|
|
2084
|
+
break;
|
|
2085
|
+
d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
|
|
2086
|
+
break;
|
|
2087
|
+
}
|
|
1552
2088
|
case "idleCelebrate": {
|
|
1553
2089
|
if (s.name !== "idle" || state.panelOpen)
|
|
1554
2090
|
break;
|
|
@@ -1672,6 +2208,11 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
1672
2208
|
if (typeof data.app === "string" && data.app !== suite.app)
|
|
1673
2209
|
return null;
|
|
1674
2210
|
const rows = Array.isArray(data.guides) ? data.guides : [];
|
|
2211
|
+
const pagesMode = data.pages_mode === "menu" || data.pages_mode === "offer" ? data.pages_mode : "off";
|
|
2212
|
+
const pageAudience = pagesMode !== "off" && (data.page_audience === "optional" || data.page_audience === "forced")
|
|
2213
|
+
? data.page_audience
|
|
2214
|
+
: "none";
|
|
2215
|
+
const pages = parsePageRows(rows, suite.app);
|
|
1675
2216
|
let row = null;
|
|
1676
2217
|
for (const r of rows) {
|
|
1677
2218
|
if (isRecord(r) && r.guide_key === suite.key) {
|
|
@@ -1692,7 +2233,58 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
1692
2233
|
progress = row;
|
|
1693
2234
|
newerOnServer = row.guide_version > suite.version;
|
|
1694
2235
|
}
|
|
1695
|
-
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer };
|
|
2236
|
+
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages };
|
|
2237
|
+
}
|
|
2238
|
+
/** The row key of a page guide: `<app>.page.<key>`. */
|
|
2239
|
+
function pageRowKey(app, key) {
|
|
2240
|
+
return `${app}.page.${key}`;
|
|
2241
|
+
}
|
|
2242
|
+
/** Page rows among the list's rows, by page key. Off-shape rows are left out. */
|
|
2243
|
+
function parsePageRows(rows, app) {
|
|
2244
|
+
const prefix = pageRowKey(app, "");
|
|
2245
|
+
const out = {};
|
|
2246
|
+
for (const r of rows) {
|
|
2247
|
+
if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
|
|
2248
|
+
continue;
|
|
2249
|
+
const row = parsePageRow(r);
|
|
2250
|
+
if (row)
|
|
2251
|
+
out[r.guide_key.slice(prefix.length)] = row;
|
|
2252
|
+
}
|
|
2253
|
+
return out;
|
|
2254
|
+
}
|
|
2255
|
+
/** One page row (a list entry, a PUT answer, a 409's `current`); null when off-shape. */
|
|
2256
|
+
function parsePageRow(raw) {
|
|
2257
|
+
if (!isRecord(raw))
|
|
2258
|
+
return null;
|
|
2259
|
+
const version = Number(raw.guide_version);
|
|
2260
|
+
const rev = Number(raw.rev);
|
|
2261
|
+
if (!Number.isInteger(version) || version < 1 || !Number.isInteger(rev) || rev < 0)
|
|
2262
|
+
return null;
|
|
2263
|
+
const status = ROW_STATUSES.includes(raw.status) ? raw.status : "in_progress";
|
|
2264
|
+
return { version, status, rev };
|
|
2265
|
+
}
|
|
2266
|
+
/**
|
|
2267
|
+
* A page row's PUT body: the least the server takes. Written once per page
|
|
2268
|
+
* and version (finished: completed; not now, exited or left: skipped).
|
|
2269
|
+
*/
|
|
2270
|
+
function pageBody(app, version, rev, status) {
|
|
2271
|
+
return {
|
|
2272
|
+
app,
|
|
2273
|
+
guide_version: version,
|
|
2274
|
+
rev,
|
|
2275
|
+
status,
|
|
2276
|
+
mission_key: null,
|
|
2277
|
+
step_key: null,
|
|
2278
|
+
step_index: 0,
|
|
2279
|
+
checkpoint_key: null,
|
|
2280
|
+
missions: {},
|
|
2281
|
+
context: {},
|
|
2282
|
+
welcome: "started",
|
|
2283
|
+
chip_hidden: false,
|
|
2284
|
+
last_error: null,
|
|
2285
|
+
skipped_step: null,
|
|
2286
|
+
restart: false,
|
|
2287
|
+
};
|
|
1696
2288
|
}
|
|
1697
2289
|
const TERMINAL = ["completed", "skipped", "aborted"];
|
|
1698
2290
|
/** The PUT body for the client's state. Drops keys the server would refuse. */
|
|
@@ -1898,6 +2490,47 @@ async function storeGuides(adapter, suite, progress) {
|
|
|
1898
2490
|
}
|
|
1899
2491
|
return null;
|
|
1900
2492
|
}
|
|
2493
|
+
/**
|
|
2494
|
+
* Writes a page guide's row (`<app>.page.<key>`) once: the minimal body,
|
|
2495
|
+
* with the same 409 handling as the tour's row. Another tab that already
|
|
2496
|
+
* recorded this version wins (its row is returned, nothing is sent again);
|
|
2497
|
+
* an older version's row is replaced with its rev; a newer one stops. Returns
|
|
2498
|
+
* the stored row, or null when it could not be saved.
|
|
2499
|
+
*/
|
|
2500
|
+
async function storePageRow(adapter, app, key, version, status, rev) {
|
|
2501
|
+
const guideKey = pageRowKey(app, key);
|
|
2502
|
+
let at = rev;
|
|
2503
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2504
|
+
let res;
|
|
2505
|
+
try {
|
|
2506
|
+
res = await adapter.saveProgress({ app, guideKey, body: pageBody(app, version, at, status) });
|
|
2507
|
+
}
|
|
2508
|
+
catch (err) {
|
|
2509
|
+
const conflict = conflictOf(err);
|
|
2510
|
+
if (!conflict)
|
|
2511
|
+
return null;
|
|
2512
|
+
res = conflict;
|
|
2513
|
+
}
|
|
2514
|
+
if (!res || typeof res.status !== "number")
|
|
2515
|
+
return null;
|
|
2516
|
+
const payload = res.data;
|
|
2517
|
+
if (res.status >= 200 && res.status < 300)
|
|
2518
|
+
return parsePageRow(payload?.data ?? payload);
|
|
2519
|
+
if (res.status !== 409)
|
|
2520
|
+
return null;
|
|
2521
|
+
const current = parsePageRow(payload?.current);
|
|
2522
|
+
if (!current) {
|
|
2523
|
+
at = 0;
|
|
2524
|
+
continue;
|
|
2525
|
+
}
|
|
2526
|
+
if (current.version > version)
|
|
2527
|
+
return null;
|
|
2528
|
+
if (current.version === version && current.status !== "in_progress")
|
|
2529
|
+
return current;
|
|
2530
|
+
at = current.rev;
|
|
2531
|
+
}
|
|
2532
|
+
return null;
|
|
2533
|
+
}
|
|
1901
2534
|
|
|
1902
2535
|
/**
|
|
1903
2536
|
* The tour's DOM side: finding anchors, deciding whether one is really on
|
|
@@ -2330,6 +2963,7 @@ const GUIDE_CSS = `
|
|
|
2330
2963
|
.eg-b-t{margin:0;font-size:16px;font-weight:600;line-height:1.45}
|
|
2331
2964
|
.eg-b-w{margin:2px 0 0;font-size:13.5px;color:var(--eg-muted)}
|
|
2332
2965
|
.eg-bubble.eg-err .eg-b-t{color:var(--eg-danger)}
|
|
2966
|
+
.eg-b-s{margin:6px 0 0;font-size:13px;font-weight:600;color:var(--eg-danger)}
|
|
2333
2967
|
.eg-k{display:inline-block;background:var(--eg-tint);color:var(--eg-strong);border-radius:7px;padding:0 7px;font-weight:600}
|
|
2334
2968
|
.eg-b-f{display:flex;align-items:center;justify-content:space-between;margin-top:12px;gap:8px}
|
|
2335
2969
|
.eg-b-f .eg-btn{height:34px;padding:0 14px;font-size:13.5px}
|
|
@@ -2416,6 +3050,12 @@ const GUIDE_CSS = `
|
|
|
2416
3050
|
.eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
|
2417
3051
|
.eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
|
|
2418
3052
|
.eg-q-f{border-top:1px solid var(--eg-line);padding:8px 12px;display:flex;justify-content:flex-end}
|
|
3053
|
+
.eg-q-sub{margin:6px 0 0;padding:12px 18px 2px;border-top:1px solid var(--eg-line);font-size:13px;font-weight:700;color:var(--eg-muted)}
|
|
3054
|
+
.eg-pg-item{padding:8px}
|
|
3055
|
+
.eg-pg-item .eg-t small.eg-new{color:var(--eg-strong);font-weight:600}
|
|
3056
|
+
.eg-card.eg-offer{left:auto;right:22px;transform:none;bottom:var(--erp-guide-bottom-offset,22px);width:min(320px,calc(100vw - 32px))}
|
|
3057
|
+
.eg-offer .eg-po-e{margin:0 0 2px;font-size:12px;font-weight:600;color:var(--eg-strong)}
|
|
3058
|
+
.eg-offer .eg-row .eg-btn{height:34px;padding:0 14px;font-size:13.5px}
|
|
2419
3059
|
.eg-toast{position:fixed;left:50%;bottom:calc(var(--erp-guide-bottom-offset,22px) + 62px);transform:translateX(-50%);background:#1c2634;color:#fff;padding:10px 16px;border-radius:10px;font-size:13.5px;box-shadow:var(--eg-shadow);max-width:calc(100vw - 32px);pointer-events:none}
|
|
2420
3060
|
svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
|
2421
3061
|
@media (max-width:767px){
|
|
@@ -2429,6 +3069,7 @@ svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;
|
|
|
2429
3069
|
.eg-panel{left:0;right:0;bottom:0;width:100%;max-height:85vh;border-radius:18px 18px 0 0;padding-bottom:env(safe-area-inset-bottom)}
|
|
2430
3070
|
.eg-q-item{padding:12px 8px}
|
|
2431
3071
|
.eg-card{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) - 4px)}
|
|
3072
|
+
.eg-card.eg-offer{left:16px;right:16px;width:auto;bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) - 4px)}
|
|
2432
3073
|
.eg-toast{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) + 60px)}
|
|
2433
3074
|
}
|
|
2434
3075
|
@keyframes eg-up{from{opacity:0;transform:translate(-50%,30px)}}
|
|
@@ -2526,6 +3167,14 @@ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
|
|
|
2526
3167
|
const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
|
|
2527
3168
|
return best >= 0 ? best : ok.indexOf(true);
|
|
2528
3169
|
}
|
|
3170
|
+
/** The way out of a step, as the bubble and the cards say it. */
|
|
3171
|
+
function exitLabelOf(b) {
|
|
3172
|
+
return b.exitLabel ?? (b.replay ? "จบการทบทวน" : "ออกจากไกด์");
|
|
3173
|
+
}
|
|
3174
|
+
/** The bubble's top line. */
|
|
3175
|
+
function headingOf(b) {
|
|
3176
|
+
return b.heading ?? `ภารกิจ ${b.missionNo} · ${b.missionTitle}${b.replay ? " (ทบทวน)" : ""}`;
|
|
3177
|
+
}
|
|
2529
3178
|
const BUBBLE_TITLE_ID = "eg-bubble-title";
|
|
2530
3179
|
const BUBBLE_WHY_ID = "eg-bubble-why";
|
|
2531
3180
|
function useNudge(nudge, ref) {
|
|
@@ -2563,7 +3212,7 @@ function StepLayer(p) {
|
|
|
2563
3212
|
const showBubble = p.phase === "ready" && (!!spot || !p.hasTarget);
|
|
2564
3213
|
const gap = p.showHand ? 34 : 14;
|
|
2565
3214
|
const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
|
|
2566
|
-
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.showNext}`;
|
|
3215
|
+
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}|${!!p.bubble.showBack}`;
|
|
2567
3216
|
// What the bubble sits beside: the target, with an open list over it (the
|
|
2568
3217
|
// list is what the person is choosing from now). What it must not cover:
|
|
2569
3218
|
// open lists, and the other anchors the step allows (a map step's
|
|
@@ -2680,13 +3329,13 @@ function StepLayer(p) {
|
|
|
2680
3329
|
top: pos?.top ?? 0,
|
|
2681
3330
|
width: phone ? viewport.width - 32 : undefined,
|
|
2682
3331
|
visibility: pos ? "visible" : "hidden",
|
|
2683
|
-
}, children: [jsxRuntime.jsx(BubbleBody, { ...p }), jsxRuntime.jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxRuntime.jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxRuntime.jsxs("div", { className: "eg-loading", children: [jsxRuntime.jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsxRuntime.jsx("div", { className: "eg-row", children: jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: p.bubble
|
|
3332
|
+
}, children: [jsxRuntime.jsx(BubbleBody, { ...p }), jsxRuntime.jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxRuntime.jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxRuntime.jsxs("div", { className: "eg-loading", children: [jsxRuntime.jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsxRuntime.jsx("div", { className: "eg-row", children: jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }) })] })) : null, p.phase === "missing" ? (jsxRuntime.jsxs("div", { className: "eg-card eg-err", role: "alertdialog", "aria-labelledby": "eg-missing-t", "data-eg-card": "", tabIndex: -1, children: [jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-missing-t", children: "\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E2A\u0E48\u0E27\u0E19\u0E19\u0E35\u0E49\u0E1A\u0E19\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onRetry, children: "\u0E25\u0E2D\u0E07\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07" })] })] })) : null, p.phase === "away" ? (jsxRuntime.jsxs("div", { className: "eg-card", role: "dialog", "aria-modal": "false", "aria-labelledby": "eg-away-t", "data-eg-card": "", tabIndex: -1, children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsx("span", { className: "eg-mis", children: headingOf(p.bubble) }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", p.bubble.index + 1, "/", p.bubble.total] })] }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-away-t", style: { marginTop: 8 }, children: jsxRuntime.jsx(GuideTitle, { text: p.bubble.title }) }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onGoResume, children: "\u0E17\u0E33\u0E15\u0E48\u0E2D" })] })] })) : null, p.confirmExit ? (jsxRuntime.jsx(ConfirmModal, { title: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E44\u0E01\u0E14\u0E4C\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49?", line: p.reopenHint || REOPEN_HINT, leaveLabel: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E44\u0E01\u0E14\u0E4C", stayLabel: "\u0E17\u0E33\u0E15\u0E48\u0E2D", onLeave: p.onExitConfirm, onStay: p.onExitCancel })) : null] }));
|
|
2684
3333
|
}
|
|
2685
3334
|
function BubbleBody(p) {
|
|
2686
3335
|
const b = p.bubble;
|
|
2687
3336
|
const title = b.error ? b.error.title : b.title;
|
|
2688
3337
|
const why = b.error ? (b.error.detail ?? "") : b.why;
|
|
2689
|
-
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.
|
|
3338
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsx("span", { className: "eg-mis", children: headingOf(b) }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsxRuntime.jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsxRuntime.jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsxRuntime.jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsxRuntime.jsx(GuideTitle, { text: title }) }), jsxRuntime.jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: jsxRuntime.jsx(GuideTitle, { text: why }) }), b.status && !b.error ? (jsxRuntime.jsx("p", { className: "eg-b-s", role: "status", "data-eg-status": "", children: b.status })) : null, jsxRuntime.jsxs("div", { className: "eg-b-f", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(b) }), jsxRuntime.jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showBack ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onBack, children: "\u0E22\u0E49\u0E2D\u0E19\u0E01\u0E25\u0E31\u0E1A" })) : null, b.showEndWithoutSave ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onEndWithoutSave, children: "\u0E08\u0E1A\u0E42\u0E14\u0E22\u0E44\u0E21\u0E48\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01" })) : null, b.showNext ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: b.nextLabel ?? "ถัดไป" })) : null] })] })] }));
|
|
2690
3339
|
}
|
|
2691
3340
|
/** The fingertip at ≈62% across and down the target; upside down near the bottom. */
|
|
2692
3341
|
function handPosition(spot, viewportHeight) {
|
|
@@ -2760,6 +3409,19 @@ function CompleteModal(p) {
|
|
|
2760
3409
|
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-scrim", "data-eg-block": "" }), jsxRuntime.jsxs("div", { className: "eg-modal eg-pop", role: "dialog", "aria-modal": "true", "aria-labelledby": "eg-f-t", "data-eg-modal": "", tabIndex: -1, children: [jsxRuntime.jsx(Confetti, { reduced: p.reduced }), jsxRuntime.jsx("div", { className: "eg-medal", children: jsxRuntime.jsx(IconTrophy, {}) }), jsxRuntime.jsxs("p", { className: "eg-eyebrow", children: ["\u0E04\u0E23\u0E1A ", p.total, " \u0E20\u0E32\u0E23\u0E01\u0E34\u0E08"] }), jsxRuntime.jsx("h2", { id: "eg-f-t", children: p.title }), p.subtitle ? jsxRuntime.jsx("p", { className: "eg-sub", children: p.subtitle }) : null, jsxRuntime.jsx("ol", { className: "eg-quests", children: p.missions.map((m, i) => (jsxRuntime.jsxs("li", { className: "eg-ok", children: [jsxRuntime.jsx("span", { className: "eg-n", children: jsxRuntime.jsx(IconCheck, {}) }), jsxRuntime.jsx("span", { className: "eg-t", children: m })] }, i))) }), jsxRuntime.jsxs("div", { className: "eg-actions", children: [jsxRuntime.jsx("button", { type: "button", ref: primary, className: "eg-btn eg-primary", onClick: p.onFinish, children: p.actionLabel }), p.secondary ? (jsxRuntime.jsx("a", { className: "eg-btn eg-ghost", href: p.secondary.href, style: { textDecoration: "none" }, children: p.secondary.label })) : null] })] })] }));
|
|
2761
3410
|
}
|
|
2762
3411
|
/* ------------------------------------------------------------------ */
|
|
3412
|
+
/* Page guides: the first-visit offer */
|
|
3413
|
+
/* ------------------------------------------------------------------ */
|
|
3414
|
+
/**
|
|
3415
|
+
* The offer of this page's guide on a first visit: a small card in the
|
|
3416
|
+
* corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
|
|
3417
|
+
*/
|
|
3418
|
+
function PageOfferCard(p) {
|
|
3419
|
+
return (jsxRuntime.jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsxRuntime.jsx("p", { className: "eg-po-e", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-po-t", children: p.title }), jsxRuntime.jsxs("p", { className: "eg-b-w", children: [p.steps, " \u0E02\u0E31\u0E49\u0E19"] }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" })] })] }));
|
|
3420
|
+
}
|
|
3421
|
+
function PageList({ items, onOpen }) {
|
|
3422
|
+
return (jsxRuntime.jsx("ul", { className: "eg-q-list eg-pg-list", children: items.map(it => (jsxRuntime.jsxs("li", { className: "eg-q-item eg-pg-item", children: [jsxRuntime.jsxs("div", { className: "eg-t", children: [jsxRuntime.jsx("b", { children: it.title }), jsxRuntime.jsx("small", { className: it.seen ? undefined : "eg-new", children: it.seen ? "ดูแล้ว" : "ใหม่" })] }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onOpen(it.key), children: "\u0E40\u0E1B\u0E34\u0E14" })] }, it.key))) }));
|
|
3423
|
+
}
|
|
3424
|
+
/* ------------------------------------------------------------------ */
|
|
2763
3425
|
/* Quest chip and panel */
|
|
2764
3426
|
/* ------------------------------------------------------------------ */
|
|
2765
3427
|
function QuestChip({ done, total, pulse, onOpen }) {
|
|
@@ -2781,7 +3443,12 @@ function QuestChip({ done, total, pulse, onOpen }) {
|
|
|
2781
3443
|
}
|
|
2782
3444
|
function QuestPanel(p) {
|
|
2783
3445
|
const ref = useAutoFocus();
|
|
2784
|
-
|
|
3446
|
+
const pages = p.pages ?? [];
|
|
3447
|
+
const openPage = p.onOpenPage ?? (() => { });
|
|
3448
|
+
if (p.showMissions === false) {
|
|
3449
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })] }));
|
|
3450
|
+
}
|
|
3451
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx("div", { className: "eg-prog", children: jsxRuntime.jsx("div", { className: "eg-bar", children: jsxRuntime.jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsxRuntime.jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsxRuntime.jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), pages.length ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h3", { className: "eg-q-sub", id: "eg-q-pages", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })) : null, p.canHideChip ? (jsxRuntime.jsx("div", { className: "eg-q-f", children: jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onHideChip, children: "\u0E0B\u0E48\u0E2D\u0E19\u0E1B\u0E38\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08" }) })) : null] })] }));
|
|
2785
3452
|
}
|
|
2786
3453
|
function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
2787
3454
|
const v = item.view;
|
|
@@ -2842,6 +3509,7 @@ class GuideErrorBoundary extends React__namespace.Component {
|
|
|
2842
3509
|
console.warn("[erp-shell guide] hidden after an error", error);
|
|
2843
3510
|
try {
|
|
2844
3511
|
publishGuideEntry(null, null);
|
|
3512
|
+
publishPageGuides(null, null, null);
|
|
2845
3513
|
}
|
|
2846
3514
|
catch {
|
|
2847
3515
|
/* ignore */
|
|
@@ -2918,6 +3586,41 @@ function writeMirror(orgId, suite, p) {
|
|
|
2918
3586
|
/* private mode: the server copy is enough */
|
|
2919
3587
|
}
|
|
2920
3588
|
}
|
|
3589
|
+
/** Page rows written in this tab, kept for a remount within the GET's cache time. */
|
|
3590
|
+
const pagesMirrorKey = (orgId, suite) => `erp-guide-pages:${orgId}:${suite.app}`;
|
|
3591
|
+
function readPagesMirror(orgId, suite) {
|
|
3592
|
+
try {
|
|
3593
|
+
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite));
|
|
3594
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
3595
|
+
const out = {};
|
|
3596
|
+
for (const [k, v] of Object.entries(parsed ?? {})) {
|
|
3597
|
+
if (v && Number.isInteger(v.version) && Number.isInteger(v.rev) && typeof v.status === "string")
|
|
3598
|
+
out[k] = v;
|
|
3599
|
+
}
|
|
3600
|
+
return out;
|
|
3601
|
+
}
|
|
3602
|
+
catch {
|
|
3603
|
+
return {};
|
|
3604
|
+
}
|
|
3605
|
+
}
|
|
3606
|
+
function writePagesMirror(orgId, suite, pages) {
|
|
3607
|
+
try {
|
|
3608
|
+
sessionStorage.setItem(pagesMirrorKey(orgId, suite), JSON.stringify(pages));
|
|
3609
|
+
}
|
|
3610
|
+
catch {
|
|
3611
|
+
/* private mode */
|
|
3612
|
+
}
|
|
3613
|
+
}
|
|
3614
|
+
/** The server's page rows, with this tab's newer ones laid over. */
|
|
3615
|
+
function mergePageRows(server, local) {
|
|
3616
|
+
const out = { ...server };
|
|
3617
|
+
for (const [k, v] of Object.entries(local)) {
|
|
3618
|
+
const r = out[k];
|
|
3619
|
+
if (!r || v.version > r.version)
|
|
3620
|
+
out[k] = v;
|
|
3621
|
+
}
|
|
3622
|
+
return out;
|
|
3623
|
+
}
|
|
2921
3624
|
/* ------------------------------------------------------------------ */
|
|
2922
3625
|
/* One tab at a time */
|
|
2923
3626
|
/* ------------------------------------------------------------------ */
|
|
@@ -3005,6 +3708,12 @@ const SAVE_DEBOUNCE_MS = 600;
|
|
|
3005
3708
|
const IDLE_POLL_MS = 100;
|
|
3006
3709
|
/** A celebration earned outside the tour waits this long of nothing loading. */
|
|
3007
3710
|
const PASSIVE_IDLE_MS = 600;
|
|
3711
|
+
/** A page guide's offer waits this long of nothing loading. */
|
|
3712
|
+
const PAGE_OFFER_IDLE_MS = 600;
|
|
3713
|
+
/** How often the page guides look at the page while idle (offer due, `when`). */
|
|
3714
|
+
const PAGE_POLL_MS = 250;
|
|
3715
|
+
/** How long a page opened from the panel's list may take to arrive. */
|
|
3716
|
+
const PAGE_ARRIVE_MS = 8000;
|
|
3008
3717
|
function GuideRuntimeInner({ config, organizationId, code, routing, progress: progressAdapter, isBusy, menu, hidden, bottomOffset }) {
|
|
3009
3718
|
const suite = config.suite;
|
|
3010
3719
|
const pathname = routing.pathname || "/";
|
|
@@ -3019,9 +3728,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3019
3728
|
// No menu: never a menu step (the machine sees it as always open).
|
|
3020
3729
|
const sidebarOpen = menu ? !!menu.open : true;
|
|
3021
3730
|
const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
|
|
3022
|
-
// Progress: read once this tab runs the tour and the app is ready
|
|
3731
|
+
// Progress: read once this tab runs the tour and the app is ready (for
|
|
3732
|
+
// the missions, or for the page guides alone: config.pagesReady).
|
|
3023
3733
|
const [answer, setAnswer] = React.useState(null);
|
|
3024
|
-
const
|
|
3734
|
+
const missionsReady = config.ready !== false;
|
|
3735
|
+
const pagesReady = config.pagesReady ?? missionsReady;
|
|
3736
|
+
const hasPages = React.useMemo(() => validPages(suite).length > 0, [suite]);
|
|
3737
|
+
const enabled = primary && (missionsReady || (hasPages && pagesReady));
|
|
3025
3738
|
const loaded = React.useRef(false);
|
|
3026
3739
|
React.useEffect(() => {
|
|
3027
3740
|
if (!enabled || loaded.current)
|
|
@@ -3043,7 +3756,11 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3043
3756
|
loaded.current = false;
|
|
3044
3757
|
};
|
|
3045
3758
|
}, [enabled, suite]);
|
|
3046
|
-
|
|
3759
|
+
// The missions run for their audience; the page guides for theirs (every
|
|
3760
|
+
// member while pages are on), each with its own ready.
|
|
3761
|
+
const missionsLive = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && missionsReady && !hidden;
|
|
3762
|
+
const pagesLive = !!answer && hasPages && answer.pageAudience !== "none" && primary && pagesReady && !hidden;
|
|
3763
|
+
const live = missionsLive || pagesLive;
|
|
3047
3764
|
/* ---------------- machine ---------------- */
|
|
3048
3765
|
const [state, setState] = React.useState(null);
|
|
3049
3766
|
const stateRef = React.useRef(null);
|
|
@@ -3051,7 +3768,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3051
3768
|
// is not sent and does not bump its rev.
|
|
3052
3769
|
const savedKey = React.useRef(null);
|
|
3053
3770
|
React.useEffect(() => {
|
|
3054
|
-
|
|
3771
|
+
const missionsOn = !!answer && answer.audience !== "none" && !answer.newerOnServer;
|
|
3772
|
+
const pagesOn = !!answer && hasPages && answer.pageAudience !== "none";
|
|
3773
|
+
if (!answer || (!missionsOn && !pagesOn)) {
|
|
3055
3774
|
stateRef.current = null;
|
|
3056
3775
|
setState(null);
|
|
3057
3776
|
return;
|
|
@@ -3065,10 +3784,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3065
3784
|
let progress = answer.progress;
|
|
3066
3785
|
if (mirror && furtherProgress(mirror, progress) === mirror)
|
|
3067
3786
|
progress = mergeProgress(mirror, progress);
|
|
3068
|
-
|
|
3787
|
+
// A tour newer on the server than this build's: the missions stay out.
|
|
3788
|
+
const s = initialGuideState(missionsOn ? answer.audience : "none", progress, {
|
|
3789
|
+
pageAudience: pagesOn ? answer.pageAudience : "none",
|
|
3790
|
+
pagesMode: answer.pagesMode,
|
|
3791
|
+
pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
|
|
3792
|
+
});
|
|
3069
3793
|
stateRef.current = s;
|
|
3070
3794
|
setState(s);
|
|
3071
|
-
}, [answer, organizationId, suite]);
|
|
3795
|
+
}, [answer, organizationId, suite, hasPages]);
|
|
3072
3796
|
// The latest page facts, read by the machine through env.
|
|
3073
3797
|
const pageRef = React.useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
|
|
3074
3798
|
pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
|
|
@@ -3165,6 +3889,17 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3165
3889
|
// After this render takes the guard away, put the page back.
|
|
3166
3890
|
runAbort(e.actions);
|
|
3167
3891
|
}
|
|
3892
|
+
else if (e.type === "savePage") {
|
|
3893
|
+
// Once per page and version; the machine already holds it as written.
|
|
3894
|
+
if (stateRef.current)
|
|
3895
|
+
writePagesMirror(organizationId, suite, stateRef.current.pages);
|
|
3896
|
+
const rev = stateRef.current?.pages[e.key]?.rev ?? 0;
|
|
3897
|
+
void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev).then(row => {
|
|
3898
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
3899
|
+
if (row)
|
|
3900
|
+
dispatchRef.current({ type: "pageRow", key: e.key, row });
|
|
3901
|
+
});
|
|
3902
|
+
}
|
|
3168
3903
|
}
|
|
3169
3904
|
catch {
|
|
3170
3905
|
/* an effect never breaks the app */
|
|
@@ -3213,16 +3948,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3213
3948
|
const screen = state?.screen ?? { name: "idle" };
|
|
3214
3949
|
const resumed = React.useRef(false);
|
|
3215
3950
|
React.useEffect(() => {
|
|
3216
|
-
if (!
|
|
3951
|
+
if (!missionsLive || !state || resumed.current)
|
|
3217
3952
|
return;
|
|
3218
3953
|
resumed.current = true;
|
|
3219
3954
|
dispatch({ type: "resume" });
|
|
3220
|
-
}, [
|
|
3955
|
+
}, [missionsLive, state, dispatch]);
|
|
3221
3956
|
const welcomeRoutes = suite.welcomeRoutes ?? ["/[code]"];
|
|
3222
3957
|
const onWelcomeRoute = welcomeRoutes.some(r => !!matchRoute(r, pathname, search, code));
|
|
3223
3958
|
const factsKey = useFactsKey(config.facts);
|
|
3224
3959
|
const welcomeCtx = state ? guideContext(env(), state.progress.context) : null;
|
|
3225
|
-
const welcomeIsDue = !!state && !!welcomeCtx &&
|
|
3960
|
+
const welcomeIsDue = !!state && !!welcomeCtx && missionsLive && welcomeDue(state, suite, welcomeCtx);
|
|
3226
3961
|
const due = welcomeIsDue && onWelcomeRoute;
|
|
3227
3962
|
// After a moment of nothing loading (the page has settled).
|
|
3228
3963
|
useAfterIdle(due, WELCOME_DELAY_MS, busy, () => dispatchRef.current({ type: "welcome" }), [factsKey]);
|
|
@@ -3230,7 +3965,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3230
3965
|
const passiveNames = React.useMemo(() => passiveEventNames(suite), [suite]);
|
|
3231
3966
|
const hasPassive = React.useMemo(() => suite.missions.some(m => !!m.completeOn), [suite]);
|
|
3232
3967
|
React.useEffect(() => {
|
|
3233
|
-
if (!
|
|
3968
|
+
if (!missionsLive || !hasPassive)
|
|
3234
3969
|
return;
|
|
3235
3970
|
const onApi = (e) => {
|
|
3236
3971
|
const d = e.detail;
|
|
@@ -3248,15 +3983,19 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3248
3983
|
window.removeEventListener(API_EVENT, onApi);
|
|
3249
3984
|
handlers.forEach(([n, h]) => window.removeEventListener(n, h));
|
|
3250
3985
|
};
|
|
3251
|
-
}, [
|
|
3986
|
+
}, [missionsLive, hasPassive, passiveNames]);
|
|
3252
3987
|
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
3253
|
-
|
|
3988
|
+
// A page guide's step runs while page guides are live; a mission's while
|
|
3989
|
+
// the missions are. The offer card is watched the same way (left: ignored).
|
|
3990
|
+
const inStep = screen.name === "step" && (screen.page ? pagesLive : missionsLive);
|
|
3991
|
+
const offerUp = pagesLive && screen.name === "pageOffer";
|
|
3992
|
+
const watching = inStep || offerUp;
|
|
3254
3993
|
React.useEffect(() => {
|
|
3255
|
-
if (
|
|
3994
|
+
if (watching)
|
|
3256
3995
|
dispatch({ type: "sync" });
|
|
3257
|
-
}, [
|
|
3996
|
+
}, [watching, pathname, search, sidebarOpen, factsKey, dispatch]);
|
|
3258
3997
|
React.useEffect(() => {
|
|
3259
|
-
if (!
|
|
3998
|
+
if (!watching || typeof MutationObserver === "undefined")
|
|
3260
3999
|
return;
|
|
3261
4000
|
let raf = 0;
|
|
3262
4001
|
const obs = new MutationObserver(records => {
|
|
@@ -3281,7 +4020,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3281
4020
|
if (raf)
|
|
3282
4021
|
cancelAnimationFrame(raf);
|
|
3283
4022
|
};
|
|
3284
|
-
}, [
|
|
4023
|
+
}, [watching]);
|
|
3285
4024
|
/* ---------------- the current step ---------------- */
|
|
3286
4025
|
const stepInfo = React.useMemo(() => {
|
|
3287
4026
|
if (!state || screen.name !== "step")
|
|
@@ -3517,7 +4256,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3517
4256
|
};
|
|
3518
4257
|
}, [inStep, stepInfo]);
|
|
3519
4258
|
/* ---------------- the guard ---------------- */
|
|
3520
|
-
const modalScreen =
|
|
4259
|
+
const modalScreen = missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
|
|
3521
4260
|
const panelOpen = live && !!state?.panelOpen;
|
|
3522
4261
|
const guarding = inStep || modalScreen;
|
|
3523
4262
|
const guardingRef = React.useRef(guarding);
|
|
@@ -3794,7 +4533,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3794
4533
|
const total = shown.length;
|
|
3795
4534
|
// A celebration earned outside the tour: at the next idle moment — no
|
|
3796
4535
|
// step, card or panel up, the welcome not waiting, nothing loading.
|
|
3797
|
-
const passiveDue =
|
|
4536
|
+
const passiveDue = missionsLive &&
|
|
3798
4537
|
!!state &&
|
|
3799
4538
|
(state.pendingCelebrate?.length ?? 0) > 0 &&
|
|
3800
4539
|
screen.name === "idle" &&
|
|
@@ -3802,13 +4541,156 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3802
4541
|
!due;
|
|
3803
4542
|
useAfterIdle(passiveDue, PASSIVE_IDLE_MS, busy, () => dispatchRef.current({ type: "idleCelebrate" }), [state?.pendingCelebrate?.join(",")]);
|
|
3804
4543
|
React.useEffect(() => {
|
|
3805
|
-
if (!
|
|
4544
|
+
if (!missionsLive || !state) {
|
|
3806
4545
|
publishGuideEntry(null, null);
|
|
3807
4546
|
return;
|
|
3808
4547
|
}
|
|
3809
4548
|
publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
|
|
3810
|
-
}, [
|
|
4549
|
+
}, [missionsLive, state, done, total]);
|
|
3811
4550
|
React.useEffect(() => () => publishGuideEntry(null, null), []);
|
|
4551
|
+
/* ---------------- page guides: this page's, the offer, the list ---------------- */
|
|
4552
|
+
// `when` anchors (LIFF's sub-screens) come and go without a route change:
|
|
4553
|
+
// look again when the page changes.
|
|
4554
|
+
const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when), [suite]);
|
|
4555
|
+
const [domTick, setDomTick] = React.useState(0);
|
|
4556
|
+
React.useEffect(() => {
|
|
4557
|
+
if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
|
|
4558
|
+
return;
|
|
4559
|
+
let t = null;
|
|
4560
|
+
const obs = new MutationObserver(records => {
|
|
4561
|
+
if (records.every(r => inGuideLayer(r.target)))
|
|
4562
|
+
return;
|
|
4563
|
+
if (t)
|
|
4564
|
+
return;
|
|
4565
|
+
t = setTimeout(() => {
|
|
4566
|
+
t = null;
|
|
4567
|
+
setDomTick(n => n + 1);
|
|
4568
|
+
}, PAGE_POLL_MS);
|
|
4569
|
+
});
|
|
4570
|
+
obs.observe(document.body, {
|
|
4571
|
+
childList: true,
|
|
4572
|
+
subtree: true,
|
|
4573
|
+
attributes: true,
|
|
4574
|
+
attributeFilter: ["data-state", "hidden", "aria-hidden", "class", "style", "open"],
|
|
4575
|
+
});
|
|
4576
|
+
return () => {
|
|
4577
|
+
obs.disconnect();
|
|
4578
|
+
if (t)
|
|
4579
|
+
clearTimeout(t);
|
|
4580
|
+
};
|
|
4581
|
+
}, [pagesLive, hasWhen]);
|
|
4582
|
+
const hasState = !!state;
|
|
4583
|
+
const pageNow = React.useMemo(() => (pagesLive && hasState ? pageHere(suite, env()) : null),
|
|
4584
|
+
// pathname/search/factsKey/domTick make it current.
|
|
4585
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4586
|
+
[pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
|
|
4587
|
+
const listed = React.useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
|
|
4588
|
+
const openPageHere = React.useCallback(() => {
|
|
4589
|
+
const p = pageHere(suite, env());
|
|
4590
|
+
if (p)
|
|
4591
|
+
dispatchRef.current({ type: "startPage", page: p.key });
|
|
4592
|
+
}, [suite, env]);
|
|
4593
|
+
React.useEffect(() => {
|
|
4594
|
+
if (!pagesLive || !hasState) {
|
|
4595
|
+
publishPageGuides(null, null, null);
|
|
4596
|
+
return;
|
|
4597
|
+
}
|
|
4598
|
+
publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }));
|
|
4599
|
+
}, [pagesLive, hasState, pageNow, listed.length, openPageHere]);
|
|
4600
|
+
React.useEffect(() => () => publishPageGuides(null, null, null), []);
|
|
4601
|
+
// The first-visit offer: in "offer" mode, nothing else up, the welcome
|
|
4602
|
+
// not due here, and a moment of nothing loading on this page.
|
|
4603
|
+
const offerWatch = pagesLive && !!state && state.pagesMode === "offer" && screen.name === "idle" && !state.panelOpen && !due;
|
|
4604
|
+
React.useEffect(() => {
|
|
4605
|
+
if (!offerWatch)
|
|
4606
|
+
return;
|
|
4607
|
+
let idleSince = null;
|
|
4608
|
+
const iv = setInterval(() => {
|
|
4609
|
+
const cur = stateRef.current;
|
|
4610
|
+
if (!cur)
|
|
4611
|
+
return;
|
|
4612
|
+
let b = false;
|
|
4613
|
+
try {
|
|
4614
|
+
b = busyRef.current();
|
|
4615
|
+
}
|
|
4616
|
+
catch {
|
|
4617
|
+
b = false;
|
|
4618
|
+
}
|
|
4619
|
+
if (b || document.visibilityState === "hidden") {
|
|
4620
|
+
idleSince = null;
|
|
4621
|
+
return;
|
|
4622
|
+
}
|
|
4623
|
+
idleSince ?? (idleSince = Date.now());
|
|
4624
|
+
if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
|
|
4625
|
+
return;
|
|
4626
|
+
let key = null;
|
|
4627
|
+
try {
|
|
4628
|
+
key = pageOfferDue(cur, suite, env());
|
|
4629
|
+
}
|
|
4630
|
+
catch {
|
|
4631
|
+
key = null;
|
|
4632
|
+
}
|
|
4633
|
+
if (key)
|
|
4634
|
+
dispatchRef.current({ type: "pageOffer", page: key });
|
|
4635
|
+
}, PAGE_POLL_MS);
|
|
4636
|
+
return () => clearInterval(iv);
|
|
4637
|
+
}, [offerWatch, suite, env, pathname, search]);
|
|
4638
|
+
// A page in the panel's list: start it here, or go there and start it on arrival.
|
|
4639
|
+
const [pending, setPending] = React.useState(null);
|
|
4640
|
+
const openListedPage = React.useCallback((key) => {
|
|
4641
|
+
const page = findPage(suite, key);
|
|
4642
|
+
if (!page)
|
|
4643
|
+
return;
|
|
4644
|
+
const e = env();
|
|
4645
|
+
if (pageHere(suite, e)?.key === key) {
|
|
4646
|
+
dispatchRef.current({ type: "startPage", page: key });
|
|
4647
|
+
return;
|
|
4648
|
+
}
|
|
4649
|
+
dispatchRef.current({ type: "closePanel" });
|
|
4650
|
+
const raw = resolveText(page.href, guideContext(e, {}));
|
|
4651
|
+
const href = raw ? (fillRoute(raw, { code }) ?? raw) : "";
|
|
4652
|
+
if (!href)
|
|
4653
|
+
return;
|
|
4654
|
+
setPending({ key, until: Date.now() + PAGE_ARRIVE_MS });
|
|
4655
|
+
try {
|
|
4656
|
+
if (/^https?:\/\//i.test(href))
|
|
4657
|
+
window.location.assign(href);
|
|
4658
|
+
else
|
|
4659
|
+
navigateRef.current(href);
|
|
4660
|
+
}
|
|
4661
|
+
catch {
|
|
4662
|
+
/* never breaks the app */
|
|
4663
|
+
}
|
|
4664
|
+
}, [suite, env, code]);
|
|
4665
|
+
React.useEffect(() => {
|
|
4666
|
+
if (!pending || !pagesLive)
|
|
4667
|
+
return;
|
|
4668
|
+
const iv = setInterval(() => {
|
|
4669
|
+
if (Date.now() > pending.until) {
|
|
4670
|
+
setPending(null);
|
|
4671
|
+
return;
|
|
4672
|
+
}
|
|
4673
|
+
const cur = stateRef.current;
|
|
4674
|
+
if (!cur || (cur.screen.name !== "idle" && cur.screen.name !== "pageOffer"))
|
|
4675
|
+
return;
|
|
4676
|
+
let b = false;
|
|
4677
|
+
try {
|
|
4678
|
+
b = busyRef.current();
|
|
4679
|
+
}
|
|
4680
|
+
catch {
|
|
4681
|
+
b = false;
|
|
4682
|
+
}
|
|
4683
|
+
if (b)
|
|
4684
|
+
return;
|
|
4685
|
+
const e = env();
|
|
4686
|
+
const page = findPage(suite, pending.key);
|
|
4687
|
+
if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
|
|
4688
|
+
return;
|
|
4689
|
+
setPending(null);
|
|
4690
|
+
dispatchRef.current({ type: "startPage", page: pending.key });
|
|
4691
|
+
}, PAGE_POLL_MS);
|
|
4692
|
+
return () => clearInterval(iv);
|
|
4693
|
+
}, [pending, pagesLive, suite, env, code]);
|
|
3812
4694
|
if (!live || !state || !layer || !ctxIdle)
|
|
3813
4695
|
return null;
|
|
3814
4696
|
/* ---------------- render ---------------- */
|
|
@@ -3817,30 +4699,52 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3817
4699
|
const gateHref = gate ? resolveText(gate.action.href, ctxIdle) : "";
|
|
3818
4700
|
const gateFailing = gate ? gateState(suite, ctxIdle) === false : false;
|
|
3819
4701
|
let content = null;
|
|
3820
|
-
if (screen.name === "welcome") {
|
|
4702
|
+
if (!missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete")) {
|
|
4703
|
+
content = null;
|
|
4704
|
+
}
|
|
4705
|
+
else if (screen.name === "welcome") {
|
|
3821
4706
|
content = (jsxRuntime.jsx(WelcomeModal, { title: resolveText(suite.welcome.title, ctxIdle), subtitle: resolveText(suite.welcome.subtitle, ctxIdle) || `มาตั้งค่าบริษัทให้พร้อมใน ${total} ภารกิจ`, missions: shown.map(m => ({ title: m.title, minutes: m.minutes })), confirm: screen.confirm, reopenHint: suite.reopenHint, onStart: () => dispatch({ type: "welcomeStart" }), onSkip: () => dispatch({ type: "welcomeSkip" }), onSkipConfirm: () => dispatch({ type: "welcomeSkipConfirm" }), onSkipCancel: () => dispatch({ type: "welcomeSkipCancel" }) }));
|
|
3822
4707
|
}
|
|
3823
4708
|
else if (screen.name === "gate" && gate) {
|
|
3824
4709
|
content = (jsxRuntime.jsx(GateModal, { title: gate.title, subtitle: gate.subtitle, actionLabel: gate.action.label, href: gateHref, onLater: () => dispatch({ type: "gateLater" }) }));
|
|
3825
4710
|
}
|
|
3826
|
-
else if (screen.name === "step" && stepInfo && stepCtx) {
|
|
4711
|
+
else if (screen.name === "step" && inStep && stepInfo && stepCtx) {
|
|
3827
4712
|
const { mission, step } = stepInfo;
|
|
3828
4713
|
const missionNo = missionNumber(suite, mission.key, ctxIdle);
|
|
3829
4714
|
const kind = stepKind(step);
|
|
3830
4715
|
const bridge = screen.bridge;
|
|
4716
|
+
const pos = stepPosition(mission, screen.index, env(), state.progress.context);
|
|
4717
|
+
const page = screen.page ? findPage(suite, screen.page) : undefined;
|
|
4718
|
+
const pageBits = page
|
|
4719
|
+
? {
|
|
4720
|
+
heading: `คู่มือหน้านี้ · ${page.title}`,
|
|
4721
|
+
exitLabel: "ปิดคู่มือ",
|
|
4722
|
+
showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
|
|
4723
|
+
nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
|
|
4724
|
+
}
|
|
4725
|
+
: {};
|
|
3831
4726
|
content = (jsxRuntime.jsx(StepLayer, { spot: layout.spot, holes: layout.holes, popovers: layout.popovers, covered: layout.covered, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
|
|
3832
4727
|
missionNo,
|
|
3833
4728
|
missionTitle: mission.title,
|
|
3834
4729
|
replay: screen.replay,
|
|
3835
4730
|
// Steps this person skips (requires / skipIf) are not counted.
|
|
3836
|
-
index:
|
|
3837
|
-
total:
|
|
4731
|
+
index: pos.no - 1,
|
|
4732
|
+
total: pos.total,
|
|
3838
4733
|
title: bridge ? "กดปุ่มเมนู" : resolveText(step.title, stepCtx),
|
|
3839
4734
|
why: bridge ? `เมนูทั้งหมดของ ${suite.appName} อยู่ในนี้` : resolveText(step.why, stepCtx),
|
|
3840
4735
|
error: screen.error,
|
|
3841
|
-
|
|
4736
|
+
status: bridge ? null : screen.status,
|
|
4737
|
+
showNext: !bridge && (kind === "input" || kind === "view" || (kind === "select" && screen.replay && screen.replayNext)),
|
|
3842
4738
|
showEndWithoutSave: !bridge && screen.replay && kind === "commit",
|
|
3843
|
-
|
|
4739
|
+
...pageBits,
|
|
4740
|
+
}, hasTarget: bridge || !!step.target, targetPressable: targetPressable(step, bridge), phase: screen.phase, confirmExit: screen.confirmExit, reopenHint: suite.reopenHint, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onBack: () => dispatch({ type: "back" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
|
|
4741
|
+
}
|
|
4742
|
+
else if (screen.name === "pageOffer" && pagesLive) {
|
|
4743
|
+
const page = findPage(suite, screen.page);
|
|
4744
|
+
if (page) {
|
|
4745
|
+
const mission = findMission(suite, PAGE_PREFIX + page.key);
|
|
4746
|
+
content = (jsxRuntime.jsx(PageOfferCard, { title: page.title, steps: mission ? stepPosition(mission, 0, env(), {}).total : page.steps.length, onStart: () => dispatch({ type: "pageOfferStart" }), onLater: () => dispatch({ type: "pageOfferLater" }) }));
|
|
4747
|
+
}
|
|
3844
4748
|
}
|
|
3845
4749
|
else if (screen.name === "celebrate" && celebrateShown) {
|
|
3846
4750
|
const mission = findMission(suite, screen.mission);
|
|
@@ -3875,8 +4779,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3875
4779
|
.join(", ")
|
|
3876
4780
|
: undefined,
|
|
3877
4781
|
}));
|
|
3878
|
-
const showChip = chipVisible(state, suite, ctxIdle);
|
|
3879
|
-
|
|
4782
|
+
const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
|
|
4783
|
+
// The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
|
|
4784
|
+
// then the page guides they have.
|
|
4785
|
+
const panelMissions = missionsLive && total > 0 && state.panelSection !== "pages";
|
|
4786
|
+
const pageItems = pagesLive
|
|
4787
|
+
? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) }))
|
|
4788
|
+
: [];
|
|
4789
|
+
const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
|
|
4790
|
+
return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("style", { children: GUIDE_CSS }), jsxRuntime.jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsxRuntime.jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsxRuntime.jsx(QuestPanel, { items: items, done: done, total: total, justDone: state.justDone, gate: gate && gateFailing ? { label: gate.action.label, pending: gate.pending, href: gateHref } : null, canHideChip: !state.progress.chip_hidden && state.progress.status !== "completed", onClose: () => dispatch({ type: "closePanel" }), onStart: (key, replayIt) => dispatch({ type: "start", mission: key, replay: replayIt }), onDismiss: key => dispatch({ type: "dismissMission", mission: key }), onHideChip: () => dispatch({ type: "hideChip" }), pages: pageItems, showMissions: panelMissions, onOpenPage: openListedPage })) : null, toast ? jsxRuntime.jsx(GuideToast, { text: toast }) : null] }), layer);
|
|
3880
4791
|
}
|
|
3881
4792
|
/** A stable key for the facts object, so effects re-run when they change. */
|
|
3882
4793
|
function useFactsKey(facts) {
|
|
@@ -3900,12 +4811,18 @@ exports.apiCallDetail = apiCallDetail;
|
|
|
3900
4811
|
exports.emitGuideEvent = emitGuideEvent;
|
|
3901
4812
|
exports.fillRoute = fillRoute;
|
|
3902
4813
|
exports.guideAnchor = guideAnchor;
|
|
4814
|
+
exports.guideMenuItems = guideMenuItems;
|
|
4815
|
+
exports.guideMenuShown = guideMenuShown;
|
|
3903
4816
|
exports.loadGuides = loadGuides;
|
|
3904
4817
|
exports.matchApiPath = matchApiPath;
|
|
3905
4818
|
exports.matchRoute = matchRoute;
|
|
3906
4819
|
exports.openGuidePanel = openGuidePanel;
|
|
4820
|
+
exports.openPageGuide = openPageGuide;
|
|
4821
|
+
exports.openPagesPanel = openPagesPanel;
|
|
3907
4822
|
exports.reportApiCall = reportApiCall;
|
|
3908
4823
|
exports.reportApiEvent = reportApiEvent;
|
|
3909
4824
|
exports.storeGuides = storeGuides;
|
|
3910
4825
|
exports.useGuideEntry = useGuideEntry;
|
|
3911
|
-
|
|
4826
|
+
exports.usePageGuideEntry = usePageGuideEntry;
|
|
4827
|
+
exports.usePageGuides = usePageGuides;
|
|
4828
|
+
//# sourceMappingURL=guide-runtime-YS2nUhwS.cjs.map
|