@eintrek/erp-shell 0.1.74 → 0.1.76

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,35 @@ 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: [], flows: [], flowsListed: 0 };
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
+ let openFlowImpl = null;
346
+ let openFlowsImpl = null;
347
+ const sectionsKey = (x) => (x ?? []).map(s => `${s.key}\u0000${s.title}`).join("\u0001");
348
+ /** Called by the host: this page's guide, the listed count, the sections on screen, and how to open them. */
349
+ function publishPageGuides(next, openPage, openPages, openSection = null, openFlow = null, openFlows = null) {
346
350
  const n = next ?? NO_PAGES;
347
351
  openPageImpl = openPage;
348
352
  openPagesImpl = openPages;
349
- const same = n.listed === pages.listed && (n.page?.title ?? null) === (pages.page?.title ?? null);
353
+ openSectionImpl = openSection;
354
+ openFlowImpl = openFlow;
355
+ openFlowsImpl = openFlows;
356
+ const same = n.listed === pages.listed &&
357
+ (n.flowsListed ?? 0) === (pages.flowsListed ?? 0) &&
358
+ (n.page?.title ?? null) === (pages.page?.title ?? null) &&
359
+ sectionsKey(n.sections) === sectionsKey(pages.sections) &&
360
+ sectionsKey(n.flows) === sectionsKey(pages.flows);
350
361
  if (!same) {
351
- pages = n.page || n.listed ? { page: n.page ? { title: n.page.title } : null, listed: n.listed } : NO_PAGES;
362
+ const sections = (n.sections ?? []).map(x => ({ key: x.key, title: x.title }));
363
+ const flows = (n.flows ?? []).map(x => ({ key: x.key, title: x.title }));
364
+ const flowsListed = n.flowsListed ?? 0;
365
+ pages =
366
+ n.page || n.listed || sections.length || flows.length || flowsListed
367
+ ? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections, flows, flowsListed }
368
+ : NO_PAGES;
352
369
  emit();
353
370
  }
354
371
  }
