@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.
@@ -357,26 +357,34 @@ function publishGuideEntry(next, openPanel) {
357
357
  emit();
358
358
  }
359
359
  }
360
- const NO_PAGES = { page: null, listed: 0, sections: [] };
360
+ const NO_PAGES = { page: null, listed: 0, sections: [], flows: [], flowsListed: 0 };
361
361
  let pages = NO_PAGES;
362
362
  let openPageImpl = null;
363
363
  let openPagesImpl = null;
364
364
  let openSectionImpl = null;
365
+ let openFlowImpl = null;
366
+ let openFlowsImpl = null;
365
367
  const sectionsKey = (x) => (x ?? []).map(s => `${s.key}\u0000${s.title}`).join("\u0001");
366
368
  /** Called by the host: this page's guide, the listed count, the sections on screen, and how to open them. */
367
- function publishPageGuides(next, openPage, openPages, openSection = null) {
369
+ function publishPageGuides(next, openPage, openPages, openSection = null, openFlow = null, openFlows = null) {
368
370
  const n = next ?? NO_PAGES;
369
371
  openPageImpl = openPage;
370
372
  openPagesImpl = openPages;
371
373
  openSectionImpl = openSection;
374
+ openFlowImpl = openFlow;
375
+ openFlowsImpl = openFlows;
372
376
  const same = n.listed === pages.listed &&
377
+ (n.flowsListed ?? 0) === (pages.flowsListed ?? 0) &&
373
378
  (n.page?.title ?? null) === (pages.page?.title ?? null) &&
374
- sectionsKey(n.sections) === sectionsKey(pages.sections);
379
+ sectionsKey(n.sections) === sectionsKey(pages.sections) &&
380
+ sectionsKey(n.flows) === sectionsKey(pages.flows);
375
381
  if (!same) {
376
382
  const sections = (n.sections ?? []).map(x => ({ key: x.key, title: x.title }));
383
+ const flows = (n.flows ?? []).map(x => ({ key: x.key, title: x.title }));
384
+ const flowsListed = n.flowsListed ?? 0;
377
385
  pages =
378
- n.page || n.listed || sections.length
379
- ? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections }
386
+ n.page || n.listed || sections.length || flows.length || flowsListed
387
+ ? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections, flows, flowsListed }
380
388
  : NO_PAGES;
381
389
  emit();
382
390
  }
