@eintrek/erp-shell 0.1.73 → 0.1.75
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 +63 -0
- package/dist/chunks/{guide-runtime-CLVSNkTN.cjs → format-CVazK0hP.cjs} +1371 -59
- package/dist/chunks/format-CVazK0hP.cjs.map +1 -0
- package/dist/chunks/{guide-runtime-C7Z00QwM.esm.js → format-DCTuLpBU.esm.js} +1360 -61
- package/dist/chunks/format-DCTuLpBU.esm.js.map +1 -0
- package/dist/guide/core.d.ts +5 -3
- package/dist/guide/format.d.ts +26 -0
- package/dist/guide/guide-store.d.ts +72 -6
- package/dist/guide/guide-styles.d.ts +1 -1
- package/dist/guide/guide-ui.d.ts +38 -0
- package/dist/guide/machine.d.ts +139 -3
- package/dist/guide/pages.d.ts +79 -0
- package/dist/guide/progress-sync.d.ts +10 -1
- package/dist/guide/progress.d.ts +27 -0
- package/dist/guide/section-button.d.ts +12 -0
- package/dist/guide/types.d.ts +111 -0
- package/dist/guide-core.cjs +32 -19
- package/dist/guide-core.cjs.map +1 -1
- package/dist/guide-core.esm.js +1 -1
- package/dist/help/help-button.d.ts +6 -3
- package/dist/index.cjs +56 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.esm.js +22 -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-C7Z00QwM.esm.js.map +0 -1
- package/dist/chunks/guide-runtime-CLVSNkTN.cjs.map +0 -1
|
@@ -357,15 +357,54 @@ function publishGuideEntry(next, openPanel) {
|
|
|
357
357
|
emit();
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
|
+
const NO_PAGES = { page: null, listed: 0, sections: [] };
|
|
361
|
+
let pages = NO_PAGES;
|
|
362
|
+
let openPageImpl = null;
|
|
363
|
+
let openPagesImpl = null;
|
|
364
|
+
let openSectionImpl = null;
|
|
365
|
+
const sectionsKey = (x) => (x ?? []).map(s => `${s.key}\u0000${s.title}`).join("\u0001");
|
|
366
|
+
/** Called by the host: this page's guide, the listed count, the sections on screen, and how to open them. */
|
|
367
|
+
function publishPageGuides(next, openPage, openPages, openSection = null) {
|
|
368
|
+
const n = next ?? NO_PAGES;
|
|
369
|
+
openPageImpl = openPage;
|
|
370
|
+
openPagesImpl = openPages;
|
|
371
|
+
openSectionImpl = openSection;
|
|
372
|
+
const same = n.listed === pages.listed &&
|
|
373
|
+
(n.page?.title ?? null) === (pages.page?.title ?? null) &&
|
|
374
|
+
sectionsKey(n.sections) === sectionsKey(pages.sections);
|
|
375
|
+
if (!same) {
|
|
376
|
+
const sections = (n.sections ?? []).map(x => ({ key: x.key, title: x.title }));
|
|
377
|
+
pages =
|
|
378
|
+
n.page || n.listed || sections.length
|
|
379
|
+
? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections }
|
|
380
|
+
: NO_PAGES;
|
|
381
|
+
emit();
|
|
382
|
+
}
|
|
383
|
+
}
|
|
384
|
+
function guideMenuItems(e, p = NO_PAGES) {
|
|
385
|
+
const items = [];
|
|
386
|
+
if (p.page)
|
|
387
|
+
items.push("page");
|
|
388
|
+
if ((p.sections ?? []).length > 0)
|
|
389
|
+
items.push("sections");
|
|
390
|
+
if (e && e.total > 0)
|
|
391
|
+
items.push("missions");
|
|
392
|
+
if (p.listed > 0)
|
|
393
|
+
items.push("pages");
|
|
394
|
+
if (items.length)
|
|
395
|
+
items.push("help");
|
|
396
|
+
return items;
|
|
397
|
+
}
|
|
360
398
|
/**
|
|
361
|
-
* Whether the "?" menu
|
|
362
|
-
*
|
|
363
|
-
*
|
|
364
|
-
*
|
|
365
|
-
*
|
|
399
|
+
* Whether the "?" press opens a menu rather than the help dialog: a tour
|
|
400
|
+
* with missions shown, a guide for this page, or page guides to list. A tour
|
|
401
|
+
* whose missions are all hidden for this user (`visible`, e.g. Plannex
|
|
402
|
+
* without an employee record) has nothing to open, and "0/0" led to an
|
|
403
|
+
* empty panel. The entry itself stays published: apps wait on it before
|
|
404
|
+
* they load the facts that make missions visible.
|
|
366
405
|
*/
|
|
367
|
-
function guideMenuShown(e) {
|
|
368
|
-
return
|
|
406
|
+
function guideMenuShown(e, p = NO_PAGES) {
|
|
407
|
+
return guideMenuItems(e, p).length > 0;
|
|
369
408
|
}
|
|
370
409
|
/** Opens the tour's quest panel, when this user has a tour here. */
|
|
371
410
|
function openGuidePanel() {
|
|
@@ -375,6 +414,413 @@ function openGuidePanel() {
|
|
|
375
414
|
function useGuideEntry() {
|
|
376
415
|
return React.useSyncExternalStore(subscribe, () => entry, () => null);
|
|
377
416
|
}
|
|
417
|
+
/** Opens this page's guide ("คู่มือหน้านี้"), when there is one. */
|
|
418
|
+
function openPageGuide() {
|
|
419
|
+
openPageImpl?.();
|
|
420
|
+
}
|
|
421
|
+
/** Opens the panel on the page guides alone ("คู่มือทุกหน้า"). */
|
|
422
|
+
function openPagesPanel() {
|
|
423
|
+
openPagesImpl?.();
|
|
424
|
+
}
|
|
425
|
+
/** Page guides here (this page's, and the listed count); stable until they change. */
|
|
426
|
+
function usePageGuides() {
|
|
427
|
+
return React.useSyncExternalStore(subscribe, () => pages, () => NO_PAGES);
|
|
428
|
+
}
|
|
429
|
+
/**
|
|
430
|
+
* This page's guide: `{ title, open }`, or null when the page (or the
|
|
431
|
+
* sub-screen, `when`) has none for this person. For an app that draws its
|
|
432
|
+
* own "?" (LIFF's page head): draw it only when this is not null.
|
|
433
|
+
*/
|
|
434
|
+
function usePageGuideEntry() {
|
|
435
|
+
const p = usePageGuides();
|
|
436
|
+
const title = p.page?.title ?? null;
|
|
437
|
+
return React.useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
|
|
438
|
+
}
|
|
439
|
+
/**
|
|
440
|
+
* Opens a section guide ("อธิบายส่วนนี้"), when it is on screen here for this
|
|
441
|
+
* person. The values it shows are the ones the app last rendered
|
|
442
|
+
* (`useSectionGuideValues`).
|
|
443
|
+
*/
|
|
444
|
+
function openSectionGuide(key) {
|
|
445
|
+
openSectionImpl?.(key);
|
|
446
|
+
}
|
|
447
|
+
const sectionValues = new Map();
|
|
448
|
+
const valueListeners = new Set();
|
|
449
|
+
let valuesVersion = 0;
|
|
450
|
+
function valuesSig(v) {
|
|
451
|
+
try {
|
|
452
|
+
return JSON.stringify(v);
|
|
453
|
+
}
|
|
454
|
+
catch {
|
|
455
|
+
return String(Math.random());
|
|
456
|
+
}
|
|
457
|
+
}
|
|
458
|
+
/**
|
|
459
|
+
* Sets (or with undefined, clears) a section guide's live values. The
|
|
460
|
+
* engine reads the latest whenever it draws a step, so a page that
|
|
461
|
+
* re-renders mid-guide never leaves an old number on screen.
|
|
462
|
+
*/
|
|
463
|
+
function setSectionGuideValues(key, values) {
|
|
464
|
+
const prev = sectionValues.get(key);
|
|
465
|
+
if (values === undefined) {
|
|
466
|
+
if (!prev)
|
|
467
|
+
return;
|
|
468
|
+
sectionValues.delete(key);
|
|
469
|
+
}
|
|
470
|
+
else {
|
|
471
|
+
const sig = valuesSig(values);
|
|
472
|
+
if (prev && prev.sig === sig)
|
|
473
|
+
return;
|
|
474
|
+
sectionValues.set(key, { values, sig });
|
|
475
|
+
}
|
|
476
|
+
valuesVersion++;
|
|
477
|
+
valueListeners.forEach(l => l());
|
|
478
|
+
}
|
|
479
|
+
/** A section guide's live values now (empty when the app has given none). */
|
|
480
|
+
function getSectionGuideValues(key) {
|
|
481
|
+
return (key && sectionValues.get(key)?.values) || {};
|
|
482
|
+
}
|
|
483
|
+
/** Re-renders when any section guide's values change (the engine's own use). */
|
|
484
|
+
function useSectionValuesVersion() {
|
|
485
|
+
return React.useSyncExternalStore(l => {
|
|
486
|
+
valueListeners.add(l);
|
|
487
|
+
return () => {
|
|
488
|
+
valueListeners.delete(l);
|
|
489
|
+
};
|
|
490
|
+
}, () => valuesVersion, () => 0);
|
|
491
|
+
}
|
|
492
|
+
/**
|
|
493
|
+
* Hands a section guide the numbers its copy uses, from the component that
|
|
494
|
+
* computes them: `useSectionGuideValues("cash-flow", { month: "ม.ค.", cashIn,
|
|
495
|
+
* … })`. Call it on every render (the latest wins); they go when the
|
|
496
|
+
* component unmounts. undefined values: nothing.
|
|
497
|
+
*/
|
|
498
|
+
function useSectionGuideValues(key, values) {
|
|
499
|
+
const sig = values === undefined ? "" : valuesSig(values);
|
|
500
|
+
React.useEffect(() => {
|
|
501
|
+
if (values !== undefined)
|
|
502
|
+
setSectionGuideValues(key, values);
|
|
503
|
+
// sig stands for the values.
|
|
504
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
505
|
+
}, [key, sig]);
|
|
506
|
+
React.useEffect(() => {
|
|
507
|
+
if (values === undefined)
|
|
508
|
+
return;
|
|
509
|
+
return () => setSectionGuideValues(key, undefined);
|
|
510
|
+
}, [key, values === undefined]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
511
|
+
}
|
|
512
|
+
|
|
513
|
+
/** The synthetic mission key of a page guide: `page:<key>`. */
|
|
514
|
+
const PAGE_PREFIX = "page:";
|
|
515
|
+
/** A page guide's key: lower case, digits and hyphens. */
|
|
516
|
+
const PAGE_KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
517
|
+
/** The server's row key limit (`<app>.page.<key>`). */
|
|
518
|
+
const ROW_KEY_MAX = 64;
|
|
519
|
+
function isPageMissionKey(key) {
|
|
520
|
+
return !!key && key.startsWith(PAGE_PREFIX);
|
|
521
|
+
}
|
|
522
|
+
function pageKeyOf(missionKey) {
|
|
523
|
+
return isPageMissionKey(missionKey) ? missionKey.slice(PAGE_PREFIX.length) : undefined;
|
|
524
|
+
}
|
|
525
|
+
function devWarn(message) {
|
|
526
|
+
try {
|
|
527
|
+
// Written out in full so the app's bundler replaces it.
|
|
528
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV === "production")
|
|
529
|
+
return;
|
|
530
|
+
if (typeof console !== "undefined")
|
|
531
|
+
console.warn(`[erp-shell guide] ${message}`);
|
|
532
|
+
}
|
|
533
|
+
catch {
|
|
534
|
+
/* never breaks the app */
|
|
535
|
+
}
|
|
536
|
+
}
|
|
537
|
+
/** Why a page guide cannot run, or null when it can. */
|
|
538
|
+
function pageProblem(app, page) {
|
|
539
|
+
if (!page || typeof page.key !== "string" || !PAGE_KEY_RE.test(page.key))
|
|
540
|
+
return "its key is not [a-z0-9-]";
|
|
541
|
+
if (`${app}.page.${page.key}`.length > ROW_KEY_MAX)
|
|
542
|
+
return `its row key is over ${ROW_KEY_MAX} characters`;
|
|
543
|
+
if (!Number.isInteger(page.version) || page.version < 1)
|
|
544
|
+
return "its version is not a whole number ≥ 1";
|
|
545
|
+
const routes = Array.isArray(page.route) ? page.route : [page.route];
|
|
546
|
+
if (!routes.length || routes.some(r => typeof r !== "string" || !r.startsWith("/")))
|
|
547
|
+
return "it has no route";
|
|
548
|
+
if (!Array.isArray(page.steps) || page.steps.length === 0)
|
|
549
|
+
return "it has no steps";
|
|
550
|
+
for (const st of page.steps) {
|
|
551
|
+
const kind = st.kind ?? "click";
|
|
552
|
+
if (kind !== "view" && kind !== "click")
|
|
553
|
+
return `step "${st.key}" is a ${kind} step (a page guide never saves)`;
|
|
554
|
+
}
|
|
555
|
+
return null;
|
|
556
|
+
}
|
|
557
|
+
const validCache = new WeakMap();
|
|
558
|
+
const warned = new Set();
|
|
559
|
+
/**
|
|
560
|
+
* The suite's page guides that can run. A page guide with a commit, input or
|
|
561
|
+
* select step (it would save), a bad key or no route is dropped, with a
|
|
562
|
+
* warning in development; so is a second one with the same key.
|
|
563
|
+
*/
|
|
564
|
+
function validPages(suite) {
|
|
565
|
+
const hit = validCache.get(suite);
|
|
566
|
+
if (hit)
|
|
567
|
+
return hit;
|
|
568
|
+
const out = [];
|
|
569
|
+
const seen = new Set();
|
|
570
|
+
for (const page of suite.pages ?? []) {
|
|
571
|
+
let problem = pageProblem(suite.app, page);
|
|
572
|
+
if (!problem && seen.has(page.key))
|
|
573
|
+
problem = "another page guide has the same key";
|
|
574
|
+
if (problem) {
|
|
575
|
+
const id = `${suite.key}:${page?.key}:${problem}`;
|
|
576
|
+
if (!warned.has(id)) {
|
|
577
|
+
warned.add(id);
|
|
578
|
+
devWarn(`page guide "${page?.key}" dropped: ${problem}`);
|
|
579
|
+
}
|
|
580
|
+
continue;
|
|
581
|
+
}
|
|
582
|
+
seen.add(page.key);
|
|
583
|
+
out.push(page);
|
|
584
|
+
}
|
|
585
|
+
validCache.set(suite, out);
|
|
586
|
+
return out;
|
|
587
|
+
}
|
|
588
|
+
function findPage(suite, key) {
|
|
589
|
+
return key ? validPages(suite).find(p => p.key === key) : undefined;
|
|
590
|
+
}
|
|
591
|
+
const missionCache = new WeakMap();
|
|
592
|
+
/**
|
|
593
|
+
* The page guide as a mission the machine plays: `page:<key>`. Steps lose
|
|
594
|
+
* any `route` (the guide is the page's, and ends when it is left) and are
|
|
595
|
+
* checkpoints unless they say otherwise.
|
|
596
|
+
*/
|
|
597
|
+
function pageMission(page) {
|
|
598
|
+
const hit = missionCache.get(page);
|
|
599
|
+
if (hit)
|
|
600
|
+
return hit;
|
|
601
|
+
const steps = page.steps.map(st => {
|
|
602
|
+
const { route: _route, resumeUrl: _resume, ...rest } = st;
|
|
603
|
+
return { ...rest, checkpoint: st.checkpoint ?? true };
|
|
604
|
+
});
|
|
605
|
+
const m = { key: PAGE_PREFIX + page.key, title: page.title, minutes: 1, steps };
|
|
606
|
+
missionCache.set(page, m);
|
|
607
|
+
return m;
|
|
608
|
+
}
|
|
609
|
+
function routesOf(page) {
|
|
610
|
+
return Array.isArray(page.route) ? page.route : [page.route];
|
|
611
|
+
}
|
|
612
|
+
/** How specific a route pattern is: its `?k=v` constraints, then its static segments. */
|
|
613
|
+
function routeSpecificity(pattern) {
|
|
614
|
+
const q = pattern.indexOf("?");
|
|
615
|
+
const path = q < 0 ? pattern : pattern.slice(0, q);
|
|
616
|
+
const query = q < 0 ? 0 : [...new URLSearchParams(pattern.slice(q + 1)).keys()].length;
|
|
617
|
+
const statics = path.split("/").filter(seg => seg && !/^\[.*\]$/.test(seg) && !seg.startsWith(":")).length;
|
|
618
|
+
return [query, statics];
|
|
619
|
+
}
|
|
620
|
+
/**
|
|
621
|
+
* Whether the page guide applies here: one of its routes matches and its
|
|
622
|
+
* `when` anchor (if any) is on screen. The score ranks matches: `when`
|
|
623
|
+
* first, then query constraints, then static segments. Null when it does
|
|
624
|
+
* not apply.
|
|
625
|
+
*/
|
|
626
|
+
function pageMatchScore(page, where) {
|
|
627
|
+
let best = null;
|
|
628
|
+
for (const r of routesOf(page)) {
|
|
629
|
+
if (!matchRoute(r, where.pathname, where.search, where.code))
|
|
630
|
+
continue;
|
|
631
|
+
const s = routeSpecificity(r);
|
|
632
|
+
if (!best || s[0] > best[0] || (s[0] === best[0] && s[1] > best[1]))
|
|
633
|
+
best = s;
|
|
634
|
+
}
|
|
635
|
+
if (!best)
|
|
636
|
+
return null;
|
|
637
|
+
if (page.when) {
|
|
638
|
+
let on = false;
|
|
639
|
+
try {
|
|
640
|
+
on = where.isVisible(page.when);
|
|
641
|
+
}
|
|
642
|
+
catch {
|
|
643
|
+
on = false;
|
|
644
|
+
}
|
|
645
|
+
if (!on)
|
|
646
|
+
return null;
|
|
647
|
+
}
|
|
648
|
+
return [page.when ? 1 : 0, best[0], best[1]];
|
|
649
|
+
}
|
|
650
|
+
const after = (a, b) => a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2];
|
|
651
|
+
/** The most specific page guide that applies here, whoever it is for (first in the list on a tie). */
|
|
652
|
+
function matchingPage(suite, where) {
|
|
653
|
+
let best = null;
|
|
654
|
+
let bestScore = null;
|
|
655
|
+
for (const p of validPages(suite)) {
|
|
656
|
+
const s = pageMatchScore(p, where);
|
|
657
|
+
if (s && (!bestScore || after(s, bestScore))) {
|
|
658
|
+
best = p;
|
|
659
|
+
bestScore = s;
|
|
660
|
+
}
|
|
661
|
+
}
|
|
662
|
+
return best;
|
|
663
|
+
}
|
|
664
|
+
/** Whether this person has the page guide: `visible` (only true) and `requires`. */
|
|
665
|
+
function pageShown(page, ctx) {
|
|
666
|
+
try {
|
|
667
|
+
if (page.visible && page.visible(ctx) !== true)
|
|
668
|
+
return false;
|
|
669
|
+
if (page.requires && !ctx.can(page.requires))
|
|
670
|
+
return false;
|
|
671
|
+
return true;
|
|
672
|
+
}
|
|
673
|
+
catch {
|
|
674
|
+
return false;
|
|
675
|
+
}
|
|
676
|
+
}
|
|
677
|
+
/**
|
|
678
|
+
* This page's guide for this person: the most specific match, when it is
|
|
679
|
+
* theirs. A more specific guide they do not have is not replaced by a less
|
|
680
|
+
* specific one (it would describe what is not on screen).
|
|
681
|
+
*/
|
|
682
|
+
function currentPage(suite, where, ctx) {
|
|
683
|
+
const p = matchingPage(suite, where);
|
|
684
|
+
return p && pageShown(p, ctx) ? p : null;
|
|
685
|
+
}
|
|
686
|
+
/** The page guides this person has, for the panel's list (those with an `href`). */
|
|
687
|
+
function listedPages(suite, ctx) {
|
|
688
|
+
return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
|
|
689
|
+
}
|
|
690
|
+
/* ------------------------------------------------------------------ */
|
|
691
|
+
/* Section guides ("อธิบายส่วนนี้", shell 0.1.75) */
|
|
692
|
+
/* ------------------------------------------------------------------ */
|
|
693
|
+
/** The synthetic mission key of a section guide: `section:<key>`. */
|
|
694
|
+
const SECTION_PREFIX = "section:";
|
|
695
|
+
function isSectionMissionKey(key) {
|
|
696
|
+
return !!key && key.startsWith(SECTION_PREFIX);
|
|
697
|
+
}
|
|
698
|
+
function sectionKeyOf(missionKey) {
|
|
699
|
+
return isSectionMissionKey(missionKey) ? missionKey.slice(SECTION_PREFIX.length) : undefined;
|
|
700
|
+
}
|
|
701
|
+
/** Why a section guide cannot run, or null when it can. */
|
|
702
|
+
function sectionProblem(app, section) {
|
|
703
|
+
if (!section || typeof section.key !== "string" || !PAGE_KEY_RE.test(section.key))
|
|
704
|
+
return "its key is not [a-z0-9-]";
|
|
705
|
+
if (`${app}.section.${section.key}`.length > ROW_KEY_MAX)
|
|
706
|
+
return `its row key is over ${ROW_KEY_MAX} characters`;
|
|
707
|
+
if (!Number.isInteger(section.version) || section.version < 1)
|
|
708
|
+
return "its version is not a whole number ≥ 1";
|
|
709
|
+
const routes = Array.isArray(section.route) ? section.route : [section.route];
|
|
710
|
+
if (!routes.length || routes.some(r => typeof r !== "string" || !r.startsWith("/")))
|
|
711
|
+
return "it has no route";
|
|
712
|
+
if (typeof section.anchor !== "string" || !section.anchor)
|
|
713
|
+
return "it has no anchor";
|
|
714
|
+
if (!Array.isArray(section.steps) || section.steps.length === 0)
|
|
715
|
+
return "it has no steps";
|
|
716
|
+
for (const st of section.steps) {
|
|
717
|
+
const kind = st.kind ?? "view";
|
|
718
|
+
if (kind !== "view")
|
|
719
|
+
return `step "${st.key}" is a ${kind} step (a section guide only reads)`;
|
|
720
|
+
}
|
|
721
|
+
return null;
|
|
722
|
+
}
|
|
723
|
+
const validSectionCache = new WeakMap();
|
|
724
|
+
/** The suite's section guides that can run (as validPages: the rest dropped, with a warning in development). */
|
|
725
|
+
function validSections(suite) {
|
|
726
|
+
const hit = validSectionCache.get(suite);
|
|
727
|
+
if (hit)
|
|
728
|
+
return hit;
|
|
729
|
+
const out = [];
|
|
730
|
+
const seen = new Set();
|
|
731
|
+
for (const section of suite.sections ?? []) {
|
|
732
|
+
let problem = sectionProblem(suite.app, section);
|
|
733
|
+
if (!problem && seen.has(section.key))
|
|
734
|
+
problem = "another section guide has the same key";
|
|
735
|
+
if (problem) {
|
|
736
|
+
const id = `${suite.key}:section:${section?.key}:${problem}`;
|
|
737
|
+
if (!warned.has(id)) {
|
|
738
|
+
warned.add(id);
|
|
739
|
+
devWarn(`section guide "${section?.key}" dropped: ${problem}`);
|
|
740
|
+
}
|
|
741
|
+
continue;
|
|
742
|
+
}
|
|
743
|
+
seen.add(section.key);
|
|
744
|
+
out.push(section);
|
|
745
|
+
}
|
|
746
|
+
validSectionCache.set(suite, out);
|
|
747
|
+
return out;
|
|
748
|
+
}
|
|
749
|
+
function findSection(suite, key) {
|
|
750
|
+
return key ? validSections(suite).find(s => s.key === key) : undefined;
|
|
751
|
+
}
|
|
752
|
+
/** A live value is missing: undefined, null, "" or a number that is not finite. */
|
|
753
|
+
function valueMissing(v) {
|
|
754
|
+
return v === undefined || v === null || v === "" || (typeof v === "number" && !Number.isFinite(v));
|
|
755
|
+
}
|
|
756
|
+
function textOf(t, ctx) {
|
|
757
|
+
try {
|
|
758
|
+
return typeof t === "function" ? t(ctx) : t;
|
|
759
|
+
}
|
|
760
|
+
catch {
|
|
761
|
+
return "";
|
|
762
|
+
}
|
|
763
|
+
}
|
|
764
|
+
const sectionMissionCache = new WeakMap();
|
|
765
|
+
/**
|
|
766
|
+
* The section guide as a mission the machine plays: `section:<key>`, read
|
|
767
|
+
* steps only, checkpoints unless they say otherwise. A step is skipped while
|
|
768
|
+
* a value it `needs` is missing, or when its title comes out empty.
|
|
769
|
+
*/
|
|
770
|
+
function sectionMission(section) {
|
|
771
|
+
const hit = sectionMissionCache.get(section);
|
|
772
|
+
if (hit)
|
|
773
|
+
return hit;
|
|
774
|
+
const steps = section.steps.map(st => {
|
|
775
|
+
const { route: _route, resumeUrl: _resume, ...rest } = st;
|
|
776
|
+
const own = st.skipIf;
|
|
777
|
+
return {
|
|
778
|
+
...rest,
|
|
779
|
+
kind: "view",
|
|
780
|
+
checkpoint: st.checkpoint ?? true,
|
|
781
|
+
skipIf: (ctx) => {
|
|
782
|
+
const values = ctx.values ?? {};
|
|
783
|
+
if ((st.needs ?? []).some(k => valueMissing(values[k])))
|
|
784
|
+
return true;
|
|
785
|
+
if (!textOf(st.title, ctx).trim())
|
|
786
|
+
return true;
|
|
787
|
+
return own ? own(ctx) : false;
|
|
788
|
+
},
|
|
789
|
+
};
|
|
790
|
+
});
|
|
791
|
+
const m = { key: SECTION_PREFIX + section.key, title: section.title, minutes: 1, steps };
|
|
792
|
+
sectionMissionCache.set(section, m);
|
|
793
|
+
return m;
|
|
794
|
+
}
|
|
795
|
+
/** Whether the section guide applies here: one of its routes matches and its root is on screen. */
|
|
796
|
+
function sectionApplies(section, where) {
|
|
797
|
+
const routes = Array.isArray(section.route) ? section.route : [section.route];
|
|
798
|
+
if (!routes.some(r => !!matchRoute(r, where.pathname, where.search, where.code)))
|
|
799
|
+
return false;
|
|
800
|
+
try {
|
|
801
|
+
return where.isVisible(section.anchor);
|
|
802
|
+
}
|
|
803
|
+
catch {
|
|
804
|
+
return false;
|
|
805
|
+
}
|
|
806
|
+
}
|
|
807
|
+
/** Whether this person has the section guide: `visible` (only true) and `requires`. */
|
|
808
|
+
function sectionShown(section, ctx) {
|
|
809
|
+
try {
|
|
810
|
+
if (section.visible && section.visible(ctx) !== true)
|
|
811
|
+
return false;
|
|
812
|
+
if (section.requires && !ctx.can(section.requires))
|
|
813
|
+
return false;
|
|
814
|
+
return true;
|
|
815
|
+
}
|
|
816
|
+
catch {
|
|
817
|
+
return false;
|
|
818
|
+
}
|
|
819
|
+
}
|
|
820
|
+
/** The section guides on screen here that this person has, in the suite's order. */
|
|
821
|
+
function sectionsHere(suite, where, ctx) {
|
|
822
|
+
return validSections(suite).filter(s => sectionApplies(s, where) && sectionShown(s, ctx));
|
|
823
|
+
}
|
|
378
824
|
|
|
379
825
|
/* ------------------------------------------------------------------ */
|
|
380
826
|
/* Copy */
|
|
@@ -388,6 +834,8 @@ const COPY = {
|
|
|
388
834
|
replayEnded: "จบการทบทวนแล้ว",
|
|
389
835
|
hiddenToast: "ซ่อนแล้ว",
|
|
390
836
|
aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
|
|
837
|
+
pageDone: "จบคู่มือหน้านี้",
|
|
838
|
+
sectionDone: "จบคำอธิบายส่วนนี้",
|
|
391
839
|
bridgeTitle: "กดปุ่มเมนู",
|
|
392
840
|
bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
|
|
393
841
|
};
|
|
@@ -395,8 +843,15 @@ const COPY = {
|
|
|
395
843
|
/* Helpers */
|
|
396
844
|
/* ------------------------------------------------------------------ */
|
|
397
845
|
const BRIDGE_TARGET = "shell.menu-toggle";
|
|
398
|
-
function initialGuideState(audience, progress) {
|
|
846
|
+
function initialGuideState(audience, progress, pages) {
|
|
399
847
|
return {
|
|
848
|
+
pageAudience: pages?.pageAudience ?? "none",
|
|
849
|
+
pagesMode: pages?.pagesMode ?? "off",
|
|
850
|
+
pages: pages?.pages ?? {},
|
|
851
|
+
sections: pages?.sections ?? {},
|
|
852
|
+
pageRun: null,
|
|
853
|
+
panelSection: "all",
|
|
854
|
+
autoPassed: null,
|
|
400
855
|
audience,
|
|
401
856
|
progress,
|
|
402
857
|
screen: { name: "idle" },
|
|
@@ -410,7 +865,17 @@ function initialGuideState(audience, progress) {
|
|
|
410
865
|
};
|
|
411
866
|
}
|
|
412
867
|
function findMission(suite, key) {
|
|
413
|
-
|
|
868
|
+
if (!key)
|
|
869
|
+
return undefined;
|
|
870
|
+
if (isPageMissionKey(key)) {
|
|
871
|
+
const page = findPage(suite, pageKeyOf(key));
|
|
872
|
+
return page ? pageMission(page) : undefined;
|
|
873
|
+
}
|
|
874
|
+
if (isSectionMissionKey(key)) {
|
|
875
|
+
const section = findSection(suite, sectionKeyOf(key));
|
|
876
|
+
return section ? sectionMission(section) : undefined;
|
|
877
|
+
}
|
|
878
|
+
return suite.missions.find(m => m.key === key);
|
|
414
879
|
}
|
|
415
880
|
/**
|
|
416
881
|
* Whether the user has this mission (GuideMission.visible). Without the
|
|
@@ -447,6 +912,7 @@ function guideContext(env, data, params = {}) {
|
|
|
447
912
|
facts: env.facts,
|
|
448
913
|
can: env.can,
|
|
449
914
|
visible: (anchor) => env.isVisible(anchor),
|
|
915
|
+
values: env.values ?? {},
|
|
450
916
|
};
|
|
451
917
|
}
|
|
452
918
|
function resolveText(t, ctx) {
|
|
@@ -573,8 +1039,10 @@ function placeRoute(steps, index) {
|
|
|
573
1039
|
return step.route;
|
|
574
1040
|
return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
|
|
575
1041
|
}
|
|
576
|
-
/** The user is not on the page the step happens on. */
|
|
577
|
-
function offRoute(steps, index, env) {
|
|
1042
|
+
/** The user is not on the page the step happens on (a page guide's step never is: leaving the page ends it). */
|
|
1043
|
+
function offRoute(steps, index, env, page) {
|
|
1044
|
+
if (page)
|
|
1045
|
+
return false;
|
|
578
1046
|
const home = stepHomeRoute(steps, index);
|
|
579
1047
|
return !!home && !matchRoute(home, env.pathname, env.search, env.code);
|
|
580
1048
|
}
|
|
@@ -717,7 +1185,8 @@ function welcomeDue(state, suite, ctx) {
|
|
|
717
1185
|
state.progress.welcome === "not_seen" &&
|
|
718
1186
|
state.progress.status === "in_progress" &&
|
|
719
1187
|
!state.progress.mission_key &&
|
|
720
|
-
|
|
1188
|
+
// The missions' welcome always wins over a page guide's offer.
|
|
1189
|
+
(state.screen.name === "idle" || state.screen.name === "pageOffer") &&
|
|
721
1190
|
(ctx ? shownMissions(suite, ctx).length : suite.missions.length) > 0);
|
|
722
1191
|
}
|
|
723
1192
|
/** Whether the floating "ภารกิจ n/N" chip shows. */
|
|
@@ -757,7 +1226,7 @@ function announceStep(d, suite, env) {
|
|
|
757
1226
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
758
1227
|
const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
|
|
759
1228
|
const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
|
|
760
|
-
const pos = stepPosition(mission, s.index, env, d.state.progress.context);
|
|
1229
|
+
const pos = stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
|
|
761
1230
|
d.effects.push({
|
|
762
1231
|
type: "announce",
|
|
763
1232
|
text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
|
|
@@ -768,7 +1237,7 @@ function announceStep(d, suite, env) {
|
|
|
768
1237
|
* whose `requires` they lack or whose `skipIf` holds is left out (the step on
|
|
769
1238
|
* screen always counts).
|
|
770
1239
|
*/
|
|
771
|
-
function stepPosition(mission, index, env, context) {
|
|
1240
|
+
function stepPosition(mission, index, env, context, autoPassed = []) {
|
|
772
1241
|
let no = 0;
|
|
773
1242
|
let total = 0;
|
|
774
1243
|
mission.steps.forEach((step, i) => {
|
|
@@ -778,6 +1247,12 @@ function stepPosition(mission, index, env, context) {
|
|
|
778
1247
|
const ctx = guideContext(env, context, routeParamsFor(step, env));
|
|
779
1248
|
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
780
1249
|
return;
|
|
1250
|
+
// A step that passed by itself (shell ≥ 0.1.75): before this one, as
|
|
1251
|
+
// recorded; after it, one whose outcome already holds now.
|
|
1252
|
+
if (i < index && autoPassed.includes(i))
|
|
1253
|
+
return;
|
|
1254
|
+
if (i > index && wouldAutoPass(step, env))
|
|
1255
|
+
return;
|
|
781
1256
|
}
|
|
782
1257
|
total++;
|
|
783
1258
|
if (i <= index)
|
|
@@ -785,6 +1260,17 @@ function stepPosition(mission, index, env, context) {
|
|
|
785
1260
|
});
|
|
786
1261
|
return { no: Math.max(no, 1), total: Math.max(total, 1) };
|
|
787
1262
|
}
|
|
1263
|
+
/** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
|
|
1264
|
+
function wouldAutoPass(step, env) {
|
|
1265
|
+
const kind = stepKind(step);
|
|
1266
|
+
if (step.autoPass === false || kind === "input" || kind === "view")
|
|
1267
|
+
return false;
|
|
1268
|
+
return safe(() => stateTriggerHolds(step, env) !== null, false);
|
|
1269
|
+
}
|
|
1270
|
+
/** The steps of this mission that passed by themselves in this run. */
|
|
1271
|
+
function autoPassedOf(state, mission) {
|
|
1272
|
+
return state.autoPassed?.mission === mission ? state.autoPassed.steps : [];
|
|
1273
|
+
}
|
|
788
1274
|
/** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
|
|
789
1275
|
function plainTitle(t) {
|
|
790
1276
|
return t.replace(/\[([^\]]+)\]/g, "$1");
|
|
@@ -797,6 +1283,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
797
1283
|
const steps = mission.steps;
|
|
798
1284
|
let i = Math.max(0, index);
|
|
799
1285
|
let firstPass = true;
|
|
1286
|
+
if (d.state.autoPassed?.mission !== mission.key)
|
|
1287
|
+
d.state = { ...d.state, autoPassed: { mission: mission.key, steps: [] } };
|
|
800
1288
|
while (i < steps.length) {
|
|
801
1289
|
const step = steps[i];
|
|
802
1290
|
const params = routeParamsFor(step, env);
|
|
@@ -818,6 +1306,9 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
818
1306
|
const held = stateTriggerHolds(step, env);
|
|
819
1307
|
if (held) {
|
|
820
1308
|
captureInto(d, step, held, ctx);
|
|
1309
|
+
const ap = d.state.autoPassed;
|
|
1310
|
+
if (!ap.steps.includes(i))
|
|
1311
|
+
d.state = { ...d.state, autoPassed: { mission: ap.mission, steps: [...ap.steps, i] } };
|
|
821
1312
|
i++;
|
|
822
1313
|
continue;
|
|
823
1314
|
}
|
|
@@ -830,8 +1321,11 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
830
1321
|
}
|
|
831
1322
|
const step = steps[i];
|
|
832
1323
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1324
|
+
const page = pageKeyOf(mission.key);
|
|
1325
|
+
const section = sectionKeyOf(mission.key);
|
|
833
1326
|
const where = placeRoute(steps, i);
|
|
834
|
-
|
|
1327
|
+
// A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
|
|
1328
|
+
const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
835
1329
|
const bridge = !away && needsBridge(step, env, ctx);
|
|
836
1330
|
const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
|
|
837
1331
|
setScreen(d, {
|
|
@@ -848,6 +1342,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
848
1342
|
nothingToSave: nts,
|
|
849
1343
|
status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
|
|
850
1344
|
replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
|
|
1345
|
+
...(page ? { page } : {}),
|
|
1346
|
+
...(section ? { section } : {}),
|
|
851
1347
|
});
|
|
852
1348
|
if (!opts.replay) {
|
|
853
1349
|
const cp = checkpointAtOrBefore(steps, i);
|
|
@@ -1012,6 +1508,11 @@ function advance(d, suite, env, capture) {
|
|
|
1012
1508
|
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
1013
1509
|
}
|
|
1014
1510
|
function finishMission(d, suite, env, mission, replay) {
|
|
1511
|
+
if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
|
|
1512
|
+
endPage(d, suite, "completed");
|
|
1513
|
+
d.effects.push({ type: "toast", text: isSectionMissionKey(mission.key) ? COPY.sectionDone : COPY.pageDone });
|
|
1514
|
+
return;
|
|
1515
|
+
}
|
|
1015
1516
|
if (replay) {
|
|
1016
1517
|
setScreen(d, { name: "idle" });
|
|
1017
1518
|
d.effects.push({ type: "toast", text: COPY.replayDone });
|
|
@@ -1113,7 +1614,8 @@ function startMission(d, suite, env, key, replay) {
|
|
|
1113
1614
|
// A mission already done is only ever replayed; its done status stays.
|
|
1114
1615
|
const isReplay = replay || entry?.status === "done";
|
|
1115
1616
|
const before = d.state.progress;
|
|
1116
|
-
|
|
1617
|
+
// A new run: what passed by itself is counted afresh.
|
|
1618
|
+
d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
|
|
1117
1619
|
if (d.state.progress.welcome === "not_seen")
|
|
1118
1620
|
withProgress(d, { welcome: "started" });
|
|
1119
1621
|
if (!isReplay && d.state.progress.mission_key !== key)
|
|
@@ -1205,6 +1707,193 @@ function pushAbort(d, suite, s) {
|
|
|
1205
1707
|
if (actions.length)
|
|
1206
1708
|
d.effects.push({ type: "abort", actions });
|
|
1207
1709
|
}
|
|
1710
|
+
/* ------------------------------------------------------------------ */
|
|
1711
|
+
/* Page guides */
|
|
1712
|
+
/* ------------------------------------------------------------------ */
|
|
1713
|
+
function whereOf(env) {
|
|
1714
|
+
return { code: env.code, pathname: env.pathname, search: env.search, isVisible: a => env.isVisible(a) };
|
|
1715
|
+
}
|
|
1716
|
+
/** This page's guide for this person (the most specific match, if theirs). */
|
|
1717
|
+
function pageHere(suite, env) {
|
|
1718
|
+
return currentPage(suite, whereOf(env), guideContext(env, {}));
|
|
1719
|
+
}
|
|
1720
|
+
/** The page guide's row says this person has ended this version of it. */
|
|
1721
|
+
function pageRecorded(state, page) {
|
|
1722
|
+
const row = state.pages[page.key];
|
|
1723
|
+
return !!row && row.version >= page.version;
|
|
1724
|
+
}
|
|
1725
|
+
/** Whether the first step this person sees has its target on screen (or none). */
|
|
1726
|
+
function firstStepReady(suite, pageKey, env) {
|
|
1727
|
+
const page = findPage(suite, pageKey);
|
|
1728
|
+
if (!page)
|
|
1729
|
+
return false;
|
|
1730
|
+
const mission = pageMission(page);
|
|
1731
|
+
for (const step of mission.steps) {
|
|
1732
|
+
const ctx = guideContext(env, {});
|
|
1733
|
+
if (step.requires && !safe(() => env.can(step.requires), true))
|
|
1734
|
+
continue;
|
|
1735
|
+
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
1736
|
+
continue;
|
|
1737
|
+
return stepTargetVisible(step, env, ctx, false);
|
|
1738
|
+
}
|
|
1739
|
+
return true;
|
|
1740
|
+
}
|
|
1741
|
+
/**
|
|
1742
|
+
* The page guide to offer now, or null. Due when: pages mode is "offer" and
|
|
1743
|
+
* this person has page guides; nothing else is up (no step, card, panel or
|
|
1744
|
+
* celebration waiting); this page's guide is theirs, may be offered, and has
|
|
1745
|
+
* no row of this version; its first step's target is on screen; no app
|
|
1746
|
+
* dialog is open. The host adds the rest: the welcome not due on this
|
|
1747
|
+
* route, a moment of nothing loading, the primary tab, not hidden.
|
|
1748
|
+
*/
|
|
1749
|
+
function pageOfferDue(state, suite, env) {
|
|
1750
|
+
if (state.pagesMode !== "offer" || state.pageAudience === "none")
|
|
1751
|
+
return null;
|
|
1752
|
+
if (state.screen.name !== "idle" || state.panelOpen || (state.pendingCelebrate?.length ?? 0) > 0)
|
|
1753
|
+
return null;
|
|
1754
|
+
const page = pageHere(suite, env);
|
|
1755
|
+
if (!page || page.offer === false || pageRecorded(state, page))
|
|
1756
|
+
return null;
|
|
1757
|
+
if (!firstStepReady(suite, page.key, env))
|
|
1758
|
+
return null;
|
|
1759
|
+
if (safe(() => env.dialogOpen?.() === true, false))
|
|
1760
|
+
return null;
|
|
1761
|
+
return page.key;
|
|
1762
|
+
}
|
|
1763
|
+
/** Whether the page guide still applies where the person is (its route, its `when`). */
|
|
1764
|
+
function pageStillHere(suite, key, env) {
|
|
1765
|
+
const page = findPage(suite, key);
|
|
1766
|
+
return !!page && pageMatchScore(page, whereOf(env)) !== null;
|
|
1767
|
+
}
|
|
1768
|
+
/** Write a page guide's row (once per page and version): recorded here at once, saved by the host. */
|
|
1769
|
+
function recordPage(d, suite, key, status) {
|
|
1770
|
+
const page = findPage(suite, key);
|
|
1771
|
+
if (!page || pageRecorded(d.state, page))
|
|
1772
|
+
return;
|
|
1773
|
+
const prev = d.state.pages[key];
|
|
1774
|
+
d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
|
|
1775
|
+
d.effects.push({ type: "savePage", key, version: page.version, status });
|
|
1776
|
+
}
|
|
1777
|
+
/** The section guide's row says this person has ended this version of it. */
|
|
1778
|
+
function sectionRecorded(state, section) {
|
|
1779
|
+
const row = state.sections?.[section.key];
|
|
1780
|
+
return !!row && row.version >= section.version;
|
|
1781
|
+
}
|
|
1782
|
+
/** Write a section guide's row (once per section and version). */
|
|
1783
|
+
function recordSection(d, suite, key, status) {
|
|
1784
|
+
const section = findSection(suite, key);
|
|
1785
|
+
if (!section || sectionRecorded(d.state, section))
|
|
1786
|
+
return;
|
|
1787
|
+
const prev = d.state.sections?.[key];
|
|
1788
|
+
d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
|
|
1789
|
+
d.effects.push({ type: "savePage", key, version: section.version, status, kind: "section" });
|
|
1790
|
+
}
|
|
1791
|
+
/** The page or section guide on screen, or null. */
|
|
1792
|
+
function readOf(s) {
|
|
1793
|
+
if (s.name !== "step")
|
|
1794
|
+
return null;
|
|
1795
|
+
if (s.page)
|
|
1796
|
+
return { kind: "page", key: s.page };
|
|
1797
|
+
if (s.section)
|
|
1798
|
+
return { kind: "section", key: s.section };
|
|
1799
|
+
return null;
|
|
1800
|
+
}
|
|
1801
|
+
/** Whether a page or section guide still applies where the person is. */
|
|
1802
|
+
function readStillHere(suite, r, env) {
|
|
1803
|
+
if (r.kind === "page")
|
|
1804
|
+
return pageStillHere(suite, r.key, env);
|
|
1805
|
+
const section = findSection(suite, r.key);
|
|
1806
|
+
return !!section && sectionApplies(section, whereOf(env));
|
|
1807
|
+
}
|
|
1808
|
+
/** The page or section guide ends: its row written when it is due one; back to idle. */
|
|
1809
|
+
function endPage(d, suite, how) {
|
|
1810
|
+
const run = d.state.pageRun;
|
|
1811
|
+
if (run?.write) {
|
|
1812
|
+
if (run.kind === "section")
|
|
1813
|
+
recordSection(d, suite, run.key, how);
|
|
1814
|
+
else
|
|
1815
|
+
recordPage(d, suite, run.key, how);
|
|
1816
|
+
}
|
|
1817
|
+
d.state = { ...d.state, pageRun: null, replayPage: null };
|
|
1818
|
+
setScreen(d, { name: "idle" });
|
|
1819
|
+
}
|
|
1820
|
+
/** The section guides on screen here that this person has (the "?" entries, the buttons). */
|
|
1821
|
+
function sectionsAvailable(suite, env) {
|
|
1822
|
+
return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
|
|
1823
|
+
}
|
|
1824
|
+
function startSection(d, suite, env, key, write) {
|
|
1825
|
+
const section = findSection(suite, key);
|
|
1826
|
+
if (!section)
|
|
1827
|
+
return;
|
|
1828
|
+
const mission = sectionMission(section);
|
|
1829
|
+
d.state = {
|
|
1830
|
+
...d.state,
|
|
1831
|
+
panelOpen: false,
|
|
1832
|
+
justDone: null,
|
|
1833
|
+
pageRun: { key, write, kind: "section" },
|
|
1834
|
+
autoPassed: null,
|
|
1835
|
+
replayPage: {
|
|
1836
|
+
mission: mission.key,
|
|
1837
|
+
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
1838
|
+
dialog: safe(() => env.dialogOpen?.() === true, false),
|
|
1839
|
+
},
|
|
1840
|
+
};
|
|
1841
|
+
enterStep(d, suite, env, mission, 0, { replay: true });
|
|
1842
|
+
}
|
|
1843
|
+
function startPage(d, suite, env, key, write) {
|
|
1844
|
+
const page = findPage(suite, key);
|
|
1845
|
+
if (!page)
|
|
1846
|
+
return;
|
|
1847
|
+
const mission = pageMission(page);
|
|
1848
|
+
d.state = {
|
|
1849
|
+
...d.state,
|
|
1850
|
+
panelOpen: false,
|
|
1851
|
+
justDone: null,
|
|
1852
|
+
pageRun: { key, write, kind: "page" },
|
|
1853
|
+
autoPassed: null,
|
|
1854
|
+
// What is open now is the person's: ending the guide leaves it alone.
|
|
1855
|
+
replayPage: {
|
|
1856
|
+
mission: mission.key,
|
|
1857
|
+
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
1858
|
+
dialog: safe(() => env.dialogOpen?.() === true, false),
|
|
1859
|
+
},
|
|
1860
|
+
};
|
|
1861
|
+
enterStep(d, suite, env, mission, 0, { replay: true });
|
|
1862
|
+
}
|
|
1863
|
+
/** The step before this one that this person sees, or -1. */
|
|
1864
|
+
function previousShownIndex(mission, index, env, context) {
|
|
1865
|
+
for (let j = index - 1; j >= 0; j--) {
|
|
1866
|
+
const st = mission.steps[j];
|
|
1867
|
+
const ctx = guideContext(env, context, routeParamsFor(st, env));
|
|
1868
|
+
if (st.requires && !safe(() => env.can(st.requires), true))
|
|
1869
|
+
continue;
|
|
1870
|
+
if (st.skipIf && safe(() => st.skipIf(ctx), false))
|
|
1871
|
+
continue;
|
|
1872
|
+
return j;
|
|
1873
|
+
}
|
|
1874
|
+
return -1;
|
|
1875
|
+
}
|
|
1876
|
+
/**
|
|
1877
|
+
* Where ย้อนกลับ goes from a page guide's step, or null. The step before it
|
|
1878
|
+
* that this person sees; from inside what a click opened (not a
|
|
1879
|
+
* checkpoint) back to that click, closing it first (the step's abort,
|
|
1880
|
+
* Escape by default). A click step whose result is still on screen (a tab
|
|
1881
|
+
* switched) is passed over: entering it again would pass it at once.
|
|
1882
|
+
*/
|
|
1883
|
+
function backTarget(mission, index, env, context) {
|
|
1884
|
+
const cur = mission.steps[index];
|
|
1885
|
+
let prev = previousShownIndex(mission, index, env, context);
|
|
1886
|
+
if (prev < 0 || !cur)
|
|
1887
|
+
return null;
|
|
1888
|
+
if (cur.checkpoint === false) {
|
|
1889
|
+
const to = mission.steps[prev];
|
|
1890
|
+
return { index: prev, close: to.checkpoint !== false };
|
|
1891
|
+
}
|
|
1892
|
+
while (prev >= 0 && stepKind(mission.steps[prev]) !== "view" && stateTriggerHolds(mission.steps[prev], env)) {
|
|
1893
|
+
prev = previousShownIndex(mission, prev, env, context);
|
|
1894
|
+
}
|
|
1895
|
+
return prev >= 0 ? { index: prev, close: false } : null;
|
|
1896
|
+
}
|
|
1208
1897
|
function reduceGuide(state, action, suite, env) {
|
|
1209
1898
|
const d = { state, effects: [] };
|
|
1210
1899
|
const s = state.screen;
|
|
@@ -1216,6 +1905,9 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1216
1905
|
const gate = gateState(suite, ctxNow());
|
|
1217
1906
|
if (gate === undefined)
|
|
1218
1907
|
break;
|
|
1908
|
+
// The welcome wins: the page offer goes, unrecorded (it comes back on a later visit).
|
|
1909
|
+
if (s.name === "pageOffer")
|
|
1910
|
+
setScreen(d, { name: "idle" });
|
|
1219
1911
|
if (gate === false) {
|
|
1220
1912
|
if (!state.gateDismissed)
|
|
1221
1913
|
setScreen(d, { name: "gate" });
|
|
@@ -1256,6 +1948,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1256
1948
|
case "start":
|
|
1257
1949
|
if (state.audience === "none")
|
|
1258
1950
|
break;
|
|
1951
|
+
// A mission always wins over a page guide: a running one ends (its row
|
|
1952
|
+
// written if due), an offer goes unrecorded.
|
|
1953
|
+
if (readOf(s))
|
|
1954
|
+
endPage(d, suite, "skipped");
|
|
1955
|
+
else if (s.name === "pageOffer")
|
|
1956
|
+
setScreen(d, { name: "idle" });
|
|
1259
1957
|
if (s.name === "step" && !s.replay && s.mission !== action.mission) {
|
|
1260
1958
|
// Leaving one mission for another: the first is set aside.
|
|
1261
1959
|
exitMission(d);
|
|
@@ -1273,12 +1971,27 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1273
1971
|
const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
|
|
1274
1972
|
const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
|
|
1275
1973
|
const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
|
|
1974
|
+
d.state = { ...d.state, autoPassed: null };
|
|
1276
1975
|
enterStep(d, suite, env, mission, index, { replay: false });
|
|
1277
1976
|
break;
|
|
1278
1977
|
}
|
|
1279
1978
|
case "sync": {
|
|
1979
|
+
if (s.name === "pageOffer") {
|
|
1980
|
+
// Ignored: the person left the page (or its sub-screen). Recorded.
|
|
1981
|
+
if (!pageStillHere(suite, s.page, env)) {
|
|
1982
|
+
recordPage(d, suite, s.page, "skipped");
|
|
1983
|
+
setScreen(d, { name: "idle" });
|
|
1984
|
+
}
|
|
1985
|
+
break;
|
|
1986
|
+
}
|
|
1280
1987
|
if (s.name !== "step")
|
|
1281
1988
|
break;
|
|
1989
|
+
const read = readOf(s);
|
|
1990
|
+
if (read && !readStillHere(suite, read, env)) {
|
|
1991
|
+
// Leaving the page (or the section's root going) ends its guide, silently.
|
|
1992
|
+
endPage(d, suite, "skipped");
|
|
1993
|
+
break;
|
|
1994
|
+
}
|
|
1282
1995
|
const mission = findMission(suite, s.mission);
|
|
1283
1996
|
if (!mission)
|
|
1284
1997
|
break;
|
|
@@ -1308,7 +2021,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1308
2021
|
break;
|
|
1309
2022
|
}
|
|
1310
2023
|
}
|
|
1311
|
-
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env)) {
|
|
2024
|
+
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1312
2025
|
// The target is missing because the page is another one.
|
|
1313
2026
|
recoverOffRoute(d, suite, env, false);
|
|
1314
2027
|
break;
|
|
@@ -1424,7 +2137,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1424
2137
|
const cur = d.state.screen;
|
|
1425
2138
|
if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
|
|
1426
2139
|
break;
|
|
1427
|
-
if (!cur.bridge && offRoute(mission.steps, cur.index, env)) {
|
|
2140
|
+
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page ?? cur.section)) {
|
|
1428
2141
|
recoverOffRoute(d, suite, env, false);
|
|
1429
2142
|
break;
|
|
1430
2143
|
}
|
|
@@ -1445,7 +2158,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1445
2158
|
if (!mission)
|
|
1446
2159
|
break;
|
|
1447
2160
|
const step = mission.steps[s.index];
|
|
1448
|
-
if (!s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
2161
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1449
2162
|
recoverOffRoute(d, suite, env, false);
|
|
1450
2163
|
break;
|
|
1451
2164
|
}
|
|
@@ -1462,7 +2175,10 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1462
2175
|
advance(d, suite, env);
|
|
1463
2176
|
}
|
|
1464
2177
|
else if (policy === "abort") {
|
|
1465
|
-
|
|
2178
|
+
if (readOf(s))
|
|
2179
|
+
endPage(d, suite, "skipped");
|
|
2180
|
+
else
|
|
2181
|
+
exitMission(d);
|
|
1466
2182
|
d.effects.push({ type: "toast", text: COPY.aborted });
|
|
1467
2183
|
}
|
|
1468
2184
|
else if ((policy === "rewind" || (policy === "retry" && misses >= 2)) && cp < s.index) {
|
|
@@ -1485,7 +2201,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1485
2201
|
if (s.name !== "step" || s.phase !== "missing")
|
|
1486
2202
|
break;
|
|
1487
2203
|
const mission = findMission(suite, s.mission);
|
|
1488
|
-
if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
2204
|
+
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1489
2205
|
// Polling again cannot help on another page: go back to the step's.
|
|
1490
2206
|
recoverOffRoute(d, suite, env, true);
|
|
1491
2207
|
break;
|
|
@@ -1501,6 +2217,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1501
2217
|
case "exit":
|
|
1502
2218
|
if (s.name !== "step")
|
|
1503
2219
|
break;
|
|
2220
|
+
if (readOf(s)) {
|
|
2221
|
+
// ✕, Escape or ปิดคู่มือ: ends silently.
|
|
2222
|
+
pushAbort(d, suite, s);
|
|
2223
|
+
endPage(d, suite, "skipped");
|
|
2224
|
+
break;
|
|
2225
|
+
}
|
|
1504
2226
|
if (s.replay) {
|
|
1505
2227
|
pushAbort(d, suite, s);
|
|
1506
2228
|
setScreen(d, { name: "idle" });
|
|
@@ -1569,11 +2291,17 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1569
2291
|
break;
|
|
1570
2292
|
setScreen(d, { name: "idle" });
|
|
1571
2293
|
break;
|
|
1572
|
-
case "openPanel":
|
|
1573
|
-
|
|
1574
|
-
|
|
1575
|
-
|
|
2294
|
+
case "openPanel": {
|
|
2295
|
+
const section = action.section ?? "all";
|
|
2296
|
+
const allowed = section === "pages" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
|
|
2297
|
+
if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
|
|
2298
|
+
break;
|
|
2299
|
+
// The offer gives way to the panel, unrecorded.
|
|
2300
|
+
if (s.name === "pageOffer")
|
|
2301
|
+
setScreen(d, { name: "idle" });
|
|
2302
|
+
d.state = { ...d.state, panelOpen: true, panelSection: section };
|
|
1576
2303
|
break;
|
|
2304
|
+
}
|
|
1577
2305
|
case "closePanel":
|
|
1578
2306
|
d.state = { ...d.state, panelOpen: false, justDone: null };
|
|
1579
2307
|
break;
|
|
@@ -1617,6 +2345,78 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1617
2345
|
passiveDone(d, suite, env, m);
|
|
1618
2346
|
break;
|
|
1619
2347
|
}
|
|
2348
|
+
case "pageOffer": {
|
|
2349
|
+
if (pageOfferDue(state, suite, env) !== action.page)
|
|
2350
|
+
break;
|
|
2351
|
+
setScreen(d, { name: "pageOffer", page: action.page });
|
|
2352
|
+
d.effects.push({ type: "announce", text: `คู่มือหน้านี้ ${findPage(suite, action.page)?.title ?? ""}` });
|
|
2353
|
+
break;
|
|
2354
|
+
}
|
|
2355
|
+
case "pageOfferStart":
|
|
2356
|
+
if (s.name !== "pageOffer" || state.pageAudience === "none")
|
|
2357
|
+
break;
|
|
2358
|
+
startPage(d, suite, env, s.page, true);
|
|
2359
|
+
break;
|
|
2360
|
+
case "pageOfferLater":
|
|
2361
|
+
if (s.name !== "pageOffer")
|
|
2362
|
+
break;
|
|
2363
|
+
recordPage(d, suite, s.page, "skipped");
|
|
2364
|
+
setScreen(d, { name: "idle" });
|
|
2365
|
+
break;
|
|
2366
|
+
case "startPage": {
|
|
2367
|
+
// Never over a mission step, a welcome, a gate or a celebration.
|
|
2368
|
+
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
2369
|
+
break;
|
|
2370
|
+
if (s.name !== "idle" && s.name !== "pageOffer")
|
|
2371
|
+
break;
|
|
2372
|
+
const page = pageHere(suite, env);
|
|
2373
|
+
if (!page || page.key !== action.page)
|
|
2374
|
+
break;
|
|
2375
|
+
startPage(d, suite, env, page.key, !pageRecorded(state, page));
|
|
2376
|
+
break;
|
|
2377
|
+
}
|
|
2378
|
+
case "back": {
|
|
2379
|
+
if (s.name !== "step" || !readOf(s) || s.phase !== "ready" || s.confirmExit)
|
|
2380
|
+
break;
|
|
2381
|
+
const mission = findMission(suite, s.mission);
|
|
2382
|
+
if (!mission)
|
|
2383
|
+
break;
|
|
2384
|
+
const to = backTarget(mission, s.index, env, d.state.progress.context);
|
|
2385
|
+
if (!to)
|
|
2386
|
+
break;
|
|
2387
|
+
// Close what the click opened, then show the click again.
|
|
2388
|
+
if (to.close)
|
|
2389
|
+
pushAbort(d, suite, s);
|
|
2390
|
+
enterStep(d, suite, env, mission, to.index, { replay: true, noAutoPass: true });
|
|
2391
|
+
break;
|
|
2392
|
+
}
|
|
2393
|
+
case "pageRow": {
|
|
2394
|
+
if (action.kind === "section") {
|
|
2395
|
+
const cur = d.state.sections?.[action.key];
|
|
2396
|
+
if (cur && cur.version > action.row.version)
|
|
2397
|
+
break;
|
|
2398
|
+
d.state = { ...d.state, sections: { ...d.state.sections, [action.key]: action.row } };
|
|
2399
|
+
break;
|
|
2400
|
+
}
|
|
2401
|
+
const cur = d.state.pages[action.key];
|
|
2402
|
+
if (cur && cur.version > action.row.version)
|
|
2403
|
+
break;
|
|
2404
|
+
d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
|
|
2405
|
+
break;
|
|
2406
|
+
}
|
|
2407
|
+
case "startSection": {
|
|
2408
|
+
// Never offered: only on request. Never over a mission step, a
|
|
2409
|
+
// welcome, a gate or a celebration; only on screen, only theirs.
|
|
2410
|
+
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
2411
|
+
break;
|
|
2412
|
+
if (s.name !== "idle" && s.name !== "pageOffer")
|
|
2413
|
+
break;
|
|
2414
|
+
if (!sectionsAvailable(suite, env).includes(action.section))
|
|
2415
|
+
break;
|
|
2416
|
+
const section = findSection(suite, action.section);
|
|
2417
|
+
startSection(d, suite, env, section.key, !sectionRecorded(state, section));
|
|
2418
|
+
break;
|
|
2419
|
+
}
|
|
1620
2420
|
case "idleCelebrate": {
|
|
1621
2421
|
if (s.name !== "idle" || state.panelOpen)
|
|
1622
2422
|
break;
|
|
@@ -1740,6 +2540,12 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
1740
2540
|
if (typeof data.app === "string" && data.app !== suite.app)
|
|
1741
2541
|
return null;
|
|
1742
2542
|
const rows = Array.isArray(data.guides) ? data.guides : [];
|
|
2543
|
+
const pagesMode = data.pages_mode === "menu" || data.pages_mode === "offer" ? data.pages_mode : "off";
|
|
2544
|
+
const pageAudience = pagesMode !== "off" && (data.page_audience === "optional" || data.page_audience === "forced")
|
|
2545
|
+
? data.page_audience
|
|
2546
|
+
: "none";
|
|
2547
|
+
const pages = parsePageRows(rows, suite.app);
|
|
2548
|
+
const sections = parsePageRows(rows, suite.app, "section");
|
|
1743
2549
|
let row = null;
|
|
1744
2550
|
for (const r of rows) {
|
|
1745
2551
|
if (isRecord(r) && r.guide_key === suite.key) {
|
|
@@ -1760,7 +2566,58 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
1760
2566
|
progress = row;
|
|
1761
2567
|
newerOnServer = row.guide_version > suite.version;
|
|
1762
2568
|
}
|
|
1763
|
-
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer };
|
|
2569
|
+
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
|
|
2570
|
+
}
|
|
2571
|
+
/** The row key of a page guide, `<app>.page.<key>`, or a section guide, `<app>.section.<key>`. */
|
|
2572
|
+
function pageRowKey(app, key, kind = "page") {
|
|
2573
|
+
return `${app}.${kind}.${key}`;
|
|
2574
|
+
}
|
|
2575
|
+
/** Page (or section) rows among the list's rows, by key. Off-shape rows are left out. */
|
|
2576
|
+
function parsePageRows(rows, app, kind = "page") {
|
|
2577
|
+
const prefix = pageRowKey(app, "", kind);
|
|
2578
|
+
const out = {};
|
|
2579
|
+
for (const r of rows) {
|
|
2580
|
+
if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
|
|
2581
|
+
continue;
|
|
2582
|
+
const row = parsePageRow(r);
|
|
2583
|
+
if (row)
|
|
2584
|
+
out[r.guide_key.slice(prefix.length)] = row;
|
|
2585
|
+
}
|
|
2586
|
+
return out;
|
|
2587
|
+
}
|
|
2588
|
+
/** One page row (a list entry, a PUT answer, a 409's `current`); null when off-shape. */
|
|
2589
|
+
function parsePageRow(raw) {
|
|
2590
|
+
if (!isRecord(raw))
|
|
2591
|
+
return null;
|
|
2592
|
+
const version = Number(raw.guide_version);
|
|
2593
|
+
const rev = Number(raw.rev);
|
|
2594
|
+
if (!Number.isInteger(version) || version < 1 || !Number.isInteger(rev) || rev < 0)
|
|
2595
|
+
return null;
|
|
2596
|
+
const status = ROW_STATUSES.includes(raw.status) ? raw.status : "in_progress";
|
|
2597
|
+
return { version, status, rev };
|
|
2598
|
+
}
|
|
2599
|
+
/**
|
|
2600
|
+
* A page row's PUT body: the least the server takes. Written once per page
|
|
2601
|
+
* and version (finished: completed; not now, exited or left: skipped).
|
|
2602
|
+
*/
|
|
2603
|
+
function pageBody(app, version, rev, status) {
|
|
2604
|
+
return {
|
|
2605
|
+
app,
|
|
2606
|
+
guide_version: version,
|
|
2607
|
+
rev,
|
|
2608
|
+
status,
|
|
2609
|
+
mission_key: null,
|
|
2610
|
+
step_key: null,
|
|
2611
|
+
step_index: 0,
|
|
2612
|
+
checkpoint_key: null,
|
|
2613
|
+
missions: {},
|
|
2614
|
+
context: {},
|
|
2615
|
+
welcome: "started",
|
|
2616
|
+
chip_hidden: false,
|
|
2617
|
+
last_error: null,
|
|
2618
|
+
skipped_step: null,
|
|
2619
|
+
restart: false,
|
|
2620
|
+
};
|
|
1764
2621
|
}
|
|
1765
2622
|
const TERMINAL = ["completed", "skipped", "aborted"];
|
|
1766
2623
|
/** The PUT body for the client's state. Drops keys the server would refuse. */
|
|
@@ -1966,6 +2823,48 @@ async function storeGuides(adapter, suite, progress) {
|
|
|
1966
2823
|
}
|
|
1967
2824
|
return null;
|
|
1968
2825
|
}
|
|
2826
|
+
/**
|
|
2827
|
+
* Writes a page guide's row (`<app>.page.<key>`), or a section guide's
|
|
2828
|
+
* (`<app>.section.<key>`, kind "section"), once: the minimal body,
|
|
2829
|
+
* with the same 409 handling as the tour's row. Another tab that already
|
|
2830
|
+
* recorded this version wins (its row is returned, nothing is sent again);
|
|
2831
|
+
* an older version's row is replaced with its rev; a newer one stops. Returns
|
|
2832
|
+
* the stored row, or null when it could not be saved.
|
|
2833
|
+
*/
|
|
2834
|
+
async function storePageRow(adapter, app, key, version, status, rev, kind = "page") {
|
|
2835
|
+
const guideKey = pageRowKey(app, key, kind);
|
|
2836
|
+
let at = rev;
|
|
2837
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2838
|
+
let res;
|
|
2839
|
+
try {
|
|
2840
|
+
res = await adapter.saveProgress({ app, guideKey, body: pageBody(app, version, at, status) });
|
|
2841
|
+
}
|
|
2842
|
+
catch (err) {
|
|
2843
|
+
const conflict = conflictOf(err);
|
|
2844
|
+
if (!conflict)
|
|
2845
|
+
return null;
|
|
2846
|
+
res = conflict;
|
|
2847
|
+
}
|
|
2848
|
+
if (!res || typeof res.status !== "number")
|
|
2849
|
+
return null;
|
|
2850
|
+
const payload = res.data;
|
|
2851
|
+
if (res.status >= 200 && res.status < 300)
|
|
2852
|
+
return parsePageRow(payload?.data ?? payload);
|
|
2853
|
+
if (res.status !== 409)
|
|
2854
|
+
return null;
|
|
2855
|
+
const current = parsePageRow(payload?.current);
|
|
2856
|
+
if (!current) {
|
|
2857
|
+
at = 0;
|
|
2858
|
+
continue;
|
|
2859
|
+
}
|
|
2860
|
+
if (current.version > version)
|
|
2861
|
+
return null;
|
|
2862
|
+
if (current.version === version && current.status !== "in_progress")
|
|
2863
|
+
return current;
|
|
2864
|
+
at = current.rev;
|
|
2865
|
+
}
|
|
2866
|
+
return null;
|
|
2867
|
+
}
|
|
1969
2868
|
|
|
1970
2869
|
/**
|
|
1971
2870
|
* The tour's DOM side: finding anchors, deciding whether one is really on
|
|
@@ -2485,6 +3384,12 @@ const GUIDE_CSS = `
|
|
|
2485
3384
|
.eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
|
2486
3385
|
.eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
|
|
2487
3386
|
.eg-q-f{border-top:1px solid var(--eg-line);padding:8px 12px;display:flex;justify-content:flex-end}
|
|
3387
|
+
.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)}
|
|
3388
|
+
.eg-pg-item{padding:8px}
|
|
3389
|
+
.eg-pg-item .eg-t small.eg-new{color:var(--eg-strong);font-weight:600}
|
|
3390
|
+
.eg-card.eg-offer{left:auto;right:22px;transform:none;bottom:var(--erp-guide-bottom-offset,22px);width:min(320px,calc(100vw - 32px))}
|
|
3391
|
+
.eg-offer .eg-po-e{margin:0 0 2px;font-size:12px;font-weight:600;color:var(--eg-strong)}
|
|
3392
|
+
.eg-offer .eg-row .eg-btn{height:34px;padding:0 14px;font-size:13.5px}
|
|
2488
3393
|
.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}
|
|
2489
3394
|
svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
|
2490
3395
|
@media (max-width:767px){
|
|
@@ -2498,6 +3403,7 @@ svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;
|
|
|
2498
3403
|
.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)}
|
|
2499
3404
|
.eg-q-item{padding:12px 8px}
|
|
2500
3405
|
.eg-card{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) - 4px)}
|
|
3406
|
+
.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)}
|
|
2501
3407
|
.eg-toast{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) + 60px)}
|
|
2502
3408
|
}
|
|
2503
3409
|
@keyframes eg-up{from{opacity:0;transform:translate(-50%,30px)}}
|
|
@@ -2595,6 +3501,14 @@ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
|
|
|
2595
3501
|
const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
|
|
2596
3502
|
return best >= 0 ? best : ok.indexOf(true);
|
|
2597
3503
|
}
|
|
3504
|
+
/** The way out of a step, as the bubble and the cards say it. */
|
|
3505
|
+
function exitLabelOf(b) {
|
|
3506
|
+
return b.exitLabel ?? (b.replay ? "จบการทบทวน" : "ออกจากไกด์");
|
|
3507
|
+
}
|
|
3508
|
+
/** The bubble's top line. */
|
|
3509
|
+
function headingOf(b) {
|
|
3510
|
+
return b.heading ?? `ภารกิจ ${b.missionNo} · ${b.missionTitle}${b.replay ? " (ทบทวน)" : ""}`;
|
|
3511
|
+
}
|
|
2598
3512
|
const BUBBLE_TITLE_ID = "eg-bubble-title";
|
|
2599
3513
|
const BUBBLE_WHY_ID = "eg-bubble-why";
|
|
2600
3514
|
function useNudge(nudge, ref) {
|
|
@@ -2632,7 +3546,7 @@ function StepLayer(p) {
|
|
|
2632
3546
|
const showBubble = p.phase === "ready" && (!!spot || !p.hasTarget);
|
|
2633
3547
|
const gap = p.showHand ? 34 : 14;
|
|
2634
3548
|
const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
|
|
2635
|
-
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}`;
|
|
3549
|
+
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}|${!!p.bubble.showBack}`;
|
|
2636
3550
|
// What the bubble sits beside: the target, with an open list over it (the
|
|
2637
3551
|
// list is what the person is choosing from now). What it must not cover:
|
|
2638
3552
|
// open lists, and the other anchors the step allows (a map step's
|
|
@@ -2749,13 +3663,13 @@ function StepLayer(p) {
|
|
|
2749
3663
|
top: pos?.top ?? 0,
|
|
2750
3664
|
width: phone ? viewport.width - 32 : undefined,
|
|
2751
3665
|
visibility: pos ? "visible" : "hidden",
|
|
2752
|
-
}, 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
|
|
3666
|
+
}, 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] }));
|
|
2753
3667
|
}
|
|
2754
3668
|
function BubbleBody(p) {
|
|
2755
3669
|
const b = p.bubble;
|
|
2756
3670
|
const title = b.error ? b.error.title : b.title;
|
|
2757
3671
|
const why = b.error ? (b.error.detail ?? "") : b.why;
|
|
2758
|
-
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.
|
|
3672
|
+
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] })] })] }));
|
|
2759
3673
|
}
|
|
2760
3674
|
/** The fingertip at ≈62% across and down the target; upside down near the bottom. */
|
|
2761
3675
|
function handPosition(spot, viewportHeight) {
|
|
@@ -2829,6 +3743,19 @@ function CompleteModal(p) {
|
|
|
2829
3743
|
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] })] })] }));
|
|
2830
3744
|
}
|
|
2831
3745
|
/* ------------------------------------------------------------------ */
|
|
3746
|
+
/* Page guides: the first-visit offer */
|
|
3747
|
+
/* ------------------------------------------------------------------ */
|
|
3748
|
+
/**
|
|
3749
|
+
* The offer of this page's guide on a first visit: a small card in the
|
|
3750
|
+
* corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
|
|
3751
|
+
*/
|
|
3752
|
+
function PageOfferCard(p) {
|
|
3753
|
+
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" })] })] }));
|
|
3754
|
+
}
|
|
3755
|
+
function PageList({ items, onOpen }) {
|
|
3756
|
+
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))) }));
|
|
3757
|
+
}
|
|
3758
|
+
/* ------------------------------------------------------------------ */
|
|
2832
3759
|
/* Quest chip and panel */
|
|
2833
3760
|
/* ------------------------------------------------------------------ */
|
|
2834
3761
|
function QuestChip({ done, total, pulse, onOpen }) {
|
|
@@ -2850,7 +3777,12 @@ function QuestChip({ done, total, pulse, onOpen }) {
|
|
|
2850
3777
|
}
|
|
2851
3778
|
function QuestPanel(p) {
|
|
2852
3779
|
const ref = useAutoFocus();
|
|
2853
|
-
|
|
3780
|
+
const pages = p.pages ?? [];
|
|
3781
|
+
const openPage = p.onOpenPage ?? (() => { });
|
|
3782
|
+
if (p.showMissions === false) {
|
|
3783
|
+
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 })] })] }));
|
|
3784
|
+
}
|
|
3785
|
+
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] })] }));
|
|
2854
3786
|
}
|
|
2855
3787
|
function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
2856
3788
|
const v = item.view;
|
|
@@ -2911,6 +3843,7 @@ class GuideErrorBoundary extends React__namespace.Component {
|
|
|
2911
3843
|
console.warn("[erp-shell guide] hidden after an error", error);
|
|
2912
3844
|
try {
|
|
2913
3845
|
publishGuideEntry(null, null);
|
|
3846
|
+
publishPageGuides(null, null, null);
|
|
2914
3847
|
}
|
|
2915
3848
|
catch {
|
|
2916
3849
|
/* ignore */
|
|
@@ -2987,6 +3920,41 @@ function writeMirror(orgId, suite, p) {
|
|
|
2987
3920
|
/* private mode: the server copy is enough */
|
|
2988
3921
|
}
|
|
2989
3922
|
}
|
|
3923
|
+
/** Page rows written in this tab, kept for a remount within the GET's cache time. */
|
|
3924
|
+
const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
|
|
3925
|
+
function readPagesMirror(orgId, suite, kind = "page") {
|
|
3926
|
+
try {
|
|
3927
|
+
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
|
|
3928
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
3929
|
+
const out = {};
|
|
3930
|
+
for (const [k, v] of Object.entries(parsed ?? {})) {
|
|
3931
|
+
if (v && Number.isInteger(v.version) && Number.isInteger(v.rev) && typeof v.status === "string")
|
|
3932
|
+
out[k] = v;
|
|
3933
|
+
}
|
|
3934
|
+
return out;
|
|
3935
|
+
}
|
|
3936
|
+
catch {
|
|
3937
|
+
return {};
|
|
3938
|
+
}
|
|
3939
|
+
}
|
|
3940
|
+
function writePagesMirror(orgId, suite, pages, kind = "page") {
|
|
3941
|
+
try {
|
|
3942
|
+
sessionStorage.setItem(pagesMirrorKey(orgId, suite, kind), JSON.stringify(pages));
|
|
3943
|
+
}
|
|
3944
|
+
catch {
|
|
3945
|
+
/* private mode */
|
|
3946
|
+
}
|
|
3947
|
+
}
|
|
3948
|
+
/** The server's page rows, with this tab's newer ones laid over. */
|
|
3949
|
+
function mergePageRows(server, local) {
|
|
3950
|
+
const out = { ...server };
|
|
3951
|
+
for (const [k, v] of Object.entries(local)) {
|
|
3952
|
+
const r = out[k];
|
|
3953
|
+
if (!r || v.version > r.version)
|
|
3954
|
+
out[k] = v;
|
|
3955
|
+
}
|
|
3956
|
+
return out;
|
|
3957
|
+
}
|
|
2990
3958
|
/* ------------------------------------------------------------------ */
|
|
2991
3959
|
/* One tab at a time */
|
|
2992
3960
|
/* ------------------------------------------------------------------ */
|
|
@@ -3074,6 +4042,12 @@ const SAVE_DEBOUNCE_MS = 600;
|
|
|
3074
4042
|
const IDLE_POLL_MS = 100;
|
|
3075
4043
|
/** A celebration earned outside the tour waits this long of nothing loading. */
|
|
3076
4044
|
const PASSIVE_IDLE_MS = 600;
|
|
4045
|
+
/** A page guide's offer waits this long of nothing loading. */
|
|
4046
|
+
const PAGE_OFFER_IDLE_MS = 600;
|
|
4047
|
+
/** How often the page guides look at the page while idle (offer due, `when`). */
|
|
4048
|
+
const PAGE_POLL_MS = 250;
|
|
4049
|
+
/** How long a page opened from the panel's list may take to arrive. */
|
|
4050
|
+
const PAGE_ARRIVE_MS = 8000;
|
|
3077
4051
|
function GuideRuntimeInner({ config, organizationId, code, routing, progress: progressAdapter, isBusy, menu, hidden, bottomOffset }) {
|
|
3078
4052
|
const suite = config.suite;
|
|
3079
4053
|
const pathname = routing.pathname || "/";
|
|
@@ -3088,9 +4062,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3088
4062
|
// No menu: never a menu step (the machine sees it as always open).
|
|
3089
4063
|
const sidebarOpen = menu ? !!menu.open : true;
|
|
3090
4064
|
const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
|
|
3091
|
-
// Progress: read once this tab runs the tour and the app is ready
|
|
4065
|
+
// Progress: read once this tab runs the tour and the app is ready (for
|
|
4066
|
+
// the missions, or for the page guides alone: config.pagesReady).
|
|
3092
4067
|
const [answer, setAnswer] = React.useState(null);
|
|
3093
|
-
const
|
|
4068
|
+
const missionsReady = config.ready !== false;
|
|
4069
|
+
const pagesReady = config.pagesReady ?? missionsReady;
|
|
4070
|
+
const hasPages = React.useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0, [suite]);
|
|
4071
|
+
const hasSections = React.useMemo(() => validSections(suite).length > 0, [suite]);
|
|
4072
|
+
const enabled = primary && (missionsReady || (hasPages && pagesReady));
|
|
3094
4073
|
const loaded = React.useRef(false);
|
|
3095
4074
|
React.useEffect(() => {
|
|
3096
4075
|
if (!enabled || loaded.current)
|
|
@@ -3112,7 +4091,11 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3112
4091
|
loaded.current = false;
|
|
3113
4092
|
};
|
|
3114
4093
|
}, [enabled, suite]);
|
|
3115
|
-
|
|
4094
|
+
// The missions run for their audience; the page guides for theirs (every
|
|
4095
|
+
// member while pages are on), each with its own ready.
|
|
4096
|
+
const missionsLive = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && missionsReady && !hidden;
|
|
4097
|
+
const pagesLive = !!answer && hasPages && answer.pageAudience !== "none" && primary && pagesReady && !hidden;
|
|
4098
|
+
const live = missionsLive || pagesLive;
|
|
3116
4099
|
/* ---------------- machine ---------------- */
|
|
3117
4100
|
const [state, setState] = React.useState(null);
|
|
3118
4101
|
const stateRef = React.useRef(null);
|
|
@@ -3120,7 +4103,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3120
4103
|
// is not sent and does not bump its rev.
|
|
3121
4104
|
const savedKey = React.useRef(null);
|
|
3122
4105
|
React.useEffect(() => {
|
|
3123
|
-
|
|
4106
|
+
const missionsOn = !!answer && answer.audience !== "none" && !answer.newerOnServer;
|
|
4107
|
+
const pagesOn = !!answer && hasPages && answer.pageAudience !== "none";
|
|
4108
|
+
if (!answer || (!missionsOn && !pagesOn)) {
|
|
3124
4109
|
stateRef.current = null;
|
|
3125
4110
|
setState(null);
|
|
3126
4111
|
return;
|
|
@@ -3134,16 +4119,26 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3134
4119
|
let progress = answer.progress;
|
|
3135
4120
|
if (mirror && furtherProgress(mirror, progress) === mirror)
|
|
3136
4121
|
progress = mergeProgress(mirror, progress);
|
|
3137
|
-
|
|
4122
|
+
// A tour newer on the server than this build's: the missions stay out.
|
|
4123
|
+
const s = initialGuideState(missionsOn ? answer.audience : "none", progress, {
|
|
4124
|
+
pageAudience: pagesOn ? answer.pageAudience : "none",
|
|
4125
|
+
pagesMode: answer.pagesMode,
|
|
4126
|
+
pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
|
|
4127
|
+
sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
|
|
4128
|
+
});
|
|
3138
4129
|
stateRef.current = s;
|
|
3139
4130
|
setState(s);
|
|
3140
|
-
}, [answer, organizationId, suite]);
|
|
4131
|
+
}, [answer, organizationId, suite, hasPages]);
|
|
3141
4132
|
// The latest page facts, read by the machine through env.
|
|
3142
4133
|
const pageRef = React.useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
|
|
3143
4134
|
pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
|
|
3144
4135
|
const targetElRef = React.useRef(null);
|
|
4136
|
+
// The section guide being opened (before its first step is on screen).
|
|
4137
|
+
const openingSection = React.useRef(null);
|
|
3145
4138
|
const env = React.useCallback(() => {
|
|
3146
4139
|
const pg = pageRef.current;
|
|
4140
|
+
const sc = stateRef.current?.screen;
|
|
4141
|
+
const sectionKey = (sc?.name === "step" ? sc.section : undefined) ?? openingSection.current;
|
|
3147
4142
|
return {
|
|
3148
4143
|
code,
|
|
3149
4144
|
pathname: pg.pathname,
|
|
@@ -3161,6 +4156,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3161
4156
|
sidebarOpen: pg.sidebarOpen,
|
|
3162
4157
|
targetValue: () => fieldValue(targetElRef.current),
|
|
3163
4158
|
dialogOpen: () => appDialogOpen(),
|
|
4159
|
+
values: getSectionGuideValues(sectionKey),
|
|
3164
4160
|
};
|
|
3165
4161
|
}, [code]);
|
|
3166
4162
|
/* ---------------- effects: save, announce, toast, navigate ---------------- */
|
|
@@ -3234,6 +4230,19 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3234
4230
|
// After this render takes the guard away, put the page back.
|
|
3235
4231
|
runAbort(e.actions);
|
|
3236
4232
|
}
|
|
4233
|
+
else if (e.type === "savePage") {
|
|
4234
|
+
// Once per page and version; the machine already holds it as written.
|
|
4235
|
+
const kind = e.kind ?? "page";
|
|
4236
|
+
const rows = stateRef.current ? (kind === "section" ? stateRef.current.sections : stateRef.current.pages) : undefined;
|
|
4237
|
+
if (rows)
|
|
4238
|
+
writePagesMirror(organizationId, suite, rows, kind);
|
|
4239
|
+
const rev = rows?.[e.key]?.rev ?? 0;
|
|
4240
|
+
void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev, kind).then(row => {
|
|
4241
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
4242
|
+
if (row)
|
|
4243
|
+
dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
|
|
4244
|
+
});
|
|
4245
|
+
}
|
|
3237
4246
|
}
|
|
3238
4247
|
catch {
|
|
3239
4248
|
/* an effect never breaks the app */
|
|
@@ -3282,16 +4291,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3282
4291
|
const screen = state?.screen ?? { name: "idle" };
|
|
3283
4292
|
const resumed = React.useRef(false);
|
|
3284
4293
|
React.useEffect(() => {
|
|
3285
|
-
if (!
|
|
4294
|
+
if (!missionsLive || !state || resumed.current)
|
|
3286
4295
|
return;
|
|
3287
4296
|
resumed.current = true;
|
|
3288
4297
|
dispatch({ type: "resume" });
|
|
3289
|
-
}, [
|
|
4298
|
+
}, [missionsLive, state, dispatch]);
|
|
3290
4299
|
const welcomeRoutes = suite.welcomeRoutes ?? ["/[code]"];
|
|
3291
4300
|
const onWelcomeRoute = welcomeRoutes.some(r => !!matchRoute(r, pathname, search, code));
|
|
3292
4301
|
const factsKey = useFactsKey(config.facts);
|
|
3293
4302
|
const welcomeCtx = state ? guideContext(env(), state.progress.context) : null;
|
|
3294
|
-
const welcomeIsDue = !!state && !!welcomeCtx &&
|
|
4303
|
+
const welcomeIsDue = !!state && !!welcomeCtx && missionsLive && welcomeDue(state, suite, welcomeCtx);
|
|
3295
4304
|
const due = welcomeIsDue && onWelcomeRoute;
|
|
3296
4305
|
// After a moment of nothing loading (the page has settled).
|
|
3297
4306
|
useAfterIdle(due, WELCOME_DELAY_MS, busy, () => dispatchRef.current({ type: "welcome" }), [factsKey]);
|
|
@@ -3299,7 +4308,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3299
4308
|
const passiveNames = React.useMemo(() => passiveEventNames(suite), [suite]);
|
|
3300
4309
|
const hasPassive = React.useMemo(() => suite.missions.some(m => !!m.completeOn), [suite]);
|
|
3301
4310
|
React.useEffect(() => {
|
|
3302
|
-
if (!
|
|
4311
|
+
if (!missionsLive || !hasPassive)
|
|
3303
4312
|
return;
|
|
3304
4313
|
const onApi = (e) => {
|
|
3305
4314
|
const d = e.detail;
|
|
@@ -3317,15 +4326,19 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3317
4326
|
window.removeEventListener(API_EVENT, onApi);
|
|
3318
4327
|
handlers.forEach(([n, h]) => window.removeEventListener(n, h));
|
|
3319
4328
|
};
|
|
3320
|
-
}, [
|
|
4329
|
+
}, [missionsLive, hasPassive, passiveNames]);
|
|
3321
4330
|
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
3322
|
-
|
|
4331
|
+
// A page guide's step runs while page guides are live; a mission's while
|
|
4332
|
+
// the missions are. The offer card is watched the same way (left: ignored).
|
|
4333
|
+
const inStep = screen.name === "step" && (screen.page || screen.section ? pagesLive : missionsLive);
|
|
4334
|
+
const offerUp = pagesLive && screen.name === "pageOffer";
|
|
4335
|
+
const watching = inStep || offerUp;
|
|
3323
4336
|
React.useEffect(() => {
|
|
3324
|
-
if (
|
|
4337
|
+
if (watching)
|
|
3325
4338
|
dispatch({ type: "sync" });
|
|
3326
|
-
}, [
|
|
4339
|
+
}, [watching, pathname, search, sidebarOpen, factsKey, dispatch]);
|
|
3327
4340
|
React.useEffect(() => {
|
|
3328
|
-
if (!
|
|
4341
|
+
if (!watching || typeof MutationObserver === "undefined")
|
|
3329
4342
|
return;
|
|
3330
4343
|
let raf = 0;
|
|
3331
4344
|
const obs = new MutationObserver(records => {
|
|
@@ -3350,7 +4363,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3350
4363
|
if (raf)
|
|
3351
4364
|
cancelAnimationFrame(raf);
|
|
3352
4365
|
};
|
|
3353
|
-
}, [
|
|
4366
|
+
}, [watching]);
|
|
3354
4367
|
/* ---------------- the current step ---------------- */
|
|
3355
4368
|
const stepInfo = React.useMemo(() => {
|
|
3356
4369
|
if (!state || screen.name !== "step")
|
|
@@ -3365,6 +4378,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3365
4378
|
const step = shownStep(raw, screen.nothingToSave);
|
|
3366
4379
|
return { mission, step, raw };
|
|
3367
4380
|
}, [state, screen, suite]);
|
|
4381
|
+
const valuesVersion = useSectionValuesVersion();
|
|
3368
4382
|
const stepCtx = React.useMemo(() => {
|
|
3369
4383
|
if (!state)
|
|
3370
4384
|
return null;
|
|
@@ -3373,7 +4387,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3373
4387
|
return guideContext(e, state.progress.context, params);
|
|
3374
4388
|
// pathname/search/factsKey make it current.
|
|
3375
4389
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3376
|
-
}, [state, stepInfo, env, code, pathname, search, factsKey]);
|
|
4390
|
+
}, [state, stepInfo, env, code, pathname, search, factsKey, valuesVersion]);
|
|
3377
4391
|
const target = stepInfo && stepCtx && screen.name === "step"
|
|
3378
4392
|
? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
|
|
3379
4393
|
: null;
|
|
@@ -3586,7 +4600,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3586
4600
|
};
|
|
3587
4601
|
}, [inStep, stepInfo]);
|
|
3588
4602
|
/* ---------------- the guard ---------------- */
|
|
3589
|
-
const modalScreen =
|
|
4603
|
+
const modalScreen = missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
|
|
3590
4604
|
const panelOpen = live && !!state?.panelOpen;
|
|
3591
4605
|
const guarding = inStep || modalScreen;
|
|
3592
4606
|
const guardingRef = React.useRef(guarding);
|
|
@@ -3863,7 +4877,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3863
4877
|
const total = shown.length;
|
|
3864
4878
|
// A celebration earned outside the tour: at the next idle moment — no
|
|
3865
4879
|
// step, card or panel up, the welcome not waiting, nothing loading.
|
|
3866
|
-
const passiveDue =
|
|
4880
|
+
const passiveDue = missionsLive &&
|
|
3867
4881
|
!!state &&
|
|
3868
4882
|
(state.pendingCelebrate?.length ?? 0) > 0 &&
|
|
3869
4883
|
screen.name === "idle" &&
|
|
@@ -3871,13 +4885,173 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3871
4885
|
!due;
|
|
3872
4886
|
useAfterIdle(passiveDue, PASSIVE_IDLE_MS, busy, () => dispatchRef.current({ type: "idleCelebrate" }), [state?.pendingCelebrate?.join(",")]);
|
|
3873
4887
|
React.useEffect(() => {
|
|
3874
|
-
if (!
|
|
4888
|
+
if (!missionsLive || !state) {
|
|
3875
4889
|
publishGuideEntry(null, null);
|
|
3876
4890
|
return;
|
|
3877
4891
|
}
|
|
3878
4892
|
publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
|
|
3879
|
-
}, [
|
|
4893
|
+
}, [missionsLive, state, done, total]);
|
|
3880
4894
|
React.useEffect(() => () => publishGuideEntry(null, null), []);
|
|
4895
|
+
/* ---------------- page guides: this page's, the offer, the list ---------------- */
|
|
4896
|
+
// `when` anchors (LIFF's sub-screens) come and go without a route change:
|
|
4897
|
+
// look again when the page changes.
|
|
4898
|
+
const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
|
|
4899
|
+
const [domTick, setDomTick] = React.useState(0);
|
|
4900
|
+
React.useEffect(() => {
|
|
4901
|
+
if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
|
|
4902
|
+
return;
|
|
4903
|
+
let t = null;
|
|
4904
|
+
const obs = new MutationObserver(records => {
|
|
4905
|
+
if (records.every(r => inGuideLayer(r.target)))
|
|
4906
|
+
return;
|
|
4907
|
+
if (t)
|
|
4908
|
+
return;
|
|
4909
|
+
t = setTimeout(() => {
|
|
4910
|
+
t = null;
|
|
4911
|
+
setDomTick(n => n + 1);
|
|
4912
|
+
}, PAGE_POLL_MS);
|
|
4913
|
+
});
|
|
4914
|
+
obs.observe(document.body, {
|
|
4915
|
+
childList: true,
|
|
4916
|
+
subtree: true,
|
|
4917
|
+
attributes: true,
|
|
4918
|
+
attributeFilter: ["data-state", "hidden", "aria-hidden", "class", "style", "open"],
|
|
4919
|
+
});
|
|
4920
|
+
return () => {
|
|
4921
|
+
obs.disconnect();
|
|
4922
|
+
if (t)
|
|
4923
|
+
clearTimeout(t);
|
|
4924
|
+
};
|
|
4925
|
+
}, [pagesLive, hasWhen]);
|
|
4926
|
+
const hasState = !!state;
|
|
4927
|
+
const pageNow = React.useMemo(() => (pagesLive && hasState ? pageHere(suite, env()) : null),
|
|
4928
|
+
// pathname/search/factsKey/domTick make it current.
|
|
4929
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4930
|
+
[pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
|
|
4931
|
+
const listed = React.useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
|
|
4932
|
+
const sectionsNow = React.useMemo(() => pagesLive && hasState && hasSections
|
|
4933
|
+
? sectionsAvailable(suite, env()).map(k => ({ key: k, title: findSection(suite, k)?.title ?? "" }))
|
|
4934
|
+
: [],
|
|
4935
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4936
|
+
[pagesLive, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
|
|
4937
|
+
const sectionsSig = sectionsNow.map(x => x.key).join(",");
|
|
4938
|
+
const openSection = React.useCallback((key) => {
|
|
4939
|
+
openingSection.current = key;
|
|
4940
|
+
try {
|
|
4941
|
+
dispatchRef.current({ type: "startSection", section: key });
|
|
4942
|
+
}
|
|
4943
|
+
finally {
|
|
4944
|
+
openingSection.current = null;
|
|
4945
|
+
}
|
|
4946
|
+
}, []);
|
|
4947
|
+
const openPageHere = React.useCallback(() => {
|
|
4948
|
+
const p = pageHere(suite, env());
|
|
4949
|
+
if (p)
|
|
4950
|
+
dispatchRef.current({ type: "startPage", page: p.key });
|
|
4951
|
+
}, [suite, env]);
|
|
4952
|
+
React.useEffect(() => {
|
|
4953
|
+
if (!pagesLive || !hasState) {
|
|
4954
|
+
publishPageGuides(null, null, null);
|
|
4955
|
+
return;
|
|
4956
|
+
}
|
|
4957
|
+
publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length, sections: sectionsNow }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection);
|
|
4958
|
+
// sectionsSig stands for sectionsNow.
|
|
4959
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4960
|
+
}, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection]);
|
|
4961
|
+
React.useEffect(() => () => publishPageGuides(null, null, null), []);
|
|
4962
|
+
// The first-visit offer: in "offer" mode, nothing else up, the welcome
|
|
4963
|
+
// not due here, and a moment of nothing loading on this page.
|
|
4964
|
+
const offerWatch = pagesLive && !!state && state.pagesMode === "offer" && screen.name === "idle" && !state.panelOpen && !due;
|
|
4965
|
+
React.useEffect(() => {
|
|
4966
|
+
if (!offerWatch)
|
|
4967
|
+
return;
|
|
4968
|
+
let idleSince = null;
|
|
4969
|
+
const iv = setInterval(() => {
|
|
4970
|
+
const cur = stateRef.current;
|
|
4971
|
+
if (!cur)
|
|
4972
|
+
return;
|
|
4973
|
+
let b = false;
|
|
4974
|
+
try {
|
|
4975
|
+
b = busyRef.current();
|
|
4976
|
+
}
|
|
4977
|
+
catch {
|
|
4978
|
+
b = false;
|
|
4979
|
+
}
|
|
4980
|
+
if (b || document.visibilityState === "hidden") {
|
|
4981
|
+
idleSince = null;
|
|
4982
|
+
return;
|
|
4983
|
+
}
|
|
4984
|
+
idleSince ?? (idleSince = Date.now());
|
|
4985
|
+
if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
|
|
4986
|
+
return;
|
|
4987
|
+
let key = null;
|
|
4988
|
+
try {
|
|
4989
|
+
key = pageOfferDue(cur, suite, env());
|
|
4990
|
+
}
|
|
4991
|
+
catch {
|
|
4992
|
+
key = null;
|
|
4993
|
+
}
|
|
4994
|
+
if (key)
|
|
4995
|
+
dispatchRef.current({ type: "pageOffer", page: key });
|
|
4996
|
+
}, PAGE_POLL_MS);
|
|
4997
|
+
return () => clearInterval(iv);
|
|
4998
|
+
}, [offerWatch, suite, env, pathname, search]);
|
|
4999
|
+
// A page in the panel's list: start it here, or go there and start it on arrival.
|
|
5000
|
+
const [pending, setPending] = React.useState(null);
|
|
5001
|
+
const openListedPage = React.useCallback((key) => {
|
|
5002
|
+
const page = findPage(suite, key);
|
|
5003
|
+
if (!page)
|
|
5004
|
+
return;
|
|
5005
|
+
const e = env();
|
|
5006
|
+
if (pageHere(suite, e)?.key === key) {
|
|
5007
|
+
dispatchRef.current({ type: "startPage", page: key });
|
|
5008
|
+
return;
|
|
5009
|
+
}
|
|
5010
|
+
dispatchRef.current({ type: "closePanel" });
|
|
5011
|
+
const raw = resolveText(page.href, guideContext(e, {}));
|
|
5012
|
+
const href = raw ? (fillRoute(raw, { code }) ?? raw) : "";
|
|
5013
|
+
if (!href)
|
|
5014
|
+
return;
|
|
5015
|
+
setPending({ key, until: Date.now() + PAGE_ARRIVE_MS });
|
|
5016
|
+
try {
|
|
5017
|
+
if (/^https?:\/\//i.test(href))
|
|
5018
|
+
window.location.assign(href);
|
|
5019
|
+
else
|
|
5020
|
+
navigateRef.current(href);
|
|
5021
|
+
}
|
|
5022
|
+
catch {
|
|
5023
|
+
/* never breaks the app */
|
|
5024
|
+
}
|
|
5025
|
+
}, [suite, env, code]);
|
|
5026
|
+
React.useEffect(() => {
|
|
5027
|
+
if (!pending || !pagesLive)
|
|
5028
|
+
return;
|
|
5029
|
+
const iv = setInterval(() => {
|
|
5030
|
+
if (Date.now() > pending.until) {
|
|
5031
|
+
setPending(null);
|
|
5032
|
+
return;
|
|
5033
|
+
}
|
|
5034
|
+
const cur = stateRef.current;
|
|
5035
|
+
if (!cur || (cur.screen.name !== "idle" && cur.screen.name !== "pageOffer"))
|
|
5036
|
+
return;
|
|
5037
|
+
let b = false;
|
|
5038
|
+
try {
|
|
5039
|
+
b = busyRef.current();
|
|
5040
|
+
}
|
|
5041
|
+
catch {
|
|
5042
|
+
b = false;
|
|
5043
|
+
}
|
|
5044
|
+
if (b)
|
|
5045
|
+
return;
|
|
5046
|
+
const e = env();
|
|
5047
|
+
const page = findPage(suite, pending.key);
|
|
5048
|
+
if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
|
|
5049
|
+
return;
|
|
5050
|
+
setPending(null);
|
|
5051
|
+
dispatchRef.current({ type: "startPage", page: pending.key });
|
|
5052
|
+
}, PAGE_POLL_MS);
|
|
5053
|
+
return () => clearInterval(iv);
|
|
5054
|
+
}, [pending, pagesLive, suite, env, code]);
|
|
3881
5055
|
if (!live || !state || !layer || !ctxIdle)
|
|
3882
5056
|
return null;
|
|
3883
5057
|
/* ---------------- render ---------------- */
|
|
@@ -3886,31 +5060,53 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3886
5060
|
const gateHref = gate ? resolveText(gate.action.href, ctxIdle) : "";
|
|
3887
5061
|
const gateFailing = gate ? gateState(suite, ctxIdle) === false : false;
|
|
3888
5062
|
let content = null;
|
|
3889
|
-
if (screen.name === "welcome") {
|
|
5063
|
+
if (!missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete")) {
|
|
5064
|
+
content = null;
|
|
5065
|
+
}
|
|
5066
|
+
else if (screen.name === "welcome") {
|
|
3890
5067
|
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" }) }));
|
|
3891
5068
|
}
|
|
3892
5069
|
else if (screen.name === "gate" && gate) {
|
|
3893
5070
|
content = (jsxRuntime.jsx(GateModal, { title: gate.title, subtitle: gate.subtitle, actionLabel: gate.action.label, href: gateHref, onLater: () => dispatch({ type: "gateLater" }) }));
|
|
3894
5071
|
}
|
|
3895
|
-
else if (screen.name === "step" && stepInfo && stepCtx) {
|
|
5072
|
+
else if (screen.name === "step" && inStep && stepInfo && stepCtx) {
|
|
3896
5073
|
const { mission, step } = stepInfo;
|
|
3897
5074
|
const missionNo = missionNumber(suite, mission.key, ctxIdle);
|
|
3898
5075
|
const kind = stepKind(step);
|
|
3899
5076
|
const bridge = screen.bridge;
|
|
5077
|
+
const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
|
|
5078
|
+
const page = screen.page ? findPage(suite, screen.page) : undefined;
|
|
5079
|
+
const section = screen.section ? findSection(suite, screen.section) : undefined;
|
|
5080
|
+
const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
|
|
5081
|
+
const pageBits = read
|
|
5082
|
+
? {
|
|
5083
|
+
...read,
|
|
5084
|
+
showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
|
|
5085
|
+
nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
|
|
5086
|
+
}
|
|
5087
|
+
: {};
|
|
3900
5088
|
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: {
|
|
3901
5089
|
missionNo,
|
|
3902
5090
|
missionTitle: mission.title,
|
|
3903
5091
|
replay: screen.replay,
|
|
3904
5092
|
// Steps this person skips (requires / skipIf) are not counted.
|
|
3905
|
-
index:
|
|
3906
|
-
total:
|
|
5093
|
+
index: pos.no - 1,
|
|
5094
|
+
total: pos.total,
|
|
3907
5095
|
title: bridge ? "กดปุ่มเมนู" : resolveText(step.title, stepCtx),
|
|
3908
5096
|
why: bridge ? `เมนูทั้งหมดของ ${suite.appName} อยู่ในนี้` : resolveText(step.why, stepCtx),
|
|
3909
5097
|
error: screen.error,
|
|
3910
5098
|
status: bridge ? null : screen.status,
|
|
3911
5099
|
showNext: !bridge && (kind === "input" || kind === "view" || (kind === "select" && screen.replay && screen.replayNext)),
|
|
3912
5100
|
showEndWithoutSave: !bridge && screen.replay && kind === "commit",
|
|
3913
|
-
|
|
5101
|
+
...pageBits,
|
|
5102
|
+
}, 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" }) }));
|
|
5103
|
+
}
|
|
5104
|
+
else if (screen.name === "pageOffer" && pagesLive) {
|
|
5105
|
+
const page = findPage(suite, screen.page);
|
|
5106
|
+
if (page) {
|
|
5107
|
+
const mission = findMission(suite, PAGE_PREFIX + page.key);
|
|
5108
|
+
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" }) }));
|
|
5109
|
+
}
|
|
3914
5110
|
}
|
|
3915
5111
|
else if (screen.name === "celebrate" && celebrateShown) {
|
|
3916
5112
|
const mission = findMission(suite, screen.mission);
|
|
@@ -3945,8 +5141,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3945
5141
|
.join(", ")
|
|
3946
5142
|
: undefined,
|
|
3947
5143
|
}));
|
|
3948
|
-
const showChip = chipVisible(state, suite, ctxIdle);
|
|
3949
|
-
|
|
5144
|
+
const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
|
|
5145
|
+
// The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
|
|
5146
|
+
// then the page guides they have.
|
|
5147
|
+
const panelMissions = missionsLive && total > 0 && state.panelSection !== "pages";
|
|
5148
|
+
const pageItems = pagesLive
|
|
5149
|
+
? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) }))
|
|
5150
|
+
: [];
|
|
5151
|
+
const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
|
|
5152
|
+
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);
|
|
3950
5153
|
}
|
|
3951
5154
|
/** A stable key for the facts object, so effects re-run when they change. */
|
|
3952
5155
|
function useFactsKey(facts) {
|
|
@@ -3960,23 +5163,132 @@ function useFactsKey(facts) {
|
|
|
3960
5163
|
}, [facts]);
|
|
3961
5164
|
}
|
|
3962
5165
|
|
|
5166
|
+
const DEFAULT_STYLE = {
|
|
5167
|
+
display: "inline-grid",
|
|
5168
|
+
placeItems: "center",
|
|
5169
|
+
width: 22,
|
|
5170
|
+
height: 22,
|
|
5171
|
+
padding: 0,
|
|
5172
|
+
border: 0,
|
|
5173
|
+
borderRadius: 999,
|
|
5174
|
+
background: "transparent",
|
|
5175
|
+
color: "inherit",
|
|
5176
|
+
opacity: 0.6,
|
|
5177
|
+
cursor: "pointer",
|
|
5178
|
+
flex: "none",
|
|
5179
|
+
};
|
|
5180
|
+
function SectionGuideButton({ guideKey, values, className }) {
|
|
5181
|
+
useSectionGuideValues(guideKey, values);
|
|
5182
|
+
const { sections } = usePageGuides();
|
|
5183
|
+
const here = (sections ?? []).find(s => s.key === guideKey);
|
|
5184
|
+
if (!here)
|
|
5185
|
+
return null;
|
|
5186
|
+
return (jsxRuntime.jsx("button", { type: "button", className: className, style: className ? undefined : DEFAULT_STYLE, "aria-label": `อธิบายส่วนนี้ ${here.title}`, title: "\u0E2D\u0E18\u0E34\u0E1A\u0E32\u0E22\u0E2A\u0E48\u0E27\u0E19\u0E19\u0E35\u0E49", "data-guide-section-button": guideKey, onClick: e => {
|
|
5187
|
+
// A header that opens or sorts on press must not hear it.
|
|
5188
|
+
e.stopPropagation();
|
|
5189
|
+
e.preventDefault();
|
|
5190
|
+
openSectionGuide(guideKey);
|
|
5191
|
+
}, children: jsxRuntime.jsxs("svg", { viewBox: "0 0 24 24", width: "16", height: "16", "aria-hidden": "true", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "10" }), jsxRuntime.jsx("path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }), jsxRuntime.jsx("path", { d: "M12 17h.01" })] }) }));
|
|
5192
|
+
}
|
|
5193
|
+
|
|
5194
|
+
/**
|
|
5195
|
+
* Small formatters for section guides' copy (shell 0.1.75): the app passes
|
|
5196
|
+
* its own computed numbers, and the copy says them the way the page does —
|
|
5197
|
+
* "ม.ค. รับ 500,000 − จ่าย 320,000 = คงเหลือ 180,000".
|
|
5198
|
+
*
|
|
5199
|
+
* Pure: the tests run it in Node.
|
|
5200
|
+
*/
|
|
5201
|
+
const THAI_MONTHS_SHORT = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."];
|
|
5202
|
+
const grouped = new Map();
|
|
5203
|
+
function nf(decimals) {
|
|
5204
|
+
let f = grouped.get(decimals);
|
|
5205
|
+
if (!f) {
|
|
5206
|
+
f = new Intl.NumberFormat("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
5207
|
+
grouped.set(decimals, f);
|
|
5208
|
+
}
|
|
5209
|
+
return f;
|
|
5210
|
+
}
|
|
5211
|
+
/**
|
|
5212
|
+
* A baht amount with thousands separators, no currency sign: 500000 →
|
|
5213
|
+
* "500,000"; 1234.5 → "1,234.50"; -320000 → "-320,000". Whole amounts
|
|
5214
|
+
* have no decimals unless `decimals` says so; others have two. Not a
|
|
5215
|
+
* finite number → "" (a step whose value is missing is skipped anyway).
|
|
5216
|
+
*/
|
|
5217
|
+
function formatBaht(n, opts = {}) {
|
|
5218
|
+
const v = typeof n === "string" && n.trim() !== "" ? Number(n) : n;
|
|
5219
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
5220
|
+
return "";
|
|
5221
|
+
const rounded = Math.round(v * 100) / 100;
|
|
5222
|
+
const decimals = opts.decimals ?? (Number.isInteger(rounded) ? 0 : 2);
|
|
5223
|
+
const out = nf(decimals).format(rounded);
|
|
5224
|
+
// No "-0".
|
|
5225
|
+
return out === "-0" || /^-0(\.0+)?$/.test(out) ? out.slice(1) : out;
|
|
5226
|
+
}
|
|
5227
|
+
/** The month's number 1–12 from "2026-01", "2026-01-15", a Date, or 1–12; null when none. */
|
|
5228
|
+
function monthOf(m) {
|
|
5229
|
+
if (m instanceof Date)
|
|
5230
|
+
return Number.isNaN(m.getTime()) ? null : { month: m.getMonth() + 1, year: m.getFullYear() };
|
|
5231
|
+
if (typeof m === "number")
|
|
5232
|
+
return Number.isInteger(m) && m >= 1 && m <= 12 ? { month: m } : null;
|
|
5233
|
+
if (typeof m === "string") {
|
|
5234
|
+
const iso = /^(\d{4})-(\d{1,2})(?:-\d{1,2})?/.exec(m.trim());
|
|
5235
|
+
if (iso) {
|
|
5236
|
+
const month = Number(iso[2]);
|
|
5237
|
+
return month >= 1 && month <= 12 ? { month, year: Number(iso[1]) } : null;
|
|
5238
|
+
}
|
|
5239
|
+
}
|
|
5240
|
+
return null;
|
|
5241
|
+
}
|
|
5242
|
+
/** "2026-01" (or a Date, or 1) → "ม.ค."; "" when it is not a month. */
|
|
5243
|
+
function thaiMonth(m) {
|
|
5244
|
+
const x = monthOf(m);
|
|
5245
|
+
return x ? THAI_MONTHS_SHORT[x.month - 1] : "";
|
|
5246
|
+
}
|
|
5247
|
+
/**
|
|
5248
|
+
* "2026-01" → "ม.ค. 69" (the Buddhist year's last two digits), or with
|
|
5249
|
+
* `{ year: "full" }` "ม.ค. 2569"; just the month when no year is known.
|
|
5250
|
+
*/
|
|
5251
|
+
function thaiMonthYear(m, opts = {}) {
|
|
5252
|
+
const x = monthOf(m);
|
|
5253
|
+
if (!x)
|
|
5254
|
+
return "";
|
|
5255
|
+
const name = THAI_MONTHS_SHORT[x.month - 1];
|
|
5256
|
+
if (x.year === undefined)
|
|
5257
|
+
return name;
|
|
5258
|
+
const be = x.year + 543;
|
|
5259
|
+
return `${name} ${opts.year === "full" ? be : String(be).slice(-2)}`;
|
|
5260
|
+
}
|
|
5261
|
+
|
|
3963
5262
|
exports.API_EVENT = API_EVENT;
|
|
3964
5263
|
exports.GUIDE_EVENT_PREFIX = GUIDE_EVENT_PREFIX;
|
|
3965
5264
|
exports.GUIDE_LAYER_ATTRIBUTE = GUIDE_LAYER_ATTRIBUTE;
|
|
3966
5265
|
exports.GuideErrorBoundary = GuideErrorBoundary;
|
|
3967
5266
|
exports.GuideProgressConflict = GuideProgressConflict;
|
|
3968
5267
|
exports.GuideRuntime = GuideRuntime;
|
|
5268
|
+
exports.SectionGuideButton = SectionGuideButton;
|
|
5269
|
+
exports.THAI_MONTHS_SHORT = THAI_MONTHS_SHORT;
|
|
3969
5270
|
exports.apiCallDetail = apiCallDetail;
|
|
3970
5271
|
exports.emitGuideEvent = emitGuideEvent;
|
|
3971
5272
|
exports.fillRoute = fillRoute;
|
|
5273
|
+
exports.formatBaht = formatBaht;
|
|
3972
5274
|
exports.guideAnchor = guideAnchor;
|
|
5275
|
+
exports.guideMenuItems = guideMenuItems;
|
|
3973
5276
|
exports.guideMenuShown = guideMenuShown;
|
|
3974
5277
|
exports.loadGuides = loadGuides;
|
|
3975
5278
|
exports.matchApiPath = matchApiPath;
|
|
3976
5279
|
exports.matchRoute = matchRoute;
|
|
3977
5280
|
exports.openGuidePanel = openGuidePanel;
|
|
5281
|
+
exports.openPageGuide = openPageGuide;
|
|
5282
|
+
exports.openPagesPanel = openPagesPanel;
|
|
5283
|
+
exports.openSectionGuide = openSectionGuide;
|
|
3978
5284
|
exports.reportApiCall = reportApiCall;
|
|
3979
5285
|
exports.reportApiEvent = reportApiEvent;
|
|
5286
|
+
exports.setSectionGuideValues = setSectionGuideValues;
|
|
3980
5287
|
exports.storeGuides = storeGuides;
|
|
5288
|
+
exports.thaiMonth = thaiMonth;
|
|
5289
|
+
exports.thaiMonthYear = thaiMonthYear;
|
|
3981
5290
|
exports.useGuideEntry = useGuideEntry;
|
|
3982
|
-
|
|
5291
|
+
exports.usePageGuideEntry = usePageGuideEntry;
|
|
5292
|
+
exports.usePageGuides = usePageGuides;
|
|
5293
|
+
exports.useSectionGuideValues = useSectionGuideValues;
|
|
5294
|
+
//# sourceMappingURL=format-CVazK0hP.cjs.map
|