@@ -356,10 +373,16 @@ function guideMenuItems(e, p = NO_PAGES) {
356
373
  const items = [];
357
374
  if (p.page)
358
375
  items.push("page");
376
+ if ((p.sections ?? []).length > 0)
377
+ items.push("sections");
378
+ if ((p.flows ?? []).length > 0)
379
+ items.push("flowsHere");
359
380
  if (e && e.total > 0)
360
381
  items.push("missions");
361
382
  if (p.listed > 0)
362
383
  items.push("pages");
384
+ if ((p.flowsListed ?? 0) > 0)
385
+ items.push("flows");
363
386
  if (items.length)
364
387
  items.push("help");
365
388
  return items;
@@ -405,6 +428,87 @@ function usePageGuideEntry() {
405
428
  const title = p.page?.title ?? null;
406
429
  return useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
407
430
  }
431
+ /**
432
+ * Opens a section guide ("อธิบายส่วนนี้"), when it is on screen here for this
433
+ * person. The values it shows are the ones the app last rendered
434
+ * (`useSectionGuideValues`).
435
+ */
436
+ function openSectionGuide(key) {
437
+ openSectionImpl?.(key);
438
+ }
439
+ /** Starts a flow that starts on this page ("พาทำ · <title>"). */
440
+ function openFlow(key) {
441
+ openFlowImpl?.(key);
442
+ }
443
+ /** Opens the panel on the flows alone ("พาทำงาน"). */
444
+ function openFlowsPanel() {
445
+ openFlowsImpl?.();
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 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
+ 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
+ useEffect(() => {
507
+ if (values === undefined)
508
+ return;
509
+ return () => setSectionGuideValues(key, undefined);
510
+ }, [key, values === undefined]); // eslint-disable-line react-hooks/exhaustive-deps
511
+ }
408
512
 
409
513
  /** The synthetic mission key of a page guide: `page:<key>`. */
410
514
  const PAGE_PREFIX = "page:";
@@ -583,6 +687,278 @@ function currentPage(suite, where, ctx) {
583
687
  function listedPages(suite, ctx) {
584
688
  return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
585
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
+ }
824
+ /* ------------------------------------------------------------------ */
825
+ /* Workflow guides ("พาทำงาน", shell 0.1.76) */
826
+ /* ------------------------------------------------------------------ */
827
+ /** The synthetic mission key of a flow: `flow:<key>`. */
828
+ const FLOW_PREFIX = "flow:";
829
+ function isFlowMissionKey(key) {
830
+ return !!key && key.startsWith(FLOW_PREFIX);
831
+ }
832
+ function flowKeyOf(missionKey) {
833
+ return isFlowMissionKey(missionKey) ? missionKey.slice(FLOW_PREFIX.length) : undefined;
834
+ }
835
+ const asList = (r) => (r === undefined ? [] : Array.isArray(r) ? r : [r]);
836
+ /** Why a flow cannot run, or null when it can. */
837
+ function flowProblem(app, flow) {
838
+ if (!flow || typeof flow.key !== "string" || !PAGE_KEY_RE.test(flow.key))
839
+ return "its key is not [a-z0-9-]";
840
+ if (`${app}.flow.${flow.key}`.length > ROW_KEY_MAX)
841
+ return `its row key is over ${ROW_KEY_MAX} characters`;
842
+ if (!Number.isInteger(flow.version) || flow.version < 1)
843
+ return "its version is not a whole number ≥ 1";
844
+ const starts = asList(flow.startRoute);
845
+ if (!starts.length || starts.some(r => typeof r !== "string" || !r.startsWith("/")))
846
+ return "it has no start route";
847
+ if (!Array.isArray(flow.steps) || flow.steps.length === 0)
848
+ return "it has no steps";
849
+ for (const st of flow.steps) {
850
+ const kind = st.kind ?? "click";
851
+ if (kind === "view" || kind === "click")
852
+ continue;
853
+ if (!flow.allowCommit)
854
+ return `step "${st.key}" is a ${kind} step and the flow does not set allowCommit`;
855
+ if (kind === "commit" && !st.abort)
856
+ return `commit step "${st.key}" has no abort`;
857
+ }
858
+ return null;
859
+ }
860
+ const validFlowCache = new WeakMap();
861
+ /** The suite's flows that can run (as validPages: the rest dropped, with a warning in development). */
862
+ function validFlows(suite) {
863
+ const hit = validFlowCache.get(suite);
864
+ if (hit)
865
+ return hit;
866
+ const out = [];
867
+ const seen = new Set();
868
+ for (const flow of suite.flows ?? []) {
869
+ let problem = flowProblem(suite.app, flow);
870
+ if (!problem && seen.has(flow.key))
871
+ problem = "another flow has the same key";
872
+ if (problem) {
873
+ const id = `${suite.key}:flow:${flow?.key}:${problem}`;
874
+ if (!warned.has(id)) {
875
+ warned.add(id);
876
+ devWarn(`flow "${flow?.key}" dropped: ${problem}`);
877
+ }
878
+ continue;
879
+ }
880
+ seen.add(flow.key);
881
+ out.push(flow);
882
+ }
883
+ validFlowCache.set(suite, out);
884
+ return out;
885
+ }
886
+ function findFlow(suite, key) {
887
+ return key ? validFlows(suite).find(f => f.key === key) : undefined;
888
+ }
889
+ const flowMissionCache = new WeakMap();
890
+ /** The flow as a mission the machine plays (in replay mode): `flow:<key>`, its steps as authored. */
891
+ function flowMission(flow) {
892
+ const hit = flowMissionCache.get(flow);
893
+ if (hit)
894
+ return hit;
895
+ const m = { key: FLOW_PREFIX + flow.key, title: flow.title, minutes: flow.minutes ?? 1, steps: flow.steps };
896
+ flowMissionCache.set(flow, m);
897
+ return m;
898
+ }
899
+ /**
900
+ * The flow's chain: its start routes, every step's `route` and route
901
+ * trigger, and its `transit` routes. Leaving all of them ends the flow.
902
+ */
903
+ function flowChain(flow) {
904
+ const out = new Set(asList(flow.startRoute));
905
+ for (const st of flow.steps) {
906
+ if (st.route)
907
+ out.add(st.route);
908
+ const triggers = st.advanceOn ? (Array.isArray(st.advanceOn) ? st.advanceOn : [st.advanceOn]) : [];
909
+ for (const t of triggers)
910
+ if ("route" in t && t.route)
911
+ out.add(t.route);
912
+ }
913
+ for (const t of flow.transit ?? [])
914
+ out.add(t);
915
+ return Array.from(out);
916
+ }
917
+ /** Path patterns only (`?k=v` dropped): the chain is about pages, not their tabs. */
918
+ const pathOf = (pattern) => (pattern.includes("?") ? pattern.slice(0, pattern.indexOf("?")) : pattern);
919
+ /** Whether the person is on one of the flow's pages. */
920
+ function inFlowChain(flow, where) {
921
+ return flowChain(flow).some(r => !!matchRoute(pathOf(r), where.pathname, null, where.code));
922
+ }
923
+ /** Whether the person is on the flow's start page. */
924
+ function onFlowStart(flow, where) {
925
+ return asList(flow.startRoute).some(r => !!matchRoute(r, where.pathname, where.search, where.code));
926
+ }
927
+ /** Whether this person has the flow: `visible` (only true) and `requires`. */
928
+ function flowShown(flow, ctx) {
929
+ try {
930
+ if (flow.visible && flow.visible(ctx) !== true)
931
+ return false;
932
+ if (flow.requires && !ctx.can(flow.requires))
933
+ return false;
934
+ return true;
935
+ }
936
+ catch {
937
+ return false;
938
+ }
939
+ }
940
+ /** The flows this person has (the panel's "พาทำงาน" list), in the suite's order. */
941
+ function shownFlows(suite, ctx) {
942
+ return validFlows(suite).filter(f => flowShown(f, ctx));
943
+ }
944
+ /** Where the panel sends the person to start a flow, or null when it needs an id it does not have. */
945
+ function flowHref(flow, ctx) {
946
+ let raw;
947
+ try {
948
+ raw = flow.href === undefined ? undefined : typeof flow.href === "function" ? flow.href(ctx) : flow.href;
949
+ }
950
+ catch {
951
+ raw = undefined;
952
+ }
953
+ if (raw)
954
+ return /^https?:\/\//i.test(raw) ? raw : (fillRoute(raw, { code: ctx.code }) ?? null);
955
+ for (const r of asList(flow.startRoute)) {
956
+ const filled = fillRoute(r, { code: ctx.code });
957
+ if (filled)
958
+ return filled;
959
+ }
960
+ return null;
961
+ }
586
962
 
587
963
  /* ------------------------------------------------------------------ */
588
964
  /* Copy */
@@ -597,6 +973,8 @@ const COPY = {
597
973
  hiddenToast: "ซ่อนแล้ว",
598
974
  aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
599
975
  pageDone: "จบคู่มือหน้านี้",
976
+ sectionDone: "จบคำอธิบายส่วนนี้",
977
+ flowDone: "พาทำงานเสร็จแล้ว",
600
978
  bridgeTitle: "กดปุ่มเมนู",
601
979
  bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
602
980
  };
@@ -609,8 +987,11 @@ function initialGuideState(audience, progress, pages) {
609
987
  pageAudience: pages?.pageAudience ?? "none",
610
988
  pagesMode: pages?.pagesMode ?? "off",
611
989
  pages: pages?.pages ?? {},
990
+ sections: pages?.sections ?? {},
991
+ flows: pages?.flows ?? {},
612
992
  pageRun: null,
613
993
  panelSection: "all",
994
+ autoPassed: null,
614
995
  audience,
615
996
  progress,
616
997
  screen: { name: "idle" },
@@ -630,6 +1011,14 @@ function findMission(suite, key) {
630
1011
  const page = findPage(suite, pageKeyOf(key));
631
1012
  return page ? pageMission(page) : undefined;
632
1013
  }
1014
+ if (isSectionMissionKey(key)) {
1015
+ const section = findSection(suite, sectionKeyOf(key));
1016
+ return section ? sectionMission(section) : undefined;
1017
+ }
1018
+ if (isFlowMissionKey(key)) {
1019
+ const flow = findFlow(suite, flowKeyOf(key));
1020
+ return flow ? flowMission(flow) : undefined;
1021
+ }
633
1022
  return suite.missions.find(m => m.key === key);
634
1023
  }
635
1024
  /**
@@ -667,6 +1056,7 @@ function guideContext(env, data, params = {}) {
667
1056
  facts: env.facts,
668
1057
  can: env.can,
669
1058
  visible: (anchor) => env.isVisible(anchor),
1059
+ values: env.values ?? {},
670
1060
  };
671
1061
  }
672
1062
  function resolveText(t, ctx) {
@@ -980,7 +1370,7 @@ function announceStep(d, suite, env) {
980
1370
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
981
1371
  const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
982
1372
  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);
1373
+ const pos = stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
984
1374
  d.effects.push({
985
1375
  type: "announce",
986
1376
  text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
@@ -991,7 +1381,7 @@ function announceStep(d, suite, env) {
991
1381
  * whose `requires` they lack or whose `skipIf` holds is left out (the step on
992
1382
  * screen always counts).
993
1383
  */
994
- function stepPosition(mission, index, env, context) {
1384
+ function stepPosition(mission, index, env, context, autoPassed = []) {
995
1385
  let no = 0;
996
1386
  let total = 0;
997
1387
  mission.steps.forEach((step, i) => {
@@ -1001,6 +1391,12 @@ function stepPosition(mission, index, env, context) {
1001
1391
  const ctx = guideContext(env, context, routeParamsFor(step, env));
1002
1392
  if (step.skipIf && safe(() => step.skipIf(ctx), false))
1003
1393
  return;
1394
+ // A step that passed by itself (shell ≥ 0.1.75): before this one, as
1395
+ // recorded; after it, one whose outcome already holds now.
1396
+ if (i < index && autoPassed.includes(i))
1397
+ return;
1398
+ if (i > index && wouldAutoPass(step, env))
1399
+ return;
1004
1400
  }
1005
1401
  total++;
1006
1402
  if (i <= index)
@@ -1008,6 +1404,17 @@ function stepPosition(mission, index, env, context) {
1008
1404
  });
1009
1405
  return { no: Math.max(no, 1), total: Math.max(total, 1) };
1010
1406
  }
1407
+ /** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
1408
+ function wouldAutoPass(step, env) {
1409
+ const kind = stepKind(step);
1410
+ if (step.autoPass === false || kind === "input" || kind === "view")
1411
+ return false;
1412
+ return safe(() => stateTriggerHolds(step, env) !== null, false);
1413
+ }
1414
+ /** The steps of this mission that passed by themselves in this run. */
1415
+ function autoPassedOf(state, mission) {
1416
+ return state.autoPassed?.mission === mission ? state.autoPassed.steps : [];
1417
+ }
1011
1418
  /** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
1012
1419
  function plainTitle(t) {
1013
1420
  return t.replace(/\[([^\]]+)\]/g, "$1");
@@ -1020,6 +1427,8 @@ function enterStep(d, suite, env, mission, index, opts) {
1020
1427
  const steps = mission.steps;
1021
1428
  let i = Math.max(0, index);
1022
1429
  let firstPass = true;
1430
+ if (d.state.autoPassed?.mission !== mission.key)
1431
+ d.state = { ...d.state, autoPassed: { mission: mission.key, steps: [] } };
1023
1432
  while (i < steps.length) {
1024
1433
  const step = steps[i];
1025
1434
  const params = routeParamsFor(step, env);
@@ -1041,6 +1450,9 @@ function enterStep(d, suite, env, mission, index, opts) {
1041
1450
  const held = stateTriggerHolds(step, env);
1042
1451
  if (held) {
1043
1452
  captureInto(d, step, held, ctx);
1453
+ const ap = d.state.autoPassed;
1454
+ if (!ap.steps.includes(i))
1455
+ d.state = { ...d.state, autoPassed: { mission: ap.mission, steps: [...ap.steps, i] } };
1044
1456
  i++;
1045
1457
  continue;
1046
1458
  }
@@ -1054,9 +1466,11 @@ function enterStep(d, suite, env, mission, index, opts) {
1054
1466
  const step = steps[i];
1055
1467
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1056
1468
  const page = pageKeyOf(mission.key);
1469
+ const section = sectionKeyOf(mission.key);
1470
+ const flow = flowKeyOf(mission.key);
1057
1471
  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);
1472
+ // A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
1473
+ const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
1060
1474
  const bridge = !away && needsBridge(step, env, ctx);
1061
1475
  const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
1062
1476
  setScreen(d, {
@@ -1074,6 +1488,8 @@ function enterStep(d, suite, env, mission, index, opts) {
1074
1488
  status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
1075
1489
  replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
1076
1490
  ...(page ? { page } : {}),
1491
+ ...(section ? { section } : {}),
1492
+ ...(flow ? { flow } : {}),
1077
1493
  });
1078
1494
  if (!opts.replay) {
1079
1495
  const cp = checkpointAtOrBefore(steps, i);
@@ -1238,9 +1654,12 @@ function advance(d, suite, env, capture) {
1238
1654
  enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
1239
1655
  }
1240
1656
  function finishMission(d, suite, env, mission, replay) {
1241
- if (isPageMissionKey(mission.key)) {
1657
+ if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key) || isFlowMissionKey(mission.key)) {
1242
1658
  endPage(d, suite, "completed");
1243
- d.effects.push({ type: "toast", text: COPY.pageDone });
1659
+ d.effects.push({
1660
+ type: "toast",
1661
+ text: isSectionMissionKey(mission.key) ? COPY.sectionDone : isFlowMissionKey(mission.key) ? COPY.flowDone : COPY.pageDone,
1662
+ });
1244
1663
  return;
1245
1664
  }
1246
1665
  if (replay) {
@@ -1344,7 +1763,8 @@ function startMission(d, suite, env, key, replay) {
1344
1763
  // A mission already done is only ever replayed; its done status stays.
1345
1764
  const isReplay = replay || entry?.status === "done";
1346
1765
  const before = d.state.progress;
1347
- d.state = { ...d.state, panelOpen: false, justDone: null };
1766
+ // A new run: what passed by itself is counted afresh.
1767
+ d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
1348
1768
  if (d.state.progress.welcome === "not_seen")
1349
1769
  withProgress(d, { welcome: "started" });
1350
1770
  if (!isReplay && d.state.progress.mission_key !== key)
@@ -1503,14 +1923,168 @@ function recordPage(d, suite, key, status) {
1503
1923
  d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
1504
1924
  d.effects.push({ type: "savePage", key, version: page.version, status });
1505
1925
  }
1506
- /** The page guide ends: its row written when it is due one; back to idle. */
1926
+ /** The section guide's row says this person has ended this version of it. */
1927
+ function sectionRecorded(state, section) {
1928
+ const row = state.sections?.[section.key];
1929
+ return !!row && row.version >= section.version;
1930
+ }
1931
+ /** Write a section guide's row (once per section and version). */
1932
+ function recordSection(d, suite, key, status) {
1933
+ const section = findSection(suite, key);
1934
+ if (!section || sectionRecorded(d.state, section))
1935
+ return;
1936
+ const prev = d.state.sections?.[key];
1937
+ d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
1938
+ d.effects.push({ type: "savePage", key, version: section.version, status, kind: "section" });
1939
+ }
1940
+ /** The page or section guide on screen, or null. */
1941
+ function readOf(s) {
1942
+ if (s.name !== "step")
1943
+ return null;
1944
+ if (s.page)
1945
+ return { kind: "page", key: s.page };
1946
+ if (s.section)
1947
+ return { kind: "section", key: s.section };
1948
+ return null;
1949
+ }
1950
+ /** Whether a page or section guide still applies where the person is. */
1951
+ function readStillHere(suite, r, env) {
1952
+ if (r.kind === "page")
1953
+ return pageStillHere(suite, r.key, env);
1954
+ const section = findSection(suite, r.key);
1955
+ return !!section && sectionApplies(section, whereOf(env));
1956
+ }
1957
+ /** The flow's row says this person has ended this version of it. */
1958
+ function flowRecorded(state, flow) {
1959
+ const row = state.flows?.[flow.key];
1960
+ return !!row && row.version >= flow.version;
1961
+ }
1962
+ /** Write a flow's row (once per flow and version). */
1963
+ function recordFlow(d, suite, key, status) {
1964
+ const flow = findFlow(suite, key);
1965
+ if (!flow || flowRecorded(d.state, flow))
1966
+ return;
1967
+ const prev = d.state.flows?.[key];
1968
+ d.state = { ...d.state, flows: { ...d.state.flows, [key]: { version: flow.version, status, rev: prev?.rev ?? 0 } } };
1969
+ d.effects.push({ type: "savePage", key, version: flow.version, status, kind: "flow" });
1970
+ }
1971
+ /** The page guide, section guide or flow ends: its row written when it is due one; back to idle. */
1507
1972
  function endPage(d, suite, how) {
1508
1973
  const run = d.state.pageRun;
1509
- if (run?.write)
1510
- recordPage(d, suite, run.key, how);
1974
+ if (run?.write) {
1975
+ if (run.kind === "section")
1976
+ recordSection(d, suite, run.key, how);
1977
+ else if (run.kind === "flow")
1978
+ recordFlow(d, suite, run.key, how);
1979
+ else
1980
+ recordPage(d, suite, run.key, how);
1981
+ }
1982
+ // A flow's captured ids were its own: the tour's come back.
1983
+ if (run?.kind === "flow" && run.context)
1984
+ withProgress(d, { context: run.context });
1511
1985
  d.state = { ...d.state, pageRun: null, replayPage: null };
1512
1986
  setScreen(d, { name: "idle" });
1513
1987
  }
1988
+ /** The flow on screen, or null. */
1989
+ function flowOf(s) {
1990
+ return s.name === "step" && s.flow ? s.flow : null;
1991
+ }
1992
+ /** The flows this person has that start on this page ("พาทำ · <title>"). */
1993
+ function flowsAvailable(suite, env) {
1994
+ if (!suite.flows?.length)
1995
+ return [];
1996
+ const ctx = guideContext(env, {});
1997
+ const where = whereOf(env);
1998
+ return (suite.flows ?? [])
1999
+ .map(f => findFlow(suite, f?.key))
2000
+ .filter((f) => !!f && onFlowStart(f, where) && flowShown(f, ctx))
2001
+ .map(f => f.key);
2002
+ }
2003
+ /**
2004
+ * The flow to offer now, or null: only in "offer" mode, only a flow that
2005
+ * says `offer: true`, on its start page, with no row of this version, when
2006
+ * nothing else is up and this page's own guide is not due (it comes first:
2007
+ * no guide here, offer: false, or already recorded), its first step's
2008
+ * target on screen, no app dialog open. The host adds the welcome and the
2009
+ * idle wait, as for the page guide's offer.
2010
+ */
2011
+ function flowOfferDue(state, suite, env) {
2012
+ if (state.pagesMode !== "offer" || state.pageAudience === "none")
2013
+ return null;
2014
+ if (state.screen.name !== "idle" || state.panelOpen || (state.pendingCelebrate?.length ?? 0) > 0)
2015
+ return null;
2016
+ const page = pageHere(suite, env);
2017
+ if (page && page.offer !== false && !pageRecorded(state, page))
2018
+ return null;
2019
+ if (safe(() => env.dialogOpen?.() === true, false))
2020
+ return null;
2021
+ for (const key of flowsAvailable(suite, env)) {
2022
+ const flow = findFlow(suite, key);
2023
+ if (flow.offer !== true || flowRecorded(state, flow))
2024
+ continue;
2025
+ const mission = flowMission(flow);
2026
+ const first = mission.steps.find(st => {
2027
+ const ctx = guideContext(env, {});
2028
+ if (st.requires && !safe(() => env.can(st.requires), true))
2029
+ return false;
2030
+ return !(st.skipIf && safe(() => st.skipIf(ctx), false));
2031
+ });
2032
+ if (first && !stepTargetVisible(first, env, guideContext(env, {}), false))
2033
+ continue;
2034
+ return key;
2035
+ }
2036
+ return null;
2037
+ }
2038
+ function startFlow(d, suite, env, key, write, at) {
2039
+ const flow = findFlow(suite, key);
2040
+ if (!flow)
2041
+ return;
2042
+ const mission = flowMission(flow);
2043
+ const saved = d.state.progress.context;
2044
+ d.state = {
2045
+ ...d.state,
2046
+ panelOpen: false,
2047
+ justDone: null,
2048
+ pageRun: { key, write, kind: "flow", context: saved },
2049
+ autoPassed: null,
2050
+ replayPage: {
2051
+ mission: mission.key,
2052
+ anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
2053
+ dialog: safe(() => env.dialogOpen?.() === true, false),
2054
+ },
2055
+ };
2056
+ withProgress(d, { context: at?.context ?? {} });
2057
+ let index = 0;
2058
+ if (at) {
2059
+ const stepAt = mission.steps.findIndex(st => st.key === at.step);
2060
+ if (stepAt > 0)
2061
+ index = checkpointAtOrBefore(mission.steps, stepAt);
2062
+ }
2063
+ enterStep(d, suite, env, mission, index, { replay: true });
2064
+ }
2065
+ /** The section guides on screen here that this person has (the "?" entries, the buttons). */
2066
+ function sectionsAvailable(suite, env) {
2067
+ return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
2068
+ }
2069
+ function startSection(d, suite, env, key, write) {
2070
+ const section = findSection(suite, key);
2071
+ if (!section)
2072
+ return;
2073
+ const mission = sectionMission(section);
2074
+ d.state = {
2075
+ ...d.state,
2076
+ panelOpen: false,
2077
+ justDone: null,
2078
+ pageRun: { key, write, kind: "section" },
2079
+ autoPassed: null,
2080
+ replayPage: {
2081
+ mission: mission.key,
2082
+ anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
2083
+ dialog: safe(() => env.dialogOpen?.() === true, false),
2084
+ },
2085
+ };
2086
+ enterStep(d, suite, env, mission, 0, { replay: true });
2087
+ }
1514
2088
  function startPage(d, suite, env, key, write) {
1515
2089
  const page = findPage(suite, key);
1516
2090
  if (!page)
@@ -1520,7 +2094,8 @@ function startPage(d, suite, env, key, write) {
1520
2094
  ...d.state,
1521
2095
  panelOpen: false,
1522
2096
  justDone: null,
1523
- pageRun: { key, write },
2097
+ pageRun: { key, write, kind: "page" },
2098
+ autoPassed: null,
1524
2099
  // What is open now is the person's: ending the guide leaves it alone.
1525
2100
  replayPage: {
1526
2101
  mission: mission.key,
@@ -1620,7 +2195,7 @@ function reduceGuide(state, action, suite, env) {
1620
2195
  break;
1621
2196
  // A mission always wins over a page guide: a running one ends (its row
1622
2197
  // written if due), an offer goes unrecorded.
1623
- if (s.name === "step" && s.page)
2198
+ if (readOf(s) || flowOf(s))
1624
2199
  endPage(d, suite, "skipped");
1625
2200
  else if (s.name === "pageOffer")
1626
2201
  setScreen(d, { name: "idle" });
@@ -1641,12 +2216,21 @@ function reduceGuide(state, action, suite, env) {
1641
2216
  const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
1642
2217
  const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
1643
2218
  const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
2219
+ d.state = { ...d.state, autoPassed: null };
1644
2220
  enterStep(d, suite, env, mission, index, { replay: false });
1645
2221
  break;
1646
2222
  }
1647
2223
  case "sync": {
1648
2224
  if (s.name === "pageOffer") {
1649
2225
  // Ignored: the person left the page (or its sub-screen). Recorded.
2226
+ if (s.flow) {
2227
+ const flow = findFlow(suite, s.page);
2228
+ if (!flow || !onFlowStart(flow, whereOf(env))) {
2229
+ recordFlow(d, suite, s.page, "skipped");
2230
+ setScreen(d, { name: "idle" });
2231
+ }
2232
+ break;
2233
+ }
1650
2234
  if (!pageStillHere(suite, s.page, env)) {
1651
2235
  recordPage(d, suite, s.page, "skipped");
1652
2236
  setScreen(d, { name: "idle" });
@@ -1655,8 +2239,18 @@ function reduceGuide(state, action, suite, env) {
1655
2239
  }
1656
2240
  if (s.name !== "step")
1657
2241
  break;
1658
- if (s.page && !pageStillHere(suite, s.page, env)) {
1659
- // Leaving the page ends its guide, silently.
2242
+ if (s.flow) {
2243
+ // Off the flow's pages: it ends, silently. Between them it goes on
2244
+ // (a step on another of its pages shows "ทำต่อ").
2245
+ const flow = findFlow(suite, s.flow);
2246
+ if (!flow || !inFlowChain(flow, env)) {
2247
+ endPage(d, suite, "skipped");
2248
+ break;
2249
+ }
2250
+ }
2251
+ const read = readOf(s);
2252
+ if (read && !readStillHere(suite, read, env)) {
2253
+ // Leaving the page (or the section's root going) ends its guide, silently.
1660
2254
  endPage(d, suite, "skipped");
1661
2255
  break;
1662
2256
  }
@@ -1689,7 +2283,7 @@ function reduceGuide(state, action, suite, env) {
1689
2283
  break;
1690
2284
  }
1691
2285
  }
1692
- if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page)) {
2286
+ if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
1693
2287
  // The target is missing because the page is another one.
1694
2288
  recoverOffRoute(d, suite, env, false);
1695
2289
  break;
@@ -1805,7 +2399,7 @@ function reduceGuide(state, action, suite, env) {
1805
2399
  const cur = d.state.screen;
1806
2400
  if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
1807
2401
  break;
1808
- if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
2402
+ if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page ?? cur.section)) {
1809
2403
  recoverOffRoute(d, suite, env, false);
1810
2404
  break;
1811
2405
  }
@@ -1826,7 +2420,7 @@ function reduceGuide(state, action, suite, env) {
1826
2420
  if (!mission)
1827
2421
  break;
1828
2422
  const step = mission.steps[s.index];
1829
- if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
2423
+ if (!s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
1830
2424
  recoverOffRoute(d, suite, env, false);
1831
2425
  break;
1832
2426
  }
@@ -1843,7 +2437,7 @@ function reduceGuide(state, action, suite, env) {
1843
2437
  advance(d, suite, env);
1844
2438
  }
1845
2439
  else if (policy === "abort") {
1846
- if (s.page)
2440
+ if (readOf(s) || flowOf(s))
1847
2441
  endPage(d, suite, "skipped");
1848
2442
  else
1849
2443
  exitMission(d);
@@ -1869,7 +2463,7 @@ function reduceGuide(state, action, suite, env) {
1869
2463
  if (s.name !== "step" || s.phase !== "missing")
1870
2464
  break;
1871
2465
  const mission = findMission(suite, s.mission);
1872
- if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
2466
+ if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
1873
2467
  // Polling again cannot help on another page: go back to the step's.
1874
2468
  recoverOffRoute(d, suite, env, true);
1875
2469
  break;
@@ -1885,7 +2479,7 @@ function reduceGuide(state, action, suite, env) {
1885
2479
  case "exit":
1886
2480
  if (s.name !== "step")
1887
2481
  break;
1888
- if (s.page) {
2482
+ if (readOf(s) || flowOf(s)) {
1889
2483
  // ✕, Escape or ปิดคู่มือ: ends silently.
1890
2484
  pushAbort(d, suite, s);
1891
2485
  endPage(d, suite, "skipped");
@@ -1913,6 +2507,11 @@ function reduceGuide(state, action, suite, env) {
1913
2507
  case "endWithoutSave":
1914
2508
  if (s.name !== "step" || !s.replay)
1915
2509
  break;
2510
+ if (flowOf(s)) {
2511
+ pushAbort(d, suite, s);
2512
+ endPage(d, suite, "skipped");
2513
+ break;
2514
+ }
1916
2515
  pushAbort(d, suite, s);
1917
2516
  setScreen(d, { name: "idle" });
1918
2517
  d.effects.push({ type: "toast", text: COPY.replayEnded });
@@ -1961,7 +2560,7 @@ function reduceGuide(state, action, suite, env) {
1961
2560
  break;
1962
2561
  case "openPanel": {
1963
2562
  const section = action.section ?? "all";
1964
- const allowed = section === "pages" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
2563
+ const allowed = section !== "all" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
1965
2564
  if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
1966
2565
  break;
1967
2566
  // The offer gives way to the panel, unrecorded.
@@ -2014,6 +2613,13 @@ function reduceGuide(state, action, suite, env) {
2014
2613
  break;
2015
2614
  }
2016
2615
  case "pageOffer": {
2616
+ if (action.flow) {
2617
+ if (flowOfferDue(state, suite, env) !== action.page)
2618
+ break;
2619
+ setScreen(d, { name: "pageOffer", page: action.page, flow: true });
2620
+ d.effects.push({ type: "announce", text: `พาทำงาน ${findFlow(suite, action.page)?.title ?? ""}` });
2621
+ break;
2622
+ }
2017
2623
  if (pageOfferDue(state, suite, env) !== action.page)
2018
2624
  break;
2019
2625
  setScreen(d, { name: "pageOffer", page: action.page });
@@ -2023,14 +2629,42 @@ function reduceGuide(state, action, suite, env) {
2023
2629
  case "pageOfferStart":
2024
2630
  if (s.name !== "pageOffer" || state.pageAudience === "none")
2025
2631
  break;
2026
- startPage(d, suite, env, s.page, true);
2632
+ if (s.flow)
2633
+ startFlow(d, suite, env, s.page, true);
2634
+ else
2635
+ startPage(d, suite, env, s.page, true);
2027
2636
  break;
2028
2637
  case "pageOfferLater":
2029
2638
  if (s.name !== "pageOffer")
2030
2639
  break;
2031
- recordPage(d, suite, s.page, "skipped");
2640
+ if (s.flow)
2641
+ recordFlow(d, suite, s.page, "skipped");
2642
+ else
2643
+ recordPage(d, suite, s.page, "skipped");
2032
2644
  setScreen(d, { name: "idle" });
2033
2645
  break;
2646
+ case "startFlow": {
2647
+ // Only on its start page; never over a mission step, a welcome, a gate
2648
+ // or a celebration.
2649
+ if (state.pageAudience === "none" || state.pagesMode === "off")
2650
+ break;
2651
+ if (s.name !== "idle" && s.name !== "pageOffer")
2652
+ break;
2653
+ if (!flowsAvailable(suite, env).includes(action.flow))
2654
+ break;
2655
+ const flow = findFlow(suite, action.flow);
2656
+ startFlow(d, suite, env, flow.key, !flowRecorded(state, flow));
2657
+ break;
2658
+ }
2659
+ case "resumeFlow": {
2660
+ if (state.pageAudience === "none" || state.pagesMode === "off" || s.name !== "idle")
2661
+ break;
2662
+ const flow = findFlow(suite, action.flow);
2663
+ if (!flow || !flowShown(flow, guideContext(env, {})) || !inFlowChain(flow, whereOf(env)))
2664
+ break;
2665
+ startFlow(d, suite, env, flow.key, action.write && !flowRecorded(state, flow), { step: action.step, context: action.context });
2666
+ break;
2667
+ }
2034
2668
  case "startPage": {
2035
2669
  // Never over a mission step, a welcome, a gate or a celebration.
2036
2670
  if (state.pageAudience === "none" || state.pagesMode === "off")
@@ -2044,7 +2678,7 @@ function reduceGuide(state, action, suite, env) {
2044
2678
  break;
2045
2679
  }
2046
2680
  case "back": {
2047
- if (s.name !== "step" || !s.page || s.phase !== "ready" || s.confirmExit)
2681
+ if (s.name !== "step" || !readOf(s) || s.phase !== "ready" || s.confirmExit)
2048
2682
  break;
2049
2683
  const mission = findMission(suite, s.mission);
2050
2684
  if (!mission)
@@ -2059,12 +2693,39 @@ function reduceGuide(state, action, suite, env) {
2059
2693
  break;
2060
2694
  }
2061
2695
  case "pageRow": {
2696
+ if (action.kind === "flow") {
2697
+ const cur = d.state.flows?.[action.key];
2698
+ if (cur && cur.version > action.row.version)
2699
+ break;
2700
+ d.state = { ...d.state, flows: { ...d.state.flows, [action.key]: action.row } };
2701
+ break;
2702
+ }
2703
+ if (action.kind === "section") {
2704
+ const cur = d.state.sections?.[action.key];
2705
+ if (cur && cur.version > action.row.version)
2706
+ break;
2707
+ d.state = { ...d.state, sections: { ...d.state.sections, [action.key]: action.row } };
2708
+ break;
2709
+ }
2062
2710
  const cur = d.state.pages[action.key];
2063
2711
  if (cur && cur.version > action.row.version)
2064
2712
  break;
2065
2713
  d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
2066
2714
  break;
2067
2715
  }
2716
+ case "startSection": {
2717
+ // Never offered: only on request. Never over a mission step, a
2718
+ // welcome, a gate or a celebration; only on screen, only theirs.
2719
+ if (state.pageAudience === "none" || state.pagesMode === "off")
2720
+ break;
2721
+ if (s.name !== "idle" && s.name !== "pageOffer")
2722
+ break;
2723
+ if (!sectionsAvailable(suite, env).includes(action.section))
2724
+ break;
2725
+ const section = findSection(suite, action.section);
2726
+ startSection(d, suite, env, section.key, !sectionRecorded(state, section));
2727
+ break;
2728
+ }
2068
2729
  case "idleCelebrate": {
2069
2730
  if (s.name !== "idle" || state.panelOpen)
2070
2731
  break;
@@ -2193,6 +2854,8 @@ function parseGuidesAnswer(payload, suite) {
2193
2854
  ? data.page_audience
2194
2855
  : "none";
2195
2856
  const pages = parsePageRows(rows, suite.app);
2857
+ const sections = parsePageRows(rows, suite.app, "section");
2858
+ const flows = parsePageRows(rows, suite.app, "flow");
2196
2859
  let row = null;
2197
2860
  for (const r of rows) {
2198
2861
  if (isRecord(r) && r.guide_key === suite.key) {
@@ -2213,15 +2876,15 @@ function parseGuidesAnswer(payload, suite) {
2213
2876
  progress = row;
2214
2877
  newerOnServer = row.guide_version > suite.version;
2215
2878
  }
2216
- return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages };
2879
+ return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections, flows };
2217
2880
  }
2218
- /** The row key of a page guide: `<app>.page.<key>`. */
2219
- function pageRowKey(app, key) {
2220
- return `${app}.page.${key}`;
2881
+ /** The row key of a page guide `<app>.page.<key>`, a section guide `<app>.section.<key>`, or a flow `<app>.flow.<key>`. */
2882
+ function pageRowKey(app, key, kind = "page") {
2883
+ return `${app}.${kind}.${key}`;
2221
2884
  }
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, "");
2885
+ /** Page (or section) rows among the list's rows, by key. Off-shape rows are left out. */
2886
+ function parsePageRows(rows, app, kind = "page") {
2887
+ const prefix = pageRowKey(app, "", kind);
2225
2888
  const out = {};
2226
2889
  for (const r of rows) {
2227
2890
  if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
@@ -2471,14 +3134,15 @@ async function storeGuides(adapter, suite, progress) {
2471
3134
  return null;
2472
3135
  }
2473
3136
  /**
2474
- * Writes a page guide's row (`<app>.page.<key>`) once: the minimal body,
3137
+ * Writes a page guide's row (`<app>.page.<key>`), or a section guide's
3138
+ * (`<app>.section.<key>`, kind "section"), once: the minimal body,
2475
3139
  * with the same 409 handling as the tour's row. Another tab that already
2476
3140
  * recorded this version wins (its row is returned, nothing is sent again);
2477
3141
  * an older version's row is replaced with its rev; a newer one stops. Returns
2478
3142
  * the stored row, or null when it could not be saved.
2479
3143
  */
2480
- async function storePageRow(adapter, app, key, version, status, rev) {
2481
- const guideKey = pageRowKey(app, key);
3144
+ async function storePageRow(adapter, app, key, version, status, rev, kind = "page") {
3145
+ const guideKey = pageRowKey(app, key, kind);
2482
3146
  let at = rev;
2483
3147
  for (let attempt = 0; attempt < 2; attempt++) {
2484
3148
  let res;
@@ -3396,7 +4060,10 @@ function CompleteModal(p) {
3396
4060
  * corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
3397
4061
  */
3398
4062
  function PageOfferCard(p) {
3399
- return (jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsx("p", { className: "eg-po-e", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49" }), jsx("p", { className: "eg-b-t", id: "eg-po-t", children: p.title }), jsxs("p", { className: "eg-b-w", children: [p.steps, " \u0E02\u0E31\u0E49\u0E19"] }), jsxs("div", { className: "eg-row", children: [jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49" }), jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" })] })] }));
4063
+ return (jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsx("p", { className: "eg-po-e", children: p.eyebrow ?? "คู่มือหน้านี้" }), jsx("p", { className: "eg-b-t", id: "eg-po-t", children: p.title }), jsxs("p", { className: "eg-b-w", children: [p.steps, " \u0E02\u0E31\u0E49\u0E19"] }), jsxs("div", { className: "eg-row", children: [jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49" }), jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" })] })] }));
4064
+ }
4065
+ function FlowList({ items, onOpen }) {
4066
+ return (jsx("ul", { className: "eg-q-list eg-pg-list", children: items.map(it => (jsxs("li", { className: "eg-q-item eg-pg-item", children: [jsxs("div", { className: "eg-t", children: [jsx("b", { children: it.title }), jsx("small", { children: it.done ? "ทำแล้ว" : it.minutes ? `ประมาณ ${it.minutes} นาที` : "ใหม่" })] }), jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onOpen(it.key), children: it.done ? "ทำอีกครั้ง" : "เริ่ม" })] }, it.key))) }));
3400
4067
  }
3401
4068
  function PageList({ items, onOpen }) {
3402
4069
  return (jsx("ul", { className: "eg-q-list eg-pg-list", children: items.map(it => (jsxs("li", { className: "eg-q-item eg-pg-item", children: [jsxs("div", { className: "eg-t", children: [jsx("b", { children: it.title }), jsx("small", { className: it.seen ? undefined : "eg-new", children: it.seen ? "ดูแล้ว" : "ใหม่" })] }), jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onOpen(it.key), children: "\u0E40\u0E1B\u0E34\u0E14" })] }, it.key))) }));
@@ -3423,12 +4090,16 @@ function QuestChip({ done, total, pulse, onOpen }) {
3423
4090
  }
3424
4091
  function QuestPanel(p) {
3425
4092
  const ref = useAutoFocus();
3426
- const pages = p.pages ?? [];
4093
+ const pages = p.only === "flows" ? [] : (p.pages ?? []);
4094
+ const flows = p.only === "pages" ? [] : (p.flows ?? []);
3427
4095
  const openPage = p.onOpenPage ?? (() => { });
4096
+ const openFlow = p.onOpenFlow ?? (() => { });
4097
+ const flowSection = flows.length ? (jsxs(Fragment, { children: [jsx("h3", { className: "eg-q-sub", id: "eg-q-flows", children: "\u0E1E\u0E32\u0E17\u0E33\u0E07\u0E32\u0E19" }), jsx(FlowList, { items: flows, onOpen: openFlow })] })) : null;
3428
4098
  if (p.showMissions === false) {
3429
- return (jsxs(Fragment, { children: [jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxs("div", { className: "eg-q-h", children: [jsx("h2", { id: "eg-q-t", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsx(IconX, {}) })] }), jsx(PageList, { items: pages, onOpen: openPage })] })] }));
4099
+ const heading = pages.length ? "คู่มือแต่ละหน้า" : "พาทำงาน";
4100
+ return (jsxs(Fragment, { children: [jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxs("div", { className: "eg-q-h", children: [jsx("h2", { id: "eg-q-t", children: heading }), jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsx(IconX, {}) })] }), pages.length ? jsx(PageList, { items: pages, onOpen: openPage }) : null, pages.length ? flowSection : flows.length ? jsx(FlowList, { items: flows, onOpen: openFlow }) : null] })] }));
3430
4101
  }
3431
- return (jsxs(Fragment, { children: [jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxs("div", { className: "eg-q-h", children: [jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsx(IconX, {}) })] }), jsx("div", { className: "eg-prog", children: jsx("div", { className: "eg-bar", children: jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), pages.length ? (jsxs(Fragment, { children: [jsx("h3", { className: "eg-q-sub", id: "eg-q-pages", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsx(PageList, { items: pages, onOpen: openPage })] })) : null, p.canHideChip ? (jsx("div", { className: "eg-q-f", children: jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onHideChip, children: "\u0E0B\u0E48\u0E2D\u0E19\u0E1B\u0E38\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08" }) })) : null] })] }));
4102
+ return (jsxs(Fragment, { children: [jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxs("div", { className: "eg-q-h", children: [jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsx(IconX, {}) })] }), jsx("div", { className: "eg-prog", children: jsx("div", { className: "eg-bar", children: jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), pages.length ? (jsxs(Fragment, { children: [jsx("h3", { className: "eg-q-sub", id: "eg-q-pages", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsx(PageList, { items: pages, onOpen: openPage })] })) : null, flowSection, p.canHideChip ? (jsx("div", { className: "eg-q-f", children: jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onHideChip, children: "\u0E0B\u0E48\u0E2D\u0E19\u0E1B\u0E38\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08" }) })) : null] })] }));
3432
4103
  }
3433
4104
  function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
3434
4105
  const v = item.view;
@@ -3566,11 +4237,10 @@ function writeMirror(orgId, suite, p) {
3566
4237
  /* private mode: the server copy is enough */
3567
4238
  }
3568
4239
  }
3569
- /** 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) {
4240
+ const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind}s:${orgId}:${suite.app}`;
4241
+ function readPagesMirror(orgId, suite, kind = "page") {
3572
4242
  try {
3573
- const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite));
4243
+ const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
3574
4244
  const parsed = raw ? JSON.parse(raw) : {};
3575
4245
  const out = {};
3576
4246
  for (const [k, v] of Object.entries(parsed ?? {})) {
@@ -3583,9 +4253,9 @@ function readPagesMirror(orgId, suite) {
3583
4253
  return {};
3584
4254
  }
3585
4255
  }
3586
- function writePagesMirror(orgId, suite, pages) {
4256
+ function writePagesMirror(orgId, suite, pages, kind = "page") {
3587
4257
  try {
3588
- sessionStorage.setItem(pagesMirrorKey(orgId, suite), JSON.stringify(pages));
4258
+ sessionStorage.setItem(pagesMirrorKey(orgId, suite, kind), JSON.stringify(pages));
3589
4259
  }
3590
4260
  catch {
3591
4261
  /* private mode */
@@ -3601,6 +4271,8 @@ function mergePageRows(server, local) {
3601
4271
  }
3602
4272
  return out;
3603
4273
  }
4274
+ /** A flow in progress in this tab (resumed after a refresh, never across sessions). */
4275
+ const flowMirrorKey = (orgId, suite) => `erp-guide-flow-run:${orgId}:${suite.app}`;
3604
4276
  /* ------------------------------------------------------------------ */
3605
4277
  /* One tab at a time */
3606
4278
  /* ------------------------------------------------------------------ */
@@ -3713,7 +4385,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3713
4385
  const [answer, setAnswer] = useState(null);
3714
4386
  const missionsReady = config.ready !== false;
3715
4387
  const pagesReady = config.pagesReady ?? missionsReady;
3716
- const hasPages = useMemo(() => validPages(suite).length > 0, [suite]);
4388
+ const hasPages = useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0 || validFlows(suite).length > 0, [suite]);
4389
+ const hasFlows = useMemo(() => validFlows(suite).length > 0, [suite]);
4390
+ const hasSections = useMemo(() => validSections(suite).length > 0, [suite]);
3717
4391
  const enabled = primary && (missionsReady || (hasPages && pagesReady));
3718
4392
  const loaded = useRef(false);
3719
4393
  useEffect(() => {
@@ -3769,6 +4443,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3769
4443
  pageAudience: pagesOn ? answer.pageAudience : "none",
3770
4444
  pagesMode: answer.pagesMode,
3771
4445
  pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
4446
+ sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
4447
+ flows: mergePageRows(answer.flows ?? {}, readPagesMirror(organizationId, suite, "flow")),
3772
4448
  });
3773
4449
  stateRef.current = s;
3774
4450
  setState(s);
@@ -3777,8 +4453,12 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3777
4453
  const pageRef = useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
3778
4454
  pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
3779
4455
  const targetElRef = useRef(null);
4456
+ // The section guide being opened (before its first step is on screen).
4457
+ const openingSection = useRef(null);
3780
4458
  const env = useCallback(() => {
3781
4459
  const pg = pageRef.current;
4460
+ const sc = stateRef.current?.screen;
4461
+ const sectionKey = (sc?.name === "step" ? sc.section : undefined) ?? openingSection.current;
3782
4462
  return {
3783
4463
  code,
3784
4464
  pathname: pg.pathname,
@@ -3796,6 +4476,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3796
4476
  sidebarOpen: pg.sidebarOpen,
3797
4477
  targetValue: () => fieldValue(targetElRef.current),
3798
4478
  dialogOpen: () => appDialogOpen(),
4479
+ values: getSectionGuideValues(sectionKey),
3799
4480
  };
3800
4481
  }, [code]);
3801
4482
  /* ---------------- effects: save, announce, toast, navigate ---------------- */
@@ -3871,13 +4552,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3871
4552
  }
3872
4553
  else if (e.type === "savePage") {
3873
4554
  // 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 => {
4555
+ const kind = e.kind ?? "page";
4556
+ const cur = stateRef.current;
4557
+ const rows = cur ? (kind === "section" ? cur.sections : kind === "flow" ? cur.flows : cur.pages) : undefined;
4558
+ if (rows)
4559
+ writePagesMirror(organizationId, suite, rows, kind);
4560
+ const rev = rows?.[e.key]?.rev ?? 0;
4561
+ void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev, kind).then(row => {
3878
4562
  // eslint-disable-next-line @typescript-eslint/no-use-before-define
3879
4563
  if (row)
3880
- dispatchRef.current({ type: "pageRow", key: e.key, row });
4564
+ dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
3881
4565
  });
3882
4566
  }
3883
4567
  }
@@ -3967,7 +4651,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3967
4651
  /* ---------------- sync on route, sidebar, facts, DOM ---------------- */
3968
4652
  // A page guide's step runs while page guides are live; a mission's while
3969
4653
  // the missions are. The offer card is watched the same way (left: ignored).
3970
- const inStep = screen.name === "step" && (screen.page ? pagesLive : missionsLive);
4654
+ const inStep = screen.name === "step" && (screen.page || screen.section || screen.flow ? pagesLive : missionsLive);
3971
4655
  const offerUp = pagesLive && screen.name === "pageOffer";
3972
4656
  const watching = inStep || offerUp;
3973
4657
  useEffect(() => {
@@ -4015,6 +4699,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4015
4699
  const step = shownStep(raw, screen.nothingToSave);
4016
4700
  return { mission, step, raw };
4017
4701
  }, [state, screen, suite]);
4702
+ const valuesVersion = useSectionValuesVersion();
4018
4703
  const stepCtx = useMemo(() => {
4019
4704
  if (!state)
4020
4705
  return null;
@@ -4023,7 +4708,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4023
4708
  return guideContext(e, state.progress.context, params);
4024
4709
  // pathname/search/factsKey make it current.
4025
4710
  // eslint-disable-next-line react-hooks/exhaustive-deps
4026
- }, [state, stepInfo, env, code, pathname, search, factsKey]);
4711
+ }, [state, stepInfo, env, code, pathname, search, factsKey, valuesVersion]);
4027
4712
  const target = stepInfo && stepCtx && screen.name === "step"
4028
4713
  ? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
4029
4714
  : null;
@@ -4531,7 +5216,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4531
5216
  /* ---------------- page guides: this page's, the offer, the list ---------------- */
4532
5217
  // `when` anchors (LIFF's sub-screens) come and go without a route change:
4533
5218
  // look again when the page changes.
4534
- const hasWhen = useMemo(() => validPages(suite).some(p => !!p.when), [suite]);
5219
+ const hasWhen = useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
4535
5220
  const [domTick, setDomTick] = useState(0);
4536
5221
  useEffect(() => {
4537
5222
  if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
@@ -4565,6 +5250,30 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4565
5250
  // eslint-disable-next-line react-hooks/exhaustive-deps
4566
5251
  [pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
4567
5252
  const listed = useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
5253
+ const sectionsNow = useMemo(() => pagesLive && hasState && hasSections
5254
+ ? sectionsAvailable(suite, env()).map(k => ({ key: k, title: findSection(suite, k)?.title ?? "" }))
5255
+ : [],
5256
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5257
+ [pagesLive, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
5258
+ const sectionsSig = sectionsNow.map(x => x.key).join(",");
5259
+ const openSection = useCallback((key) => {
5260
+ openingSection.current = key;
5261
+ try {
5262
+ dispatchRef.current({ type: "startSection", section: key });
5263
+ }
5264
+ finally {
5265
+ openingSection.current = null;
5266
+ }
5267
+ }, []);
5268
+ const flowsNow = useMemo(() => pagesLive && hasState && hasFlows
5269
+ ? flowsAvailable(suite, env()).map(k => ({ key: k, title: findFlow(suite, k)?.title ?? "" }))
5270
+ : [],
5271
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5272
+ [pagesLive, hasState, hasFlows, suite, env, pathname, search, factsKey]);
5273
+ const flowsSig = flowsNow.map(x => x.key).join(",");
5274
+ /** The flows the panel lists: this person's, startable here or with somewhere to go. */
5275
+ const flowsListed = useMemo(() => (pagesLive && hasFlows && ctxIdle ? shownFlows(suite, ctxIdle).filter(f => onFlowStart(f, { code, pathname, search: new URLSearchParams(search) }) || !!flowHref(f, ctxIdle)) : []), [pagesLive, hasFlows, suite, ctxIdle, code, pathname, search]);
5276
+ const openFlowHere = useCallback((key) => dispatchRef.current({ type: "startFlow", flow: key }), []);
4568
5277
  const openPageHere = useCallback(() => {
4569
5278
  const p = pageHere(suite, env());
4570
5279
  if (p)
@@ -4575,8 +5284,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4575
5284
  publishPageGuides(null, null, null);
4576
5285
  return;
4577
5286
  }
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]);
5287
+ publishPageGuides({
5288
+ page: pageNow ? { title: pageNow.title } : null,
5289
+ listed: listed.length,
5290
+ sections: sectionsNow,
5291
+ flows: flowsNow,
5292
+ flowsListed: flowsListed.length,
5293
+ }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection, openFlowHere, () => dispatchRef.current({ type: "openPanel", section: "flows" }));
5294
+ // sectionsSig / flowsSig stand for sectionsNow / flowsNow.
5295
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5296
+ }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere]);
4580
5297
  useEffect(() => () => publishPageGuides(null, null, null), []);
4581
5298
  // The first-visit offer: in "offer" mode, nothing else up, the welcome
4582
5299
  // not due here, and a moment of nothing loading on this page.
@@ -4604,14 +5321,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4604
5321
  if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
4605
5322
  return;
4606
5323
  let key = null;
5324
+ let flow = null;
4607
5325
  try {
4608
5326
  key = pageOfferDue(cur, suite, env());
5327
+ // A flow's offer comes after the page guide's (and only for `offer: true` flows).
5328
+ if (!key)
5329
+ flow = flowOfferDue(cur, suite, env());
4609
5330
  }
4610
5331
  catch {
4611
5332
  key = null;
5333
+ flow = null;
4612
5334
  }
4613
5335
  if (key)
4614
5336
  dispatchRef.current({ type: "pageOffer", page: key });
5337
+ else if (flow)
5338
+ dispatchRef.current({ type: "pageOffer", page: flow, flow: true });
4615
5339
  }, PAGE_POLL_MS);
4616
5340
  return () => clearInterval(iv);
4617
5341
  }, [offerWatch, suite, env, pathname, search]);
