@eintrek/erp-shell 0.1.75 → 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.
@@ -337,26 +337,34 @@ function publishGuideEntry(next, openPanel) {
337
337
  emit();
338
338
  }
339
339
  }
340
- const NO_PAGES = { page: null, listed: 0, sections: [] };
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
344
  let openSectionImpl = null;
345
+ let openFlowImpl = null;
346
+ let openFlowsImpl = null;
345
347
  const sectionsKey = (x) => (x ?? []).map(s => `${s.key}\u0000${s.title}`).join("\u0001");
346
348
  /** 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) {
349
+ function publishPageGuides(next, openPage, openPages, openSection = null, openFlow = null, openFlows = null) {
348
350
  const n = next ?? NO_PAGES;
349
351
  openPageImpl = openPage;
350
352
  openPagesImpl = openPages;
351
353
  openSectionImpl = openSection;
354
+ openFlowImpl = openFlow;
355
+ openFlowsImpl = openFlows;
352
356
  const same = n.listed === pages.listed &&
357
+ (n.flowsListed ?? 0) === (pages.flowsListed ?? 0) &&
353
358
  (n.page?.title ?? null) === (pages.page?.title ?? null) &&
354
- sectionsKey(n.sections) === sectionsKey(pages.sections);
359
+ sectionsKey(n.sections) === sectionsKey(pages.sections) &&
360
+ sectionsKey(n.flows) === sectionsKey(pages.flows);
355
361
  if (!same) {
356
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;
357
365
  pages =
358
- n.page || n.listed || sections.length
359
- ? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections }
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 }
360
368
  : NO_PAGES;
361
369
  emit();
362
370
  }
@@ -367,10 +375,14 @@ function guideMenuItems(e, p = NO_PAGES) {
367
375
  items.push("page");
368
376
  if ((p.sections ?? []).length > 0)
369
377
  items.push("sections");
378
+ if ((p.flows ?? []).length > 0)
379
+ items.push("flowsHere");
370
380
  if (e && e.total > 0)
371
381
  items.push("missions");
372
382
  if (p.listed > 0)
373
383
  items.push("pages");
384
+ if ((p.flowsListed ?? 0) > 0)
385
+ items.push("flows");
374
386
  if (items.length)
375
387
  items.push("help");
376
388
  return items;
@@ -424,6 +436,14 @@ function usePageGuideEntry() {
424
436
  function openSectionGuide(key) {
425
437
  openSectionImpl?.(key);
426
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
+ }
427
447
  const sectionValues = new Map();
428
448
  const valueListeners = new Set();
429
449
  let valuesVersion = 0;
@@ -801,6 +821,144 @@ function sectionShown(section, ctx) {
801
821
  function sectionsHere(suite, where, ctx) {
802
822
  return validSections(suite).filter(s => sectionApplies(s, where) && sectionShown(s, ctx));
803
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
+ }
804
962
 
805
963
  /* ------------------------------------------------------------------ */
806
964
  /* Copy */
@@ -816,6 +974,7 @@ const COPY = {
816
974
  aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
817
975
  pageDone: "จบคู่มือหน้านี้",
818
976
  sectionDone: "จบคำอธิบายส่วนนี้",
977
+ flowDone: "พาทำงานเสร็จแล้ว",
819
978
  bridgeTitle: "กดปุ่มเมนู",
820
979
  bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
821
980
  };
@@ -829,6 +988,7 @@ function initialGuideState(audience, progress, pages) {
829
988
  pagesMode: pages?.pagesMode ?? "off",
830
989
  pages: pages?.pages ?? {},
831
990
  sections: pages?.sections ?? {},
991
+ flows: pages?.flows ?? {},
832
992
  pageRun: null,
833
993
  panelSection: "all",
834
994
  autoPassed: null,
@@ -855,6 +1015,10 @@ function findMission(suite, key) {
855
1015
  const section = findSection(suite, sectionKeyOf(key));
856
1016
  return section ? sectionMission(section) : undefined;
857
1017
  }
1018
+ if (isFlowMissionKey(key)) {
1019
+ const flow = findFlow(suite, flowKeyOf(key));
1020
+ return flow ? flowMission(flow) : undefined;
1021
+ }
858
1022
  return suite.missions.find(m => m.key === key);
859
1023
  }
