@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.
@@ -1,7 +1,7 @@
1
1
  'use client';
2
2
  import { jsxs, Fragment, jsx } from 'react/jsx-runtime';
3
3
  import * as React from 'react';
4
- import { useSyncExternalStore, useMemo, useRef, useState, useEffect, useLayoutEffect, useCallback } from 'react';
4
+ import { useSyncExternalStore, useMemo, useEffect, useRef, useState, useLayoutEffect, useCallback } from 'react';
5
5
  import { createPortal } from 'react-dom';
6
6
  import { computePosition, offset, shift, arrow } from '@floating-ui/dom';
7
7
 
@@ -337,18 +337,27 @@ function publishGuideEntry(next, openPanel) {
337
337
  emit();
338
338
  }
339
339
  }
340
- const NO_PAGES = { page: null, listed: 0 };
340
+ const NO_PAGES = { page: null, listed: 0, sections: [] };
341
341
  let pages = NO_PAGES;
342
342
  let openPageImpl = null;
343
343
  let openPagesImpl = null;
344
- /** Called by the host: this page's guide and the listed count, and how to open them. */
345
- function publishPageGuides(next, openPage, openPages) {
344
+ let openSectionImpl = null;
345
+ const sectionsKey = (x) => (x ?? []).map(s => `${s.key}\u0000${s.title}`).join("\u0001");
346
+ /** Called by the host: this page's guide, the listed count, the sections on screen, and how to open them. */
347
+ function publishPageGuides(next, openPage, openPages, openSection = null) {
346
348
  const n = next ?? NO_PAGES;
347
349
  openPageImpl = openPage;
348
350
  openPagesImpl = openPages;
349
- const same = n.listed === pages.listed && (n.page?.title ?? null) === (pages.page?.title ?? null);
351
+ openSectionImpl = openSection;
352
+ const same = n.listed === pages.listed &&
353
+ (n.page?.title ?? null) === (pages.page?.title ?? null) &&
354
+ sectionsKey(n.sections) === sectionsKey(pages.sections);
350
355
  if (!same) {
351
- pages = n.page || n.listed ? { page: n.page ? { title: n.page.title } : null, listed: n.listed } : NO_PAGES;
356
+ const sections = (n.sections ?? []).map(x => ({ key: x.key, title: x.title }));
357
+ pages =
358
+ n.page || n.listed || sections.length
359
+ ? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections }
360
+ : NO_PAGES;
352
361
  emit();
353
362
  }
354
363
  }