@@ -4663,6 +5387,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4663
5387
  if (b)
4664
5388
  return;
4665
5389
  const e = env();
5390
+ if (pending.flow) {
5391
+ const flow = findFlow(suite, pending.key);
5392
+ if (!flow || !onFlowStart(flow, e))
5393
+ return;
5394
+ setPending(null);
5395
+ dispatchRef.current({ type: "startFlow", flow: pending.key });
5396
+ return;
5397
+ }
4666
5398
  const page = findPage(suite, pending.key);
4667
5399
  if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
4668
5400
  return;
@@ -4671,6 +5403,93 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4671
5403
  }, PAGE_POLL_MS);
4672
5404
  return () => clearInterval(iv);
4673
5405
  }, [pending, pagesLive, suite, env, code]);
5406
+ // A flow in the panel's list: start it here, or go to its start page and start it there.
5407
+ const openListedFlow = useCallback((key) => {
5408
+ const flow = findFlow(suite, key);
5409
+ if (!flow)
5410
+ return;
5411
+ const e = env();
5412
+ if (onFlowStart(flow, e)) {
5413
+ dispatchRef.current({ type: "startFlow", flow: key });
5414
+ return;
5415
+ }
5416
+ dispatchRef.current({ type: "closePanel" });
5417
+ const href = flowHref(flow, guideContext(e, {}));
5418
+ if (!href)
5419
+ return;
5420
+ setPending({ key, until: Date.now() + PAGE_ARRIVE_MS, flow: true });
5421
+ try {
5422
+ if (/^https?:\/\//i.test(href))
5423
+ window.location.assign(href);
5424
+ else
5425
+ navigateRef.current(href);
5426
+ }
5427
+ catch {
5428
+ /* never breaks the app */
5429
+ }
5430
+ }, [suite, env]);
5431
+ // A flow in progress is kept in this tab's session (sessionStorage), so a
5432
+ // refresh or a full page load on one of its pages picks it up again at
5433
+ // its checkpoint ("ทำต่อ" when its step is on another page). Never across
5434
+ // sessions; a flow that ends is forgotten.
5435
+ const flowRun = state?.pageRun?.kind === "flow" ? state.pageRun : null;
5436
+ const flowStepKey = screen.name === "step" && screen.flow ? (findMission(suite, screen.mission)?.steps[screen.index]?.key ?? null) : null;
5437
+ useEffect(() => {
5438
+ if (!hasFlows)
5439
+ return;
5440
+ const k = flowMirrorKey(organizationId, suite);
5441
+ try {
5442
+ if (!flowRun) {
5443
+ if (state)
5444
+ sessionStorage.removeItem(k);
5445
+ return;
5446
+ }
5447
+ if (!flowStepKey)
5448
+ return;
5449
+ const flow = findFlow(suite, flowRun.key);
5450
+ sessionStorage.setItem(k, JSON.stringify({ flow: flowRun.key, version: flow?.version, step: flowStepKey, write: flowRun.write, context: state?.progress.context ?? {} }));
5451
+ }
5452
+ catch {
5453
+ /* private mode: no resume after a refresh */
5454
+ }
5455
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5456
+ }, [hasFlows, flowRun?.key, flowRun?.write, flowStepKey, organizationId, suite]);
5457
+ const flowResumed = useRef(false);
5458
+ useEffect(() => {
5459
+ if (flowResumed.current || !pagesLive || !state || !hasFlows)
5460
+ return;
5461
+ // The missions resume first.
5462
+ if (missionsLive && !resumed.current)
5463
+ return;
5464
+ flowResumed.current = true;
5465
+ const k = flowMirrorKey(organizationId, suite);
5466
+ let saved = null;
5467
+ try {
5468
+ saved = JSON.parse(sessionStorage.getItem(k) ?? "null");
5469
+ }
5470
+ catch {
5471
+ saved = null;
5472
+ }
5473
+ if (!saved?.flow || !saved.step)
5474
+ return;
5475
+ const flow = findFlow(suite, saved.flow);
5476
+ const ok = !!flow && flow.version === saved.version && stateRef.current?.screen.name === "idle" && inFlowChain(flow, { code, pathname });
5477
+ if (!ok) {
5478
+ try {
5479
+ sessionStorage.removeItem(k);
5480
+ }
5481
+ catch {
5482
+ /* ignore */
5483
+ }
5484
+ return;
5485
+ }
5486
+ const context = {};
5487
+ for (const [ck, cv] of Object.entries(saved.context ?? {}))
5488
+ if (typeof cv === "string")
5489
+ context[ck] = cv;
5490
+ dispatchRef.current({ type: "resumeFlow", flow: saved.flow, step: saved.step, context, write: saved.write === true });
5491
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5492
+ }, [pagesLive, !!state, hasFlows, missionsLive]);
4674
5493
  if (!live || !state || !layer || !ctxIdle)
4675
5494
  return null;
4676
5495
  /* ---------------- render ---------------- */
@@ -4693,16 +5512,24 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4693
5512
  const missionNo = missionNumber(suite, mission.key, ctxIdle);
4694
5513
  const kind = stepKind(step);
4695
5514
  const bridge = screen.bridge;
4696
- const pos = stepPosition(mission, screen.index, env(), state.progress.context);
5515
+ const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
4697
5516
  const page = screen.page ? findPage(suite, screen.page) : undefined;
4698
- const pageBits = page
5517
+ const section = screen.section ? findSection(suite, screen.section) : undefined;
5518
+ const flow = screen.flow ? findFlow(suite, screen.flow) : undefined;
5519
+ const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
5520
+ const pageBits = read
4699
5521
  ? {
4700
- heading: `คู่มือหน้านี้ · ${page.title}`,
4701
- exitLabel: "ปิดคู่มือ",
5522
+ ...read,
4702
5523
  showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
4703
5524
  nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
4704
5525
  }
4705
- : {};
5526
+ : flow
5527
+ ? {
5528
+ heading: `พาทำ · ${flow.title}`,
5529
+ exitLabel: "ออกจากการพาทำ",
5530
+ nextLabel: kind === "view" && pos.no >= pos.total ? "เสร็จ" : undefined,
5531
+ }
5532
+ : {};
4706
5533
  content = (jsx(StepLayer, { spot: layout.spot, holes: layout.holes, popovers: layout.popovers, covered: layout.covered, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
4707
5534
  missionNo,
4708
5535
  missionTitle: mission.title,
@@ -4719,6 +5546,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4719
5546
  ...pageBits,
4720
5547
  }, hasTarget: bridge || !!step.target, targetPressable: targetPressable(step, bridge), phase: screen.phase, confirmExit: screen.confirmExit, reopenHint: suite.reopenHint, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onBack: () => dispatch({ type: "back" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
4721
5548
  }
5549
+ else if (screen.name === "pageOffer" && pagesLive && screen.flow) {
5550
+ const flow = findFlow(suite, screen.page);
5551
+ const mission = flow ? findMission(suite, FLOW_PREFIX + flow.key) : undefined;
5552
+ if (flow && mission) {
5553
+ content = (jsx(PageOfferCard, { eyebrow: "\u0E1E\u0E32\u0E17\u0E33\u0E07\u0E32\u0E19", title: flow.title, steps: stepPosition(mission, 0, env(), {}).total, onStart: () => dispatch({ type: "pageOfferStart" }), onLater: () => dispatch({ type: "pageOfferLater" }) }));
5554
+ }
5555
+ }
4722
5556
  else if (screen.name === "pageOffer" && pagesLive) {
4723
5557
  const page = findPage(suite, screen.page);
4724
5558
  if (page) {
@@ -4762,12 +5596,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4762
5596
  const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
4763
5597
  // The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
4764
5598
  // then the page guides they have.
4765
- const panelMissions = missionsLive && total > 0 && state.panelSection !== "pages";
4766
- const pageItems = pagesLive
4767
- ? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) }))
5599
+ const panelMissions = missionsLive && total > 0 && state.panelSection === "all";
5600
+ const pageItems = pagesLive && state.panelSection !== "flows" ? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) })) : [];
5601
+ const flowItems = pagesLive && state.panelSection !== "pages"
5602
+ ? flowsListed.map(f => ({ key: f.key, title: f.title, minutes: f.minutes, done: flowRecorded(state, f) && state.flows[f.key]?.status === "completed" }))
4768
5603
  : [];