860
1024
  /**
@@ -1303,6 +1467,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1303
1467
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1304
1468
  const page = pageKeyOf(mission.key);
1305
1469
  const section = sectionKeyOf(mission.key);
1470
+ const flow = flowKeyOf(mission.key);
1306
1471
  const where = placeRoute(steps, i);
1307
1472
  // A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
1308
1473
  const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
@@ -1324,6 +1489,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1324
1489
  replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
1325
1490
  ...(page ? { page } : {}),
1326
1491
  ...(section ? { section } : {}),
1492
+ ...(flow ? { flow } : {}),
1327
1493
  });
1328
1494
  if (!opts.replay) {
1329
1495
  const cp = checkpointAtOrBefore(steps, i);
@@ -1488,9 +1654,12 @@ function advance(d, suite, env, capture) {
1488
1654
  enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
1489
1655
  }
1490
1656
  function finishMission(d, suite, env, mission, replay) {
1491
- if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
1657
+ if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key) || isFlowMissionKey(mission.key)) {
1492
1658
  endPage(d, suite, "completed");
1493
- d.effects.push({ type: "toast", text: isSectionMissionKey(mission.key) ? COPY.sectionDone : COPY.pageDone });
1659
+ d.effects.push({
1660
+ type: "toast",
1661
+ text: isSectionMissionKey(mission.key) ? COPY.sectionDone : isFlowMissionKey(mission.key) ? COPY.flowDone : COPY.pageDone,
1662
+ });
1494
1663
  return;
1495
1664
  }
1496
1665
  if (replay) {
@@ -1785,18 +1954,114 @@ function readStillHere(suite, r, env) {
1785
1954
  const section = findSection(suite, r.key);
1786
1955
  return !!section && sectionApplies(section, whereOf(env));
1787
1956
  }
1788
- /** The page or section guide ends: its row written when it is due one; back to idle. */
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. */
1789
1972
  function endPage(d, suite, how) {
1790
1973
  const run = d.state.pageRun;
1791
1974
  if (run?.write) {
1792
1975
  if (run.kind === "section")
1793
1976
  recordSection(d, suite, run.key, how);
1977
+ else if (run.kind === "flow")
1978
+ recordFlow(d, suite, run.key, how);
1794
1979
  else
1795
1980
  recordPage(d, suite, run.key, how);
1796
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 });
1797
1985
  d.state = { ...d.state, pageRun: null, replayPage: null };
1798
1986
  setScreen(d, { name: "idle" });
1799
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
+ }
1800
2065
  /** The section guides on screen here that this person has (the "?" entries, the buttons). */