@@ -387,10 +395,14 @@ function guideMenuItems(e, p = NO_PAGES) {
387
395
  items.push("page");
388
396
  if ((p.sections ?? []).length > 0)
389
397
  items.push("sections");
398
+ if ((p.flows ?? []).length > 0)
399
+ items.push("flowsHere");
390
400
  if (e && e.total > 0)
391
401
  items.push("missions");
392
402
  if (p.listed > 0)
393
403
  items.push("pages");
404
+ if ((p.flowsListed ?? 0) > 0)
405
+ items.push("flows");
394
406
  if (items.length)
395
407
  items.push("help");
396
408
  return items;
@@ -444,6 +456,14 @@ function usePageGuideEntry() {
444
456
  function openSectionGuide(key) {
445
457
  openSectionImpl?.(key);
446
458
  }
459
+ /** Starts a flow that starts on this page ("พาทำ · <title>"). */
460
+ function openFlow(key) {
461
+ openFlowImpl?.(key);
462
+ }
463
+ /** Opens the panel on the flows alone ("พาทำงาน"). */
464
+ function openFlowsPanel() {
465
+ openFlowsImpl?.();
466
+ }
447
467
  const sectionValues = new Map();
448
468
  const valueListeners = new Set();
449
469
  let valuesVersion = 0;
@@ -821,6 +841,144 @@ function sectionShown(section, ctx) {
821
841
  function sectionsHere(suite, where, ctx) {
822
842
  return validSections(suite).filter(s => sectionApplies(s, where) && sectionShown(s, ctx));
823
843
  }
844
+ /* ------------------------------------------------------------------ */
845
+ /* Workflow guides ("พาทำงาน", shell 0.1.76) */
846
+ /* ------------------------------------------------------------------ */
847
+ /** The synthetic mission key of a flow: `flow:<key>`. */
848
+ const FLOW_PREFIX = "flow:";
849
+ function isFlowMissionKey(key) {
850
+ return !!key && key.startsWith(FLOW_PREFIX);
851
+ }
852
+ function flowKeyOf(missionKey) {
853
+ return isFlowMissionKey(missionKey) ? missionKey.slice(FLOW_PREFIX.length) : undefined;
854
+ }
855
+ const asList = (r) => (r === undefined ? [] : Array.isArray(r) ? r : [r]);
856
+ /** Why a flow cannot run, or null when it can. */
857
+ function flowProblem(app, flow) {
858
+ if (!flow || typeof flow.key !== "string" || !PAGE_KEY_RE.test(flow.key))
859
+ return "its key is not [a-z0-9-]";
860
+ if (`${app}.flow.${flow.key}`.length > ROW_KEY_MAX)
861
+ return `its row key is over ${ROW_KEY_MAX} characters`;
862
+ if (!Number.isInteger(flow.version) || flow.version < 1)
863
+ return "its version is not a whole number ≥ 1";
864
+ const starts = asList(flow.startRoute);
865
+ if (!starts.length || starts.some(r => typeof r !== "string" || !r.startsWith("/")))
866
+ return "it has no start route";
867
+ if (!Array.isArray(flow.steps) || flow.steps.length === 0)
868
+ return "it has no steps";
869
+ for (const st of flow.steps) {
870
+ const kind = st.kind ?? "click";
871
+ if (kind === "view" || kind === "click")
872
+ continue;
873
+ if (!flow.allowCommit)
874
+ return `step "${st.key}" is a ${kind} step and the flow does not set allowCommit`;
875
+ if (kind === "commit" && !st.abort)
876
+ return `commit step "${st.key}" has no abort`;
877
+ }
878
+ return null;
879
+ }
880
+ const validFlowCache = new WeakMap();
881
+ /** The suite's flows that can run (as validPages: the rest dropped, with a warning in development). */
882
+ function validFlows(suite) {
883
+ const hit = validFlowCache.get(suite);
884
+ if (hit)
885
+ return hit;
886
+ const out = [];
887
+ const seen = new Set();
888
+ for (const flow of suite.flows ?? []) {
889
+ let problem = flowProblem(suite.app, flow);
890
+ if (!problem && seen.has(flow.key))
891
+ problem = "another flow has the same key";
892
+ if (problem) {
893
+ const id = `${suite.key}:flow:${flow?.key}:${problem}`;
894
+ if (!warned.has(id)) {
895
+ warned.add(id);
896
+ devWarn(`flow "${flow?.key}" dropped: ${problem}`);
897
+ }
898
+ continue;
899
+ }
900
+ seen.add(flow.key);
901
+ out.push(flow);
902
+ }
903
+ validFlowCache.set(suite, out);
904
+ return out;
905
+ }
906
+ function findFlow(suite, key) {
907
+ return key ? validFlows(suite).find(f => f.key === key) : undefined;
908
+ }
909
+ const flowMissionCache = new WeakMap();
910
+ /** The flow as a mission the machine plays (in replay mode): `flow:<key>`, its steps as authored. */
911
+ function flowMission(flow) {
912
+ const hit = flowMissionCache.get(flow);
913
+ if (hit)
914
+ return hit;
915
+ const m = { key: FLOW_PREFIX + flow.key, title: flow.title, minutes: flow.minutes ?? 1, steps: flow.steps };
916
+ flowMissionCache.set(flow, m);
917
+ return m;
918
+ }
919
+ /**
920
+ * The flow's chain: its start routes, every step's `route` and route
921
+ * trigger, and its `transit` routes. Leaving all of them ends the flow.
922
+ */
923
+ function flowChain(flow) {
924
+ const out = new Set(asList(flow.startRoute));
925
+ for (const st of flow.steps) {
926
+ if (st.route)
927
+ out.add(st.route);
928
+ const triggers = st.advanceOn ? (Array.isArray(st.advanceOn) ? st.advanceOn : [st.advanceOn]) : [];
929
+ for (const t of triggers)
930
+ if ("route" in t && t.route)
931
+ out.add(t.route);
932
+ }
933
+ for (const t of flow.transit ?? [])
934
+ out.add(t);
935
+ return Array.from(out);
936
+ }
937
+ /** Path patterns only (`?k=v` dropped): the chain is about pages, not their tabs. */
938
+ const pathOf = (pattern) => (pattern.includes("?") ? pattern.slice(0, pattern.indexOf("?")) : pattern);
939
+ /** Whether the person is on one of the flow's pages. */
940
+ function inFlowChain(flow, where) {
941
+ return flowChain(flow).some(r => !!matchRoute(pathOf(r), where.pathname, null, where.code));
942
+ }
943
+ /** Whether the person is on the flow's start page. */
944
+ function onFlowStart(flow, where) {
945
+ return asList(flow.startRoute).some(r => !!matchRoute(r, where.pathname, where.search, where.code));
946
+ }
947
+ /** Whether this person has the flow: `visible` (only true) and `requires`. */
948
+ function flowShown(flow, ctx) {
949
+ try {
950
+ if (flow.visible && flow.visible(ctx) !== true)
951
+ return false;
952
+ if (flow.requires && !ctx.can(flow.requires))
953
+ return false;
954
+ return true;
955
+ }
956
+ catch {
957
+ return false;
958
+ }
959
+ }
960
+ /** The flows this person has (the panel's "พาทำงาน" list), in the suite's order. */
961
+ function shownFlows(suite, ctx) {
962
+ return validFlows(suite).filter(f => flowShown(f, ctx));
963
+ }
964
+ /** Where the panel sends the person to start a flow, or null when it needs an id it does not have. */
965
+ function flowHref(flow, ctx) {
966
+ let raw;
967
+ try {
968
+ raw = flow.href === undefined ? undefined : typeof flow.href === "function" ? flow.href(ctx) : flow.href;
969
+ }
970
+ catch {
971
+ raw = undefined;
972
+ }
973
+ if (raw)
974
+ return /^https?:\/\//i.test(raw) ? raw : (fillRoute(raw, { code: ctx.code }) ?? null);
975
+ for (const r of asList(flow.startRoute)) {
976
+ const filled = fillRoute(r, { code: ctx.code });
977
+ if (filled)
978
+ return filled;
979
+ }
980
+ return null;
981
+ }
824
982
 
825
983
  /* ------------------------------------------------------------------ */
826
984
  /* Copy */
@@ -836,6 +994,7 @@ const COPY = {
836
994
  aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
837
995
  pageDone: "จบคู่มือหน้านี้",
838
996
  sectionDone: "จบคำอธิบายส่วนนี้",
997
+ flowDone: "พาทำงานเสร็จแล้ว",
839
998
  bridgeTitle: "กดปุ่มเมนู",
840
999
  bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
841
1000
  };
@@ -849,6 +1008,7 @@ function initialGuideState(audience, progress, pages) {
849
1008
  pagesMode: pages?.pagesMode ?? "off",
850
1009
  pages: pages?.pages ?? {},
851
1010
  sections: pages?.sections ?? {},
1011
+ flows: pages?.flows ?? {},
852
1012
  pageRun: null,
853
1013
  panelSection: "all",
854
1014
  autoPassed: null,
@@ -875,6 +1035,10 @@ function findMission(suite, key) {
875
1035
  const section = findSection(suite, sectionKeyOf(key));
876
1036
  return section ? sectionMission(section) : undefined;
877
1037
  }
1038
+ if (isFlowMissionKey(key)) {
1039
+ const flow = findFlow(suite, flowKeyOf(key));
1040
+ return flow ? flowMission(flow) : undefined;
1041
+ }
878
1042
  return suite.missions.find(m => m.key === key);
879
1043
  }
880
1044
  /**
@@ -1323,6 +1487,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1323
1487
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1324
1488
  const page = pageKeyOf(mission.key);
1325
1489
  const section = sectionKeyOf(mission.key);
1490
+ const flow = flowKeyOf(mission.key);
1326
1491
  const where = placeRoute(steps, i);
1327
1492
  // A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
1328
1493
  const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
@@ -1344,6 +1509,7 @@ function enterStep(d, suite, env, mission, index, opts) {
1344
1509
  replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
1345
1510
  ...(page ? { page } : {}),
1346
1511
  ...(section ? { section } : {}),
1512
+ ...(flow ? { flow } : {}),
1347
1513
  });
1348
1514
  if (!opts.replay) {
1349
1515
  const cp = checkpointAtOrBefore(steps, i);
@@ -1508,9 +1674,12 @@ function advance(d, suite, env, capture) {
1508
1674
  enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
1509
1675
  }
1510
1676
  function finishMission(d, suite, env, mission, replay) {
1511
- if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
1677
+ if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key) || isFlowMissionKey(mission.key)) {
1512
1678
  endPage(d, suite, "completed");
1513
- d.effects.push({ type: "toast", text: isSectionMissionKey(mission.key) ? COPY.sectionDone : COPY.pageDone });
1679
+ d.effects.push({
1680
+ type: "toast",
1681
+ text: isSectionMissionKey(mission.key) ? COPY.sectionDone : isFlowMissionKey(mission.key) ? COPY.flowDone : COPY.pageDone,
1682
+ });
1514
1683
  return;
1515
1684
  }
1516
1685
  if (replay) {
@@ -1805,18 +1974,114 @@ function readStillHere(suite, r, env) {
1805
1974
  const section = findSection(suite, r.key);
1806
1975
  return !!section && sectionApplies(section, whereOf(env));
1807
1976
  }
1808
- /** The page or section guide ends: its row written when it is due one; back to idle. */
1977
+ /** The flow's row says this person has ended this version of it. */
1978
+ function flowRecorded(state, flow) {
1979
+ const row = state.flows?.[flow.key];
1980
+ return !!row && row.version >= flow.version;
1981
+ }
1982
+ /** Write a flow's row (once per flow and version). */
1983
+ function recordFlow(d, suite, key, status) {
1984
+ const flow = findFlow(suite, key);
1985
+ if (!flow || flowRecorded(d.state, flow))
1986
+ return;
1987
+ const prev = d.state.flows?.[key];
1988
+ d.state = { ...d.state, flows: { ...d.state.flows, [key]: { version: flow.version, status, rev: prev?.rev ?? 0 } } };
1989
+ d.effects.push({ type: "savePage", key, version: flow.version, status, kind: "flow" });
1990
+ }
1991
+ /** The page guide, section guide or flow ends: its row written when it is due one; back to idle. */
1809
1992
  function endPage(d, suite, how) {
1810
1993
  const run = d.state.pageRun;
1811
1994
  if (run?.write) {
1812
1995
  if (run.kind === "section")
1813
1996
  recordSection(d, suite, run.key, how);
1997
+ else if (run.kind === "flow")
1998
+ recordFlow(d, suite, run.key, how);
1814
1999
  else
1815
2000
  recordPage(d, suite, run.key, how);
1816
2001
  }
2002
+ // A flow's captured ids were its own: the tour's come back.
2003
+ if (run?.kind === "flow" && run.context)
2004
+ withProgress(d, { context: run.context });
1817
2005
  d.state = { ...d.state, pageRun: null, replayPage: null };
1818
2006
  setScreen(d, { name: "idle" });
1819
2007
  }
2008
+ /** The flow on screen, or null. */
2009
+ function flowOf(s) {
2010
+ return s.name === "step" && s.flow ? s.flow : null;
2011
+ }
2012
+ /** The flows this person has that start on this page ("พาทำ · <title>"). */
2013
+ function flowsAvailable(suite, env) {
2014
+ if (!suite.flows?.length)
2015
+ return [];
2016
+ const ctx = guideContext(env, {});
2017
+ const where = whereOf(env);
2018
+ return (suite.flows ?? [])
2019
+ .map(f => findFlow(suite, f?.key))
2020
+ .filter((f) => !!f && onFlowStart(f, where) && flowShown(f, ctx))
2021
+ .map(f => f.key);
2022
+ }
2023
+ /**
2024
+ * The flow to offer now, or null: only in "offer" mode, only a flow that
2025
+ * says `offer: true`, on its start page, with no row of this version, when
2026
+ * nothing else is up and this page's own guide is not due (it comes first:
2027
+ * no guide here, offer: false, or already recorded), its first step's
2028
+ * target on screen, no app dialog open. The host adds the welcome and the
2029
+ * idle wait, as for the page guide's offer.
2030
+ */
2031
+ function flowOfferDue(state, suite, env) {
2032
+ if (state.pagesMode !== "offer" || state.pageAudience === "none")
2033
+ return null;
2034
+ if (state.screen.name !== "idle" || state.panelOpen || (state.pendingCelebrate?.length ?? 0) > 0)
2035
+ return null;
2036
+ const page = pageHere(suite, env);
2037
+ if (page && page.offer !== false && !pageRecorded(state, page))
2038
+ return null;
2039
+ if (safe(() => env.dialogOpen?.() === true, false))
2040
+ return null;
2041
+ for (const key of flowsAvailable(suite, env)) {
2042
+ const flow = findFlow(suite, key);
2043
+ if (flow.offer !== true || flowRecorded(state, flow))
2044
+ continue;
2045
+ const mission = flowMission(flow);
2046
+ const first = mission.steps.find(st => {
2047
+ const ctx = guideContext(env, {});
2048
+ if (st.requires && !safe(() => env.can(st.requires), true))
2049
+ return false;
2050
+ return !(st.skipIf && safe(() => st.skipIf(ctx), false));
2051
+ });
2052
+ if (first && !stepTargetVisible(first, env, guideContext(env, {}), false))
2053
+ continue;
2054
+ return key;
2055
+ }
2056
+ return null;
2057
+ }
2058
+ function startFlow(d, suite, env, key, write, at) {
2059
+ const flow = findFlow(suite, key);
2060
+ if (!flow)
2061
+ return;
2062
+ const mission = flowMission(flow);
2063
+ const saved = d.state.progress.context;
2064
+ d.state = {
2065
+ ...d.state,
2066
+ panelOpen: false,
2067
+ justDone: null,
2068
+ pageRun: { key, write, kind: "flow", context: saved },
2069
+ autoPassed: null,
2070
+ replayPage: {
2071
+ mission: mission.key,
2072
+ anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
2073
+ dialog: safe(() => env.dialogOpen?.() === true, false),
2074
+ },
2075
+ };
2076
+ withProgress(d, { context: at?.context ?? {} });
2077
+ let index = 0;
2078
+ if (at) {
2079
+ const stepAt = mission.steps.findIndex(st => st.key === at.step);
2080
+ if (stepAt > 0)
2081
+ index = checkpointAtOrBefore(mission.steps, stepAt);
2082
+ }
2083
+ enterStep(d, suite, env, mission, index, { replay: true });
2084
+ }
1820
2085
  /** The section guides on screen here that this person has (the "?" entries, the buttons). */
1821
2086
  function sectionsAvailable(suite, env) {
1822
2087
  return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
@@ -1950,7 +2215,7 @@ function reduceGuide(state, action, suite, env) {
1950
2215
  break;
1951
2216
  // A mission always wins over a page guide: a running one ends (its row
1952
2217
  // written if due), an offer goes unrecorded.
1953
- if (readOf(s))
2218
+ if (readOf(s) || flowOf(s))
1954
2219
  endPage(d, suite, "skipped");
1955
2220
  else if (s.name === "pageOffer")
1956
2221
  setScreen(d, { name: "idle" });
@@ -1978,6 +2243,14 @@ function reduceGuide(state, action, suite, env) {
1978
2243
  case "sync": {
1979
2244
  if (s.name === "pageOffer") {
1980
2245
  // Ignored: the person left the page (or its sub-screen). Recorded.
2246
+ if (s.flow) {
2247
+ const flow = findFlow(suite, s.page);
2248
+ if (!flow || !onFlowStart(flow, whereOf(env))) {
2249
+ recordFlow(d, suite, s.page, "skipped");
2250
+ setScreen(d, { name: "idle" });
2251
+ }
2252
+ break;
2253
+ }
1981
2254
  if (!pageStillHere(suite, s.page, env)) {
1982
2255
  recordPage(d, suite, s.page, "skipped");
1983
2256
  setScreen(d, { name: "idle" });
@@ -1986,6 +2259,15 @@ function reduceGuide(state, action, suite, env) {
1986
2259
  }
1987
2260
  if (s.name !== "step")
1988
2261
  break;
2262
+ if (s.flow) {
2263
+ // Off the flow's pages: it ends, silently. Between them it goes on
2264
+ // (a step on another of its pages shows "ทำต่อ").
2265
+ const flow = findFlow(suite, s.flow);
2266
+ if (!flow || !inFlowChain(flow, env)) {
2267
+ endPage(d, suite, "skipped");
2268
+ break;
2269
+ }
2270
+ }
1989
2271
  const read = readOf(s);
1990
2272
  if (read && !readStillHere(suite, read, env)) {
1991
2273
  // Leaving the page (or the section's root going) ends its guide, silently.
@@ -2175,7 +2457,7 @@ function reduceGuide(state, action, suite, env) {
2175
2457
  advance(d, suite, env);
2176
2458
  }
2177
2459
  else if (policy === "abort") {
2178
- if (readOf(s))
2460
+ if (readOf(s) || flowOf(s))
2179
2461
  endPage(d, suite, "skipped");
2180
2462
  else
2181
2463
  exitMission(d);
@@ -2217,7 +2499,7 @@ function reduceGuide(state, action, suite, env) {
2217
2499
  case "exit":
2218
2500
  if (s.name !== "step")
2219
2501
  break;
2220
- if (readOf(s)) {
2502
+ if (readOf(s) || flowOf(s)) {
2221
2503
  // ✕, Escape or ปิดคู่มือ: ends silently.
2222
2504
  pushAbort(d, suite, s);
2223
2505
  endPage(d, suite, "skipped");
@@ -2245,6 +2527,11 @@ function reduceGuide(state, action, suite, env) {
2245
2527
  case "endWithoutSave":
2246
2528
  if (s.name !== "step" || !s.replay)
2247
2529
  break;
2530
+ if (flowOf(s)) {
2531
+ pushAbort(d, suite, s);
2532
+ endPage(d, suite, "skipped");
2533
+ break;
2534
+ }
2248
2535
  pushAbort(d, suite, s);
2249
2536
  setScreen(d, { name: "idle" });
2250
2537
  d.effects.push({ type: "toast", text: COPY.replayEnded });
@@ -2293,7 +2580,7 @@ function reduceGuide(state, action, suite, env) {
2293
2580
  break;
2294
2581
  case "openPanel": {
2295
2582
  const section = action.section ?? "all";
2296
- const allowed = section === "pages" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
2583
+ const allowed = section !== "all" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
2297
2584
  if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
2298
2585
  break;
2299
2586
  // The offer gives way to the panel, unrecorded.
@@ -2346,6 +2633,13 @@ function reduceGuide(state, action, suite, env) {
2346
2633
  break;
2347
2634
  }
2348
2635
  case "pageOffer": {
2636
+ if (action.flow) {
2637
+ if (flowOfferDue(state, suite, env) !== action.page)
2638
+ break;
2639
+ setScreen(d, { name: "pageOffer", page: action.page, flow: true });
2640
+ d.effects.push({ type: "announce", text: `พาทำงาน ${findFlow(suite, action.page)?.title ?? ""}` });
2641
+ break;
2642
+ }
2349
2643
  if (pageOfferDue(state, suite, env) !== action.page)
2350
2644
  break;
2351
2645
  setScreen(d, { name: "pageOffer", page: action.page });
@@ -2355,14 +2649,42 @@ function reduceGuide(state, action, suite, env) {
2355
2649
  case "pageOfferStart":
2356
2650
  if (s.name !== "pageOffer" || state.pageAudience === "none")
2357
2651
  break;
2358
- startPage(d, suite, env, s.page, true);
2652
+ if (s.flow)
2653
+ startFlow(d, suite, env, s.page, true);
2654
+ else
2655
+ startPage(d, suite, env, s.page, true);
2359
2656
  break;
2360
2657
  case "pageOfferLater":
2361
2658
  if (s.name !== "pageOffer")
2362
2659
  break;
2363
- recordPage(d, suite, s.page, "skipped");
2660
+ if (s.flow)
2661
+ recordFlow(d, suite, s.page, "skipped");
2662
+ else
2663
+ recordPage(d, suite, s.page, "skipped");
2364
2664
  setScreen(d, { name: "idle" });
2365
2665
  break;
2666
+ case "startFlow": {
2667
+ // Only on its start page; never over a mission step, a welcome, a gate
2668
+ // or a celebration.
2669
+ if (state.pageAudience === "none" || state.pagesMode === "off")
2670
+ break;
2671
+ if (s.name !== "idle" && s.name !== "pageOffer")
2672
+ break;
2673
+ if (!flowsAvailable(suite, env).includes(action.flow))
2674
+ break;
2675
+ const flow = findFlow(suite, action.flow);
2676
+ startFlow(d, suite, env, flow.key, !flowRecorded(state, flow));
2677
+ break;
2678
+ }
2679
+ case "resumeFlow": {
2680
+ if (state.pageAudience === "none" || state.pagesMode === "off" || s.name !== "idle")
2681
+ break;
2682
+ const flow = findFlow(suite, action.flow);
2683
+ if (!flow || !flowShown(flow, guideContext(env, {})) || !inFlowChain(flow, whereOf(env)))
2684
+ break;
2685
+ startFlow(d, suite, env, flow.key, action.write && !flowRecorded(state, flow), { step: action.step, context: action.context });
2686
+ break;
2687
+ }
2366
2688
  case "startPage": {
2367
2689
  // Never over a mission step, a welcome, a gate or a celebration.
2368
2690
  if (state.pageAudience === "none" || state.pagesMode === "off")
@@ -2391,6 +2713,13 @@ function reduceGuide(state, action, suite, env) {
2391
2713
  break;
2392
2714
  }
2393
2715
  case "pageRow": {
2716
+ if (action.kind === "flow") {
2717
+ const cur = d.state.flows?.[action.key];
2718
+ if (cur && cur.version > action.row.version)
2719
+ break;
2720
+ d.state = { ...d.state, flows: { ...d.state.flows, [action.key]: action.row } };
2721
+ break;
2722
+ }
2394
2723
  if (action.kind === "section") {
2395
2724
  const cur = d.state.sections?.[action.key];
2396
2725
  if (cur && cur.version > action.row.version)
@@ -2546,6 +2875,7 @@ function parseGuidesAnswer(payload, suite) {
2546
2875
  : "none";
2547
2876
  const pages = parsePageRows(rows, suite.app);
2548
2877
  const sections = parsePageRows(rows, suite.app, "section");
2878
+ const flows = parsePageRows(rows, suite.app, "flow");
2549
2879
  let row = null;
2550
2880
  for (const r of rows) {
2551
2881
  if (isRecord(r) && r.guide_key === suite.key) {
@@ -2566,9 +2896,9 @@ function parseGuidesAnswer(payload, suite) {
2566
2896
  progress = row;
2567
2897
  newerOnServer = row.guide_version > suite.version;
2568
2898
  }
2569
- return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
2899
+ return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections, flows };
2570
2900
  }
2571
- /** The row key of a page guide, `<app>.page.<key>`, or a section guide, `<app>.section.<key>`. */
2901
+ /** The row key of a page guide `<app>.page.<key>`, a section guide `<app>.section.<key>`, or a flow `<app>.flow.<key>`. */
2572
2902
  function pageRowKey(app, key, kind = "page") {
2573
2903
  return `${app}.${kind}.${key}`;
2574
2904
  }
@@ -3750,7 +4080,10 @@ function CompleteModal(p) {
3750
4080
  * corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
3751
4081
  */
3752
4082
  function PageOfferCard(p) {
3753
- return (jsxRuntime.jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsxRuntime.jsx("p", { className: "eg-po-e", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-po-t", children: p.title }), jsxRuntime.jsxs("p", { className: "eg-b-w", children: [p.steps, " \u0E02\u0E31\u0E49\u0E19"] }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" })] })] }));
4083
+ return (jsxRuntime.jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsxRuntime.jsx("p", { className: "eg-po-e", children: p.eyebrow ?? "คู่มือหน้านี้" }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-po-t", children: p.title }), jsxRuntime.jsxs("p", { className: "eg-b-w", children: [p.steps, " \u0E02\u0E31\u0E49\u0E19"] }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" })] })] }));
4084
+ }
4085
+ function FlowList({ items, onOpen }) {
4086
+ return (jsxRuntime.jsx("ul", { className: "eg-q-list eg-pg-list", children: items.map(it => (jsxRuntime.jsxs("li", { className: "eg-q-item eg-pg-item", children: [jsxRuntime.jsxs("div", { className: "eg-t", children: [jsxRuntime.jsx("b", { children: it.title }), jsxRuntime.jsx("small", { children: it.done ? "ทำแล้ว" : it.minutes ? `ประมาณ ${it.minutes} นาที` : "ใหม่" })] }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onOpen(it.key), children: it.done ? "ทำอีกครั้ง" : "เริ่ม" })] }, it.key))) }));
3754
4087
  }
3755
4088
  function PageList({ items, onOpen }) {
3756
4089
  return (jsxRuntime.jsx("ul", { className: "eg-q-list eg-pg-list", children: items.map(it => (jsxRuntime.jsxs("li", { className: "eg-q-item eg-pg-item", children: [jsxRuntime.jsxs("div", { className: "eg-t", children: [jsxRuntime.jsx("b", { children: it.title }), jsxRuntime.jsx("small", { className: it.seen ? undefined : "eg-new", children: it.seen ? "ดูแล้ว" : "ใหม่" })] }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onOpen(it.key), children: "\u0E40\u0E1B\u0E34\u0E14" })] }, it.key))) }));
@@ -3777,12 +4110,16 @@ function QuestChip({ done, total, pulse, onOpen }) {
3777
4110
  }
3778
4111
  function QuestPanel(p) {
3779
4112
  const ref = useAutoFocus();
3780
- const pages = p.pages ?? [];
4113
+ const pages = p.only === "flows" ? [] : (p.pages ?? []);
4114
+ const flows = p.only === "pages" ? [] : (p.flows ?? []);
3781
4115
  const openPage = p.onOpenPage ?? (() => { });
4116
+ const openFlow = p.onOpenFlow ?? (() => { });
4117
+ const flowSection = flows.length ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h3", { className: "eg-q-sub", id: "eg-q-flows", children: "\u0E1E\u0E32\u0E17\u0E33\u0E07\u0E32\u0E19" }), jsxRuntime.jsx(FlowList, { items: flows, onOpen: openFlow })] })) : null;
3782
4118
  if (p.showMissions === false) {
3783
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })] }));
4119
+ const heading = pages.length ? "คู่มือแต่ละหน้า" : "พาทำงาน";
4120
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: heading }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), pages.length ? jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage }) : null, pages.length ? flowSection : flows.length ? jsxRuntime.jsx(FlowList, { items: flows, onOpen: openFlow }) : null] })] }));
3784
4121
  }