4769
- const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
4770
- return createPortal(jsxs(Fragment, { children: [jsx("style", { children: GUIDE_CSS }), jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsx(QuestPanel, { items: items, done: done, total: total, justDone: state.justDone, gate: gate && gateFailing ? { label: gate.action.label, pending: gate.pending, href: gateHref } : null, canHideChip: !state.progress.chip_hidden && state.progress.status !== "completed", onClose: () => dispatch({ type: "closePanel" }), onStart: (key, replayIt) => dispatch({ type: "start", mission: key, replay: replayIt }), onDismiss: key => dispatch({ type: "dismissMission", mission: key }), onHideChip: () => dispatch({ type: "hideChip" }), pages: pageItems, showMissions: panelMissions, onOpenPage: openListedPage })) : null, toast ? jsx(GuideToast, { text: toast }) : null] }), layer);
5604
+ const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0 || flowItems.length > 0);
5605
+ return createPortal(jsxs(Fragment, { children: [jsx("style", { children: GUIDE_CSS }), jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsx(QuestPanel, { items: items, done: done, total: total, justDone: state.justDone, gate: gate && gateFailing ? { label: gate.action.label, pending: gate.pending, href: gateHref } : null, canHideChip: !state.progress.chip_hidden && state.progress.status !== "completed", onClose: () => dispatch({ type: "closePanel" }), onStart: (key, replayIt) => dispatch({ type: "start", mission: key, replay: replayIt }), onDismiss: key => dispatch({ type: "dismissMission", mission: key }), onHideChip: () => dispatch({ type: "hideChip" }), pages: pageItems, showMissions: panelMissions, onOpenPage: openListedPage, flows: flowItems, onOpenFlow: openListedFlow })) : null, toast ? jsx(GuideToast, { text: toast }) : null] }), layer);
4771
5606
  }
