@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.
@@ -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 offers "ไกด์เริ่มต้น": a tour whose missions are all
362
- * hidden for this user (`visible`, e.g. Plannex without an employee record)
363
- * has nothing to open, and "0/0" led to an empty panel. The entry itself
364
- * stays published: apps wait on it before they load the facts that make
365
- * missions visible.
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 !!e && e.total > 0;
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
- return key ? suite.missions.find(m => m.key === key) : undefined;
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
- state.screen.name === "idle" &&
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
- const away = !!where && !matchRoute(where, env.pathname, env.search, env.code);
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
- d.state = { ...d.state, panelOpen: false, justDone: null };
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
- exitMission(d);
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
- if (state.audience !== "none" && s.name === "idle") {
1574
- d.state = { ...d.state, panelOpen: true };
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.replay ? "จบการทบทวน" : "ออกจากไกด์" }) })] })) : 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: p.bubble.replay ? "จบการทบทวน" : "ออกจากไกด์" }), 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.jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", p.bubble.missionNo, " \u00B7 ", p.bubble.missionTitle, p.bubble.replay ? " (ทบทวน)" : ""] }), 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: p.bubble.replay ? "จบการทบทวน" : "ออกจากไกด์" }), 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] }));
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.jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), 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: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxRuntime.jsxs("span", { style: { display: "flex", gap: 6 }, children: [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: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
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
- 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))) }), 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] })] }));
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 enabled = primary && config.ready !== false;
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
- const live = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && config.ready !== false && !hidden;
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
- if (!answer || answer.audience === "none" || answer.newerOnServer) {
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
- const s = initialGuideState(answer.audience, progress);
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 (!live || !state || resumed.current)
4294
+ if (!missionsLive || !state || resumed.current)
3286
4295
  return;
3287
4296
  resumed.current = true;
3288
4297
  dispatch({ type: "resume" });
3289
- }, [live, state, dispatch]);
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 && live && welcomeDue(state, suite, 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 (!live || !hasPassive)
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
- }, [live, hasPassive, passiveNames]);
4329
+ }, [missionsLive, hasPassive, passiveNames]);
3321
4330
  /* ---------------- sync on route, sidebar, facts, DOM ---------------- */
3322
- const inStep = live && screen.name === "step";
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 (inStep)
4337
+ if (watching)
3325
4338
  dispatch({ type: "sync" });
3326
- }, [inStep, pathname, search, sidebarOpen, factsKey, dispatch]);
4339
+ }, [watching, pathname, search, sidebarOpen, factsKey, dispatch]);
3327
4340
  React.useEffect(() => {
3328
- if (!inStep || typeof MutationObserver === "undefined")
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
- }, [inStep]);
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 = live && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
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 = live &&
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 (!live || !state) {
4888
+ if (!missionsLive || !state) {
3875
4889
  publishGuideEntry(null, null);
3876
4890
  return;
3877
4891
  }
3878
4892
  publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
3879
- }, [live, state, done, total]);
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: stepPosition(mission, screen.index, env(), state.progress.context).no - 1,
3906
- total: stepPosition(mission, screen.index, env(), state.progress.context).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
- }, 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" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
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
- 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, state.panelOpen && screen.name === "idle" ? (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" }) })) : null, toast ? jsxRuntime.jsx(GuideToast, { text: toast }) : null] }), layer);
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
- //# sourceMappingURL=guide-runtime-CLVSNkTN.cjs.map
5291
+ exports.usePageGuideEntry = usePageGuideEntry;
5292
+ exports.usePageGuides = usePageGuides;
5293
+ exports.useSectionGuideValues = useSectionGuideValues;
5294
+ //# sourceMappingURL=format-CVazK0hP.cjs.map