3785
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx("div", { className: "eg-prog", children: jsxRuntime.jsx("div", { className: "eg-bar", children: jsxRuntime.jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsxRuntime.jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsxRuntime.jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), pages.length ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h3", { className: "eg-q-sub", id: "eg-q-pages", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })) : null, p.canHideChip ? (jsxRuntime.jsx("div", { className: "eg-q-f", children: jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onHideChip, children: "\u0E0B\u0E48\u0E2D\u0E19\u0E1B\u0E38\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08" }) })) : null] })] }));
4122
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx("div", { className: "eg-prog", children: jsxRuntime.jsx("div", { className: "eg-bar", children: jsxRuntime.jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsxRuntime.jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsxRuntime.jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), pages.length ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("h3", { className: "eg-q-sub", id: "eg-q-pages", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })) : null, flowSection, p.canHideChip ? (jsxRuntime.jsx("div", { className: "eg-q-f", children: jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onHideChip, children: "\u0E0B\u0E48\u0E2D\u0E19\u0E1B\u0E38\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08" }) })) : null] })] }));
3786
4123
  }
3787
4124
  function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
3788
4125
  const v = item.view;
@@ -3920,8 +4257,7 @@ function writeMirror(orgId, suite, p) {
3920
4257
  /* private mode: the server copy is enough */
3921
4258
  }