1801
2066
  function sectionsAvailable(suite, env) {
1802
2067
  return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
@@ -1930,7 +2195,7 @@ function reduceGuide(state, action, suite, env) {
1930
2195
  break;
1931
2196
  // A mission always wins over a page guide: a running one ends (its row
1932
2197
  // written if due), an offer goes unrecorded.
1933
- if (readOf(s))
2198
+ if (readOf(s) || flowOf(s))
1934
2199
  endPage(d, suite, "skipped");
1935
2200
  else if (s.name === "pageOffer")
1936
2201
  setScreen(d, { name: "idle" });
@@ -1958,6 +2223,14 @@ function reduceGuide(state, action, suite, env) {
1958
2223
  case "sync": {
1959
2224
  if (s.name === "pageOffer") {
1960
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
+ }
1961
2234
  if (!pageStillHere(suite, s.page, env)) {
1962
2235
  recordPage(d, suite, s.page, "skipped");
1963
2236
  setScreen(d, { name: "idle" });
@@ -1966,6 +2239,15 @@ function reduceGuide(state, action, suite, env) {
1966
2239
  }
1967
2240
  if (s.name !== "step")
1968
2241
  break;
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
+ }
1969
2251
  const read = readOf(s);
1970
2252
  if (read && !readStillHere(suite, read, env)) {
1971
2253
  // Leaving the page (or the section's root going) ends its guide, silently.
@@ -2155,7 +2437,7 @@ function reduceGuide(state, action, suite, env) {
2155
2437
  advance(d, suite, env);
2156
2438
  }
2157
2439
  else if (policy === "abort") {
2158
- if (readOf(s))
2440
+ if (readOf(s) || flowOf(s))
2159
2441
  endPage(d, suite, "skipped");
2160
2442
  else
2161
2443
  exitMission(d);
@@ -2197,7 +2479,7 @@ function reduceGuide(state, action, suite, env) {
2197
2479
  case "exit":
2198
2480
  if (s.name !== "step")
2199
2481
  break;
2200
- if (readOf(s)) {
2482
+ if (readOf(s) || flowOf(s)) {
2201
2483
  // ✕, Escape or ปิดคู่มือ: ends silently.
2202
2484
  pushAbort(d, suite, s);
2203
2485
  endPage(d, suite, "skipped");
@@ -2225,6 +2507,11 @@ function reduceGuide(state, action, suite, env) {
2225
2507
  case "endWithoutSave":
2226
2508
  if (s.name !== "step" || !s.replay)
2227
2509
  break;
2510
+ if (flowOf(s)) {
2511
+ pushAbort(d, suite, s);
2512
+ endPage(d, suite, "skipped");
2513
+ break;
2514
+ }
2228
2515
  pushAbort(d, suite, s);
2229
2516
  setScreen(d, { name: "idle" });
2230
2517
  d.effects.push({ type: "toast", text: COPY.replayEnded });
@@ -2273,7 +2560,7 @@ function reduceGuide(state, action, suite, env) {
2273
2560
  break;
2274
2561
  case "openPanel": {
2275
2562
  const section = action.section ?? "all";
2276
- 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";
2277
2564
  if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
2278
2565
  break;
2279
2566
  // The offer gives way to the panel, unrecorded.
@@ -2326,6 +2613,13 @@ function reduceGuide(state, action, suite, env) {
2326
2613
  break;
2327
2614
  }
2328
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
+ }
2329
2623
  if (pageOfferDue(state, suite, env) !== action.page)
2330
2624
  break;
2331
2625
  setScreen(d, { name: "pageOffer", page: action.page });
@@ -2335,14 +2629,42 @@ function reduceGuide(state, action, suite, env) {
2335
2629
  case "pageOfferStart":
2336
2630
  if (s.name !== "pageOffer" || state.pageAudience === "none")
2337
2631
  break;
2338
- 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);
2339
2636
  break;
2340
2637
  case "pageOfferLater":
2341
2638
  if (s.name !== "pageOffer")
2342
2639
  break;
2343
- 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");
2344
2644
  setScreen(d, { name: "idle" });
2345
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
+ }
2346
2668
  case "startPage": {
2347
2669
  // Never over a mission step, a welcome, a gate or a celebration.
2348
2670
  if (state.pageAudience === "none" || state.pagesMode === "off")
@@ -2371,6 +2693,13 @@ function reduceGuide(state, action, suite, env) {
2371
2693
  break;
2372
2694
  }
2373
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
+ }
2374
2703
  if (action.kind === "section") {
2375
2704
  const cur = d.state.sections?.[action.key];
2376
2705
  if (cur && cur.version > action.row.version)
@@ -2526,6 +2855,7 @@ function parseGuidesAnswer(payload, suite) {
2526
2855
  : "none";
2527
2856
  const pages = parsePageRows(rows, suite.app);
2528
2857
  const sections = parsePageRows(rows, suite.app, "section");
2858
+ const flows = parsePageRows(rows, suite.app, "flow");
2529
2859
  let row = null;
2530
2860
  for (const r of rows) {
2531
2861
  if (isRecord(r) && r.guide_key === suite.key) {
@@ -2546,9 +2876,9 @@ function parseGuidesAnswer(payload, suite) {
2546
2876
  progress = row;
2547
2877
  newerOnServer = row.guide_version > suite.version;
2548
2878
  }
2549
- return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
2879
+ return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections, flows };
2550
2880
  }
2551
- /** The row key of a page guide, `<app>.page.<key>`, or a section guide, `<app>.section.<key>`. */
2881
+ /** The row key of a page guide `<app>.page.<key>`, a section guide `<app>.section.<key>`, or a flow `<app>.flow.<key>`. */
2552
2882
  function pageRowKey(app, key, kind = "page") {
2553
2883
  return `${app}.${kind}.${key}`;
2554
2884
  }
@@ -3730,7 +4060,10 @@ function CompleteModal(p) {
3730
4060
  * corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
3731
4061
  */
3732
4062
  function PageOfferCard(p) {
3733
- 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))) }));
3734
4067
  }
3735
4068
  function PageList({ items, onOpen }) {
3736
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))) }));
@@ -3757,12 +4090,16 @@ function QuestChip({ done, total, pulse, onOpen }) {
3757
4090
  }
3758
4091
  function QuestPanel(p) {
3759
4092
  const ref = useAutoFocus();
3760
- const pages = p.pages ?? [];
4093
+ const pages = p.only === "flows" ? [] : (p.pages ?? []);
4094
+ const flows = p.only === "pages" ? [] : (p.flows ?? []);
3761
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;
3762
4098
  if (p.showMissions === false) {
3763
- 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] })] }));
3764
4101
  }
