@eintrek/erp-shell 0.1.75 → 0.1.77
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.
- package/README.md +52 -0
- package/dist/chunks/{format-CVazK0hP.cjs → format-DQLPHq7h.cjs} +728 -63
- package/dist/chunks/format-DQLPHq7h.cjs.map +1 -0
- package/dist/chunks/{format-DCTuLpBU.esm.js → format-ILrTMV4G.esm.js} +727 -64
- package/dist/chunks/format-ILrTMV4G.esm.js.map +1 -0
- package/dist/guide/core.d.ts +2 -2
- package/dist/guide/guide-store.d.ts +18 -3
- package/dist/guide/guide-ui.d.ts +22 -2
- package/dist/guide/machine.d.ts +85 -7
- package/dist/guide/pages.d.ts +29 -1
- package/dist/guide/progress-sync.d.ts +1 -1
- package/dist/guide/progress.d.ts +5 -3
- package/dist/guide/types.d.ts +59 -0
- package/dist/guide-core.cjs +3 -1
- package/dist/guide-core.cjs.map +1 -1
- package/dist/guide-core.esm.js +1 -1
- package/dist/help/help-button.d.ts +4 -2
- package/dist/index.cjs +11 -5
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +2 -2
- package/dist/index.esm.js +12 -8
- package/dist/index.esm.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/dist/chunks/format-CVazK0hP.cjs.map +0 -1
- package/dist/chunks/format-DCTuLpBU.esm.js.map +0 -1
|
@@ -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,9 +988,11 @@ 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,
|
|
995
|
+
counter: null,
|
|
835
996
|
audience,
|
|
836
997
|
progress,
|
|
837
998
|
screen: { name: "idle" },
|
|
@@ -855,6 +1016,10 @@ function findMission(suite, key) {
|
|
|
855
1016
|
const section = findSection(suite, sectionKeyOf(key));
|
|
856
1017
|
return section ? sectionMission(section) : undefined;
|
|
857
1018
|
}
|
|
1019
|
+
if (isFlowMissionKey(key)) {
|
|
1020
|
+
const flow = findFlow(suite, flowKeyOf(key));
|
|
1021
|
+
return flow ? flowMission(flow) : undefined;
|
|
1022
|
+
}
|
|
858
1023
|
return suite.missions.find(m => m.key === key);
|
|
859
1024
|
}
|
|
860
1025
|
/**
|
|
@@ -1206,7 +1371,7 @@ function announceStep(d, suite, env) {
|
|
|
1206
1371
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1207
1372
|
const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
|
|
1208
1373
|
const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
|
|
1209
|
-
const pos = stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
|
|
1374
|
+
const pos = s.pos ?? stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
|
|
1210
1375
|
d.effects.push({
|
|
1211
1376
|
type: "announce",
|
|
1212
1377
|
text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
|
|
@@ -1240,6 +1405,54 @@ function stepPosition(mission, index, env, context, autoPassed = []) {
|
|
|
1240
1405
|
});
|
|
1241
1406
|
return { no: Math.max(no, 1), total: Math.max(total, 1) };
|
|
1242
1407
|
}
|
|
1408
|
+
/**
|
|
1409
|
+
* The steps after `index` this person will see, as far as can be known from
|
|
1410
|
+
* here: on this page, everything is read (requires, skipIf, a click whose
|
|
1411
|
+
* outcome already holds); on another page, a step whose showing depends on
|
|
1412
|
+
* what is on that page (`skipIf`, `onMissing: "skip"`) is not counted until
|
|
1413
|
+
* its page is reached.
|
|
1414
|
+
*/
|
|
1415
|
+
function stepsAhead(mission, index, env, context) {
|
|
1416
|
+
let n = 0;
|
|
1417
|
+
for (let j = index + 1; j < mission.steps.length; j++) {
|
|
1418
|
+
const step = mission.steps[j];
|
|
1419
|
+
if (step.requires && !safe(() => env.can(step.requires), true))
|
|
1420
|
+
continue;
|
|
1421
|
+
const home = stepHomeRoute(mission.steps, j);
|
|
1422
|
+
const here = !home || !!matchRoute(home, env.pathname, env.search, env.code);
|
|
1423
|
+
if (here) {
|
|
1424
|
+
const ctx = guideContext(env, context, routeParamsFor(step, env));
|
|
1425
|
+
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
1426
|
+
continue;
|
|
1427
|
+
if (wouldAutoPass(step, env))
|
|
1428
|
+
continue;
|
|
1429
|
+
}
|
|
1430
|
+
else if (step.skipIf || step.onMissing === "skip") {
|
|
1431
|
+
continue;
|
|
1432
|
+
}
|
|
1433
|
+
n++;
|
|
1434
|
+
}
|
|
1435
|
+
return n;
|
|
1436
|
+
}
|
|
1437
|
+
/**
|
|
1438
|
+
* The counter as shown when entering step `index` (shell ≥ 0.1.77). `no`
|
|
1439
|
+
* counts the steps actually shown in this run up to this one. The total is
|
|
1440
|
+
* worked out on entering a step on a new page — the shown steps so far plus
|
|
1441
|
+
* stepsAhead — and kept while the run stays on that page; it never goes
|
|
1442
|
+
* down (the larger of the old and the new), and is never below `no`.
|
|
1443
|
+
*/
|
|
1444
|
+
function countStep(d, env, mission, index) {
|
|
1445
|
+
const prev = d.state.counter?.mission === mission.key ? d.state.counter : null;
|
|
1446
|
+
const shown = prev ? (prev.shown.includes(index) ? prev.shown : [...prev.shown, index]) : [index];
|
|
1447
|
+
const no = Math.max(1, shown.filter(i => i <= index).length);
|
|
1448
|
+
let total;
|
|
1449
|
+
if (prev && prev.path === env.pathname)
|
|
1450
|
+
total = Math.max(prev.total, no);
|
|
1451
|
+
else
|
|
1452
|
+
total = Math.max(prev?.total ?? 0, no + stepsAhead(mission, index, env, d.state.progress.context), no);
|
|
1453
|
+
d.state = { ...d.state, counter: { mission: mission.key, shown, total, path: env.pathname } };
|
|
1454
|
+
return { no, total };
|
|
1455
|
+
}
|
|
1243
1456
|
/** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
|
|
1244
1457
|
function wouldAutoPass(step, env) {
|
|
1245
1458
|
const kind = stepKind(step);
|
|
@@ -1303,11 +1516,13 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1303
1516
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1304
1517
|
const page = pageKeyOf(mission.key);
|
|
1305
1518
|
const section = sectionKeyOf(mission.key);
|
|
1519
|
+
const flow = flowKeyOf(mission.key);
|
|
1306
1520
|
const where = placeRoute(steps, i);
|
|
1307
1521
|
// A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
|
|
1308
1522
|
const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
1309
1523
|
const bridge = !away && needsBridge(step, env, ctx);
|
|
1310
1524
|
const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
|
|
1525
|
+
const pos = countStep(d, env, mission, i);
|
|
1311
1526
|
setScreen(d, {
|
|
1312
1527
|
name: "step",
|
|
1313
1528
|
mission: mission.key,
|
|
@@ -1324,6 +1539,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1324
1539
|
replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
|
|
1325
1540
|
...(page ? { page } : {}),
|
|
1326
1541
|
...(section ? { section } : {}),
|
|
1542
|
+
...(flow ? { flow } : {}),
|
|
1543
|
+
pos,
|
|
1327
1544
|
});
|
|
1328
1545
|
if (!opts.replay) {
|
|
1329
1546
|
const cp = checkpointAtOrBefore(steps, i);
|
|
@@ -1488,9 +1705,12 @@ function advance(d, suite, env, capture) {
|
|
|
1488
1705
|
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
1489
1706
|
}
|
|
1490
1707
|
function finishMission(d, suite, env, mission, replay) {
|
|
1491
|
-
if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
|
|
1708
|
+
if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key) || isFlowMissionKey(mission.key)) {
|
|
1492
1709
|
endPage(d, suite, "completed");
|
|
1493
|
-
d.effects.push({
|
|
1710
|
+
d.effects.push({
|
|
1711
|
+
type: "toast",
|
|
1712
|
+
text: isSectionMissionKey(mission.key) ? COPY.sectionDone : isFlowMissionKey(mission.key) ? COPY.flowDone : COPY.pageDone,
|
|
1713
|
+
});
|
|
1494
1714
|
return;
|
|
1495
1715
|
}
|
|
1496
1716
|
if (replay) {
|
|
@@ -1595,7 +1815,7 @@ function startMission(d, suite, env, key, replay) {
|
|
|
1595
1815
|
const isReplay = replay || entry?.status === "done";
|
|
1596
1816
|
const before = d.state.progress;
|
|
1597
1817
|
// A new run: what passed by itself is counted afresh.
|
|
1598
|
-
d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
|
|
1818
|
+
d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null, counter: null };
|
|
1599
1819
|
if (d.state.progress.welcome === "not_seen")
|
|
1600
1820
|
withProgress(d, { welcome: "started" });
|
|
1601
1821
|
if (!isReplay && d.state.progress.mission_key !== key)
|
|
@@ -1745,24 +1965,33 @@ function pageStillHere(suite, key, env) {
|
|
|
1745
1965
|
const page = findPage(suite, key);
|
|
1746
1966
|
return !!page && pageMatchScore(page, whereOf(env)) !== null;
|
|
1747
1967
|
}
|
|
1748
|
-
/**
|
|
1968
|
+
/**
|
|
1969
|
+
* Whether a row may be written now: none of this version yet, or — once —
|
|
1970
|
+
* a skipped one that the person has now finished (skipped → completed,
|
|
1971
|
+
* shell ≥ 0.1.77). A completed row is final; skipped again changes nothing.
|
|
1972
|
+
*/
|
|
1973
|
+
function rowWritable(row, version, status) {
|
|
1974
|
+
if (!row || row.version < version)
|
|
1975
|
+
return true;
|
|
1976
|
+
return row.version === version && row.status === "skipped" && status === "completed";
|
|
1977
|
+
}
|
|
1978
|
+
/** Whether a run should keep its row to write at its end: no row yet, or only a skipped one. */
|
|
1979
|
+
function rowStillDue(row, version) {
|
|
1980
|
+
return rowWritable(row, version, "completed");
|
|
1981
|
+
}
|
|
1982
|
+
/** Write a page guide's row (once per page and version; skipped → completed once): recorded here at once, saved by the host. */
|
|
1749
1983
|
function recordPage(d, suite, key, status) {
|
|
1750
1984
|
const page = findPage(suite, key);
|
|
1751
|
-
if (!page ||
|
|
1985
|
+
if (!page || !rowWritable(d.state.pages[key], page.version, status))
|
|
1752
1986
|
return;
|
|
1753
1987
|
const prev = d.state.pages[key];
|
|
1754
1988
|
d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
|
|
1755
1989
|
d.effects.push({ type: "savePage", key, version: page.version, status });
|
|
1756
1990
|
}
|
|
1757
|
-
/** The section guide's row says this person has ended this version of it. */
|
|
1758
|
-
function sectionRecorded(state, section) {
|
|
1759
|
-
const row = state.sections?.[section.key];
|
|
1760
|
-
return !!row && row.version >= section.version;
|
|
1761
|
-
}
|
|
1762
1991
|
/** Write a section guide's row (once per section and version). */
|
|
1763
1992
|
function recordSection(d, suite, key, status) {
|
|
1764
1993
|
const section = findSection(suite, key);
|
|
1765
|
-
if (!section ||
|
|
1994
|
+
if (!section || !rowWritable(d.state.sections?.[key], section.version, status))
|
|
1766
1995
|
return;
|
|
1767
1996
|
const prev = d.state.sections?.[key];
|
|
1768
1997
|
d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
|
|
@@ -1785,18 +2014,115 @@ function readStillHere(suite, r, env) {
|
|
|
1785
2014
|
const section = findSection(suite, r.key);
|
|
1786
2015
|
return !!section && sectionApplies(section, whereOf(env));
|
|
1787
2016
|
}
|
|
1788
|
-
/** The
|
|
2017
|
+
/** The flow's row says this person has ended this version of it. */
|
|
2018
|
+
function flowRecorded(state, flow) {
|
|
2019
|
+
const row = state.flows?.[flow.key];
|
|
2020
|
+
return !!row && row.version >= flow.version;
|
|
2021
|
+
}
|
|
2022
|
+
/** Write a flow's row (once per flow and version). */
|
|
2023
|
+
function recordFlow(d, suite, key, status) {
|
|
2024
|
+
const flow = findFlow(suite, key);
|
|
2025
|
+
if (!flow || !rowWritable(d.state.flows?.[key], flow.version, status))
|
|
2026
|
+
return;
|
|
2027
|
+
const prev = d.state.flows?.[key];
|
|
2028
|
+
d.state = { ...d.state, flows: { ...d.state.flows, [key]: { version: flow.version, status, rev: prev?.rev ?? 0 } } };
|
|
2029
|
+
d.effects.push({ type: "savePage", key, version: flow.version, status, kind: "flow" });
|
|
2030
|
+
}
|
|
2031
|
+
/** The page guide, section guide or flow ends: its row written when it is due one; back to idle. */
|
|
1789
2032
|
function endPage(d, suite, how) {
|
|
1790
2033
|
const run = d.state.pageRun;
|
|
1791
2034
|
if (run?.write) {
|
|
1792
2035
|
if (run.kind === "section")
|
|
1793
2036
|
recordSection(d, suite, run.key, how);
|
|
2037
|
+
else if (run.kind === "flow")
|
|
2038
|
+
recordFlow(d, suite, run.key, how);
|
|
1794
2039
|
else
|
|
1795
2040
|
recordPage(d, suite, run.key, how);
|
|
1796
2041
|
}
|
|
2042
|
+
// A flow's captured ids were its own: the tour's come back.
|
|
2043
|
+
if (run?.kind === "flow" && run.context)
|
|
2044
|
+
withProgress(d, { context: run.context });
|
|
1797
2045
|
d.state = { ...d.state, pageRun: null, replayPage: null };
|
|
1798
2046
|
setScreen(d, { name: "idle" });
|
|
1799
2047
|
}
|
|
2048
|
+
/** The flow on screen, or null. */
|
|
2049
|
+
function flowOf(s) {
|
|
2050
|
+
return s.name === "step" && s.flow ? s.flow : null;
|
|
2051
|
+
}
|
|
2052
|
+
/** The flows this person has that start on this page ("พาทำ · <title>"). */
|
|
2053
|
+
function flowsAvailable(suite, env) {
|
|
2054
|
+
if (!suite.flows?.length)
|
|
2055
|
+
return [];
|
|
2056
|
+
const ctx = guideContext(env, {});
|
|
2057
|
+
const where = whereOf(env);
|
|
2058
|
+
return (suite.flows ?? [])
|
|
2059
|
+
.map(f => findFlow(suite, f?.key))
|
|
2060
|
+
.filter((f) => !!f && onFlowStart(f, where) && flowShown(f, ctx))
|
|
2061
|
+
.map(f => f.key);
|
|
2062
|
+
}
|
|
2063
|
+
/**
|
|
2064
|
+
* The flow to offer now, or null: only in "offer" mode, only a flow that
|
|
2065
|
+
* says `offer: true`, on its start page, with no row of this version, when
|
|
2066
|
+
* nothing else is up and this page's own guide is not due (it comes first:
|
|
2067
|
+
* no guide here, offer: false, or already recorded), its first step's
|
|
2068
|
+
* target on screen, no app dialog open. The host adds the welcome and the
|
|
2069
|
+
* idle wait, as for the page guide's offer.
|
|
2070
|
+
*/
|
|
2071
|
+
function flowOfferDue(state, suite, env) {
|
|
2072
|
+
if (state.pagesMode !== "offer" || state.pageAudience === "none")
|
|
2073
|
+
return null;
|
|
2074
|
+
if (state.screen.name !== "idle" || state.panelOpen || (state.pendingCelebrate?.length ?? 0) > 0)
|
|
2075
|
+
return null;
|
|
2076
|
+
const page = pageHere(suite, env);
|
|
2077
|
+
if (page && page.offer !== false && !pageRecorded(state, page))
|
|
2078
|
+
return null;
|
|
2079
|
+
if (safe(() => env.dialogOpen?.() === true, false))
|
|
2080
|
+
return null;
|
|
2081
|
+
for (const key of flowsAvailable(suite, env)) {
|
|
2082
|
+
const flow = findFlow(suite, key);
|
|
2083
|
+
if (flow.offer !== true || flowRecorded(state, flow))
|
|
2084
|
+
continue;
|
|
2085
|
+
const mission = flowMission(flow);
|
|
2086
|
+
const first = mission.steps.find(st => {
|
|
2087
|
+
const ctx = guideContext(env, {});
|
|
2088
|
+
if (st.requires && !safe(() => env.can(st.requires), true))
|
|
2089
|
+
return false;
|
|
2090
|
+
return !(st.skipIf && safe(() => st.skipIf(ctx), false));
|
|
2091
|
+
});
|
|
2092
|
+
if (first && !stepTargetVisible(first, env, guideContext(env, {}), false))
|
|
2093
|
+
continue;
|
|
2094
|
+
return key;
|
|
2095
|
+
}
|
|
2096
|
+
return null;
|
|
2097
|
+
}
|
|
2098
|
+
function startFlow(d, suite, env, key, write, at) {
|
|
2099
|
+
const flow = findFlow(suite, key);
|
|
2100
|
+
if (!flow)
|
|
2101
|
+
return;
|
|
2102
|
+
const mission = flowMission(flow);
|
|
2103
|
+
const saved = d.state.progress.context;
|
|
2104
|
+
d.state = {
|
|
2105
|
+
...d.state,
|
|
2106
|
+
panelOpen: false,
|
|
2107
|
+
justDone: null,
|
|
2108
|
+
pageRun: { key, write, kind: "flow", context: saved },
|
|
2109
|
+
autoPassed: null,
|
|
2110
|
+
counter: null,
|
|
2111
|
+
replayPage: {
|
|
2112
|
+
mission: mission.key,
|
|
2113
|
+
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
2114
|
+
dialog: safe(() => env.dialogOpen?.() === true, false),
|
|
2115
|
+
},
|
|
2116
|
+
};
|
|
2117
|
+
withProgress(d, { context: at?.context ?? {} });
|
|
2118
|
+
let index = 0;
|
|
2119
|
+
if (at) {
|
|
2120
|
+
const stepAt = mission.steps.findIndex(st => st.key === at.step);
|
|
2121
|
+
if (stepAt > 0)
|
|
2122
|
+
index = checkpointAtOrBefore(mission.steps, stepAt);
|
|
2123
|
+
}
|
|
2124
|
+
enterStep(d, suite, env, mission, index, { replay: true });
|
|
2125
|
+
}
|
|
1800
2126
|
/** The section guides on screen here that this person has (the "?" entries, the buttons). */
|
|
1801
2127
|
function sectionsAvailable(suite, env) {
|
|
1802
2128
|
return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
|
|
@@ -1812,6 +2138,7 @@ function startSection(d, suite, env, key, write) {
|
|
|
1812
2138
|
justDone: null,
|
|
1813
2139
|
pageRun: { key, write, kind: "section" },
|
|
1814
2140
|
autoPassed: null,
|
|
2141
|
+
counter: null,
|
|
1815
2142
|
replayPage: {
|
|
1816
2143
|
mission: mission.key,
|
|
1817
2144
|
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
@@ -1831,6 +2158,7 @@ function startPage(d, suite, env, key, write) {
|
|
|
1831
2158
|
justDone: null,
|
|
1832
2159
|
pageRun: { key, write, kind: "page" },
|
|
1833
2160
|
autoPassed: null,
|
|
2161
|
+
counter: null,
|
|
1834
2162
|
// What is open now is the person's: ending the guide leaves it alone.
|
|
1835
2163
|
replayPage: {
|
|
1836
2164
|
mission: mission.key,
|
|
@@ -1874,6 +2202,28 @@ function backTarget(mission, index, env, context) {
|
|
|
1874
2202
|
}
|
|
1875
2203
|
return prev >= 0 ? { index: prev, close: false } : null;
|
|
1876
2204
|
}
|
|
2205
|
+
/**
|
|
2206
|
+
* A flow's next step (the next one this person sees) when the person is on
|
|
2207
|
+
* its page and no longer on the current step's: they moved on by
|
|
2208
|
+
* themselves. -1 otherwise (still on this step's page, or elsewhere).
|
|
2209
|
+
*/
|
|
2210
|
+
function flowStepArrivedAt(mission, index, env, context) {
|
|
2211
|
+
const here = (route) => !!route && !!matchRoute(route, env.pathname, env.search, env.code);
|
|
2212
|
+
const curHome = stepHomeRoute(mission.steps, index);
|
|
2213
|
+
if (!curHome || here(curHome))
|
|
2214
|
+
return -1;
|
|
2215
|
+
for (let j = index + 1; j < mission.steps.length; j++) {
|
|
2216
|
+
const st = mission.steps[j];
|
|
2217
|
+
const ctx = guideContext(env, context, routeParamsFor(st, env));
|
|
2218
|
+
if (st.requires && !safe(() => env.can(st.requires), true))
|
|
2219
|
+
continue;
|
|
2220
|
+
if (st.skipIf && safe(() => st.skipIf(ctx), false))
|
|
2221
|
+
continue;
|
|
2222
|
+
const where = st.route ?? stepHomeRoute(mission.steps, j);
|
|
2223
|
+
return here(where) ? j : -1;
|
|
2224
|
+
}
|
|
2225
|
+
return -1;
|
|
2226
|
+
}
|
|
1877
2227
|
function reduceGuide(state, action, suite, env) {
|
|
1878
2228
|
const d = { state, effects: [] };
|
|
1879
2229
|
const s = state.screen;
|
|
@@ -1930,7 +2280,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1930
2280
|
break;
|
|
1931
2281
|
// A mission always wins over a page guide: a running one ends (its row
|
|
1932
2282
|
// written if due), an offer goes unrecorded.
|
|
1933
|
-
if (readOf(s))
|
|
2283
|
+
if (readOf(s) || flowOf(s))
|
|
1934
2284
|
endPage(d, suite, "skipped");
|
|
1935
2285
|
else if (s.name === "pageOffer")
|
|
1936
2286
|
setScreen(d, { name: "idle" });
|
|
@@ -1951,13 +2301,21 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1951
2301
|
const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
|
|
1952
2302
|
const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
|
|
1953
2303
|
const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
|
|
1954
|
-
d.state = { ...d.state, autoPassed: null };
|
|
2304
|
+
d.state = { ...d.state, autoPassed: null, counter: null };
|
|
1955
2305
|
enterStep(d, suite, env, mission, index, { replay: false });
|
|
1956
2306
|
break;
|
|
1957
2307
|
}
|
|
1958
2308
|
case "sync": {
|
|
1959
2309
|
if (s.name === "pageOffer") {
|
|
1960
2310
|
// Ignored: the person left the page (or its sub-screen). Recorded.
|
|
2311
|
+
if (s.flow) {
|
|
2312
|
+
const flow = findFlow(suite, s.page);
|
|
2313
|
+
if (!flow || !onFlowStart(flow, whereOf(env))) {
|
|
2314
|
+
recordFlow(d, suite, s.page, "skipped");
|
|
2315
|
+
setScreen(d, { name: "idle" });
|
|
2316
|
+
}
|
|
2317
|
+
break;
|
|
2318
|
+
}
|
|
1961
2319
|
if (!pageStillHere(suite, s.page, env)) {
|
|
1962
2320
|
recordPage(d, suite, s.page, "skipped");
|
|
1963
2321
|
setScreen(d, { name: "idle" });
|
|
@@ -1966,6 +2324,15 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1966
2324
|
}
|
|
1967
2325
|
if (s.name !== "step")
|
|
1968
2326
|
break;
|
|
2327
|
+
if (s.flow) {
|
|
2328
|
+
// Off the flow's pages: it ends, silently. Between them it goes on
|
|
2329
|
+
// (a step on another of its pages shows "ทำต่อ").
|
|
2330
|
+
const flow = findFlow(suite, s.flow);
|
|
2331
|
+
if (!flow || !inFlowChain(flow, env)) {
|
|
2332
|
+
endPage(d, suite, "skipped");
|
|
2333
|
+
break;
|
|
2334
|
+
}
|
|
2335
|
+
}
|
|
1969
2336
|
const read = readOf(s);
|
|
1970
2337
|
if (read && !readStillHere(suite, read, env)) {
|
|
1971
2338
|
// Leaving the page (or the section's root going) ends its guide, silently.
|
|
@@ -1977,6 +2344,15 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1977
2344
|
break;
|
|
1978
2345
|
const step = mission.steps[s.index];
|
|
1979
2346
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
2347
|
+
if (s.flow && !s.confirmExit) {
|
|
2348
|
+
// Within the chain, the person went on to the next step's page by
|
|
2349
|
+
// themselves: carry on there, no "ทำต่อ" (shell ≥ 0.1.77).
|
|
2350
|
+
const next = flowStepArrivedAt(mission, s.index, env, d.state.progress.context);
|
|
2351
|
+
if (next >= 0) {
|
|
2352
|
+
enterStep(d, suite, env, mission, next, { replay: s.replay });
|
|
2353
|
+
break;
|
|
2354
|
+
}
|
|
2355
|
+
}
|
|
1980
2356
|
if (s.phase === "away") {
|
|
1981
2357
|
const where = placeRoute(mission.steps, s.index);
|
|
1982
2358
|
if (!where || matchRoute(where, env.pathname, env.search, env.code)) {
|
|
@@ -2155,7 +2531,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2155
2531
|
advance(d, suite, env);
|
|
2156
2532
|
}
|
|
2157
2533
|
else if (policy === "abort") {
|
|
2158
|
-
if (readOf(s))
|
|
2534
|
+
if (readOf(s) || flowOf(s))
|
|
2159
2535
|
endPage(d, suite, "skipped");
|
|
2160
2536
|
else
|
|
2161
2537
|
exitMission(d);
|
|
@@ -2197,7 +2573,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2197
2573
|
case "exit":
|
|
2198
2574
|
if (s.name !== "step")
|
|
2199
2575
|
break;
|
|
2200
|
-
if (readOf(s)) {
|
|
2576
|
+
if (readOf(s) || flowOf(s)) {
|
|
2201
2577
|
// ✕, Escape or ปิดคู่มือ: ends silently.
|
|
2202
2578
|
pushAbort(d, suite, s);
|
|
2203
2579
|
endPage(d, suite, "skipped");
|
|
@@ -2225,6 +2601,11 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2225
2601
|
case "endWithoutSave":
|
|
2226
2602
|
if (s.name !== "step" || !s.replay)
|
|
2227
2603
|
break;
|
|
2604
|
+
if (flowOf(s)) {
|
|
2605
|
+
pushAbort(d, suite, s);
|
|
2606
|
+
endPage(d, suite, "skipped");
|
|
2607
|
+
break;
|
|
2608
|
+
}
|
|
2228
2609
|
pushAbort(d, suite, s);
|
|
2229
2610
|
setScreen(d, { name: "idle" });
|
|
2230
2611
|
d.effects.push({ type: "toast", text: COPY.replayEnded });
|
|
@@ -2273,7 +2654,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2273
2654
|
break;
|
|
2274
2655
|
case "openPanel": {
|
|
2275
2656
|
const section = action.section ?? "all";
|
|
2276
|
-
const allowed = section
|
|
2657
|
+
const allowed = section !== "all" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
|
|
2277
2658
|
if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
|
|
2278
2659
|
break;
|
|
2279
2660
|
// The offer gives way to the panel, unrecorded.
|
|
@@ -2326,6 +2707,13 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2326
2707
|
break;
|
|
2327
2708
|
}
|
|
2328
2709
|
case "pageOffer": {
|
|
2710
|
+
if (action.flow) {
|
|
2711
|
+
if (flowOfferDue(state, suite, env) !== action.page)
|
|
2712
|
+
break;
|
|
2713
|
+
setScreen(d, { name: "pageOffer", page: action.page, flow: true });
|
|
2714
|
+
d.effects.push({ type: "announce", text: `พาทำงาน ${findFlow(suite, action.page)?.title ?? ""}` });
|
|
2715
|
+
break;
|
|
2716
|
+
}
|
|
2329
2717
|
if (pageOfferDue(state, suite, env) !== action.page)
|
|
2330
2718
|
break;
|
|
2331
2719
|
setScreen(d, { name: "pageOffer", page: action.page });
|
|
@@ -2335,14 +2723,42 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2335
2723
|
case "pageOfferStart":
|
|
2336
2724
|
if (s.name !== "pageOffer" || state.pageAudience === "none")
|
|
2337
2725
|
break;
|
|
2338
|
-
|
|
2726
|
+
if (s.flow)
|
|
2727
|
+
startFlow(d, suite, env, s.page, true);
|
|
2728
|
+
else
|
|
2729
|
+
startPage(d, suite, env, s.page, true);
|
|
2339
2730
|
break;
|
|
2340
2731
|
case "pageOfferLater":
|
|
2341
2732
|
if (s.name !== "pageOffer")
|
|
2342
2733
|
break;
|
|
2343
|
-
|
|
2734
|
+
if (s.flow)
|
|
2735
|
+
recordFlow(d, suite, s.page, "skipped");
|
|
2736
|
+
else
|
|
2737
|
+
recordPage(d, suite, s.page, "skipped");
|
|
2344
2738
|
setScreen(d, { name: "idle" });
|
|
2345
2739
|
break;
|
|
2740
|
+
case "startFlow": {
|
|
2741
|
+
// Only on its start page; never over a mission step, a welcome, a gate
|
|
2742
|
+
// or a celebration.
|
|
2743
|
+
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
2744
|
+
break;
|
|
2745
|
+
if (s.name !== "idle" && s.name !== "pageOffer")
|
|
2746
|
+
break;
|
|
2747
|
+
if (!flowsAvailable(suite, env).includes(action.flow))
|
|
2748
|
+
break;
|
|
2749
|
+
const flow = findFlow(suite, action.flow);
|
|
2750
|
+
startFlow(d, suite, env, flow.key, rowStillDue(state.flows?.[flow.key], flow.version));
|
|
2751
|
+
break;
|
|
2752
|
+
}
|
|
2753
|
+
case "resumeFlow": {
|
|
2754
|
+
if (state.pageAudience === "none" || state.pagesMode === "off" || s.name !== "idle")
|
|
2755
|
+
break;
|
|
2756
|
+
const flow = findFlow(suite, action.flow);
|
|
2757
|
+
if (!flow || !flowShown(flow, guideContext(env, {})) || !inFlowChain(flow, whereOf(env)))
|
|
2758
|
+
break;
|
|
2759
|
+
startFlow(d, suite, env, flow.key, action.write && rowStillDue(state.flows?.[flow.key], flow.version), { step: action.step, context: action.context });
|
|
2760
|
+
break;
|
|
2761
|
+
}
|
|
2346
2762
|
case "startPage": {
|
|
2347
2763
|
// Never over a mission step, a welcome, a gate or a celebration.
|
|
2348
2764
|
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
@@ -2352,7 +2768,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2352
2768
|
const page = pageHere(suite, env);
|
|
2353
2769
|
if (!page || page.key !== action.page)
|
|
2354
2770
|
break;
|
|
2355
|
-
startPage(d, suite, env, page.key,
|
|
2771
|
+
startPage(d, suite, env, page.key, rowStillDue(state.pages[page.key], page.version));
|
|
2356
2772
|
break;
|
|
2357
2773
|
}
|
|
2358
2774
|
case "back": {
|
|
@@ -2371,6 +2787,13 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2371
2787
|
break;
|
|
2372
2788
|
}
|
|
2373
2789
|
case "pageRow": {
|
|
2790
|
+
if (action.kind === "flow") {
|
|
2791
|
+
const cur = d.state.flows?.[action.key];
|
|
2792
|
+
if (cur && cur.version > action.row.version)
|
|
2793
|
+
break;
|
|
2794
|
+
d.state = { ...d.state, flows: { ...d.state.flows, [action.key]: action.row } };
|
|
2795
|
+
break;
|
|
2796
|
+
}
|
|
2374
2797
|
if (action.kind === "section") {
|
|
2375
2798
|
const cur = d.state.sections?.[action.key];
|
|
2376
2799
|
if (cur && cur.version > action.row.version)
|
|
@@ -2394,7 +2817,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2394
2817
|
if (!sectionsAvailable(suite, env).includes(action.section))
|
|
2395
2818
|
break;
|
|
2396
2819
|
const section = findSection(suite, action.section);
|
|
2397
|
-
startSection(d, suite, env, section.key,
|
|
2820
|
+
startSection(d, suite, env, section.key, rowStillDue(state.sections?.[section.key], section.version));
|
|
2398
2821
|
break;
|
|
2399
2822
|
}
|
|
2400
2823
|
case "idleCelebrate": {
|
|
@@ -2526,6 +2949,7 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
2526
2949
|
: "none";
|
|
2527
2950
|
const pages = parsePageRows(rows, suite.app);
|
|
2528
2951
|
const sections = parsePageRows(rows, suite.app, "section");
|
|
2952
|
+
const flows = parsePageRows(rows, suite.app, "flow");
|
|
2529
2953
|
let row = null;
|
|
2530
2954
|
for (const r of rows) {
|
|
2531
2955
|
if (isRecord(r) && r.guide_key === suite.key) {
|
|
@@ -2546,9 +2970,9 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
2546
2970
|
progress = row;
|
|
2547
2971
|
newerOnServer = row.guide_version > suite.version;
|
|
2548
2972
|
}
|
|
2549
|
-
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
|
|
2973
|
+
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections, flows };
|
|
2550
2974
|
}
|
|
2551
|
-
/** The row key of a page guide
|
|
2975
|
+
/** The row key of a page guide `<app>.page.<key>`, a section guide `<app>.section.<key>`, or a flow `<app>.flow.<key>`. */
|
|
2552
2976
|
function pageRowKey(app, key, kind = "page") {
|
|
2553
2977
|
return `${app}.${kind}.${key}`;
|
|
2554
2978
|
}
|
|
@@ -2839,7 +3263,10 @@ async function storePageRow(adapter, app, key, version, status, rev, kind = "pag
|
|
|
2839
3263
|
}
|
|
2840
3264
|
if (current.version > version)
|
|
2841
3265
|
return null;
|
|
2842
|
-
|
|
3266
|
+
// This version already recorded: kept, except a skipped row this run
|
|
3267
|
+
// finished (skipped → completed, once, shell ≥ 0.1.77).
|
|
3268
|
+
const finishing = status === "completed" && current.status === "skipped";
|
|
3269
|
+
if (current.version === version && current.status !== "in_progress" && !finishing)
|
|
2843
3270
|
return current;
|
|
2844
3271
|
at = current.rev;
|
|
2845
3272
|
}
|
|
@@ -3506,6 +3933,9 @@ function useNudge(nudge, ref) {
|
|
|
3506
3933
|
}
|
|
3507
3934
|
function StepLayer(p) {
|
|
3508
3935
|
const { spot, phone, viewport } = p;
|
|
3936
|
+
// Where the bubble may sit: the content column when the app has one.
|
|
3937
|
+
const col = p.bounds && p.bounds.width > 0 ? p.bounds : { x: 0, width: viewport.width || (typeof window !== "undefined" ? window.innerWidth : 0)};
|
|
3938
|
+
const colKey = `${col.x}|${col.width}`;
|
|
3509
3939
|
const bubbleRef = useRef(null);
|
|
3510
3940
|
const arrowRef = useRef(null);
|
|
3511
3941
|
const ringRef = useRef(null);
|
|
@@ -3546,8 +3976,8 @@ function StepLayer(p) {
|
|
|
3546
3976
|
const bw = el.offsetWidth;
|
|
3547
3977
|
const bh = el.offsetHeight;
|
|
3548
3978
|
setPos(phone
|
|
3549
|
-
? { left: m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
|
|
3550
|
-
: { left: (
|
|
3979
|
+
? { left: col.x + m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
|
|
3980
|
+
: { left: col.x + (col.width - bw) / 2, top: viewport.offsetTop + (viewport.height - bh) / 2, side: "dock" });
|
|
3551
3981
|
return;
|
|
3552
3982
|
}
|
|
3553
3983
|
if (phone) {
|
|
@@ -3568,7 +3998,7 @@ function StepLayer(p) {
|
|
|
3568
3998
|
: overlapBot <= overlapTop
|
|
3569
3999
|
? botT
|
|
3570
4000
|
: topT;
|
|
3571
|
-
setPos({ left: m, top, side: "dock" });
|
|
4001
|
+
setPos({ left: col.x + m, top, side: "dock" });
|
|
3572
4002
|
return;
|
|
3573
4003
|
}
|
|
3574
4004
|
let cancelled = false;
|
|
@@ -3590,7 +4020,7 @@ function StepLayer(p) {
|
|
|
3590
4020
|
placement,
|
|
3591
4021
|
middleware: [
|
|
3592
4022
|
offset(placement.startsWith("bottom") ? gap : 14),
|
|
3593
|
-
shift({ padding: m }),
|
|
4023
|
+
shift({ padding: m, rootBoundary: { x: col.x, y: 0, width: col.width, height: window.innerHeight } }),
|
|
3594
4024
|
...(arrowRef.current ? [arrow({ element: arrowRef.current, padding: 14 })] : []),
|
|
3595
4025
|
],
|
|
3596
4026
|
});
|
|
@@ -3600,11 +4030,11 @@ function StepLayer(p) {
|
|
|
3600
4030
|
return;
|
|
3601
4031
|
const bw = el.offsetWidth;
|
|
3602
4032
|
const bh = el.offsetHeight;
|
|
3603
|
-
const bounds = { x: m, y: viewport.offsetTop + m, width:
|
|
4033
|
+
const bounds = { x: col.x + m, y: viewport.offsetTop + m, width: col.width - 2 * m, height: viewport.height - 2 * m };
|
|
3604
4034
|
const i = pickPlacement(results, bw, bh, a, clear, bounds);
|
|
3605
4035
|
if (i < 0) {
|
|
3606
4036
|
// Nowhere beside it: the bottom-right corner.
|
|
3607
|
-
setPos({ left:
|
|
4037
|
+
setPos({ left: col.x + col.width - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
|
|
3608
4038
|
return;
|
|
3609
4039
|
}
|
|
3610
4040
|
const { x, y, placement, middlewareData } = results[i];
|
|
@@ -3620,7 +4050,7 @@ function StepLayer(p) {
|
|
|
3620
4050
|
};
|
|
3621
4051
|
// The keys stand for spot, the lists, the holes and the bubble's size.
|
|
3622
4052
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3623
|
-
}, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
|
|
4053
|
+
}, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop, colKey]);
|
|
3624
4054
|
const popovers = p.popovers ?? [];
|
|
3625
4055
|
const covered = !!p.covered;
|
|
3626
4056
|
const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
|
|
@@ -3641,7 +4071,7 @@ function StepLayer(p) {
|
|
|
3641
4071
|
return (jsxs(Fragment, { children: [jsx(Scrim, { holes: p.phase === "ready" ? holes : [], open: p.phase === "ready" ? openHoles : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && ringShown ? (jsx("div", { ref: ringRef, className: "eg-ring" + (p.moving ? " eg-move" : ""), style: { left: spot.x, top: spot.y, width: spot.width, height: spot.height }, "aria-hidden": "true" })) : null, hand ? (jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsx("svg", { viewBox: "0 0 24 24", children: jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (jsxs("div", { ref: bubbleRef, className: "eg-bubble" + (p.bubble.error ? " eg-err" : ""), role: "dialog", "aria-modal": "false", "aria-labelledby": BUBBLE_TITLE_ID, "aria-describedby": BUBBLE_WHY_ID, tabIndex: -1, "data-eg-bubble": "", style: {
|
|
3642
4072
|
left: pos?.left ?? 0,
|
|
3643
4073
|
top: pos?.top ?? 0,
|
|
3644
|
-
width: phone ?
|
|
4074
|
+
width: phone ? col.width - 32 : col.width < 352 ? col.width - 32 : undefined,
|
|
3645
4075
|
visibility: pos ? "visible" : "hidden",
|
|
3646
4076
|
}, children: [jsx(BubbleBody, { ...p }), jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxs("div", { className: "eg-loading", children: [jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsx("div", { className: "eg-row", children: jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }) })] })) : null, p.phase === "missing" ? (jsxs("div", { className: "eg-card eg-err", role: "alertdialog", "aria-labelledby": "eg-missing-t", "data-eg-card": "", tabIndex: -1, children: [jsx("p", { className: "eg-b-t", id: "eg-missing-t", children: "\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E2A\u0E48\u0E27\u0E19\u0E19\u0E35\u0E49\u0E1A\u0E19\u0E2B\u0E19\u0E49\u0E32" }), jsxs("div", { className: "eg-row", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onRetry, children: "\u0E25\u0E2D\u0E07\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07" })] })] })) : null, p.phase === "away" ? (jsxs("div", { className: "eg-card", role: "dialog", "aria-modal": "false", "aria-labelledby": "eg-away-t", "data-eg-card": "", tabIndex: -1, children: [jsxs("div", { className: "eg-b-top", children: [jsx("span", { className: "eg-mis", children: headingOf(p.bubble) }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", p.bubble.index + 1, "/", p.bubble.total] })] }), jsx("p", { className: "eg-b-t", id: "eg-away-t", style: { marginTop: 8 }, children: jsx(GuideTitle, { text: p.bubble.title }) }), jsxs("div", { className: "eg-row", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onGoResume, children: "\u0E17\u0E33\u0E15\u0E48\u0E2D" })] })] })) : null, p.confirmExit ? (jsx(ConfirmModal, { title: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E44\u0E01\u0E14\u0E4C\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49?", line: p.reopenHint || REOPEN_HINT, leaveLabel: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E44\u0E01\u0E14\u0E4C", stayLabel: "\u0E17\u0E33\u0E15\u0E48\u0E2D", onLeave: p.onExitConfirm, onStay: p.onExitCancel })) : null] }));
|
|
3647
4077
|
}
|
|
@@ -3730,7 +4160,10 @@ function CompleteModal(p) {
|
|
|
3730
4160
|
* corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
|
|
3731
4161
|
*/
|
|
3732
4162
|
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: "
|
|
4163
|
+
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" })] })] }));
|
|
4164
|
+
}
|
|
4165
|
+
function FlowList({ items, onOpen }) {
|
|
4166
|
+
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
4167
|
}
|
|
3735
4168
|
function PageList({ items, onOpen }) {
|
|
3736
4169
|
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 +4190,16 @@ function QuestChip({ done, total, pulse, onOpen }) {
|
|
|
3757
4190
|
}
|
|
3758
4191
|
function QuestPanel(p) {
|
|
3759
4192
|
const ref = useAutoFocus();
|
|
3760
|
-
const pages = p.pages ?? [];
|
|
4193
|
+
const pages = p.only === "flows" ? [] : (p.pages ?? []);
|
|
4194
|
+
const flows = p.only === "pages" ? [] : (p.flows ?? []);
|
|
3761
4195
|
const openPage = p.onOpenPage ?? (() => { });
|
|
4196
|
+
const openFlow = p.onOpenFlow ?? (() => { });
|
|
4197
|
+
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
4198
|
if (p.showMissions === false) {
|
|
3763
|
-
|
|
4199
|
+
const heading = pages.length ? "คู่มือแต่ละหน้า" : "พาทำงาน";
|
|
4200
|
+
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
4201
|
}
|
|
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] })] }));
|
|
4202
|
+
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
4203
|
}
|
|
3767
4204
|
function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
3768
4205
|
const v = item.view;
|
|
@@ -3900,8 +4337,7 @@ function writeMirror(orgId, suite, p) {
|
|
|
3900
4337
|
/* private mode: the server copy is enough */
|
|
3901
4338
|
}
|
|
3902
4339
|
}
|
|
3903
|
-
|
|
3904
|
-
const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
|
|
4340
|
+
const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind}s:${orgId}:${suite.app}`;
|
|
3905
4341
|
function readPagesMirror(orgId, suite, kind = "page") {
|
|
3906
4342
|
try {
|
|
3907
4343
|
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
|
|
@@ -3935,6 +4371,8 @@ function mergePageRows(server, local) {
|
|
|
3935
4371
|
}
|
|
3936
4372
|
return out;
|
|
3937
4373
|
}
|
|
4374
|
+
/** A flow in progress in this tab (resumed after a refresh, never across sessions). */
|
|
4375
|
+
const flowMirrorKey = (orgId, suite) => `erp-guide-flow-run:${orgId}:${suite.app}`;
|
|
3938
4376
|
/* ------------------------------------------------------------------ */
|
|
3939
4377
|
/* One tab at a time */
|
|
3940
4378
|
/* ------------------------------------------------------------------ */
|
|
@@ -4001,6 +4439,42 @@ function runAbort(actions) {
|
|
|
4001
4439
|
}, ABORT_DELAY_MS + i * ABORT_GAP_MS);
|
|
4002
4440
|
});
|
|
4003
4441
|
}
|
|
4442
|
+
/** The app's content column (GuideConfig.contentRoot), cut to the viewport; null when none is declared or found. */
|
|
4443
|
+
function contentColumn(selector, vw) {
|
|
4444
|
+
if (!selector || typeof document === "undefined")
|
|
4445
|
+
return null;
|
|
4446
|
+
let el = null;
|
|
4447
|
+
try {
|
|
4448
|
+
el = document.querySelector(selector);
|
|
4449
|
+
}
|
|
4450
|
+
catch {
|
|
4451
|
+
return null;
|
|
4452
|
+
}
|
|
4453
|
+
if (!el)
|
|
4454
|
+
return null;
|
|
4455
|
+
const r = el.getBoundingClientRect();
|
|
4456
|
+
const x = Math.max(0, r.left);
|
|
4457
|
+
const right = Math.min(vw || window.innerWidth, r.right);
|
|
4458
|
+
if (right - x <= 0)
|
|
4459
|
+
return null;
|
|
4460
|
+
return { x, y: 0, width: right - x, height: 0 };
|
|
4461
|
+
}
|
|
4462
|
+
/** The control a form's implicit submit (Enter in a field) presses: its first submit button. */
|
|
4463
|
+
function defaultSubmitControl(form) {
|
|
4464
|
+
for (const el of Array.from(form.elements)) {
|
|
4465
|
+
if (el instanceof HTMLButtonElement && (el.type === "submit" || !el.getAttribute("type")))
|
|
4466
|
+
return el;
|
|
4467
|
+
if (el instanceof HTMLInputElement && (el.type === "submit" || el.type === "image"))
|
|
4468
|
+
return el;
|
|
4469
|
+
}
|
|
4470
|
+
return null;
|
|
4471
|
+
}
|
|
4472
|
+
/** A field where Enter submits its form (not a textarea, a button or a select). */
|
|
4473
|
+
function submitsOnEnter(el) {
|
|
4474
|
+
if (!(el instanceof HTMLInputElement))
|
|
4475
|
+
return false;
|
|
4476
|
+
return !["button", "submit", "reset", "image", "file", "hidden"].includes(el.type);
|
|
4477
|
+
}
|
|
4004
4478
|
/** A step's own target takes presses: not on a "view" step, unless the step allows it. */
|
|
4005
4479
|
function targetPressable(step, bridge) {
|
|
4006
4480
|
if (!step || bridge)
|
|
@@ -4047,7 +4521,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4047
4521
|
const [answer, setAnswer] = useState(null);
|
|
4048
4522
|
const missionsReady = config.ready !== false;
|
|
4049
4523
|
const pagesReady = config.pagesReady ?? missionsReady;
|
|
4050
|
-
const hasPages = useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0, [suite]);
|
|
4524
|
+
const hasPages = useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0 || validFlows(suite).length > 0, [suite]);
|
|
4525
|
+
const hasFlows = useMemo(() => validFlows(suite).length > 0, [suite]);
|
|
4051
4526
|
const hasSections = useMemo(() => validSections(suite).length > 0, [suite]);
|
|
4052
4527
|
const enabled = primary && (missionsReady || (hasPages && pagesReady));
|
|
4053
4528
|
const loaded = useRef(false);
|
|
@@ -4105,6 +4580,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4105
4580
|
pagesMode: answer.pagesMode,
|
|
4106
4581
|
pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
|
|
4107
4582
|
sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
|
|
4583
|
+
flows: mergePageRows(answer.flows ?? {}, readPagesMirror(organizationId, suite, "flow")),
|
|
4108
4584
|
});
|
|
4109
4585
|
stateRef.current = s;
|
|
4110
4586
|
setState(s);
|
|
@@ -4213,7 +4689,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4213
4689
|
else if (e.type === "savePage") {
|
|
4214
4690
|
// Once per page and version; the machine already holds it as written.
|
|
4215
4691
|
const kind = e.kind ?? "page";
|
|
4216
|
-
const
|
|
4692
|
+
const cur = stateRef.current;
|
|
4693
|
+
const rows = cur ? (kind === "section" ? cur.sections : kind === "flow" ? cur.flows : cur.pages) : undefined;
|
|
4217
4694
|
if (rows)
|
|
4218
4695
|
writePagesMirror(organizationId, suite, rows, kind);
|
|
4219
4696
|
const rev = rows?.[e.key]?.rev ?? 0;
|
|
@@ -4310,7 +4787,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4310
4787
|
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
4311
4788
|
// A page guide's step runs while page guides are live; a mission's while
|
|
4312
4789
|
// 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);
|
|
4790
|
+
const inStep = screen.name === "step" && (screen.page || screen.section || screen.flow ? pagesLive : missionsLive);
|
|
4314
4791
|
const offerUp = pagesLive && screen.name === "pageOffer";
|
|
4315
4792
|
const watching = inStep || offerUp;
|
|
4316
4793
|
useEffect(() => {
|
|
@@ -4376,6 +4853,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4376
4853
|
const stepId = screen.name === "step" ? `${screen.mission}:${screen.index}:${screen.bridge ? "b" : ""}${screen.nothingToSave ? "n" : ""}` : "";
|
|
4377
4854
|
/* Measure every frame while a step is shown: the spotlight follows
|
|
4378
4855
|
scrolling, dialogs animating in, the drawer sliding, the keyboard. */
|
|
4856
|
+
const contentRoot = config.contentRoot;
|
|
4379
4857
|
const [layout, setLayout] = useState({
|
|
4380
4858
|
spot: null,
|
|
4381
4859
|
covered: false,
|
|
@@ -4384,6 +4862,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4384
4862
|
vw: 0,
|
|
4385
4863
|
vh: 0,
|
|
4386
4864
|
oy: 0,
|
|
4865
|
+
col: null,
|
|
4387
4866
|
});
|
|
4388
4867
|
useEffect(() => {
|
|
4389
4868
|
if (!inStep) {
|
|
@@ -4392,7 +4871,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4392
4871
|
}
|
|
4393
4872
|
let raf = 0;
|
|
4394
4873
|
let lostSince = null;
|
|
4395
|
-
let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
|
|
4874
|
+
let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0, col: null };
|
|
4396
4875
|
targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
|
|
4397
4876
|
const allow = stepInfo?.step.allow ?? [];
|
|
4398
4877
|
// The spotlight is the part of the target a person can see: cut by the
|
|
@@ -4452,13 +4931,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4452
4931
|
holes.push(padRect(rectOf(ael), TARGET_PAD));
|
|
4453
4932
|
}
|
|
4454
4933
|
const covered = !!spot && coveredByDialog(targetElRef.current);
|
|
4455
|
-
const
|
|
4934
|
+
const col = contentColumn(contentRoot, vw);
|
|
4935
|
+
const next = { spot, covered, holes, popovers, vw, vh, oy, col };
|
|
4456
4936
|
const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
|
|
4457
4937
|
if (!sameRect(next.spot, last.spot) ||
|
|
4458
4938
|
next.covered !== last.covered ||
|
|
4459
4939
|
next.vw !== last.vw ||
|
|
4460
4940
|
next.vh !== last.vh ||
|
|
4461
4941
|
next.oy !== last.oy ||
|
|
4942
|
+
!sameRect(next.col, last.col) ||
|
|
4462
4943
|
!sameList(next.holes, last.holes) ||
|
|
4463
4944
|
!sameList(next.popovers, last.popovers)) {
|
|
4464
4945
|
last = next;
|
|
@@ -4613,6 +5094,43 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4613
5094
|
}
|
|
4614
5095
|
return false;
|
|
4615
5096
|
};
|
|
5097
|
+
/**
|
|
5098
|
+
* Whether a form may be submitted during this step (shell ≥ 0.1.77): only
|
|
5099
|
+
* when its submit control — the button pressed, or the one Enter in a
|
|
5100
|
+
* field would press — is the step's pressable target or in its `allow`.
|
|
5101
|
+
* Otherwise Enter in an allowed field would save a draft (and take a
|
|
5102
|
+
* document number) behind the guide's back.
|
|
5103
|
+
*/
|
|
5104
|
+
const submitAllowed = (form, submitter) => {
|
|
5105
|
+
const btn = submitter ?? defaultSubmitControl(form);
|
|
5106
|
+
if (!btn)
|
|
5107
|
+
return false;
|
|
5108
|
+
const el = targetElRef.current;
|
|
5109
|
+
const sc = stateRef.current?.screen;
|
|
5110
|
+
const pressable = targetPressable(stepInfoRef.current?.step, sc?.name === "step" && sc.bridge);
|
|
5111
|
+
if (el && pressable && el.contains(btn))
|
|
5112
|
+
return true;
|
|
5113
|
+
for (const a of stepInfoRef.current?.step.allow ?? []) {
|
|
5114
|
+
const ael = findAnchor(a);
|
|
5115
|
+
if (ael && ael.contains(btn))
|
|
5116
|
+
return true;
|
|
5117
|
+
}
|
|
5118
|
+
return false;
|
|
5119
|
+
};
|
|
5120
|
+
const onSubmit = (e) => {
|
|
5121
|
+
const sc = stateRef.current?.screen;
|
|
5122
|
+
if (!guardingRef.current || sc?.name !== "step")
|
|
5123
|
+
return;
|
|
5124
|
+
const form = e.target;
|
|
5125
|
+
if (!(form instanceof HTMLFormElement) || inGuideLayer(form))
|
|
5126
|
+
return;
|
|
5127
|
+
const submitter = e.submitter ?? null;
|
|
5128
|
+
if (submitAllowed(form, submitter))
|
|
5129
|
+
return;
|
|
5130
|
+
e.preventDefault();
|
|
5131
|
+
e.stopPropagation();
|
|
5132
|
+
dispatchRef.current({ type: "blocked" });
|
|
5133
|
+
};
|
|
4616
5134
|
const onPointer = (e) => {
|
|
4617
5135
|
const t = e.target;
|
|
4618
5136
|
if (inGuideLayer(t)) {
|
|
@@ -4732,8 +5250,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4732
5250
|
return;
|
|
4733
5251
|
}
|
|
4734
5252
|
}
|
|
4735
|
-
if (modalRef.current || !allowedTarget(active))
|
|
5253
|
+
if (modalRef.current || !allowedTarget(active)) {
|
|
4736
5254
|
stop();
|
|
5255
|
+
return;
|
|
5256
|
+
}
|
|
5257
|
+
// Enter in a field the step lets the person type in: no implicit
|
|
5258
|
+
// submit unless the form's submit control is allowed now.
|
|
5259
|
+
if (e.key === "Enter" && sc.name === "step" && submitsOnEnter(active) && active.form && !submitAllowed(active.form, null)) {
|
|
5260
|
+
e.preventDefault();
|
|
5261
|
+
}
|
|
4737
5262
|
}
|
|
4738
5263
|
};
|
|
4739
5264
|
window.addEventListener("pointerdown", onPointer, true);
|
|
@@ -4743,7 +5268,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4743
5268
|
window.addEventListener("focusin", onFocusIn, true);
|
|
4744
5269
|
window.addEventListener("focusout", onFocusOut, true);
|
|
4745
5270
|
window.addEventListener("keydown", onKey, true);
|
|
5271
|
+
window.addEventListener("submit", onSubmit, true);
|
|
4746
5272
|
return () => {
|
|
5273
|
+
window.removeEventListener("submit", onSubmit, true);
|
|
4747
5274
|
window.removeEventListener("pointerdown", onPointer, true);
|
|
4748
5275
|
window.removeEventListener("mousedown", onPointer, true);
|
|
4749
5276
|
window.removeEventListener("touchstart", onPointer, true);
|
|
@@ -4924,6 +5451,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4924
5451
|
openingSection.current = null;
|
|
4925
5452
|
}
|
|
4926
5453
|
}, []);
|
|
5454
|
+
const flowsNow = useMemo(() => pagesLive && hasState && hasFlows
|
|
5455
|
+
? flowsAvailable(suite, env()).map(k => ({ key: k, title: findFlow(suite, k)?.title ?? "" }))
|
|
5456
|
+
: [],
|
|
5457
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5458
|
+
[pagesLive, hasState, hasFlows, suite, env, pathname, search, factsKey]);
|
|
5459
|
+
const flowsSig = flowsNow.map(x => x.key).join(",");
|
|
5460
|
+
/** The flows the panel lists: this person's, startable here or with somewhere to go. */
|
|
5461
|
+
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]);
|
|
5462
|
+
const openFlowHere = useCallback((key) => dispatchRef.current({ type: "startFlow", flow: key }), []);
|
|
4927
5463
|
const openPageHere = useCallback(() => {
|
|
4928
5464
|
const p = pageHere(suite, env());
|
|
4929
5465
|
if (p)
|
|
@@ -4934,10 +5470,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4934
5470
|
publishPageGuides(null, null, null);
|
|
4935
5471
|
return;
|
|
4936
5472
|
}
|
|
4937
|
-
publishPageGuides({
|
|
4938
|
-
|
|
5473
|
+
publishPageGuides({
|
|
5474
|
+
page: pageNow ? { title: pageNow.title } : null,
|
|
5475
|
+
listed: listed.length,
|
|
5476
|
+
sections: sectionsNow,
|
|
5477
|
+
flows: flowsNow,
|
|
5478
|
+
flowsListed: flowsListed.length,
|
|
5479
|
+
}, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection, openFlowHere, () => dispatchRef.current({ type: "openPanel", section: "flows" }));
|
|
5480
|
+
// sectionsSig / flowsSig stand for sectionsNow / flowsNow.
|
|
4939
5481
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4940
|
-
}, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection]);
|
|
5482
|
+
}, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere]);
|
|
4941
5483
|
useEffect(() => () => publishPageGuides(null, null, null), []);
|
|
4942
5484
|
// The first-visit offer: in "offer" mode, nothing else up, the welcome
|
|
4943
5485
|
// not due here, and a moment of nothing loading on this page.
|
|
@@ -4965,14 +5507,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4965
5507
|
if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
|
|
4966
5508
|
return;
|
|
4967
5509
|
let key = null;
|
|
5510
|
+
let flow = null;
|
|
4968
5511
|
try {
|
|
4969
5512
|
key = pageOfferDue(cur, suite, env());
|
|
5513
|
+
// A flow's offer comes after the page guide's (and only for `offer: true` flows).
|
|
5514
|
+
if (!key)
|
|
5515
|
+
flow = flowOfferDue(cur, suite, env());
|
|
4970
5516
|
}
|
|
4971
5517
|
catch {
|
|
4972
5518
|
key = null;
|
|
5519
|
+
flow = null;
|
|
4973
5520
|
}
|
|
4974
5521
|
if (key)
|
|
4975
5522
|
dispatchRef.current({ type: "pageOffer", page: key });
|
|
5523
|
+
else if (flow)
|
|
5524
|
+
dispatchRef.current({ type: "pageOffer", page: flow, flow: true });
|
|
4976
5525
|
}, PAGE_POLL_MS);
|
|
4977
5526
|
return () => clearInterval(iv);
|
|
4978
5527
|
}, [offerWatch, suite, env, pathname, search]);
|
|
@@ -5024,6 +5573,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
5024
5573
|
if (b)
|
|
5025
5574
|
return;
|
|
5026
5575
|
const e = env();
|
|
5576
|
+
if (pending.flow) {
|
|
5577
|
+
const flow = findFlow(suite, pending.key);
|
|
5578
|
+
if (!flow || !onFlowStart(flow, e))
|
|
5579
|
+
return;
|
|
5580
|
+
setPending(null);
|
|
5581
|
+
dispatchRef.current({ type: "startFlow", flow: pending.key });
|
|
5582
|
+
return;
|
|
5583
|
+
}
|
|
5027
5584
|
const page = findPage(suite, pending.key);
|
|
5028
5585
|
if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
|
|
5029
5586
|
return;
|
|
@@ -5032,6 +5589,93 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
5032
5589
|
}, PAGE_POLL_MS);
|
|
5033
5590
|
return () => clearInterval(iv);
|
|
5034
5591
|
}, [pending, pagesLive, suite, env, code]);
|
|
5592
|
+
// A flow in the panel's list: start it here, or go to its start page and start it there.
|
|
5593
|
+
const openListedFlow = useCallback((key) => {
|
|
5594
|
+
const flow = findFlow(suite, key);
|
|
5595
|
+
if (!flow)
|
|
5596
|
+
return;
|
|
5597
|
+
const e = env();
|
|
5598
|
+
if (onFlowStart(flow, e)) {
|
|
5599
|
+
dispatchRef.current({ type: "startFlow", flow: key });
|
|
5600
|
+
return;
|
|
5601
|
+
}
|
|
5602
|
+
dispatchRef.current({ type: "closePanel" });
|
|
5603
|
+
const href = flowHref(flow, guideContext(e, {}));
|
|
5604
|
+
if (!href)
|
|
5605
|
+
return;
|
|
5606
|
+
setPending({ key, until: Date.now() + PAGE_ARRIVE_MS, flow: true });
|
|
5607
|
+
try {
|
|
5608
|
+
if (/^https?:\/\//i.test(href))
|
|
5609
|
+
window.location.assign(href);
|
|
5610
|
+
else
|
|
5611
|
+
navigateRef.current(href);
|
|
5612
|
+
}
|
|
5613
|
+
catch {
|
|
5614
|
+
/* never breaks the app */
|
|
5615
|
+
}
|
|
5616
|
+
}, [suite, env]);
|
|
5617
|
+
// A flow in progress is kept in this tab's session (sessionStorage), so a
|
|
5618
|
+
// refresh or a full page load on one of its pages picks it up again at
|
|
5619
|
+
// its checkpoint ("ทำต่อ" when its step is on another page). Never across
|
|
5620
|
+
// sessions; a flow that ends is forgotten.
|
|
5621
|
+
const flowRun = state?.pageRun?.kind === "flow" ? state.pageRun : null;
|
|
5622
|
+
const flowStepKey = screen.name === "step" && screen.flow ? (findMission(suite, screen.mission)?.steps[screen.index]?.key ?? null) : null;
|
|
5623
|
+
useEffect(() => {
|
|
5624
|
+
if (!hasFlows)
|
|
5625
|
+
return;
|
|
5626
|
+
const k = flowMirrorKey(organizationId, suite);
|
|
5627
|
+
try {
|
|
5628
|
+
if (!flowRun) {
|
|
5629
|
+
if (state)
|
|
5630
|
+
sessionStorage.removeItem(k);
|
|
5631
|
+
return;
|
|
5632
|
+
}
|
|
5633
|
+
if (!flowStepKey)
|
|
5634
|
+
return;
|
|
5635
|
+
const flow = findFlow(suite, flowRun.key);
|
|
5636
|
+
sessionStorage.setItem(k, JSON.stringify({ flow: flowRun.key, version: flow?.version, step: flowStepKey, write: flowRun.write, context: state?.progress.context ?? {} }));
|
|
5637
|
+
}
|
|
5638
|
+
catch {
|
|
5639
|
+
/* private mode: no resume after a refresh */
|
|
5640
|
+
}
|
|
5641
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5642
|
+
}, [hasFlows, flowRun?.key, flowRun?.write, flowStepKey, organizationId, suite]);
|
|
5643
|
+
const flowResumed = useRef(false);
|
|
5644
|
+
useEffect(() => {
|
|
5645
|
+
if (flowResumed.current || !pagesLive || !state || !hasFlows)
|
|
5646
|
+
return;
|
|
5647
|
+
// The missions resume first.
|
|
5648
|
+
if (missionsLive && !resumed.current)
|
|
5649
|
+
return;
|
|
5650
|
+
flowResumed.current = true;
|
|
5651
|
+
const k = flowMirrorKey(organizationId, suite);
|
|
5652
|
+
let saved = null;
|
|
5653
|
+
try {
|
|
5654
|
+
saved = JSON.parse(sessionStorage.getItem(k) ?? "null");
|
|
5655
|
+
}
|
|
5656
|
+
catch {
|
|
5657
|
+
saved = null;
|
|
5658
|
+
}
|
|
5659
|
+
if (!saved?.flow || !saved.step)
|
|
5660
|
+
return;
|
|
5661
|
+
const flow = findFlow(suite, saved.flow);
|
|
5662
|
+
const ok = !!flow && flow.version === saved.version && stateRef.current?.screen.name === "idle" && inFlowChain(flow, { code, pathname });
|
|
5663
|
+
if (!ok) {
|
|
5664
|
+
try {
|
|
5665
|
+
sessionStorage.removeItem(k);
|
|
5666
|
+
}
|
|
5667
|
+
catch {
|
|
5668
|
+
/* ignore */
|
|
5669
|
+
}
|
|
5670
|
+
return;
|
|
5671
|
+
}
|
|
5672
|
+
const context = {};
|
|
5673
|
+
for (const [ck, cv] of Object.entries(saved.context ?? {}))
|
|
5674
|
+
if (typeof cv === "string")
|
|
5675
|
+
context[ck] = cv;
|
|
5676
|
+
dispatchRef.current({ type: "resumeFlow", flow: saved.flow, step: saved.step, context, write: saved.write === true });
|
|
5677
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5678
|
+
}, [pagesLive, !!state, hasFlows, missionsLive]);
|
|
5035
5679
|
if (!live || !state || !layer || !ctxIdle)
|
|
5036
5680
|
return null;
|
|
5037
5681
|
/* ---------------- render ---------------- */
|
|
@@ -5054,18 +5698,29 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
5054
5698
|
const missionNo = missionNumber(suite, mission.key, ctxIdle);
|
|
5055
5699
|
const kind = stepKind(step);
|
|
5056
5700
|
const bridge = screen.bridge;
|
|
5057
|
-
|
|
5701
|
+
// What is ahead now decides เสร็จ; the number shown is the run's counter,
|
|
5702
|
+
// which never goes down (0.1.77).
|
|
5703
|
+
const raw = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
|
|
5704
|
+
const last = raw.no >= raw.total;
|
|
5705
|
+
const pos = screen.pos ?? raw;
|
|
5058
5706
|
const page = screen.page ? findPage(suite, screen.page) : undefined;
|
|
5059
5707
|
const section = screen.section ? findSection(suite, screen.section) : undefined;
|
|
5708
|
+
const flow = screen.flow ? findFlow(suite, screen.flow) : undefined;
|
|
5060
5709
|
const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
|
|
5061
5710
|
const pageBits = read
|
|
5062
5711
|
? {
|
|
5063
5712
|
...read,
|
|
5064
5713
|
showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
|
|
5065
|
-
nextLabel:
|
|
5714
|
+
nextLabel: last ? "เสร็จ" : undefined,
|
|
5066
5715
|
}
|
|
5067
|
-
:
|
|
5068
|
-
|
|
5716
|
+
: flow
|
|
5717
|
+
? {
|
|
5718
|
+
heading: `พาทำ · ${flow.title}`,
|
|
5719
|
+
exitLabel: "ออกจากการพาทำ",
|
|
5720
|
+
nextLabel: kind === "view" && last ? "เสร็จ" : undefined,
|
|
5721
|
+
}
|
|
5722
|
+
: {};
|
|
5723
|
+
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 }, bounds: layout.col, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
|
|
5069
5724
|
missionNo,
|
|
5070
5725
|
missionTitle: mission.title,
|
|
5071
5726
|
replay: screen.replay,
|
|
@@ -5081,6 +5736,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
5081
5736
|
...pageBits,
|
|
5082
5737
|
}, 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
5738
|
}
|
|
5739
|
+
else if (screen.name === "pageOffer" && pagesLive && screen.flow) {
|
|
5740
|
+
const flow = findFlow(suite, screen.page);
|
|
5741
|
+
const mission = flow ? findMission(suite, FLOW_PREFIX + flow.key) : undefined;
|
|
5742
|
+
if (flow && mission) {
|
|
5743
|
+
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" }) }));
|
|
5744
|
+
}
|
|
5745
|
+
}
|
|
5084
5746
|
else if (screen.name === "pageOffer" && pagesLive) {
|
|
5085
5747
|
const page = findPage(suite, screen.page);
|
|
5086
5748
|
if (page) {
|
|
@@ -5124,12 +5786,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
5124
5786
|
const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
|
|
5125
5787
|
// The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
|
|
5126
5788
|
// then the page guides they have.
|
|
5127
|
-
const panelMissions = missionsLive && total > 0 && state.panelSection
|
|
5128
|
-
const pageItems = pagesLive
|
|
5129
|
-
|
|
5789
|
+
const panelMissions = missionsLive && total > 0 && state.panelSection === "all";
|
|
5790
|
+
const pageItems = pagesLive && state.panelSection !== "flows" ? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) })) : [];
|
|
5791
|
+
const flowItems = pagesLive && state.panelSection !== "pages"
|
|
5792
|
+
? flowsListed.map(f => ({ key: f.key, title: f.title, minutes: f.minutes, done: flowRecorded(state, f) && state.flows[f.key]?.status === "completed" }))
|
|
5130
5793
|
: [];
|
|
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);
|
|
5794
|
+
const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0 || flowItems.length > 0);
|
|
5795
|
+
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
5796
|
}
|
|
5134
5797
|
/** A stable key for the facts object, so effects re-run when they change. */
|
|
5135
5798
|
function useFactsKey(facts) {
|
|
@@ -5239,5 +5902,5 @@ function thaiMonthYear(m, opts = {}) {
|
|
|
5239
5902
|
return `${name} ${opts.year === "full" ? be : String(be).slice(-2)}`;
|
|
5240
5903
|
}
|
|
5241
5904
|
|
|
5242
|
-
export { API_EVENT as A,
|
|
5243
|
-
//# sourceMappingURL=format-
|
|
5905
|
+
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 };
|
|
5906
|
+
//# sourceMappingURL=format-ILrTMV4G.esm.js.map
|