@@ -356,6 +365,8 @@ function guideMenuItems(e, p = NO_PAGES) {
356
365
  const items = [];
357
366
  if (p.page)
358
367
  items.push("page");
368
+ if ((p.sections ?? []).length > 0)
369
+ items.push("sections");
359
370
  if (e && e.total > 0)
360
371
  items.push("missions");
361
372
  if (p.listed > 0)
@@ -405,6 +416,79 @@ function usePageGuideEntry() {
405
416
  const title = p.page?.title ?? null;
406
417
  return useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
407
418
  }
419
+ /**
420
+ * Opens a section guide ("อธิบายส่วนนี้"), when it is on screen here for this
421
+ * person. The values it shows are the ones the app last rendered
422
+ * (`useSectionGuideValues`).
423
+ */
424
+ function openSectionGuide(key) {
425
+ openSectionImpl?.(key);
426
+ }
427
+ const sectionValues = new Map();
428
+ const valueListeners = new Set();
429
+ let valuesVersion = 0;
430
+ function valuesSig(v) {
431
+ try {
432
+ return JSON.stringify(v);
433
+ }
434
+ catch {
435
+ return String(Math.random());
436
+ }
437
+ }
438
+ /**
439
+ * Sets (or with undefined, clears) a section guide's live values. The
440
+ * engine reads the latest whenever it draws a step, so a page that
441
+ * re-renders mid-guide never leaves an old number on screen.
442
+ */
443
+ function setSectionGuideValues(key, values) {
444
+ const prev = sectionValues.get(key);
445
+ if (values === undefined) {
446
+ if (!prev)
447
+ return;
448
+ sectionValues.delete(key);
449
+ }
450
+ else {
451
+ const sig = valuesSig(values);
452
+ if (prev && prev.sig === sig)
453
+ return;
454
+ sectionValues.set(key, { values, sig });
455
+ }
456
+ valuesVersion++;
457
+ valueListeners.forEach(l => l());
458
+ }
459
+ /** A section guide's live values now (empty when the app has given none). */
460
+ function getSectionGuideValues(key) {
461
+ return (key && sectionValues.get(key)?.values) || {};
462
+ }
463
+ /** Re-renders when any section guide's values change (the engine's own use). */
464
+ function useSectionValuesVersion() {
465
+ return useSyncExternalStore(l => {
466
+ valueListeners.add(l);
467
+ return () => {
468
+ valueListeners.delete(l);
469
+ };
470
+ }, () => valuesVersion, () => 0);
471
+ }
472
+ /**
473
+ * Hands a section guide the numbers its copy uses, from the component that
474
+ * computes them: `useSectionGuideValues("cash-flow", { month: "ม.ค.", cashIn,
475
+ * … })`. Call it on every render (the latest wins); they go when the
476
+ * component unmounts. undefined values: nothing.
477
+ */
478
+ function useSectionGuideValues(key, values) {
479
+ const sig = values === undefined ? "" : valuesSig(values);
480
+ useEffect(() => {
481
+ if (values !== undefined)
482
+ setSectionGuideValues(key, values);
483
+ // sig stands for the values.
484
+ // eslint-disable-next-line react-hooks/exhaustive-deps
485
+ }, [key, sig]);
486
+ useEffect(() => {
487
+ if (values === undefined)
488
+ return;
489
+ return () => setSectionGuideValues(key, undefined);
490
+ }, [key, values === undefined]); // eslint-disable-line react-hooks/exhaustive-deps
491
+ }
408
492
 
409
493
  /** The synthetic mission key of a page guide: `page:<key>`. */
410
494
  const PAGE_PREFIX = "page:";
@@ -583,6 +667,140 @@ function currentPage(suite, where, ctx) {
583
667
  function listedPages(suite, ctx) {
584
668
  return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
585
669
  }
670
+ /* ------------------------------------------------------------------ */
671
+ /* Section guides ("อธิบายส่วนนี้", shell 0.1.75) */
672
+ /* ------------------------------------------------------------------ */
673
+ /** The synthetic mission key of a section guide: `section:<key>`. */
674
+ const SECTION_PREFIX = "section:";
675
+ function isSectionMissionKey(key) {
676
+ return !!key && key.startsWith(SECTION_PREFIX);
677
+ }
678
+ function sectionKeyOf(missionKey) {
679
+ return isSectionMissionKey(missionKey) ? missionKey.slice(SECTION_PREFIX.length) : undefined;
680
+ }
681
+ /** Why a section guide cannot run, or null when it can. */
682
+ function sectionProblem(app, section) {
683
+ if (!section || typeof section.key !== "string" || !PAGE_KEY_RE.test(section.key))
684
+ return "its key is not [a-z0-9-]";
685
+ if (`${app}.section.${section.key}`.length > ROW_KEY_MAX)
686
+ return `its row key is over ${ROW_KEY_MAX} characters`;
687
+ if (!Number.isInteger(section.version) || section.version < 1)
688
+ return "its version is not a whole number ≥ 1";
689
+ const routes = Array.isArray(section.route) ? section.route : [section.route];
690
+ if (!routes.length || routes.some(r => typeof r !== "string" || !r.startsWith("/")))
691
+ return "it has no route";
692
+ if (typeof section.anchor !== "string" || !section.anchor)
693
+ return "it has no anchor";
694
+ if (!Array.isArray(section.steps) || section.steps.length === 0)
695
+ return "it has no steps";
696
+ for (const st of section.steps) {
697
+ const kind = st.kind ?? "view";
698
+ if (kind !== "view")
699
+ return `step "${st.key}" is a ${kind} step (a section guide only reads)`;
700
+ }
701
+ return null;
702
+ }
703
+ const validSectionCache = new WeakMap();
704
+ /** The suite's section guides that can run (as validPages: the rest dropped, with a warning in development). */
705
+ function validSections(suite) {
706
+ const hit = validSectionCache.get(suite);
707
+ if (hit)
708
+ return hit;
709
+ const out = [];
710
+ const seen = new Set();
711
+ for (const section of suite.sections ?? []) {
712
+ let problem = sectionProblem(suite.app, section);
713
+ if (!problem && seen.has(section.key))
714
+ problem = "another section guide has the same key";
715
+ if (problem) {
716
+ const id = `${suite.key}:section:${section?.key}:${problem}`;
717
+ if (!warned.has(id)) {
718
+ warned.add(id);
719
+ devWarn(`section guide "${section?.key}" dropped: ${problem}`);
720
+ }
721
+ continue;
722
+ }
723
+ seen.add(section.key);
724
+ out.push(section);
725
+ }
726
+ validSectionCache.set(suite, out);
727
+ return out;
728
+ }
729
+ function findSection(suite, key) {
730
+ return key ? validSections(suite).find(s => s.key === key) : undefined;
731
+ }
732
+ /** A live value is missing: undefined, null, "" or a number that is not finite. */
733
+ function valueMissing(v) {
734
+ return v === undefined || v === null || v === "" || (typeof v === "number" && !Number.isFinite(v));
735
+ }
736
+ function textOf(t, ctx) {
737
+ try {
738
+ return typeof t === "function" ? t(ctx) : t;
739
+ }
740
+ catch {
741
+ return "";
742
+ }
743
+ }
744
+ const sectionMissionCache = new WeakMap();
745
+ /**
746
+ * The section guide as a mission the machine plays: `section:<key>`, read
747
+ * steps only, checkpoints unless they say otherwise. A step is skipped while
748
+ * a value it `needs` is missing, or when its title comes out empty.
749
+ */
750
+ function sectionMission(section) {
751
+ const hit = sectionMissionCache.get(section);
752
+ if (hit)
753
+ return hit;
754
+ const steps = section.steps.map(st => {
755
+ const { route: _route, resumeUrl: _resume, ...rest } = st;
756
+ const own = st.skipIf;
757
+ return {
758
+ ...rest,
759
+ kind: "view",
760
+ checkpoint: st.checkpoint ?? true,
761
+ skipIf: (ctx) => {
762
+ const values = ctx.values ?? {};
763
+ if ((st.needs ?? []).some(k => valueMissing(values[k])))
764
+ return true;
765
+ if (!textOf(st.title, ctx).trim())
766
+ return true;
767
+ return own ? own(ctx) : false;
768
+ },
769
+ };
770
+ });
771
+ const m = { key: SECTION_PREFIX + section.key, title: section.title, minutes: 1, steps };
772
+ sectionMissionCache.set(section, m);
773
+ return m;
774
+ }
775
+ /** Whether the section guide applies here: one of its routes matches and its root is on screen. */
776
+ function sectionApplies(section, where) {
777
+ const routes = Array.isArray(section.route) ? section.route : [section.route];
778
+ if (!routes.some(r => !!matchRoute(r, where.pathname, where.search, where.code)))
779
+ return false;
780
+ try {
781
+ return where.isVisible(section.anchor);
782
+ }
783
+ catch {
784
+ return false;
785
+ }
786
+ }
787
+ /** Whether this person has the section guide: `visible` (only true) and `requires`. */
788
+ function sectionShown(section, ctx) {
789
+ try {
790
+ if (section.visible && section.visible(ctx) !== true)
791
+ return false;
792
+ if (section.requires && !ctx.can(section.requires))
793
+ return false;
794
+ return true;
795
+ }
796
+ catch {
797
+ return false;
798
+ }
799
+ }
800
+ /** The section guides on screen here that this person has, in the suite's order. */
801
+ function sectionsHere(suite, where, ctx) {
802
+ return validSections(suite).filter(s => sectionApplies(s, where) && sectionShown(s, ctx));
803
+ }
586
804
 
587
805
  /* ------------------------------------------------------------------ */
588
806
  /* Copy */
@@ -597,6 +815,7 @@ const COPY = {
597
815
  hiddenToast: "ซ่อนแล้ว",
598
816
  aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
599
817
  pageDone: "จบคู่มือหน้านี้",
818
+ sectionDone: "จบคำอธิบายส่วนนี้",
600
819
  bridgeTitle: "กดปุ่มเมนู",
601
820
  bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
602
821
  };
@@ -609,8 +828,10 @@ function initialGuideState(audience, progress, pages) {
609
828
  pageAudience: pages?.pageAudience ?? "none",
610
829
  pagesMode: pages?.pagesMode ?? "off",
611
830
  pages: pages?.pages ?? {},
831
+ sections: pages?.sections ?? {},
612
832
  pageRun: null,
613
833
  panelSection: "all",
834
+ autoPassed: null,
614
835
  audience,
615
836
  progress,
616
837
  screen: { name: "idle" },
@@ -630,6 +851,10 @@ function findMission(suite, key) {
630
851
  const page = findPage(suite, pageKeyOf(key));
631
852
  return page ? pageMission(page) : undefined;
632
853
  }
854
+ if (isSectionMissionKey(key)) {
855
+ const section = findSection(suite, sectionKeyOf(key));
856
+ return section ? sectionMission(section) : undefined;
857
+ }
633
858
  return suite.missions.find(m => m.key === key);
634
859
  }
635
860
  /**
@@ -667,6 +892,7 @@ function guideContext(env, data, params = {}) {
667
892
  facts: env.facts,
668
893
  can: env.can,
669
894
  visible: (anchor) => env.isVisible(anchor),
895
+ values: env.values ?? {},
670
896
  };
671
897
  }
672
898
  function resolveText(t, ctx) {
@@ -980,7 +1206,7 @@ function announceStep(d, suite, env) {
980
1206
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
981
1207
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
982
1208
  const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
983
- const pos = stepPosition(mission, s.index, env, d.state.progress.context);
1209
+ const pos = stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
984
1210
  d.effects.push({
985
1211
  type: "announce",
986
1212
  text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
@@ -991,7 +1217,7 @@ function announceStep(d, suite, env) {
991
1217
  * whose `requires` they lack or whose `skipIf` holds is left out (the step on
992
1218
  * screen always counts).
993
1219
  */
994
- function stepPosition(mission, index, env, context) {
1220
+ function stepPosition(mission, index, env, context, autoPassed = []) {
995
1221
  let no = 0;
996
1222
  let total = 0;
997
1223
  mission.steps.forEach((step, i) => {
@@ -1001,6 +1227,12 @@ function stepPosition(mission, index, env, context) {
1001
1227
  const ctx = guideContext(env, context, routeParamsFor(step, env));
1002
1228
  if (step.skipIf && safe(() => step.skipIf(ctx), false))
1003
1229
  return;
1230
+ // A step that passed by itself (shell ≥ 0.1.75): before this one, as
1231
+ // recorded; after it, one whose outcome already holds now.
1232
+ if (i < index && autoPassed.includes(i))
1233
+ return;
1234
+ if (i > index && wouldAutoPass(step, env))
1235
+ return;
1004
1236
  }
1005
1237
  total++;
1006
1238
  if (i <= index)
@@ -1008,6 +1240,17 @@ function stepPosition(mission, index, env, context) {
1008
1240
  });
1009
1241
  return { no: Math.max(no, 1), total: Math.max(total, 1) };
1010
1242
  }
1243
+ /** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
1244
+ function wouldAutoPass(step, env) {
1245
+ const kind = stepKind(step);
1246
+ if (step.autoPass === false || kind === "input" || kind === "view")
1247
+ return false;
1248
+ return safe(() => stateTriggerHolds(step, env) !== null, false);
1249
+ }
1250
+ /** The steps of this mission that passed by themselves in this run. */
1251
+ function autoPassedOf(state, mission) {
1252
+ return state.autoPassed?.mission === mission ? state.autoPassed.steps : [];
1253
+ }
1011
1254
  /** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
1012
1255
  function plainTitle(t) {
1013
1256
  return t.replace(/\[([^\]]+)\]/g, "$1");
@@ -1020,6 +1263,8 @@ function enterStep(d, suite, env, mission, index, opts) {
1020
1263
  const steps = mission.steps;
1021
1264
  let i = Math.max(0, index);
1022
1265
  let firstPass = true;
1266
+ if (d.state.autoPassed?.mission !== mission.key)
1267
+ d.state = { ...d.state, autoPassed: { mission: mission.key, steps: [] } };
1023
1268
  while (i < steps.length) {
1024
1269
  const step = steps[i];
1025
1270
  const params = routeParamsFor(step, env);
@@ -1041,6 +1286,9 @@ function enterStep(d, suite, env, mission, index, opts) {
1041
1286
  const held = stateTriggerHolds(step, env);
1042
1287
  if (held) {
1043
1288
  captureInto(d, step, held, ctx);
1289
+ const ap = d.state.autoPassed;
1290
+ if (!ap.steps.includes(i))
1291
+ d.state = { ...d.state, autoPassed: { mission: ap.mission, steps: [...ap.steps, i] } };
1044
1292
  i++;
1045
1293
  continue;
1046
1294
  }
@@ -1054,9 +1302,10 @@ function enterStep(d, suite, env, mission, index, opts) {
1054
1302
  const step = steps[i];
1055
1303
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1056
1304
  const page = pageKeyOf(mission.key);
1305
+ const section = sectionKeyOf(mission.key);
1057
1306
  const where = placeRoute(steps, i);
1058
- // A page guide is on its page: leaving it ends the guide (never "ทำต่อ").
1059
- const away = !page && !!where && !matchRoute(where, env.pathname, env.search, env.code);
1307
+ // A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
1308
+ const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
1060
1309
  const bridge = !away && needsBridge(step, env, ctx);
1061
1310
  const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
1062
1311
  setScreen(d, {
@@ -1074,6 +1323,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1074
1323
  status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
1075
1324
  replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
1076
1325
  ...(page ? { page } : {}),
1326
+ ...(section ? { section } : {}),
1077
1327
  });
1078
1328
  if (!opts.replay) {
1079
1329
  const cp = checkpointAtOrBefore(steps, i);
@@ -1238,9 +1488,9 @@ function advance(d, suite, env, capture) {
1238
1488
  enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
1239
1489
  }
1240
1490
  function finishMission(d, suite, env, mission, replay) {
1241
- if (isPageMissionKey(mission.key)) {
1491
+ if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
1242
1492
  endPage(d, suite, "completed");
1243
- d.effects.push({ type: "toast", text: COPY.pageDone });
1493
+ d.effects.push({ type: "toast", text: isSectionMissionKey(mission.key) ? COPY.sectionDone : COPY.pageDone });
1244
1494
  return;
1245
1495
  }
1246
1496
  if (replay) {
@@ -1344,7 +1594,8 @@ function startMission(d, suite, env, key, replay) {
1344
1594
  // A mission already done is only ever replayed; its done status stays.
1345
1595
  const isReplay = replay || entry?.status === "done";
1346
1596
  const before = d.state.progress;
1347
- d.state = { ...d.state, panelOpen: false, justDone: null };
1597
+ // A new run: what passed by itself is counted afresh.
1598
+ d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
1348
1599
  if (d.state.progress.welcome === "not_seen")
1349
1600
  withProgress(d, { welcome: "started" });
1350
1601
  if (!isReplay && d.state.progress.mission_key !== key)
@@ -1503,14 +1754,72 @@ function recordPage(d, suite, key, status) {
1503
1754
  d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
1504
1755
  d.effects.push({ type: "savePage", key, version: page.version, status });
1505
1756
  }
1506
- /** The page guide ends: its row written when it is due one; back to idle. */
1757
+ /** The section guide's row says this person has ended this version of it. */
1758
+ function sectionRecorded(state, section) {
1759
+ const row = state.sections?.[section.key];
1760
+ return !!row && row.version >= section.version;
1761
+ }
1762
+ /** Write a section guide's row (once per section and version). */
1763
+ function recordSection(d, suite, key, status) {
1764
+ const section = findSection(suite, key);
1765
+ if (!section || sectionRecorded(d.state, section))
1766
+ return;
1767
+ const prev = d.state.sections?.[key];
1768
+ d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
1769
+ d.effects.push({ type: "savePage", key, version: section.version, status, kind: "section" });
1770
+ }
1771
+ /** The page or section guide on screen, or null. */
1772
+ function readOf(s) {
1773
+ if (s.name !== "step")
1774
+ return null;
1775
+ if (s.page)
1776
+ return { kind: "page", key: s.page };
1777
+ if (s.section)
1778
+ return { kind: "section", key: s.section };
1779
+ return null;
1780
+ }
1781
+ /** Whether a page or section guide still applies where the person is. */
1782
+ function readStillHere(suite, r, env) {
1783
+ if (r.kind === "page")
1784
+ return pageStillHere(suite, r.key, env);
1785
+ const section = findSection(suite, r.key);
1786
+ return !!section && sectionApplies(section, whereOf(env));
1787
+ }
1788
+ /** The page or section guide ends: its row written when it is due one; back to idle. */
1507
1789
  function endPage(d, suite, how) {
1508
1790
  const run = d.state.pageRun;
1509
- if (run?.write)
1510
- recordPage(d, suite, run.key, how);
1791
+ if (run?.write) {
1792
+ if (run.kind === "section")
1793
+ recordSection(d, suite, run.key, how);
1794
+ else
1795
+ recordPage(d, suite, run.key, how);
1796
+ }
1511
1797
  d.state = { ...d.state, pageRun: null, replayPage: null };
1512
1798
  setScreen(d, { name: "idle" });
1513
1799
  }
1800
+ /** The section guides on screen here that this person has (the "?" entries, the buttons). */
1801
+ function sectionsAvailable(suite, env) {
1802
+ return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
1803
+ }
1804
+ function startSection(d, suite, env, key, write) {
1805
+ const section = findSection(suite, key);
1806
+ if (!section)
1807
+ return;
1808
+ const mission = sectionMission(section);
1809
+ d.state = {
1810
+ ...d.state,
1811
+ panelOpen: false,
1812
+ justDone: null,
1813
+ pageRun: { key, write, kind: "section" },
1814
+ autoPassed: null,
1815
+ replayPage: {
1816
+ mission: mission.key,
1817
+ anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
1818
+ dialog: safe(() => env.dialogOpen?.() === true, false),
1819
+ },
1820
+ };
1821
+ enterStep(d, suite, env, mission, 0, { replay: true });
1822
+ }
1514
1823
  function startPage(d, suite, env, key, write) {
1515
1824
  const page = findPage(suite, key);
1516
1825
  if (!page)
@@ -1520,7 +1829,8 @@ function startPage(d, suite, env, key, write) {
1520
1829
  ...d.state,
1521
1830
  panelOpen: false,
1522
1831
  justDone: null,
1523
- pageRun: { key, write },
1832
+ pageRun: { key, write, kind: "page" },
1833
+ autoPassed: null,
1524
1834
  // What is open now is the person's: ending the guide leaves it alone.
1525
1835
  replayPage: {
1526
1836
  mission: mission.key,
@@ -1620,7 +1930,7 @@ function reduceGuide(state, action, suite, env) {
1620
1930
  break;
1621
1931
  // A mission always wins over a page guide: a running one ends (its row
1622
1932
  // written if due), an offer goes unrecorded.
1623
- if (s.name === "step" && s.page)
1933
+ if (readOf(s))
1624
1934
  endPage(d, suite, "skipped");
1625
1935
  else if (s.name === "pageOffer")
1626
1936
  setScreen(d, { name: "idle" });
@@ -1641,6 +1951,7 @@ function reduceGuide(state, action, suite, env) {
1641
1951
  const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
1642
1952
  const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
1643
1953
  const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
1954
+ d.state = { ...d.state, autoPassed: null };
1644
1955
  enterStep(d, suite, env, mission, index, { replay: false });
1645
1956
  break;
1646
1957
  }
@@ -1655,8 +1966,9 @@ function reduceGuide(state, action, suite, env) {
1655
1966
  }
1656
1967
  if (s.name !== "step")
1657
1968
  break;
1658
- if (s.page && !pageStillHere(suite, s.page, env)) {
1659
- // Leaving the page ends its guide, silently.
1969
+ const read = readOf(s);
1970
+ if (read && !readStillHere(suite, read, env)) {
1971
+ // Leaving the page (or the section's root going) ends its guide, silently.
1660
1972
  endPage(d, suite, "skipped");
1661
1973
  break;
1662
1974
  }
@@ -1689,7 +2001,7 @@ function reduceGuide(state, action, suite, env) {
1689
2001
  break;
1690
2002
  }
1691
2003
  }
1692
- if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page)) {
2004
+ if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
1693
2005
  // The target is missing because the page is another one.
1694
2006
  recoverOffRoute(d, suite, env, false);
1695
2007
  break;
@@ -1805,7 +2117,7 @@ function reduceGuide(state, action, suite, env) {
1805
2117
  const cur = d.state.screen;
1806
2118
  if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
1807
2119
  break;
1808
- if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
2120
+ if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page ?? cur.section)) {
1809
2121
  recoverOffRoute(d, suite, env, false);
1810
2122
  break;
1811
2123
  }
@@ -1826,7 +2138,7 @@ function reduceGuide(state, action, suite, env) {
1826
2138
  if (!mission)
1827
2139
  break;
1828
2140
  const step = mission.steps[s.index];
1829
- if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
2141
+ if (!s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
1830
2142
  recoverOffRoute(d, suite, env, false);
1831
2143
  break;
1832
2144
  }
@@ -1843,7 +2155,7 @@ function reduceGuide(state, action, suite, env) {
1843
2155
  advance(d, suite, env);
1844
2156
  }
1845
2157
  else if (policy === "abort") {
1846
- if (s.page)
2158
+ if (readOf(s))
1847
2159
  endPage(d, suite, "skipped");
1848
2160
  else
1849
2161
  exitMission(d);
@@ -1869,7 +2181,7 @@ function reduceGuide(state, action, suite, env) {
1869
2181
  if (s.name !== "step" || s.phase !== "missing")
1870
2182
  break;
1871
2183
  const mission = findMission(suite, s.mission);
1872
- if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
2184
+ if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
1873
2185
  // Polling again cannot help on another page: go back to the step's.
1874
2186
  recoverOffRoute(d, suite, env, true);
1875
2187
  break;
@@ -1885,7 +2197,7 @@ function reduceGuide(state, action, suite, env) {
1885
2197
  case "exit":
1886
2198
  if (s.name !== "step")
1887
2199
  break;
1888
- if (s.page) {
2200
+ if (readOf(s)) {
1889
2201
  // ✕, Escape or ปิดคู่มือ: ends silently.
1890
2202
  pushAbort(d, suite, s);
1891
2203
  endPage(d, suite, "skipped");
@@ -2044,7 +2356,7 @@ function reduceGuide(state, action, suite, env) {
2044
2356
  break;
2045
2357
  }
2046
2358
  case "back": {
2047
- if (s.name !== "step" || !s.page || s.phase !== "ready" || s.confirmExit)
2359
+ if (s.name !== "step" || !readOf(s) || s.phase !== "ready" || s.confirmExit)
2048
2360
  break;
2049
2361
  const mission = findMission(suite, s.mission);
2050
2362
  if (!mission)
@@ -2059,12 +2371,32 @@ function reduceGuide(state, action, suite, env) {
2059
2371
  break;
2060
2372
  }
2061
2373
  case "pageRow": {
2374
+ if (action.kind === "section") {
2375
+ const cur = d.state.sections?.[action.key];
2376
+ if (cur && cur.version > action.row.version)
2377
+ break;
2378
+ d.state = { ...d.state, sections: { ...d.state.sections, [action.key]: action.row } };
2379
+ break;
2380
+ }
2062
2381
  const cur = d.state.pages[action.key];
2063
2382
  if (cur && cur.version > action.row.version)
2064
2383
  break;
2065
2384
  d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
2066
2385
  break;
2067
2386
  }
2387
+ case "startSection": {
2388
+ // Never offered: only on request. Never over a mission step, a
2389
+ // welcome, a gate or a celebration; only on screen, only theirs.
2390
+ if (state.pageAudience === "none" || state.pagesMode === "off")
2391
+ break;
2392
+ if (s.name !== "idle" && s.name !== "pageOffer")
2393
+ break;
2394
+ if (!sectionsAvailable(suite, env).includes(action.section))
2395
+ break;
2396
+ const section = findSection(suite, action.section);
2397
+ startSection(d, suite, env, section.key, !sectionRecorded(state, section));
2398
+ break;
2399
+ }
2068
2400
  case "idleCelebrate": {
2069
2401
  if (s.name !== "idle" || state.panelOpen)
2070
2402
  break;
@@ -2193,6 +2525,7 @@ function parseGuidesAnswer(payload, suite) {
2193
2525
  ? data.page_audience
2194
2526
  : "none";
2195
2527
  const pages = parsePageRows(rows, suite.app);
2528
+ const sections = parsePageRows(rows, suite.app, "section");
2196
2529
  let row = null;
2197
2530
  for (const r of rows) {
2198
2531
  if (isRecord(r) && r.guide_key === suite.key) {
@@ -2213,15 +2546,15 @@ function parseGuidesAnswer(payload, suite) {
2213
2546
  progress = row;
2214
2547
  newerOnServer = row.guide_version > suite.version;
2215
2548
  }
2216
- return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages };
2549
+ return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
2217
2550
  }
2218
- /** The row key of a page guide: `<app>.page.<key>`. */
2219
- function pageRowKey(app, key) {
2220
- return `${app}.page.${key}`;
2551
+ /** The row key of a page guide, `<app>.page.<key>`, or a section guide, `<app>.section.<key>`. */
2552
+ function pageRowKey(app, key, kind = "page") {
2553
+ return `${app}.${kind}.${key}`;
2221
2554
  }
2222
- /** Page rows among the list's rows, by page key. Off-shape rows are left out. */
2223
- function parsePageRows(rows, app) {
2224
- const prefix = pageRowKey(app, "");
2555
+ /** Page (or section) rows among the list's rows, by key. Off-shape rows are left out. */
2556
+ function parsePageRows(rows, app, kind = "page") {
2557
+ const prefix = pageRowKey(app, "", kind);
2225
2558
  const out = {};
2226
2559
  for (const r of rows) {
2227
2560
  if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
@@ -2471,14 +2804,15 @@ async function storeGuides(adapter, suite, progress) {
2471
2804
  return null;
2472
2805
  }
2473
2806
  /**
2474
- * Writes a page guide's row (`<app>.page.<key>`) once: the minimal body,
2807
+ * Writes a page guide's row (`<app>.page.<key>`), or a section guide's
2808
+ * (`<app>.section.<key>`, kind "section"), once: the minimal body,
2475
2809
  * with the same 409 handling as the tour's row. Another tab that already
2476
2810
  * recorded this version wins (its row is returned, nothing is sent again);
2477
2811
  * an older version's row is replaced with its rev; a newer one stops. Returns
2478
2812
  * the stored row, or null when it could not be saved.
2479
2813
  */
2480
- async function storePageRow(adapter, app, key, version, status, rev) {
2481
- const guideKey = pageRowKey(app, key);
2814
+ async function storePageRow(adapter, app, key, version, status, rev, kind = "page") {
2815
+ const guideKey = pageRowKey(app, key, kind);
2482
2816
  let at = rev;
2483
2817
  for (let attempt = 0; attempt < 2; attempt++) {
2484
2818
  let res;
@@ -3567,10 +3901,10 @@ function writeMirror(orgId, suite, p) {
3567
3901
  }
3568
3902
  }
3569
3903
  /** Page rows written in this tab, kept for a remount within the GET's cache time. */
3570
- const pagesMirrorKey = (orgId, suite) => `erp-guide-pages:${orgId}:${suite.app}`;
3571
- function readPagesMirror(orgId, suite) {
3904
+ const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
3905
+ function readPagesMirror(orgId, suite, kind = "page") {
3572
3906
  try {
3573
- const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite));
3907
+ const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
3574
3908
  const parsed = raw ? JSON.parse(raw) : {};
3575
3909
  const out = {};
3576
3910
  for (const [k, v] of Object.entries(parsed ?? {})) {
@@ -3583,9 +3917,9 @@ function readPagesMirror(orgId, suite) {
3583
3917
  return {};
3584
3918
  }
3585
3919
  }
3586
- function writePagesMirror(orgId, suite, pages) {
3920
+ function writePagesMirror(orgId, suite, pages, kind = "page") {
3587
3921
  try {
3588
- sessionStorage.setItem(pagesMirrorKey(orgId, suite), JSON.stringify(pages));
3922
+ sessionStorage.setItem(pagesMirrorKey(orgId, suite, kind), JSON.stringify(pages));
3589
3923
  }
3590
3924
  catch {
3591
3925
  /* private mode */
@@ -3713,7 +4047,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3713
4047
  const [answer, setAnswer] = useState(null);
3714
4048
  const missionsReady = config.ready !== false;
3715
4049
  const pagesReady = config.pagesReady ?? missionsReady;
3716
- const hasPages = useMemo(() => validPages(suite).length > 0, [suite]);
4050
+ const hasPages = useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0, [suite]);
4051
+ const hasSections = useMemo(() => validSections(suite).length > 0, [suite]);
3717
4052
  const enabled = primary && (missionsReady || (hasPages && pagesReady));
3718
4053
  const loaded = useRef(false);
3719
4054
  useEffect(() => {
@@ -3769,6 +4104,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3769
4104
  pageAudience: pagesOn ? answer.pageAudience : "none",
3770
4105
  pagesMode: answer.pagesMode,
3771
4106
  pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
4107
+ sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
3772
4108
  });
3773
4109
  stateRef.current = s;
3774
4110
  setState(s);
@@ -3777,8 +4113,12 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3777
4113
  const pageRef = useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
3778
4114
  pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
3779
4115
  const targetElRef = useRef(null);
4116
+ // The section guide being opened (before its first step is on screen).
4117
+ const openingSection = useRef(null);
3780
4118
  const env = useCallback(() => {
3781
4119
  const pg = pageRef.current;
4120
+ const sc = stateRef.current?.screen;
4121
+ const sectionKey = (sc?.name === "step" ? sc.section : undefined) ?? openingSection.current;
3782
4122
  return {
3783
4123
  code,
3784
4124
  pathname: pg.pathname,
@@ -3796,6 +4136,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3796
4136
  sidebarOpen: pg.sidebarOpen,
3797
4137
  targetValue: () => fieldValue(targetElRef.current),
3798
4138
  dialogOpen: () => appDialogOpen(),
4139
+ values: getSectionGuideValues(sectionKey),
3799
4140
  };
3800
4141
  }, [code]);
3801
4142
  /* ---------------- effects: save, announce, toast, navigate ---------------- */
@@ -3871,13 +4212,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3871
4212
  }
3872
4213
  else if (e.type === "savePage") {
3873
4214
  // Once per page and version; the machine already holds it as written.
3874
- if (stateRef.current)
3875
- writePagesMirror(organizationId, suite, stateRef.current.pages);
3876
- const rev = stateRef.current?.pages[e.key]?.rev ?? 0;
3877
- void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev).then(row => {
4215
+ const kind = e.kind ?? "page";
4216
+ const rows = stateRef.current ? (kind === "section" ? stateRef.current.sections : stateRef.current.pages) : undefined;
4217
+ if (rows)
4218
+ writePagesMirror(organizationId, suite, rows, kind);
4219
+ const rev = rows?.[e.key]?.rev ?? 0;
4220
+ void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev, kind).then(row => {
3878
4221
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
3879
4222
  if (row)
3880
- dispatchRef.current({ type: "pageRow", key: e.key, row });
4223
+ dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
3881
4224
  });
3882
4225
  }
3883
4226
  }
@@ -3967,7 +4310,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3967
4310
  /* ---------------- sync on route, sidebar, facts, DOM ---------------- */
3968
4311
  // A page guide's step runs while page guides are live; a mission's while
3969
4312
  // the missions are. The offer card is watched the same way (left: ignored).
3970
- const inStep = screen.name === "step" && (screen.page ? pagesLive : missionsLive);
4313
+ const inStep = screen.name === "step" && (screen.page || screen.section ? pagesLive : missionsLive);
3971
4314
  const offerUp = pagesLive && screen.name === "pageOffer";
3972
4315
  const watching = inStep || offerUp;
3973
4316
  useEffect(() => {
@@ -4015,6 +4358,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4015
4358
  const step = shownStep(raw, screen.nothingToSave);
4016
4359
  return { mission, step, raw };
4017
4360
  }, [state, screen, suite]);
4361
+ const valuesVersion = useSectionValuesVersion();
4018
4362
  const stepCtx = useMemo(() => {
4019
4363
  if (!state)
4020
4364
  return null;
@@ -4023,7 +4367,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4023
4367
  return guideContext(e, state.progress.context, params);
4024
4368
  // pathname/search/factsKey make it current.
4025
4369
  // eslint-disable-next-line react-hooks/exhaustive-deps
4026
- }, [state, stepInfo, env, code, pathname, search, factsKey]);
4370
+ }, [state, stepInfo, env, code, pathname, search, factsKey, valuesVersion]);
4027
4371
  const target = stepInfo && stepCtx && screen.name === "step"
4028
4372
  ? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
4029
4373
  : null;
@@ -4531,7 +4875,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4531
4875
  /* ---------------- page guides: this page's, the offer, the list ---------------- */
4532
4876
  // `when` anchors (LIFF's sub-screens) come and go without a route change:
4533
4877
  // look again when the page changes.
4534
- const hasWhen = useMemo(() => validPages(suite).some(p => !!p.when), [suite]);
4878
+ const hasWhen = useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
4535
4879
  const [domTick, setDomTick] = useState(0);
4536
4880
  useEffect(() => {
4537
4881
  if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
@@ -4565,6 +4909,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4565
4909
  // eslint-disable-next-line react-hooks/exhaustive-deps
4566
4910
  [pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
4567
4911
  const listed = useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
4912
+ const sectionsNow = useMemo(() => pagesLive && hasState && hasSections
4913
+ ? sectionsAvailable(suite, env()).map(k => ({ key: k, title: findSection(suite, k)?.title ?? "" }))
4914
+ : [],
4915
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4916
+ [pagesLive, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
4917
+ const sectionsSig = sectionsNow.map(x => x.key).join(",");
4918
+ const openSection = useCallback((key) => {
4919
+ openingSection.current = key;
4920
+ try {
4921
+ dispatchRef.current({ type: "startSection", section: key });
4922
+ }
4923
+ finally {
4924
+ openingSection.current = null;
4925
+ }
4926
+ }, []);
4568
4927
  const openPageHere = useCallback(() => {
4569
4928
  const p = pageHere(suite, env());
4570
4929
  if (p)
@@ -4575,8 +4934,10 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4575
4934
  publishPageGuides(null, null, null);
4576
4935
  return;
4577
4936
  }
4578
- publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }));
4579
- }, [pagesLive, hasState, pageNow, listed.length, openPageHere]);
4937
+ publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length, sections: sectionsNow }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection);
4938
+ // sectionsSig stands for sectionsNow.
4939
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4940
+ }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection]);
4580
4941
  useEffect(() => () => publishPageGuides(null, null, null), []);
4581
4942
  // The first-visit offer: in "offer" mode, nothing else up, the welcome
4582
4943
  // not due here, and a moment of nothing loading on this page.
@@ -4693,12 +5054,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4693
5054
  const missionNo = missionNumber(suite, mission.key, ctxIdle);
4694
5055
  const kind = stepKind(step);
4695
5056
  const bridge = screen.bridge;
4696
- const pos = stepPosition(mission, screen.index, env(), state.progress.context);
5057
+ const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
4697
5058
  const page = screen.page ? findPage(suite, screen.page) : undefined;
4698
- const pageBits = page
5059
+ const section = screen.section ? findSection(suite, screen.section) : undefined;
5060
+ const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
5061
+ const pageBits = read
4699
5062
  ? {
4700
- heading: `คู่มือหน้านี้ · ${page.title}`,
4701
- exitLabel: "ปิดคู่มือ",
5063
+ ...read,
4702
5064
  showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
4703
5065
  nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
4704
5066
  }
@@ -4781,5 +5143,101 @@ function useFactsKey(facts) {
4781
5143
  }, [facts]);
4782
5144
  }
4783
5145
 
4784
- export { API_EVENT as A, GUIDE_EVENT_PREFIX as G, GUIDE_LAYER_ATTRIBUTE as a, GuideErrorBoundary as b, GuideProgressConflict as c, GuideRuntime as d, apiCallDetail as e, emitGuideEvent as f, fillRoute as g, guideAnchor as h, guideMenuItems as i, guideMenuShown as j, matchRoute as k, loadGuides as l, matchApiPath as m, openPageGuide as n, openGuidePanel as o, openPagesPanel as p, reportApiEvent as q, reportApiCall as r, storeGuides as s, usePageGuideEntry as t, useGuideEntry as u, usePageGuides as v };
4785
- //# sourceMappingURL=guide-runtime-Dl8V1iy9.esm.js.map
5146
+ const DEFAULT_STYLE = {
5147
+ display: "inline-grid",
5148
+ placeItems: "center",
5149
+ width: 22,
5150
+ height: 22,
5151
+ padding: 0,
5152
+ border: 0,
5153
+ borderRadius: 999,
5154
+ background: "transparent",
5155
+ color: "inherit",
5156
+ opacity: 0.6,
5157
+ cursor: "pointer",
5158
+ flex: "none",
5159
+ };
5160
+ function SectionGuideButton({ guideKey, values, className }) {
5161
+ useSectionGuideValues(guideKey, values);
5162
+ const { sections } = usePageGuides();
5163
+ const here = (sections ?? []).find(s => s.key === guideKey);
5164
+ if (!here)
5165
+ return null;
5166
+ return (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 => {
5167
+ // A header that opens or sorts on press must not hear it.
5168
+ e.stopPropagation();
5169
+ e.preventDefault();
5170
+ openSectionGuide(guideKey);
5171
+ }, children: 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: [jsx("circle", { cx: "12", cy: "12", r: "10" }), jsx("path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }), jsx("path", { d: "M12 17h.01" })] }) }));
5172
+ }
5173
+
5174
+ /**
5175
+ * Small formatters for section guides' copy (shell 0.1.75): the app passes
5176
+ * its own computed numbers, and the copy says them the way the page does —
5177
+ * "ม.ค. รับ 500,000 − จ่าย 320,000 = คงเหลือ 180,000".
5178
+ *
5179
+ * Pure: the tests run it in Node.
5180
+ */
5181
+ const THAI_MONTHS_SHORT = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."];
5182
+ const grouped = new Map();
5183
+ function nf(decimals) {
5184
+ let f = grouped.get(decimals);
5185
+ if (!f) {
5186
+ f = new Intl.NumberFormat("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
5187
+ grouped.set(decimals, f);
5188
+ }
5189
+ return f;
5190
+ }
5191
+ /**
5192
+ * A baht amount with thousands separators, no currency sign: 500000 →
5193
+ * "500,000"; 1234.5 → "1,234.50"; -320000 → "-320,000". Whole amounts
5194
+ * have no decimals unless `decimals` says so; others have two. Not a
5195
+ * finite number → "" (a step whose value is missing is skipped anyway).
5196
+ */
5197
+ function formatBaht(n, opts = {}) {
5198
+ const v = typeof n === "string" && n.trim() !== "" ? Number(n) : n;
5199
+ if (typeof v !== "number" || !Number.isFinite(v))
5200
+ return "";
5201
+ const rounded = Math.round(v * 100) / 100;
5202
+ const decimals = opts.decimals ?? (Number.isInteger(rounded) ? 0 : 2);
5203
+ const out = nf(decimals).format(rounded);
5204
+ // No "-0".
5205
+ return out === "-0" || /^-0(\.0+)?$/.test(out) ? out.slice(1) : out;
5206
+ }
5207
+ /** The month's number 1–12 from "2026-01", "2026-01-15", a Date, or 1–12; null when none. */
5208
+ function monthOf(m) {
5209
+ if (m instanceof Date)
5210
+ return Number.isNaN(m.getTime()) ? null : { month: m.getMonth() + 1, year: m.getFullYear() };
5211
+ if (typeof m === "number")
5212
+ return Number.isInteger(m) && m >= 1 && m <= 12 ? { month: m } : null;
5213
+ if (typeof m === "string") {
5214
+ const iso = /^(\d{4})-(\d{1,2})(?:-\d{1,2})?/.exec(m.trim());
5215
+ if (iso) {
5216
+ const month = Number(iso[2]);
5217
+ return month >= 1 && month <= 12 ? { month, year: Number(iso[1]) } : null;
5218
+ }
5219
+ }
5220
+ return null;
5221
+ }
5222
+ /** "2026-01" (or a Date, or 1) → "ม.ค."; "" when it is not a month. */
5223
+ function thaiMonth(m) {
5224
+ const x = monthOf(m);
5225
+ return x ? THAI_MONTHS_SHORT[x.month - 1] : "";
5226
+ }
5227
+ /**
5228
+ * "2026-01" → "ม.ค. 69" (the Buddhist year's last two digits), or with
5229
+ * `{ year: "full" }` "ม.ค. 2569"; just the month when no year is known.
5230
+ */
5231
+ function thaiMonthYear(m, opts = {}) {
5232
+ const x = monthOf(m);
5233
+ if (!x)
5234
+ return "";
5235
+ const name = THAI_MONTHS_SHORT[x.month - 1];
5236
+ if (x.year === undefined)
5237
+ return name;
5238
+ const be = x.year + 543;
5239
+ return `${name} ${opts.year === "full" ? be : String(be).slice(-2)}`;
5240
+ }
5241
+
5242
+ export { API_EVENT as A, usePageGuides as B, useSectionGuideValues as C, GUIDE_EVENT_PREFIX as G, SectionGuideButton as S, THAI_MONTHS_SHORT as T, GUIDE_LAYER_ATTRIBUTE as a, GuideErrorBoundary as b, GuideProgressConflict as c, GuideRuntime as d, apiCallDetail as e, emitGuideEvent as f, fillRoute as g, formatBaht as h, guideAnchor as i, guideMenuItems as j, guideMenuShown as k, loadGuides as l, matchApiPath as m, matchRoute as n, openGuidePanel as o, openPageGuide as p, openPagesPanel as q, openSectionGuide as r, reportApiCall as s, reportApiEvent as t, setSectionGuideValues as u, storeGuides as v, thaiMonth as w, thaiMonthYear as x, useGuideEntry as y, usePageGuideEntry as z };
5243
+ //# sourceMappingURL=format-DCTuLpBU.esm.js.map