4772
5607
  /** A stable key for the facts object, so effects re-run when they change. */
4773
5608
  function useFactsKey(facts) {
@@ -4781,5 +5616,101 @@ function useFactsKey(facts) {
4781
5616
  }, [facts]);
4782
5617
  }
4783
5618
 
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
5619
+ const DEFAULT_STYLE = {
5620
+ display: "inline-grid",
5621
+ placeItems: "center",
5622
+ width: 22,
5623
+ height: 22,
5624
+ padding: 0,
5625
+ border: 0,
5626
+ borderRadius: 999,
5627
+ background: "transparent",
5628
+ color: "inherit",
5629
+ opacity: 0.6,
5630
+ cursor: "pointer",
5631
+ flex: "none",
5632
+ };
5633
+ function SectionGuideButton({ guideKey, values, className }) {
5634
+ useSectionGuideValues(guideKey, values);
5635
+ const { sections } = usePageGuides();
5636
+ const here = (sections ?? []).find(s => s.key === guideKey);
5637
+ if (!here)
5638
+ return null;
5639
+ 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 => {
5640
+ // A header that opens or sorts on press must not hear it.
5641
+ e.stopPropagation();
5642
+ e.preventDefault();
5643
+ openSectionGuide(guideKey);
5644
+ }, 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" })] }) }));
5645
+ }
5646
+
5647
+ /**
5648
+ * Small formatters for section guides' copy (shell 0.1.75): the app passes
5649
+ * its own computed numbers, and the copy says them the way the page does —
5650
+ * "ม.ค. รับ 500,000 − จ่าย 320,000 = คงเหลือ 180,000".
5651
+ *
5652
+ * Pure: the tests run it in Node.
5653
+ */
5654
+ const THAI_MONTHS_SHORT = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."];
5655
+ const grouped = new Map();
5656
+ function nf(decimals) {
5657
+ let f = grouped.get(decimals);
5658
+ if (!f) {
5659
+ f = new Intl.NumberFormat("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
5660
+ grouped.set(decimals, f);
5661
+ }
5662
+ return f;
5663
+ }
5664
+ /**
5665
+ * A baht amount with thousands separators, no currency sign: 500000 →
5666
+ * "500,000"; 1234.5 → "1,234.50"; -320000 → "-320,000". Whole amounts
5667
+ * have no decimals unless `decimals` says so; others have two. Not a
5668
+ * finite number → "" (a step whose value is missing is skipped anyway).
5669
+ */
5670
+ function formatBaht(n, opts = {}) {
5671
+ const v = typeof n === "string" && n.trim() !== "" ? Number(n) : n;
5672
+ if (typeof v !== "number" || !Number.isFinite(v))
5673
+ return "";
5674
+ const rounded = Math.round(v * 100) / 100;
5675
+ const decimals = opts.decimals ?? (Number.isInteger(rounded) ? 0 : 2);
5676
+ const out = nf(decimals).format(rounded);
5677
+ // No "-0".
5678
+ return out === "-0" || /^-0(\.0+)?$/.test(out) ? out.slice(1) : out;
5679
+ }
5680
+ /** The month's number 1–12 from "2026-01", "2026-01-15", a Date, or 1–12; null when none. */
5681
+ function monthOf(m) {
5682
+ if (m instanceof Date)
5683
+ return Number.isNaN(m.getTime()) ? null : { month: m.getMonth() + 1, year: m.getFullYear() };
5684
+ if (typeof m === "number")
5685
+ return Number.isInteger(m) && m >= 1 && m <= 12 ? { month: m } : null;
5686
+ if (typeof m === "string") {
5687
+ const iso = /^(\d{4})-(\d{1,2})(?:-\d{1,2})?/.exec(m.trim());
5688
+ if (iso) {
5689
+ const month = Number(iso[2]);
5690
+ return month >= 1 && month <= 12 ? { month, year: Number(iso[1]) } : null;
5691
+ }
5692
+ }
5693
+ return null;
5694
+ }
5695
+ /** "2026-01" (or a Date, or 1) → "ม.ค."; "" when it is not a month. */
5696
+ function thaiMonth(m) {
5697
+ const x = monthOf(m);
5698
+ return x ? THAI_MONTHS_SHORT[x.month - 1] : "";
5699
+ }
5700
+ /**
5701
+ * "2026-01" → "ม.ค. 69" (the Buddhist year's last two digits), or with
5702
+ * `{ year: "full" }` "ม.ค. 2569"; just the month when no year is known.
5703
+ */
5704
+ function thaiMonthYear(m, opts = {}) {
5705
+ const x = monthOf(m);
5706
+ if (!x)
5707
+ return "";
5708
+ const name = THAI_MONTHS_SHORT[x.month - 1];
5709
+ if (x.year === undefined)
5710
+ return name;
5711
+ const be = x.year + 543;
5712
+ return `${name} ${opts.year === "full" ? be : String(be).slice(-2)}`;
5713
+ }
5714
+
5715
+ export { API_EVENT as A, useGuideEntry as B, usePageGuideEntry as C, usePageGuides as D, useSectionGuideValues as E, 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, openFlow as o, openFlowsPanel as p, openGuidePanel as q, openPageGuide as r, openPagesPanel as s, openSectionGuide as t, reportApiCall as u, reportApiEvent as v, setSectionGuideValues as w, storeGuides as x, thaiMonth as y, thaiMonthYear as z };
5716
+ //# sourceMappingURL=format-BWUKNKxg.esm.js.map