3922
4259
  }
3923
- /** Page rows written in this tab, kept for a remount within the GET's cache time. */
3924
- const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
4260
+ const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind}s:${orgId}:${suite.app}`;
3925
4261
  function readPagesMirror(orgId, suite, kind = "page") {
3926
4262
  try {
3927
4263
  const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
@@ -3955,6 +4291,8 @@ function mergePageRows(server, local) {
3955
4291
  }
3956
4292
  return out;
3957
4293
  }
4294
+ /** A flow in progress in this tab (resumed after a refresh, never across sessions). */
4295
+ const flowMirrorKey = (orgId, suite) => `erp-guide-flow-run:${orgId}:${suite.app}`;
3958
4296
  /* ------------------------------------------------------------------ */
3959
4297
  /* One tab at a time */
3960
4298
  /* ------------------------------------------------------------------ */
@@ -4067,7 +4405,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4067
4405
  const [answer, setAnswer] = React.useState(null);
4068
4406
  const missionsReady = config.ready !== false;
4069
4407
  const pagesReady = config.pagesReady ?? missionsReady;
4070
- const hasPages = React.useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0, [suite]);
4408
+ const hasPages = React.useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0 || validFlows(suite).length > 0, [suite]);
4409
+ const hasFlows = React.useMemo(() => validFlows(suite).length > 0, [suite]);
4071
4410
  const hasSections = React.useMemo(() => validSections(suite).length > 0, [suite]);
4072
4411
  const enabled = primary && (missionsReady || (hasPages && pagesReady));
4073
4412
  const loaded = React.useRef(false);
@@ -4125,6 +4464,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4125
4464
  pagesMode: answer.pagesMode,
4126
4465
  pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
4127
4466
  sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
4467
+ flows: mergePageRows(answer.flows ?? {}, readPagesMirror(organizationId, suite, "flow")),
4128
4468
  });
4129
4469
  stateRef.current = s;
4130
4470
  setState(s);
@@ -4233,7 +4573,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4233
4573
  else if (e.type === "savePage") {
4234
4574
  // Once per page and version; the machine already holds it as written.
4235
4575
  const kind = e.kind ?? "page";
4236
- const rows = stateRef.current ? (kind === "section" ? stateRef.current.sections : stateRef.current.pages) : undefined;
4576
+ const cur = stateRef.current;
4577
+ const rows = cur ? (kind === "section" ? cur.sections : kind === "flow" ? cur.flows : cur.pages) : undefined;
4237
4578
  if (rows)
4238
4579
  writePagesMirror(organizationId, suite, rows, kind);
4239
4580
  const rev = rows?.[e.key]?.rev ?? 0;
@@ -4330,7 +4671,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4330
4671
  /* ---------------- sync on route, sidebar, facts, DOM ---------------- */
4331
4672
  // A page guide's step runs while page guides are live; a mission's while
4332
4673
  // the missions are. The offer card is watched the same way (left: ignored).
4333
- const inStep = screen.name === "step" && (screen.page || screen.section ? pagesLive : missionsLive);
4674
+ const inStep = screen.name === "step" && (screen.page || screen.section || screen.flow ? pagesLive : missionsLive);
4334
4675
  const offerUp = pagesLive && screen.name === "pageOffer";
4335
4676
  const watching = inStep || offerUp;
4336
4677
  React.useEffect(() => {
@@ -4944,6 +5285,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4944
5285
  openingSection.current = null;
4945
5286
  }
4946
5287
  }, []);
5288
+ const flowsNow = React.useMemo(() => pagesLive && hasState && hasFlows
5289
+ ? flowsAvailable(suite, env()).map(k => ({ key: k, title: findFlow(suite, k)?.title ?? "" }))
5290
+ : [],
5291
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5292
+ [pagesLive, hasState, hasFlows, suite, env, pathname, search, factsKey]);
5293
+ const flowsSig = flowsNow.map(x => x.key).join(",");
5294
+ /** The flows the panel lists: this person's, startable here or with somewhere to go. */
5295
+ const flowsListed = React.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]);
5296
+ const openFlowHere = React.useCallback((key) => dispatchRef.current({ type: "startFlow", flow: key }), []);
4947
5297
  const openPageHere = React.useCallback(() => {
4948
5298
  const p = pageHere(suite, env());
4949
5299
  if (p)
@@ -4954,10 +5304,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4954
5304
  publishPageGuides(null, null, null);
4955
5305
  return;
4956
5306
  }
4957
- publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length, sections: sectionsNow }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection);
4958
- // sectionsSig stands for sectionsNow.
5307
+ publishPageGuides({
5308
+ page: pageNow ? { title: pageNow.title } : null,
5309
+ listed: listed.length,
5310
+ sections: sectionsNow,
5311
+ flows: flowsNow,
5312
+ flowsListed: flowsListed.length,
5313
+ }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection, openFlowHere, () => dispatchRef.current({ type: "openPanel", section: "flows" }));
5314
+ // sectionsSig / flowsSig stand for sectionsNow / flowsNow.
4959
5315
  // eslint-disable-next-line react-hooks/exhaustive-deps
4960
- }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection]);
5316
+ }, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere]);
4961
5317
  React.useEffect(() => () => publishPageGuides(null, null, null), []);
4962
5318
  // The first-visit offer: in "offer" mode, nothing else up, the welcome
4963
5319
  // not due here, and a moment of nothing loading on this page.
@@ -4985,14 +5341,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
4985
5341
  if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
4986
5342
  return;
4987
5343
  let key = null;
5344
+ let flow = null;
4988
5345
  try {
4989
5346
  key = pageOfferDue(cur, suite, env());
5347
+ // A flow's offer comes after the page guide's (and only for `offer: true` flows).
5348
+ if (!key)
5349
+ flow = flowOfferDue(cur, suite, env());
4990
5350
  }
4991
5351
  catch {
4992
5352
  key = null;
5353
+ flow = null;
4993
5354
  }
4994
5355
  if (key)
4995
5356
  dispatchRef.current({ type: "pageOffer", page: key });
5357
+ else if (flow)
5358
+ dispatchRef.current({ type: "pageOffer", page: flow, flow: true });
4996
5359
  }, PAGE_POLL_MS);
4997
5360
  return () => clearInterval(iv);
4998
5361
  }, [offerWatch, suite, env, pathname, search]);
@@ -5044,6 +5407,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5044
5407
  if (b)
5045
5408
  return;
5046
5409
  const e = env();
5410
+ if (pending.flow) {
5411
+ const flow = findFlow(suite, pending.key);
5412
+ if (!flow || !onFlowStart(flow, e))
5413
+ return;
5414
+ setPending(null);
5415
+ dispatchRef.current({ type: "startFlow", flow: pending.key });
5416
+ return;
5417
+ }
5047
5418
  const page = findPage(suite, pending.key);
5048
5419
  if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
5049
5420
  return;
@@ -5052,6 +5423,93 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5052
5423
  }, PAGE_POLL_MS);
5053
5424
  return () => clearInterval(iv);
5054
5425
  }, [pending, pagesLive, suite, env, code]);
5426
+ // A flow in the panel's list: start it here, or go to its start page and start it there.
5427
+ const openListedFlow = React.useCallback((key) => {
5428
+ const flow = findFlow(suite, key);
5429
+ if (!flow)
5430
+ return;
5431
+ const e = env();
5432
+ if (onFlowStart(flow, e)) {
5433
+ dispatchRef.current({ type: "startFlow", flow: key });
5434
+ return;
5435
+ }
5436
+ dispatchRef.current({ type: "closePanel" });
5437
+ const href = flowHref(flow, guideContext(e, {}));
5438
+ if (!href)
5439
+ return;
5440
+ setPending({ key, until: Date.now() + PAGE_ARRIVE_MS, flow: true });
5441
+ try {
5442
+ if (/^https?:\/\//i.test(href))
5443
+ window.location.assign(href);
5444
+ else
5445
+ navigateRef.current(href);
5446
+ }
5447
+ catch {
5448
+ /* never breaks the app */
5449
+ }
5450
+ }, [suite, env]);
5451
+ // A flow in progress is kept in this tab's session (sessionStorage), so a
5452
+ // refresh or a full page load on one of its pages picks it up again at
5453
+ // its checkpoint ("ทำต่อ" when its step is on another page). Never across
5454
+ // sessions; a flow that ends is forgotten.
5455
+ const flowRun = state?.pageRun?.kind === "flow" ? state.pageRun : null;
5456
+ const flowStepKey = screen.name === "step" && screen.flow ? (findMission(suite, screen.mission)?.steps[screen.index]?.key ?? null) : null;
5457
+ React.useEffect(() => {
5458
+ if (!hasFlows)
5459
+ return;
5460
+ const k = flowMirrorKey(organizationId, suite);
5461
+ try {
5462
+ if (!flowRun) {
5463
+ if (state)
5464
+ sessionStorage.removeItem(k);
5465
+ return;
5466
+ }
5467
+ if (!flowStepKey)
5468
+ return;
5469
+ const flow = findFlow(suite, flowRun.key);
5470
+ sessionStorage.setItem(k, JSON.stringify({ flow: flowRun.key, version: flow?.version, step: flowStepKey, write: flowRun.write, context: state?.progress.context ?? {} }));
5471
+ }
5472
+ catch {
5473
+ /* private mode: no resume after a refresh */
5474
+ }
5475
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5476
+ }, [hasFlows, flowRun?.key, flowRun?.write, flowStepKey, organizationId, suite]);
5477
+ const flowResumed = React.useRef(false);
5478
+ React.useEffect(() => {
5479
+ if (flowResumed.current || !pagesLive || !state || !hasFlows)
5480
+ return;
5481
+ // The missions resume first.
5482
+ if (missionsLive && !resumed.current)
5483
+ return;
5484
+ flowResumed.current = true;
5485
+ const k = flowMirrorKey(organizationId, suite);
5486
+ let saved = null;
5487
+ try {
5488
+ saved = JSON.parse(sessionStorage.getItem(k) ?? "null");
5489
+ }
5490
+ catch {
5491
+ saved = null;
5492
+ }
5493
+ if (!saved?.flow || !saved.step)
5494
+ return;
5495
+ const flow = findFlow(suite, saved.flow);
5496
+ const ok = !!flow && flow.version === saved.version && stateRef.current?.screen.name === "idle" && inFlowChain(flow, { code, pathname });
5497
+ if (!ok) {
5498
+ try {
5499
+ sessionStorage.removeItem(k);
5500
+ }
5501
+ catch {
5502
+ /* ignore */
5503
+ }
5504
+ return;
5505
+ }
5506
+ const context = {};
5507
+ for (const [ck, cv] of Object.entries(saved.context ?? {}))
5508
+ if (typeof cv === "string")
5509
+ context[ck] = cv;
5510
+ dispatchRef.current({ type: "resumeFlow", flow: saved.flow, step: saved.step, context, write: saved.write === true });
5511
+ // eslint-disable-next-line react-hooks/exhaustive-deps
5512
+ }, [pagesLive, !!state, hasFlows, missionsLive]);
5055
5513
  if (!live || !state || !layer || !ctxIdle)
5056
5514
  return null;
5057
5515
  /* ---------------- render ---------------- */
@@ -5077,6 +5535,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5077
5535
  const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
5078
5536
  const page = screen.page ? findPage(suite, screen.page) : undefined;
5079
5537
  const section = screen.section ? findSection(suite, screen.section) : undefined;
5538
+ const flow = screen.flow ? findFlow(suite, screen.flow) : undefined;
5080
5539
  const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
5081
5540
  const pageBits = read
5082
5541
  ? {
@@ -5084,7 +5543,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5084
5543
  showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
5085
5544
  nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
5086
5545
  }
5087
- : {};
5546
+ : flow
5547
+ ? {
5548
+ heading: `พาทำ · ${flow.title}`,
5549
+ exitLabel: "ออกจากการพาทำ",
5550
+ nextLabel: kind === "view" && pos.no >= pos.total ? "เสร็จ" : undefined,
5551
+ }
5552
+ : {};
5088
5553
  content = (jsxRuntime.jsx(StepLayer, { spot: layout.spot, holes: layout.holes, popovers: layout.popovers, covered: layout.covered, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
5089
5554
  missionNo,
5090
5555
  missionTitle: mission.title,
@@ -5101,6 +5566,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5101
5566
  ...pageBits,
5102
5567
  }, hasTarget: bridge || !!step.target, targetPressable: targetPressable(step, bridge), phase: screen.phase, confirmExit: screen.confirmExit, reopenHint: suite.reopenHint, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onBack: () => dispatch({ type: "back" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
5103
5568
  }
5569
+ else if (screen.name === "pageOffer" && pagesLive && screen.flow) {
5570
+ const flow = findFlow(suite, screen.page);
5571
+ const mission = flow ? findMission(suite, FLOW_PREFIX + flow.key) : undefined;
5572
+ if (flow && mission) {
5573
+ content = (jsxRuntime.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" }) }));
5574
+ }
5575
+ }
5104
5576
  else if (screen.name === "pageOffer" && pagesLive) {
5105
5577
  const page = findPage(suite, screen.page);
5106
5578
  if (page) {
@@ -5144,12 +5616,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
5144
5616
  const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
5145
5617
  // The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
5146
5618
  // then the page guides they have.
5147
- const panelMissions = missionsLive && total > 0 && state.panelSection !== "pages";
5148
- const pageItems = pagesLive
5149
- ? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) }))
5619
+ const panelMissions = missionsLive && total > 0 && state.panelSection === "all";
5620
+ const pageItems = pagesLive && state.panelSection !== "flows" ? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) })) : [];
5621
+ const flowItems = pagesLive && state.panelSection !== "pages"
5622
+ ? flowsListed.map(f => ({ key: f.key, title: f.title, minutes: f.minutes, done: flowRecorded(state, f) && state.flows[f.key]?.status === "completed" }))
5150
5623
  : [];
5151
- const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
5152
- return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("style", { children: GUIDE_CSS }), jsxRuntime.jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsxRuntime.jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsxRuntime.jsx(QuestPanel, { items: items, done: done, total: total, justDone: state.justDone, gate: gate && gateFailing ? { label: gate.action.label, pending: gate.pending, href: gateHref } : null, canHideChip: !state.progress.chip_hidden && state.progress.status !== "completed", onClose: () => dispatch({ type: "closePanel" }), onStart: (key, replayIt) => dispatch({ type: "start", mission: key, replay: replayIt }), onDismiss: key => dispatch({ type: "dismissMission", mission: key }), onHideChip: () => dispatch({ type: "hideChip" }), pages: pageItems, showMissions: panelMissions, onOpenPage: openListedPage })) : null, toast ? jsxRuntime.jsx(GuideToast, { text: toast }) : null] }), layer);
5624
+ const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0 || flowItems.length > 0);
5625
+ return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("style", { children: GUIDE_CSS }), jsxRuntime.jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsxRuntime.jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsxRuntime.jsx(QuestPanel, { items: items, done: done, total: total, justDone: state.justDone, gate: gate && gateFailing ? { label: gate.action.label, pending: gate.pending, href: gateHref } : null, canHideChip: !state.progress.chip_hidden && state.progress.status !== "completed", onClose: () => dispatch({ type: "closePanel" }), onStart: (key, replayIt) => dispatch({ type: "start", mission: key, replay: replayIt }), onDismiss: key => dispatch({ type: "dismissMission", mission: key }), onHideChip: () => dispatch({ type: "hideChip" }), pages: pageItems, showMissions: panelMissions, onOpenPage: openListedPage, flows: flowItems, onOpenFlow: openListedFlow })) : null, toast ? jsxRuntime.jsx(GuideToast, { text: toast }) : null] }), layer);
5153
5626
  }
5154
5627
  /** A stable key for the facts object, so effects re-run when they change. */
5155
5628
  function useFactsKey(facts) {
@@ -5277,6 +5750,8 @@ exports.guideMenuShown = guideMenuShown;
5277
5750
  exports.loadGuides = loadGuides;
5278
5751
  exports.matchApiPath = matchApiPath;
5279
5752
  exports.matchRoute = matchRoute;
5753
+ exports.openFlow = openFlow;
5754
+ exports.openFlowsPanel = openFlowsPanel;
5280
5755
  exports.openGuidePanel = openGuidePanel;
5281
5756
  exports.openPageGuide = openPageGuide;
5282
5757
  exports.openPagesPanel = openPagesPanel;
@@ -5291,4 +5766,4 @@ exports.useGuideEntry = useGuideEntry;
5291
5766
  exports.usePageGuideEntry = usePageGuideEntry;
5292
5767
  exports.usePageGuides = usePageGuides;
5293
5768
  exports.useSectionGuideValues = useSectionGuideValues;
5294
- //# sourceMappingURL=format-CVazK0hP.cjs.map
5769
+ //# sourceMappingURL=format-BVoVc7Yk.cjs.map