@eintrek/erp-shell 0.1.74 → 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,18 +357,27 @@ function publishGuideEntry(next, openPanel) {
357
357
  emit();
358
358
  }
359
359
  }
360
- const NO_PAGES = { page: null, listed: 0 };
360
+ const NO_PAGES = { page: null, listed: 0, sections: [] };
361
361
  let pages = NO_PAGES;
362
362
  let openPageImpl = null;
363
363
  let openPagesImpl = null;
364
- /** Called by the host: this page's guide and the listed count, and how to open them. */
365
- function publishPageGuides(next, openPage, openPages) {
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) {
366
368
  const n = next ?? NO_PAGES;
367
369
  openPageImpl = openPage;
368
370
  openPagesImpl = openPages;
369
- const same = n.listed === pages.listed && (n.page?.title ?? null) === (pages.page?.title ?? null);
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);
370
375
  if (!same) {
371
- pages = n.page || n.listed ? { page: n.page ? { title: n.page.title } : null, listed: n.listed } : NO_PAGES;
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;
372
381
  emit();
373
382
  }
374
383
  }
@@ -376,6 +385,8 @@ function guideMenuItems(e, p = NO_PAGES) {
376
385
  const items = [];
377
386
  if (p.page)
378
387
  items.push("page");
388
+ if ((p.sections ?? []).length > 0)
389
+ items.push("sections");
379
390
  if (e && e.total > 0)
380
391
  items.push("missions");
381
392
  if (p.listed > 0)
@@ -425,6 +436,79 @@ function usePageGuideEntry() {
425
436
  const title = p.page?.title ?? null;
426
437
  return React.useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
427
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
+ }
428
512
 
429
513
  /** The synthetic mission key of a page guide: `page:<key>`. */
430
514
  const PAGE_PREFIX = "page:";
@@ -603,6 +687,140 @@ function currentPage(suite, where, ctx) {
603
687
  function listedPages(suite, ctx) {
604
688
  return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
605
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
+ }
606
824
 
607
825
  /* ------------------------------------------------------------------ */
608
826
  /* Copy */
@@ -617,6 +835,7 @@ const COPY = {
617
835
  hiddenToast: "ซ่อนแล้ว",
618
836
  aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
619
837
  pageDone: "จบคู่มือหน้านี้",
838
+ sectionDone: "จบคำอธิบายส่วนนี้",
620
839
  bridgeTitle: "กดปุ่มเมนู",
621
840
  bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
622
841
  };
@@ -629,8 +848,10 @@ function initialGuideState(audience, progress, pages) {
629
848
  pageAudience: pages?.pageAudience ?? "none",
630
849
  pagesMode: pages?.pagesMode ?? "off",
631
850
  pages: pages?.pages ?? {},
851
+ sections: pages?.sections ?? {},
632
852
  pageRun: null,
633
853
  panelSection: "all",
854
+ autoPassed: null,
634
855
  audience,
635
856
  progress,
636
857
  screen: { name: "idle" },
@@ -650,6 +871,10 @@ function findMission(suite, key) {
650
871
  const page = findPage(suite, pageKeyOf(key));
651
872
  return page ? pageMission(page) : undefined;
652
873
  }
874
+ if (isSectionMissionKey(key)) {
875
+ const section = findSection(suite, sectionKeyOf(key));
876
+ return section ? sectionMission(section) : undefined;
877
+ }
653
878
  return suite.missions.find(m => m.key === key);
654
879
  }
655
880
  /**
@@ -687,6 +912,7 @@ function guideContext(env, data, params = {}) {
687
912
  facts: env.facts,
688
913
  can: env.can,
689
914
  visible: (anchor) => env.isVisible(anchor),
915
+ values: env.values ?? {},
690
916
  };
691
917
  }
692
918
  function resolveText(t, ctx) {
@@ -1000,7 +1226,7 @@ function announceStep(d, suite, env) {
1000
1226
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1001
1227
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
1002
1228
  const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
1003
- 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));
1004
1230
  d.effects.push({
1005
1231
  type: "announce",
1006
1232
  text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
@@ -1011,7 +1237,7 @@ function announceStep(d, suite, env) {
1011
1237
  * whose `requires` they lack or whose `skipIf` holds is left out (the step on
1012
1238
  * screen always counts).
1013
1239
  */
1014
- function stepPosition(mission, index, env, context) {
1240
+ function stepPosition(mission, index, env, context, autoPassed = []) {
1015
1241
  let no = 0;
1016
1242
  let total = 0;
1017
1243
  mission.steps.forEach((step, i) => {
@@ -1021,6 +1247,12 @@ function stepPosition(mission, index, env, context) {
1021
1247
  const ctx = guideContext(env, context, routeParamsFor(step, env));
1022
1248
  if (step.skipIf && safe(() => step.skipIf(ctx), false))
1023
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;
1024
1256
  }
1025
1257
  total++;
1026
1258
  if (i <= index)
@@ -1028,6 +1260,17 @@ function stepPosition(mission, index, env, context) {
1028
1260
  });
1029
1261
  return { no: Math.max(no, 1), total: Math.max(total, 1) };
1030
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
+ }
1031
1274
  /** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
1032
1275
  function plainTitle(t) {
1033
1276
  return t.replace(/\[([^\]]+)\]/g, "$1");
@@ -1040,6 +1283,8 @@ function enterStep(d, suite, env, mission, index, opts) {
1040
1283
  const steps = mission.steps;
1041
1284
  let i = Math.max(0, index);
1042
1285
  let firstPass = true;
1286
+ if (d.state.autoPassed?.mission !== mission.key)
1287
+ d.state = { ...d.state, autoPassed: { mission: mission.key, steps: [] } };
1043
1288
  while (i < steps.length) {
1044
1289
  const step = steps[i];
1045
1290
  const params = routeParamsFor(step, env);
@@ -1061,6 +1306,9 @@ function enterStep(d, suite, env, mission, index, opts) {
1061
1306
  const held = stateTriggerHolds(step, env);
1062
1307
  if (held) {
1063
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] } };
1064
1312
  i++;
1065
1313
  continue;
1066
1314
  }
@@ -1074,9 +1322,10 @@ function enterStep(d, suite, env, mission, index, opts) {
1074
1322
  const step = steps[i];
1075
1323
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1076
1324
  const page = pageKeyOf(mission.key);
1325
+ const section = sectionKeyOf(mission.key);
1077
1326
  const where = placeRoute(steps, i);
1078
- // A page guide is on its page: leaving it ends the guide (never "ทำต่อ").
1079
- const away = !page && !!where && !matchRoute(where, env.pathname, env.search, env.code);
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);
1080
1329
  const bridge = !away && needsBridge(step, env, ctx);
1081
1330
  const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
1082
1331
  setScreen(d, {
@@ -1094,6 +1343,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1094
1343
  status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
1095
1344
  replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
1096
1345
  ...(page ? { page } : {}),
1346
+ ...(section ? { section } : {}),
1097
1347
  });
1098
1348
  if (!opts.replay) {
1099
1349
  const cp = checkpointAtOrBefore(steps, i);
@@ -1258,9 +1508,9 @@ function advance(d, suite, env, capture) {
1258
1508
  enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
1259
1509
  }
1260
1510
  function finishMission(d, suite, env, mission, replay) {
1261
- if (isPageMissionKey(mission.key)) {
1511
+ if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
1262
1512
  endPage(d, suite, "completed");
1263
- d.effects.push({ type: "toast", text: COPY.pageDone });
1513
+ d.effects.push({ type: "toast", text: isSectionMissionKey(mission.key) ? COPY.sectionDone : COPY.pageDone });
1264
1514
  return;
1265
1515
  }
1266
1516
  if (replay) {
@@ -1364,7 +1614,8 @@ function startMission(d, suite, env, key, replay) {
1364
1614
  // A mission already done is only ever replayed; its done status stays.
1365
1615
  const isReplay = replay || entry?.status === "done";
1366
1616
  const before = d.state.progress;
1367
- 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 };
1368
1619
  if (d.state.progress.welcome === "not_seen")
1369
1620
  withProgress(d, { welcome: "started" });
1370
1621
  if (!isReplay && d.state.progress.mission_key !== key)
@@ -1523,14 +1774,72 @@ function recordPage(d, suite, key, status) {
1523
1774
  d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
1524
1775
  d.effects.push({ type: "savePage", key, version: page.version, status });
1525
1776
  }
1526
- /** The page guide ends: its row written when it is due one; back to idle. */
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. */
1527
1809
  function endPage(d, suite, how) {
1528
1810
  const run = d.state.pageRun;
1529
- if (run?.write)
1530
- recordPage(d, suite, run.key, how);
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
+ }
1531
1817
  d.state = { ...d.state, pageRun: null, replayPage: null };
1532
1818
  setScreen(d, { name: "idle" });
1533
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
+ }
1534
1843
  function startPage(d, suite, env, key, write) {
1535
1844
  const page = findPage(suite, key);
1536
1845
  if (!page)
@@ -1540,7 +1849,8 @@ function startPage(d, suite, env, key, write) {
1540
1849
  ...d.state,
1541
1850
  panelOpen: false,
1542
1851
  justDone: null,
1543
- pageRun: { key, write },
1852
+ pageRun: { key, write, kind: "page" },
1853
+ autoPassed: null,
1544
1854
  // What is open now is the person's: ending the guide leaves it alone.
1545
1855
  replayPage: {
1546
1856
  mission: mission.key,
@@ -1640,7 +1950,7 @@ function reduceGuide(state, action, suite, env) {
1640
1950
  break;
1641
1951
  // A mission always wins over a page guide: a running one ends (its row
1642
1952
  // written if due), an offer goes unrecorded.
1643
- if (s.name === "step" && s.page)
1953
+ if (readOf(s))
1644
1954
  endPage(d, suite, "skipped");
1645
1955
  else if (s.name === "pageOffer")
1646
1956
  setScreen(d, { name: "idle" });
@@ -1661,6 +1971,7 @@ function reduceGuide(state, action, suite, env) {
1661
1971
  const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
1662
1972
  const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
1663
1973
  const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
1974
+ d.state = { ...d.state, autoPassed: null };
1664
1975
  enterStep(d, suite, env, mission, index, { replay: false });
1665
1976
  break;
1666
1977
  }
@@ -1675,8 +1986,9 @@ function reduceGuide(state, action, suite, env) {
1675
1986
  }
1676
1987
  if (s.name !== "step")
1677
1988
  break;
1678
- if (s.page && !pageStillHere(suite, s.page, env)) {
1679
- // Leaving the page ends its guide, silently.
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.
1680
1992
  endPage(d, suite, "skipped");
1681
1993
  break;
1682
1994
  }
@@ -1709,7 +2021,7 @@ function reduceGuide(state, action, suite, env) {
1709
2021
  break;
1710
2022
  }
1711
2023
  }
1712
- if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page)) {
2024
+ if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
1713
2025
  // The target is missing because the page is another one.
1714
2026
  recoverOffRoute(d, suite, env, false);
1715
2027
  break;
@@ -1825,7 +2137,7 @@ function reduceGuide(state, action, suite, env) {
1825
2137
  const cur = d.state.screen;
1826
2138
  if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
1827
2139
  break;
1828
- if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
2140
+ if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page ?? cur.section)) {
1829
2141
  recoverOffRoute(d, suite, env, false);
1830
2142
  break;
1831
2143
  }
@@ -1846,7 +2158,7 @@ function reduceGuide(state, action, suite, env) {
1846
2158
  if (!mission)
1847
2159
  break;
1848
2160
  const step = mission.steps[s.index];
1849
- if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
2161
+ if (!s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
1850
2162
  recoverOffRoute(d, suite, env, false);
1851
2163
  break;
1852
2164
  }
@@ -1863,7 +2175,7 @@ function reduceGuide(state, action, suite, env) {
1863
2175
  advance(d, suite, env);
1864
2176
  }
1865
2177
  else if (policy === "abort") {
1866
- if (s.page)
2178
+ if (readOf(s))
1867
2179
  endPage(d, suite, "skipped");
1868
2180
  else
1869
2181
  exitMission(d);
@@ -1889,7 +2201,7 @@ function reduceGuide(state, action, suite, env) {
1889
2201
  if (s.name !== "step" || s.phase !== "missing")
1890
2202
  break;
1891
2203
  const mission = findMission(suite, s.mission);
1892
- if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
2204
+ if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
1893
2205
  // Polling again cannot help on another page: go back to the step's.
1894
2206
  recoverOffRoute(d, suite, env, true);
1895
2207
  break;
@@ -1905,7 +2217,7 @@ function reduceGuide(state, action, suite, env) {
1905
2217
  case "exit":
1906
2218
  if (s.name !== "step")
1907
2219
  break;
1908
- if (s.page) {
2220
+ if (readOf(s)) {
1909
2221
  // ✕, Escape or ปิดคู่มือ: ends silently.
1910
2222
  pushAbort(d, suite, s);
1911
2223
  endPage(d, suite, "skipped");
@@ -2064,7 +2376,7 @@ function reduceGuide(state, action, suite, env) {
2064
2376
  break;
2065
2377
  }
2066
2378
  case "back": {
2067
- if (s.name !== "step" || !s.page || s.phase !== "ready" || s.confirmExit)
2379
+ if (s.name !== "step" || !readOf(s) || s.phase !== "ready" || s.confirmExit)
2068
2380
  break;
2069
2381
  const mission = findMission(suite, s.mission);
2070
2382
  if (!mission)
@@ -2079,12 +2391,32 @@ function reduceGuide(state, action, suite, env) {
2079
2391
  break;
2080
2392
  }
2081
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
+ }
2082
2401
  const cur = d.state.pages[action.key];
2083
2402
  if (cur && cur.version > action.row.version)
2084
2403
  break;
2085
2404
  d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
2086
2405
  break;
2087
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
+ }
2088
2420
  case "idleCelebrate": {
2089
2421
  if (s.name !== "idle" || state.panelOpen)
2090
2422
  break;
@@ -2213,6 +2545,7 @@ function parseGuidesAnswer(payload, suite) {
2213
2545
  ? data.page_audience
2214
2546
  : "none";
2215
2547
  const pages = parsePageRows(rows, suite.app);
2548
+ const sections = parsePageRows(rows, suite.app, "section");
2216
2549
  let row = null;
2217
2550
  for (const r of rows) {
2218
2551
  if (isRecord(r) && r.guide_key === suite.key) {
@@ -2233,15 +2566,15 @@ function parseGuidesAnswer(payload, suite) {
2233
2566
  progress = row;
2234
2567
  newerOnServer = row.guide_version > suite.version;
2235
2568
  }
2236
- return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages };
2569
+ return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
2237
2570
  }
2238
- /** The row key of a page guide: `<app>.page.<key>`. */
2239
- function pageRowKey(app, key) {
2240
- return `${app}.page.${key}`;
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}`;
2241
2574
  }
2242
- /** Page rows among the list's rows, by page key. Off-shape rows are left out. */
2243
- function parsePageRows(rows, app) {
2244
- const prefix = pageRowKey(app, "");
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);
2245
2578
  const out = {};
2246
2579
  for (const r of rows) {
2247
2580
  if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
@@ -2491,14 +2824,15 @@ async function storeGuides(adapter, suite, progress) {
2491
2824
  return null;
2492
2825
  }
2493
2826
  /**
2494
- * Writes a page guide's row (`<app>.page.<key>`) once: the minimal body,
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,
2495
2829
  * with the same 409 handling as the tour's row. Another tab that already
2496
2830
  * recorded this version wins (its row is returned, nothing is sent again);
2497
2831
  * an older version's row is replaced with its rev; a newer one stops. Returns
2498
2832
  * the stored row, or null when it could not be saved.
2499
2833
  */
2500
- async function storePageRow(adapter, app, key, version, status, rev) {
2501
- const guideKey = pageRowKey(app, key);
2834
+ async function storePageRow(adapter, app, key, version, status, rev, kind = "page") {
2835
+ const guideKey = pageRowKey(app, key, kind);
2502
2836
  let at = rev;
2503
2837
  for (let attempt = 0; attempt < 2; attempt++) {
2504
2838
  let res;
@@ -3587,10 +3921,10 @@ function writeMirror(orgId, suite, p) {
3587
3921
  }
3588
3922
  }
3589
3923
  /** Page rows written in this tab, kept for a remount within the GET's cache time. */
3590
- const pagesMirrorKey = (orgId, suite) => `erp-guide-pages:${orgId}:${suite.app}`;
3591
- function readPagesMirror(orgId, suite) {
3924
+ const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
3925
+ function readPagesMirror(orgId, suite, kind = "page") {
3592
3926
  try {
3593
- const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite));
3927
+ const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
3594
3928
  const parsed = raw ? JSON.parse(raw) : {};
3595
3929
  const out = {};
3596
3930
  for (const [k, v] of Object.entries(parsed ?? {})) {
@@ -3603,9 +3937,9 @@ function readPagesMirror(orgId, suite) {
3603
3937
  return {};
3604
3938
  }
3605
3939
  }
3606
- function writePagesMirror(orgId, suite, pages) {
3940
+ function writePagesMirror(orgId, suite, pages, kind = "page") {
3607
3941
  try {
3608
- sessionStorage.setItem(pagesMirrorKey(orgId, suite), JSON.stringify(pages));
3942
+ sessionStorage.setItem(pagesMirrorKey(orgId, suite, kind), JSON.stringify(pages));
3609
3943
  }
3610
3944
  catch {
3611
3945
  /* private mode */
@@ -3733,7 +4067,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3733
4067
  const [answer, setAnswer] = React.useState(null);
3734
4068
  const missionsReady = config.ready !== false;
3735
4069
  const pagesReady = config.pagesReady ?? missionsReady;
3736
- const hasPages = React.useMemo(() => validPages(suite).length > 0, [suite]);
4070
+ const hasPages = React.useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0, [suite]);
4071
+ const hasSections = React.useMemo(() => validSections(suite).length > 0, [suite]);
3737
4072
  const enabled = primary && (missionsReady || (hasPages && pagesReady));
3738
4073
  const loaded = React.useRef(false);
3739
4074
  React.useEffect(() => {
@@ -3789,6 +4124,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3789
4124
  pageAudience: pagesOn ? answer.pageAudience : "none",
3790
4125
  pagesMode: answer.pagesMode,
3791
4126
  pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
4127
+ sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
3792
4128
  });
3793
4129
  stateRef.current = s;
3794
4130
  setState(s);
@@ -3797,8 +4133,12 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3797
4133
  const pageRef = React.useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
3798
4134
  pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
3799
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);
3800
4138
  const env = React.useCallback(() => {
3801
4139
  const pg = pageRef.current;
4140
+ const sc = stateRef.current?.screen;
4141
+ const sectionKey = (sc?.name === "step" ? sc.section : undefined) ?? openingSection.current;
3802
4142
  return {
3803
4143
  code,
3804
4144
  pathname: pg.pathname,
@@ -3816,6 +4156,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3816
4156
  sidebarOpen: pg.sidebarOpen,
3817
4157
  targetValue: () => fieldValue(targetElRef.current),
3818
4158
  dialogOpen: () => appDialogOpen(),
4159
+ values: getSectionGuideValues(sectionKey),
3819
4160
  };
3820
4161
  }, [code]);
3821
4162
  /* ---------------- effects: save, announce, toast, navigate ---------------- */
@@ -3891,13 +4232,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3891
4232
  }
3892
4233
  else if (e.type === "savePage") {
3893
4234
  // Once per page and version; the machine already holds it as written.
3894
- if (stateRef.current)
3895
- writePagesMirror(organizationId, suite, stateRef.current.pages);
3896
- const rev = stateRef.current?.pages[e.key]?.rev ?? 0;
3897
- void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev).then(row => {
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 => {
3898
4241
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
3899
4242
  if (row)
3900
- dispatchRef.current({ type: "pageRow", key: e.key, row });
4243
+ dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
3901
4244
  });
3902
4245
  }
3903
4246
  }
@@ -3987,7 +4330,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3987
4330
  /* ---------------- sync on route, sidebar, facts, DOM ---------------- */
3988
4331
  // A page guide's step runs while page guides are live; a mission's while
3989
4332
  // the missions are. The offer card is watched the same way (left: ignored).
3990
- const inStep = screen.name === "step" && (screen.page ? pagesLive : missionsLive);
4333
+ const inStep = screen.name === "step" && (screen.page || screen.section ? pagesLive : missionsLive);
3991
4334
  const offerUp = pagesLive && screen.name === "pageOffer";
3992
4335
  const watching = inStep || offerUp;
3993
4336
  React.useEffect(() => {
@@ -4035,6 +4378,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4035
4378
  const step = shownStep(raw, screen.nothingToSave);
4036
4379
  return { mission, step, raw };
4037
4380
  }, [state, screen, suite]);
4381
+ const valuesVersion = useSectionValuesVersion();
4038
4382
  const stepCtx = React.useMemo(() => {
4039
4383
  if (!state)
4040
4384
  return null;
@@ -4043,7 +4387,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4043
4387
  return guideContext(e, state.progress.context, params);
4044
4388
  // pathname/search/factsKey make it current.
4045
4389
  // eslint-disable-next-line react-hooks/exhaustive-deps
4046
- }, [state, stepInfo, env, code, pathname, search, factsKey]);
4390
+ }, [state, stepInfo, env, code, pathname, search, factsKey, valuesVersion]);
4047
4391
  const target = stepInfo && stepCtx && screen.name === "step"
4048
4392
  ? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
4049
4393
  : null;
@@ -4551,7 +4895,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4551
4895
  /* ---------------- page guides: this page's, the offer, the list ---------------- */
4552
4896
  // `when` anchors (LIFF's sub-screens) come and go without a route change:
4553
4897
  // look again when the page changes.
4554
- const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when), [suite]);
4898
+ const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
4555
4899
  const [domTick, setDomTick] = React.useState(0);
4556
4900
  React.useEffect(() => {
4557
4901
  if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
@@ -4585,6 +4929,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4585
4929
  // eslint-disable-next-line react-hooks/exhaustive-deps
4586
4930
  [pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
4587
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
+ }, []);
4588
4947
  const openPageHere = React.useCallback(() => {
4589
4948
  const p = pageHere(suite, env());
4590
4949
  if (p)
@@ -4595,8 +4954,10 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4595
4954
  publishPageGuides(null, null, null);
4596
4955
  return;
4597
4956
  }
4598
- publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }));
4599
- }, [pagesLive, hasState, pageNow, listed.length, openPageHere]);
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]);
4600
4961
  React.useEffect(() => () => publishPageGuides(null, null, null), []);
4601
4962
  // The first-visit offer: in "offer" mode, nothing else up, the welcome
4602
4963
  // not due here, and a moment of nothing loading on this page.
@@ -4713,12 +5074,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4713
5074
  const missionNo = missionNumber(suite, mission.key, ctxIdle);
4714
5075
  const kind = stepKind(step);
4715
5076
  const bridge = screen.bridge;
4716
- const pos = stepPosition(mission, screen.index, env(), state.progress.context);
5077
+ const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
4717
5078
  const page = screen.page ? findPage(suite, screen.page) : undefined;
4718
- const pageBits = page
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
4719
5082
  ? {
4720
- heading: `คู่มือหน้านี้ · ${page.title}`,
4721
- exitLabel: "ปิดคู่มือ",
5083
+ ...read,
4722
5084
  showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
4723
5085
  nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
4724
5086
  }
@@ -4801,15 +5163,114 @@ function useFactsKey(facts) {
4801
5163
  }, [facts]);
4802
5164
  }
4803
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
+
4804
5262
  exports.API_EVENT = API_EVENT;
4805
5263
  exports.GUIDE_EVENT_PREFIX = GUIDE_EVENT_PREFIX;
4806
5264
  exports.GUIDE_LAYER_ATTRIBUTE = GUIDE_LAYER_ATTRIBUTE;
4807
5265
  exports.GuideErrorBoundary = GuideErrorBoundary;
4808
5266
  exports.GuideProgressConflict = GuideProgressConflict;
4809
5267
  exports.GuideRuntime = GuideRuntime;
5268
+ exports.SectionGuideButton = SectionGuideButton;
5269
+ exports.THAI_MONTHS_SHORT = THAI_MONTHS_SHORT;
4810
5270
  exports.apiCallDetail = apiCallDetail;
4811
5271
  exports.emitGuideEvent = emitGuideEvent;
4812
5272
  exports.fillRoute = fillRoute;
5273
+ exports.formatBaht = formatBaht;
4813
5274
  exports.guideAnchor = guideAnchor;
4814
5275
  exports.guideMenuItems = guideMenuItems;
4815
5276
  exports.guideMenuShown = guideMenuShown;
@@ -4819,10 +5280,15 @@ exports.matchRoute = matchRoute;
4819
5280
  exports.openGuidePanel = openGuidePanel;
4820
5281
  exports.openPageGuide = openPageGuide;
4821
5282
  exports.openPagesPanel = openPagesPanel;
5283
+ exports.openSectionGuide = openSectionGuide;
4822
5284
  exports.reportApiCall = reportApiCall;
4823
5285
  exports.reportApiEvent = reportApiEvent;
5286
+ exports.setSectionGuideValues = setSectionGuideValues;
4824
5287
  exports.storeGuides = storeGuides;
5288
+ exports.thaiMonth = thaiMonth;
5289
+ exports.thaiMonthYear = thaiMonthYear;
4825
5290
  exports.useGuideEntry = useGuideEntry;
4826
5291
  exports.usePageGuideEntry = usePageGuideEntry;
4827
5292
  exports.usePageGuides = usePageGuides;
4828
- //# sourceMappingURL=guide-runtime-YS2nUhwS.cjs.map
5293
+ exports.useSectionGuideValues = useSectionGuideValues;
5294
+ //# sourceMappingURL=format-CVazK0hP.cjs.map