3765
- 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] })] }));
3766
4103
  }
3767
4104
  function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
3768
4105
  const v = item.view;
@@ -3900,8 +4237,7 @@ function writeMirror(orgId, suite, p) {
3900
4237
  /* private mode: the server copy is enough */
3901
4238
  }
3902
4239
  }
3903
- /** Page rows written in this tab, kept for a remount within the GET's cache time. */
3904
- const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
4240
+ const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind}s:${orgId}:${suite.app}`;
3905
4241
  function readPagesMirror(orgId, suite, kind = "page") {
3906
4242
  try {
3907
4243
  const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
@@ -3935,6 +4271,8 @@ function mergePageRows(server, local) {
3935
4271
  }
3936
4272
  return out;
3937
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}`;
3938
4276
  /* ------------------------------------------------------------------ */
3939
4277
  /* One tab at a time */
3940
4278
  /* ------------------------------------------------------------------ */
@@ -4047,7 +4385,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4047
4385
  const [answer, setAnswer] = useState(null);
4048
4386
  const missionsReady = config.ready !== false;
4049
4387
  const pagesReady = config.pagesReady ?? missionsReady;
4050
- const hasPages = useMemo(() => validPages(suite).length > 0 || validSections(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]);
4051
4390
  const hasSections = useMemo(() => validSections(suite).length > 0, [suite]);
4052
4391
  const enabled = primary && (missionsReady || (hasPages && pagesReady));
4053
4392
  const loaded = useRef(false);
@@ -4105,6 +4444,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4105
4444
  pagesMode: answer.pagesMode,
4106
4445
  pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
4107
4446
  sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
4447
+ flows: mergePageRows(answer.flows ?? {}, readPagesMirror(organizationId, suite, "flow")),
4108
4448
  });
4109
4449
  stateRef.current = s;
4110
4450
  setState(s);
@@ -4213,7 +4553,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4213
4553
  else if (e.type === "savePage") {
4214
4554
  // Once per page and version; the machine already holds it as written.
4215
4555
  const kind = e.kind ?? "page";
4216
- const rows = stateRef.current ? (kind === "section" ? stateRef.current.sections : stateRef.current.pages) : undefined;
4556
+ const cur = stateRef.current;
4557
+ const rows = cur ? (kind === "section" ? cur.sections : kind === "flow" ? cur.flows : cur.pages) : undefined;
4217
4558
  if (rows)
4218
4559
  writePagesMirror(organizationId, suite, rows, kind);
4219
4560
  const rev = rows?.[e.key]?.rev ?? 0;
@@ -4310,7 +4651,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4310
4651
  /* ---------------- sync on route, sidebar, facts, DOM ---------------- */
4311
4652
  // A page guide's step runs while page guides are live; a mission's while
4312
4653
  // the missions are. The offer card is watched the same way (left: ignored).
4313
- const inStep = screen.name === "step" && (screen.page || screen.section ? pagesLive : missionsLive);
4654
+ const inStep = screen.name === "step" && (screen.page || screen.section || screen.flow ? pagesLive : missionsLive);
4314
4655
  const offerUp = pagesLive && screen.name === "pageOffer";
4315
4656
  const watching = inStep || offerUp;
4316
4657
  useEffect(() => {
@@ -4924,6 +5265,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4924
5265
  openingSection.current = null;
4925
5266
  }
4926
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 }), []);
4927
5277
  const openPageHere = useCallback(() => {
4928
5278
  const p = pageHere(suite, env());
4929
5279
  if (p)
@@ -4934,10 +5284,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4934
5284
  publishPageGuides(null, null, null);
4935
5285
  return;
4936
5286
  }
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.
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.
4939
5295
  // eslint-disable-next-line react-hooks/exhaustive-deps
4940
- }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection]);
5296
+ }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere]);
4941
5297
  useEffect(() => () => publishPageGuides(null, null, null), []);
4942
5298
  // The first-visit offer: in "offer" mode, nothing else up, the welcome
4943
5299
  // not due here, and a moment of nothing loading on this page.
@@ -4965,14 +5321,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4965
5321
  if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
4966
5322
  return;
4967
5323
  let key = null;
5324
+ let flow = null;
4968
5325
  try {
4969
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());
4970
5330
  }
4971
5331
  catch {
4972
5332
  key = null;
5333
+ flow = null;
4973
5334
  }
4974
5335
  if (key)
4975
5336
  dispatchRef.current({ type: "pageOffer", page: key });
5337
+ else if (flow)
5338
+ dispatchRef.current({ type: "pageOffer", page: flow, flow: true });
4976
5339
  }, PAGE_POLL_MS);
4977
5340
  return () => clearInterval(iv);
4978
5341
  }, [offerWatch, suite, env, pathname, search]);
@@ -5024,6 +5387,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5024
5387
  if (b)
5025
5388
  return;
5026
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
+ }
5027
5398
  const page = findPage(suite, pending.key);
5028
5399
  if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
5029
5400
  return;
@@ -5032,6 +5403,93 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5032
5403
  }, PAGE_POLL_MS);
5033
5404
  return () => clearInterval(iv);
5034
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]);
5035
5493
  if (!live || !state || !layer || !ctxIdle)
5036
5494
  return null;
5037
5495
  /* ---------------- render ---------------- */
@@ -5057,6 +5515,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5057
5515
  const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
5058
5516
  const page = screen.page ? findPage(suite, screen.page) : undefined;
5059
5517
  const section = screen.section ? findSection(suite, screen.section) : undefined;
5518
+ const flow = screen.flow ? findFlow(suite, screen.flow) : undefined;
5060
5519
  const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
5061
5520
  const pageBits = read
5062
5521
  ? {
@@ -5064,7 +5523,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5064
5523
  showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
5065
5524
  nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
5066
5525
  }
5067
- : {};
5526
+ : flow
5527
+ ? {
5528
+ heading: `พาทำ · ${flow.title}`,
5529
+ exitLabel: "ออกจากการพาทำ",
5530
+ nextLabel: kind === "view" && pos.no >= pos.total ? "เสร็จ" : undefined,
5531
+ }
5532
+ : {};
5068
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: {
5069
5534
  missionNo,
5070
5535
  missionTitle: mission.title,
@@ -5081,6 +5546,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5081
5546
  ...pageBits,
5082
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" }) }));
5083
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
+ }
5084
5556
  else if (screen.name === "pageOffer" && pagesLive) {
5085
5557
  const page = findPage(suite, screen.page);
5086
5558
  if (page) {
@@ -5124,12 +5596,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5124
5596
  const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
5125
5597
  // The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
5126
5598
  // then the page guides they have.
5127
- const panelMissions = missionsLive && total > 0 && state.panelSection !== "pages";
5128
- const pageItems = pagesLive
5129
- ? 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" }))
5130
5603
  : [];
5131
- const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
5132
- 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);
5133
5606
  }
5134
5607
  /** A stable key for the facts object, so effects re-run when they change. */
5135
5608
  function useFactsKey(facts) {
@@ -5239,5 +5712,5 @@ function thaiMonthYear(m, opts = {}) {
5239
5712
  return `${name} ${opts.year === "full" ? be : String(be).slice(-2)}`;
5240
5713
  }
5241
5714
 
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
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