@eintrek/erp-shell 0.1.74 → 0.1.76
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +50 -0
- package/dist/chunks/{guide-runtime-YS2nUhwS.cjs → format-BVoVc7Yk.cjs} +1012 -71
- package/dist/chunks/format-BVoVc7Yk.cjs.map +1 -0
- package/dist/chunks/{guide-runtime-Dl8V1iy9.esm.js → format-BWUKNKxg.esm.js} +1004 -73
- package/dist/chunks/format-BWUKNKxg.esm.js.map +1 -0
- package/dist/guide/core.d.ts +4 -2
- package/dist/guide/format.d.ts +26 -0
- package/dist/guide/guide-store.d.ts +52 -4
- package/dist/guide/guide-ui.d.ts +16 -2
- package/dist/guide/machine.d.ts +86 -10
- package/dist/guide/pages.d.ts +52 -1
- package/dist/guide/progress-sync.d.ts +3 -2
- package/dist/guide/progress.d.ts +8 -4
- package/dist/guide/section-button.d.ts +12 -0
- package/dist/guide/types.d.ts +100 -0
- package/dist/guide-core.cjs +34 -24
- package/dist/guide-core.cjs.map +1 -1
- package/dist/guide-core.esm.js +1 -1
- package/dist/help/help-button.d.ts +5 -2
- package/dist/index.cjs +53 -36
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.esm.js +15 -8
- package/dist/index.esm.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +1 -1
- package/dist/chunks/guide-runtime-Dl8V1iy9.esm.js.map +0 -1
- package/dist/chunks/guide-runtime-YS2nUhwS.cjs.map +0 -1
|
@@ -357,18 +357,35 @@ function publishGuideEntry(next, openPanel) {
|
|
|
357
357
|
emit();
|
|
358
358
|
}
|
|
359
359
|
}
|
|
360
|
-
const NO_PAGES = { page: null, listed: 0 };
|
|
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
|
-
|
|
365
|
-
|
|
364
|
+
let openSectionImpl = null;
|
|
365
|
+
let openFlowImpl = null;
|
|
366
|
+
let openFlowsImpl = null;
|
|
367
|
+
const sectionsKey = (x) => (x ?? []).map(s => `${s.key}\u0000${s.title}`).join("\u0001");
|
|
368
|
+
/** Called by the host: this page's guide, the listed count, the sections on screen, and how to open them. */
|
|
369
|
+
function publishPageGuides(next, openPage, openPages, openSection = null, openFlow = null, openFlows = null) {
|
|
366
370
|
const n = next ?? NO_PAGES;
|
|
367
371
|
openPageImpl = openPage;
|
|
368
372
|
openPagesImpl = openPages;
|
|
369
|
-
|
|
373
|
+
openSectionImpl = openSection;
|
|
374
|
+
openFlowImpl = openFlow;
|
|
375
|
+
openFlowsImpl = openFlows;
|
|
376
|
+
const same = n.listed === pages.listed &&
|
|
377
|
+
(n.flowsListed ?? 0) === (pages.flowsListed ?? 0) &&
|
|
378
|
+
(n.page?.title ?? null) === (pages.page?.title ?? null) &&
|
|
379
|
+
sectionsKey(n.sections) === sectionsKey(pages.sections) &&
|
|
380
|
+
sectionsKey(n.flows) === sectionsKey(pages.flows);
|
|
370
381
|
if (!same) {
|
|
371
|
-
|
|
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;
|
|
385
|
+
pages =
|
|
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 }
|
|
388
|
+
: NO_PAGES;
|
|
372
389
|
emit();
|
|
373
390
|
}
|
|
374
391
|
}
|
|
@@ -376,10 +393,16 @@ function guideMenuItems(e, p = NO_PAGES) {
|
|
|
376
393
|
const items = [];
|
|
377
394
|
if (p.page)
|
|
378
395
|
items.push("page");
|
|
396
|
+
if ((p.sections ?? []).length > 0)
|
|
397
|
+
items.push("sections");
|
|
398
|
+
if ((p.flows ?? []).length > 0)
|
|
399
|
+
items.push("flowsHere");
|
|
379
400
|
if (e && e.total > 0)
|
|
380
401
|
items.push("missions");
|
|
381
402
|
if (p.listed > 0)
|
|
382
403
|
items.push("pages");
|
|
404
|
+
if ((p.flowsListed ?? 0) > 0)
|
|
405
|
+
items.push("flows");
|
|
383
406
|
if (items.length)
|
|
384
407
|
items.push("help");
|
|
385
408
|
return items;
|
|
@@ -425,6 +448,87 @@ function usePageGuideEntry() {
|
|
|
425
448
|
const title = p.page?.title ?? null;
|
|
426
449
|
return React.useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
|
|
427
450
|
}
|
|
451
|
+
/**
|
|
452
|
+
* Opens a section guide ("อธิบายส่วนนี้"), when it is on screen here for this
|
|
453
|
+
* person. The values it shows are the ones the app last rendered
|
|
454
|
+
* (`useSectionGuideValues`).
|
|
455
|
+
*/
|
|
456
|
+
function openSectionGuide(key) {
|
|
457
|
+
openSectionImpl?.(key);
|
|
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
|
+
}
|
|
467
|
+
const sectionValues = new Map();
|
|
468
|
+
const valueListeners = new Set();
|
|
469
|
+
let valuesVersion = 0;
|
|
470
|
+
function valuesSig(v) {
|
|
471
|
+
try {
|
|
472
|
+
return JSON.stringify(v);
|
|
473
|
+
}
|
|
474
|
+
catch {
|
|
475
|
+
return String(Math.random());
|
|
476
|
+
}
|
|
477
|
+
}
|
|
478
|
+
/**
|
|
479
|
+
* Sets (or with undefined, clears) a section guide's live values. The
|
|
480
|
+
* engine reads the latest whenever it draws a step, so a page that
|
|
481
|
+
* re-renders mid-guide never leaves an old number on screen.
|
|
482
|
+
*/
|
|
483
|
+
function setSectionGuideValues(key, values) {
|
|
484
|
+
const prev = sectionValues.get(key);
|
|
485
|
+
if (values === undefined) {
|
|
486
|
+
if (!prev)
|
|
487
|
+
return;
|
|
488
|
+
sectionValues.delete(key);
|
|
489
|
+
}
|
|
490
|
+
else {
|
|
491
|
+
const sig = valuesSig(values);
|
|
492
|
+
if (prev && prev.sig === sig)
|
|
493
|
+
return;
|
|
494
|
+
sectionValues.set(key, { values, sig });
|
|
495
|
+
}
|
|
496
|
+
valuesVersion++;
|
|
497
|
+
valueListeners.forEach(l => l());
|
|
498
|
+
}
|
|
499
|
+
/** A section guide's live values now (empty when the app has given none). */
|
|
500
|
+
function getSectionGuideValues(key) {
|
|
501
|
+
return (key && sectionValues.get(key)?.values) || {};
|
|
502
|
+
}
|
|
503
|
+
/** Re-renders when any section guide's values change (the engine's own use). */
|
|
504
|
+
function useSectionValuesVersion() {
|
|
505
|
+
return React.useSyncExternalStore(l => {
|
|
506
|
+
valueListeners.add(l);
|
|
507
|
+
return () => {
|
|
508
|
+
valueListeners.delete(l);
|
|
509
|
+
};
|
|
510
|
+
}, () => valuesVersion, () => 0);
|
|
511
|
+
}
|
|
512
|
+
/**
|
|
513
|
+
* Hands a section guide the numbers its copy uses, from the component that
|
|
514
|
+
* computes them: `useSectionGuideValues("cash-flow", { month: "ม.ค.", cashIn,
|
|
515
|
+
* … })`. Call it on every render (the latest wins); they go when the
|
|
516
|
+
* component unmounts. undefined values: nothing.
|
|
517
|
+
*/
|
|
518
|
+
function useSectionGuideValues(key, values) {
|
|
519
|
+
const sig = values === undefined ? "" : valuesSig(values);
|
|
520
|
+
React.useEffect(() => {
|
|
521
|
+
if (values !== undefined)
|
|
522
|
+
setSectionGuideValues(key, values);
|
|
523
|
+
// sig stands for the values.
|
|
524
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
525
|
+
}, [key, sig]);
|
|
526
|
+
React.useEffect(() => {
|
|
527
|
+
if (values === undefined)
|
|
528
|
+
return;
|
|
529
|
+
return () => setSectionGuideValues(key, undefined);
|
|
530
|
+
}, [key, values === undefined]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
531
|
+
}
|
|
428
532
|
|
|
429
533
|
/** The synthetic mission key of a page guide: `page:<key>`. */
|
|
430
534
|
const PAGE_PREFIX = "page:";
|
|
@@ -603,6 +707,278 @@ function currentPage(suite, where, ctx) {
|
|
|
603
707
|
function listedPages(suite, ctx) {
|
|
604
708
|
return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
|
|
605
709
|
}
|
|
710
|
+
/* ------------------------------------------------------------------ */
|
|
711
|
+
/* Section guides ("อธิบายส่วนนี้", shell 0.1.75) */
|
|
712
|
+
/* ------------------------------------------------------------------ */
|
|
713
|
+
/** The synthetic mission key of a section guide: `section:<key>`. */
|
|
714
|
+
const SECTION_PREFIX = "section:";
|
|
715
|
+
function isSectionMissionKey(key) {
|
|
716
|
+
return !!key && key.startsWith(SECTION_PREFIX);
|
|
717
|
+
}
|
|
718
|
+
function sectionKeyOf(missionKey) {
|
|
719
|
+
return isSectionMissionKey(missionKey) ? missionKey.slice(SECTION_PREFIX.length) : undefined;
|
|
720
|
+
}
|
|
721
|
+
/** Why a section guide cannot run, or null when it can. */
|
|
722
|
+
function sectionProblem(app, section) {
|
|
723
|
+
if (!section || typeof section.key !== "string" || !PAGE_KEY_RE.test(section.key))
|
|
724
|
+
return "its key is not [a-z0-9-]";
|
|
725
|
+
if (`${app}.section.${section.key}`.length > ROW_KEY_MAX)
|
|
726
|
+
return `its row key is over ${ROW_KEY_MAX} characters`;
|
|
727
|
+
if (!Number.isInteger(section.version) || section.version < 1)
|
|
728
|
+
return "its version is not a whole number ≥ 1";
|
|
729
|
+
const routes = Array.isArray(section.route) ? section.route : [section.route];
|
|
730
|
+
if (!routes.length || routes.some(r => typeof r !== "string" || !r.startsWith("/")))
|
|
731
|
+
return "it has no route";
|
|
732
|
+
if (typeof section.anchor !== "string" || !section.anchor)
|
|
733
|
+
return "it has no anchor";
|
|
734
|
+
if (!Array.isArray(section.steps) || section.steps.length === 0)
|
|
735
|
+
return "it has no steps";
|
|
736
|
+
for (const st of section.steps) {
|
|
737
|
+
const kind = st.kind ?? "view";
|
|
738
|
+
if (kind !== "view")
|
|
739
|
+
return `step "${st.key}" is a ${kind} step (a section guide only reads)`;
|
|
740
|
+
}
|
|
741
|
+
return null;
|
|
742
|
+
}
|
|
743
|
+
const validSectionCache = new WeakMap();
|
|
744
|
+
/** The suite's section guides that can run (as validPages: the rest dropped, with a warning in development). */
|
|
745
|
+
function validSections(suite) {
|
|
746
|
+
const hit = validSectionCache.get(suite);
|
|
747
|
+
if (hit)
|
|
748
|
+
return hit;
|
|
749
|
+
const out = [];
|
|
750
|
+
const seen = new Set();
|
|
751
|
+
for (const section of suite.sections ?? []) {
|
|
752
|
+
let problem = sectionProblem(suite.app, section);
|
|
753
|
+
if (!problem && seen.has(section.key))
|
|
754
|
+
problem = "another section guide has the same key";
|
|
755
|
+
if (problem) {
|
|
756
|
+
const id = `${suite.key}:section:${section?.key}:${problem}`;
|
|
757
|
+
if (!warned.has(id)) {
|
|
758
|
+
warned.add(id);
|
|
759
|
+
devWarn(`section guide "${section?.key}" dropped: ${problem}`);
|
|
760
|
+
}
|
|
761
|
+
continue;
|
|
762
|
+
}
|
|
763
|
+
seen.add(section.key);
|
|
764
|
+
out.push(section);
|
|
765
|
+
}
|
|
766
|
+
validSectionCache.set(suite, out);
|
|
767
|
+
return out;
|
|
768
|
+
}
|
|
769
|
+
function findSection(suite, key) {
|
|
770
|
+
return key ? validSections(suite).find(s => s.key === key) : undefined;
|
|
771
|
+
}
|
|
772
|
+
/** A live value is missing: undefined, null, "" or a number that is not finite. */
|
|
773
|
+
function valueMissing(v) {
|
|
774
|
+
return v === undefined || v === null || v === "" || (typeof v === "number" && !Number.isFinite(v));
|
|
775
|
+
}
|
|
776
|
+
function textOf(t, ctx) {
|
|
777
|
+
try {
|
|
778
|
+
return typeof t === "function" ? t(ctx) : t;
|
|
779
|
+
}
|
|
780
|
+
catch {
|
|
781
|
+
return "";
|
|
782
|
+
}
|
|
783
|
+
}
|
|
784
|
+
const sectionMissionCache = new WeakMap();
|
|
785
|
+
/**
|
|
786
|
+
* The section guide as a mission the machine plays: `section:<key>`, read
|
|
787
|
+
* steps only, checkpoints unless they say otherwise. A step is skipped while
|
|
788
|
+
* a value it `needs` is missing, or when its title comes out empty.
|
|
789
|
+
*/
|
|
790
|
+
function sectionMission(section) {
|
|
791
|
+
const hit = sectionMissionCache.get(section);
|
|
792
|
+
if (hit)
|
|
793
|
+
return hit;
|
|
794
|
+
const steps = section.steps.map(st => {
|
|
795
|
+
const { route: _route, resumeUrl: _resume, ...rest } = st;
|
|
796
|
+
const own = st.skipIf;
|
|
797
|
+
return {
|
|
798
|
+
...rest,
|
|
799
|
+
kind: "view",
|
|
800
|
+
checkpoint: st.checkpoint ?? true,
|
|
801
|
+
skipIf: (ctx) => {
|
|
802
|
+
const values = ctx.values ?? {};
|
|
803
|
+
if ((st.needs ?? []).some(k => valueMissing(values[k])))
|
|
804
|
+
return true;
|
|
805
|
+
if (!textOf(st.title, ctx).trim())
|
|
806
|
+
return true;
|
|
807
|
+
return own ? own(ctx) : false;
|
|
808
|
+
},
|
|
809
|
+
};
|
|
810
|
+
});
|
|
811
|
+
const m = { key: SECTION_PREFIX + section.key, title: section.title, minutes: 1, steps };
|
|
812
|
+
sectionMissionCache.set(section, m);
|
|
813
|
+
return m;
|
|
814
|
+
}
|
|
815
|
+
/** Whether the section guide applies here: one of its routes matches and its root is on screen. */
|
|
816
|
+
function sectionApplies(section, where) {
|
|
817
|
+
const routes = Array.isArray(section.route) ? section.route : [section.route];
|
|
818
|
+
if (!routes.some(r => !!matchRoute(r, where.pathname, where.search, where.code)))
|
|
819
|
+
return false;
|
|
820
|
+
try {
|
|
821
|
+
return where.isVisible(section.anchor);
|
|
822
|
+
}
|
|
823
|
+
catch {
|
|
824
|
+
return false;
|
|
825
|
+
}
|
|
826
|
+
}
|
|
827
|
+
/** Whether this person has the section guide: `visible` (only true) and `requires`. */
|
|
828
|
+
function sectionShown(section, ctx) {
|
|
829
|
+
try {
|
|
830
|
+
if (section.visible && section.visible(ctx) !== true)
|
|
831
|
+
return false;
|
|
832
|
+
if (section.requires && !ctx.can(section.requires))
|
|
833
|
+
return false;
|
|
834
|
+
return true;
|
|
835
|
+
}
|
|
836
|
+
catch {
|
|
837
|
+
return false;
|
|
838
|
+
}
|
|
839
|
+
}
|
|
840
|
+
/** The section guides on screen here that this person has, in the suite's order. */
|
|
841
|
+
function sectionsHere(suite, where, ctx) {
|
|
842
|
+
return validSections(suite).filter(s => sectionApplies(s, where) && sectionShown(s, ctx));
|
|
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
|
+
}
|
|
606
982
|
|
|
607
983
|
/* ------------------------------------------------------------------ */
|
|
608
984
|
/* Copy */
|
|
@@ -617,6 +993,8 @@ const COPY = {
|
|
|
617
993
|
hiddenToast: "ซ่อนแล้ว",
|
|
618
994
|
aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
|
|
619
995
|
pageDone: "จบคู่มือหน้านี้",
|
|
996
|
+
sectionDone: "จบคำอธิบายส่วนนี้",
|
|
997
|
+
flowDone: "พาทำงานเสร็จแล้ว",
|
|
620
998
|
bridgeTitle: "กดปุ่มเมนู",
|
|
621
999
|
bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
|
|
622
1000
|
};
|
|
@@ -629,8 +1007,11 @@ function initialGuideState(audience, progress, pages) {
|
|
|
629
1007
|
pageAudience: pages?.pageAudience ?? "none",
|
|
630
1008
|
pagesMode: pages?.pagesMode ?? "off",
|
|
631
1009
|
pages: pages?.pages ?? {},
|
|
1010
|
+
sections: pages?.sections ?? {},
|
|
1011
|
+
flows: pages?.flows ?? {},
|
|
632
1012
|
pageRun: null,
|
|
633
1013
|
panelSection: "all",
|
|
1014
|
+
autoPassed: null,
|
|
634
1015
|
audience,
|
|
635
1016
|
progress,
|
|
636
1017
|
screen: { name: "idle" },
|
|
@@ -650,6 +1031,14 @@ function findMission(suite, key) {
|
|
|
650
1031
|
const page = findPage(suite, pageKeyOf(key));
|
|
651
1032
|
return page ? pageMission(page) : undefined;
|
|
652
1033
|
}
|
|
1034
|
+
if (isSectionMissionKey(key)) {
|
|
1035
|
+
const section = findSection(suite, sectionKeyOf(key));
|
|
1036
|
+
return section ? sectionMission(section) : undefined;
|
|
1037
|
+
}
|
|
1038
|
+
if (isFlowMissionKey(key)) {
|
|
1039
|
+
const flow = findFlow(suite, flowKeyOf(key));
|
|
1040
|
+
return flow ? flowMission(flow) : undefined;
|
|
1041
|
+
}
|
|
653
1042
|
return suite.missions.find(m => m.key === key);
|
|
654
1043
|
}
|
|
655
1044
|
/**
|
|
@@ -687,6 +1076,7 @@ function guideContext(env, data, params = {}) {
|
|
|
687
1076
|
facts: env.facts,
|
|
688
1077
|
can: env.can,
|
|
689
1078
|
visible: (anchor) => env.isVisible(anchor),
|
|
1079
|
+
values: env.values ?? {},
|
|
690
1080
|
};
|
|
691
1081
|
}
|
|
692
1082
|
function resolveText(t, ctx) {
|
|
@@ -1000,7 +1390,7 @@ function announceStep(d, suite, env) {
|
|
|
1000
1390
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1001
1391
|
const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
|
|
1002
1392
|
const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
|
|
1003
|
-
const pos = stepPosition(mission, s.index, env, d.state.progress.context);
|
|
1393
|
+
const pos = stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
|
|
1004
1394
|
d.effects.push({
|
|
1005
1395
|
type: "announce",
|
|
1006
1396
|
text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
|
|
@@ -1011,7 +1401,7 @@ function announceStep(d, suite, env) {
|
|
|
1011
1401
|
* whose `requires` they lack or whose `skipIf` holds is left out (the step on
|
|
1012
1402
|
* screen always counts).
|
|
1013
1403
|
*/
|
|
1014
|
-
function stepPosition(mission, index, env, context) {
|
|
1404
|
+
function stepPosition(mission, index, env, context, autoPassed = []) {
|
|
1015
1405
|
let no = 0;
|
|
1016
1406
|
let total = 0;
|
|
1017
1407
|
mission.steps.forEach((step, i) => {
|
|
@@ -1021,6 +1411,12 @@ function stepPosition(mission, index, env, context) {
|
|
|
1021
1411
|
const ctx = guideContext(env, context, routeParamsFor(step, env));
|
|
1022
1412
|
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
1023
1413
|
return;
|
|
1414
|
+
// A step that passed by itself (shell ≥ 0.1.75): before this one, as
|
|
1415
|
+
// recorded; after it, one whose outcome already holds now.
|
|
1416
|
+
if (i < index && autoPassed.includes(i))
|
|
1417
|
+
return;
|
|
1418
|
+
if (i > index && wouldAutoPass(step, env))
|
|
1419
|
+
return;
|
|
1024
1420
|
}
|
|
1025
1421
|
total++;
|
|
1026
1422
|
if (i <= index)
|
|
@@ -1028,6 +1424,17 @@ function stepPosition(mission, index, env, context) {
|
|
|
1028
1424
|
});
|
|
1029
1425
|
return { no: Math.max(no, 1), total: Math.max(total, 1) };
|
|
1030
1426
|
}
|
|
1427
|
+
/** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
|
|
1428
|
+
function wouldAutoPass(step, env) {
|
|
1429
|
+
const kind = stepKind(step);
|
|
1430
|
+
if (step.autoPass === false || kind === "input" || kind === "view")
|
|
1431
|
+
return false;
|
|
1432
|
+
return safe(() => stateTriggerHolds(step, env) !== null, false);
|
|
1433
|
+
}
|
|
1434
|
+
/** The steps of this mission that passed by themselves in this run. */
|
|
1435
|
+
function autoPassedOf(state, mission) {
|
|
1436
|
+
return state.autoPassed?.mission === mission ? state.autoPassed.steps : [];
|
|
1437
|
+
}
|
|
1031
1438
|
/** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
|
|
1032
1439
|
function plainTitle(t) {
|
|
1033
1440
|
return t.replace(/\[([^\]]+)\]/g, "$1");
|
|
@@ -1040,6 +1447,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1040
1447
|
const steps = mission.steps;
|
|
1041
1448
|
let i = Math.max(0, index);
|
|
1042
1449
|
let firstPass = true;
|
|
1450
|
+
if (d.state.autoPassed?.mission !== mission.key)
|
|
1451
|
+
d.state = { ...d.state, autoPassed: { mission: mission.key, steps: [] } };
|
|
1043
1452
|
while (i < steps.length) {
|
|
1044
1453
|
const step = steps[i];
|
|
1045
1454
|
const params = routeParamsFor(step, env);
|
|
@@ -1061,6 +1470,9 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1061
1470
|
const held = stateTriggerHolds(step, env);
|
|
1062
1471
|
if (held) {
|
|
1063
1472
|
captureInto(d, step, held, ctx);
|
|
1473
|
+
const ap = d.state.autoPassed;
|
|
1474
|
+
if (!ap.steps.includes(i))
|
|
1475
|
+
d.state = { ...d.state, autoPassed: { mission: ap.mission, steps: [...ap.steps, i] } };
|
|
1064
1476
|
i++;
|
|
1065
1477
|
continue;
|
|
1066
1478
|
}
|
|
@@ -1074,9 +1486,11 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1074
1486
|
const step = steps[i];
|
|
1075
1487
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1076
1488
|
const page = pageKeyOf(mission.key);
|
|
1489
|
+
const section = sectionKeyOf(mission.key);
|
|
1490
|
+
const flow = flowKeyOf(mission.key);
|
|
1077
1491
|
const where = placeRoute(steps, i);
|
|
1078
|
-
// A page guide is on its page: leaving it ends the guide (never "ทำต่อ").
|
|
1079
|
-
const away = !page && !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
1492
|
+
// A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
|
|
1493
|
+
const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
1080
1494
|
const bridge = !away && needsBridge(step, env, ctx);
|
|
1081
1495
|
const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
|
|
1082
1496
|
setScreen(d, {
|
|
@@ -1094,6 +1508,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1094
1508
|
status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
|
|
1095
1509
|
replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
|
|
1096
1510
|
...(page ? { page } : {}),
|
|
1511
|
+
...(section ? { section } : {}),
|
|
1512
|
+
...(flow ? { flow } : {}),
|
|
1097
1513
|
});
|
|
1098
1514
|
if (!opts.replay) {
|
|
1099
1515
|
const cp = checkpointAtOrBefore(steps, i);
|
|
@@ -1258,9 +1674,12 @@ function advance(d, suite, env, capture) {
|
|
|
1258
1674
|
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
1259
1675
|
}
|
|
1260
1676
|
function finishMission(d, suite, env, mission, replay) {
|
|
1261
|
-
if (isPageMissionKey(mission.key)) {
|
|
1677
|
+
if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key) || isFlowMissionKey(mission.key)) {
|
|
1262
1678
|
endPage(d, suite, "completed");
|
|
1263
|
-
d.effects.push({
|
|
1679
|
+
d.effects.push({
|
|
1680
|
+
type: "toast",
|
|
1681
|
+
text: isSectionMissionKey(mission.key) ? COPY.sectionDone : isFlowMissionKey(mission.key) ? COPY.flowDone : COPY.pageDone,
|
|
1682
|
+
});
|
|
1264
1683
|
return;
|
|
1265
1684
|
}
|
|
1266
1685
|
if (replay) {
|
|
@@ -1364,7 +1783,8 @@ function startMission(d, suite, env, key, replay) {
|
|
|
1364
1783
|
// A mission already done is only ever replayed; its done status stays.
|
|
1365
1784
|
const isReplay = replay || entry?.status === "done";
|
|
1366
1785
|
const before = d.state.progress;
|
|
1367
|
-
|
|
1786
|
+
// A new run: what passed by itself is counted afresh.
|
|
1787
|
+
d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
|
|
1368
1788
|
if (d.state.progress.welcome === "not_seen")
|
|
1369
1789
|
withProgress(d, { welcome: "started" });
|
|
1370
1790
|
if (!isReplay && d.state.progress.mission_key !== key)
|
|
@@ -1523,14 +1943,168 @@ function recordPage(d, suite, key, status) {
|
|
|
1523
1943
|
d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
|
|
1524
1944
|
d.effects.push({ type: "savePage", key, version: page.version, status });
|
|
1525
1945
|
}
|
|
1526
|
-
/** The
|
|
1946
|
+
/** The section guide's row says this person has ended this version of it. */
|
|
1947
|
+
function sectionRecorded(state, section) {
|
|
1948
|
+
const row = state.sections?.[section.key];
|
|
1949
|
+
return !!row && row.version >= section.version;
|
|
1950
|
+
}
|
|
1951
|
+
/** Write a section guide's row (once per section and version). */
|
|
1952
|
+
function recordSection(d, suite, key, status) {
|
|
1953
|
+
const section = findSection(suite, key);
|
|
1954
|
+
if (!section || sectionRecorded(d.state, section))
|
|
1955
|
+
return;
|
|
1956
|
+
const prev = d.state.sections?.[key];
|
|
1957
|
+
d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
|
|
1958
|
+
d.effects.push({ type: "savePage", key, version: section.version, status, kind: "section" });
|
|
1959
|
+
}
|
|
1960
|
+
/** The page or section guide on screen, or null. */
|
|
1961
|
+
function readOf(s) {
|
|
1962
|
+
if (s.name !== "step")
|
|
1963
|
+
return null;
|
|
1964
|
+
if (s.page)
|
|
1965
|
+
return { kind: "page", key: s.page };
|
|
1966
|
+
if (s.section)
|
|
1967
|
+
return { kind: "section", key: s.section };
|
|
1968
|
+
return null;
|
|
1969
|
+
}
|
|
1970
|
+
/** Whether a page or section guide still applies where the person is. */
|
|
1971
|
+
function readStillHere(suite, r, env) {
|
|
1972
|
+
if (r.kind === "page")
|
|
1973
|
+
return pageStillHere(suite, r.key, env);
|
|
1974
|
+
const section = findSection(suite, r.key);
|
|
1975
|
+
return !!section && sectionApplies(section, whereOf(env));
|
|
1976
|
+
}
|
|
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. */
|
|
1527
1992
|
function endPage(d, suite, how) {
|
|
1528
1993
|
const run = d.state.pageRun;
|
|
1529
|
-
if (run?.write)
|
|
1530
|
-
|
|
1994
|
+
if (run?.write) {
|
|
1995
|
+
if (run.kind === "section")
|
|
1996
|
+
recordSection(d, suite, run.key, how);
|
|
1997
|
+
else if (run.kind === "flow")
|
|
1998
|
+
recordFlow(d, suite, run.key, how);
|
|
1999
|
+
else
|
|
2000
|
+
recordPage(d, suite, run.key, how);
|
|
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 });
|
|
1531
2005
|
d.state = { ...d.state, pageRun: null, replayPage: null };
|
|
1532
2006
|
setScreen(d, { name: "idle" });
|
|
1533
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
|
+
}
|
|
2085
|
+
/** The section guides on screen here that this person has (the "?" entries, the buttons). */
|
|
2086
|
+
function sectionsAvailable(suite, env) {
|
|
2087
|
+
return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
|
|
2088
|
+
}
|
|
2089
|
+
function startSection(d, suite, env, key, write) {
|
|
2090
|
+
const section = findSection(suite, key);
|
|
2091
|
+
if (!section)
|
|
2092
|
+
return;
|
|
2093
|
+
const mission = sectionMission(section);
|
|
2094
|
+
d.state = {
|
|
2095
|
+
...d.state,
|
|
2096
|
+
panelOpen: false,
|
|
2097
|
+
justDone: null,
|
|
2098
|
+
pageRun: { key, write, kind: "section" },
|
|
2099
|
+
autoPassed: null,
|
|
2100
|
+
replayPage: {
|
|
2101
|
+
mission: mission.key,
|
|
2102
|
+
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
2103
|
+
dialog: safe(() => env.dialogOpen?.() === true, false),
|
|
2104
|
+
},
|
|
2105
|
+
};
|
|
2106
|
+
enterStep(d, suite, env, mission, 0, { replay: true });
|
|
2107
|
+
}
|
|
1534
2108
|
function startPage(d, suite, env, key, write) {
|
|
1535
2109
|
const page = findPage(suite, key);
|
|
1536
2110
|
if (!page)
|
|
@@ -1540,7 +2114,8 @@ function startPage(d, suite, env, key, write) {
|
|
|
1540
2114
|
...d.state,
|
|
1541
2115
|
panelOpen: false,
|
|
1542
2116
|
justDone: null,
|
|
1543
|
-
pageRun: { key, write },
|
|
2117
|
+
pageRun: { key, write, kind: "page" },
|
|
2118
|
+
autoPassed: null,
|
|
1544
2119
|
// What is open now is the person's: ending the guide leaves it alone.
|
|
1545
2120
|
replayPage: {
|
|
1546
2121
|
mission: mission.key,
|
|
@@ -1640,7 +2215,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1640
2215
|
break;
|
|
1641
2216
|
// A mission always wins over a page guide: a running one ends (its row
|
|
1642
2217
|
// written if due), an offer goes unrecorded.
|
|
1643
|
-
if (s
|
|
2218
|
+
if (readOf(s) || flowOf(s))
|
|
1644
2219
|
endPage(d, suite, "skipped");
|
|
1645
2220
|
else if (s.name === "pageOffer")
|
|
1646
2221
|
setScreen(d, { name: "idle" });
|
|
@@ -1661,12 +2236,21 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1661
2236
|
const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
|
|
1662
2237
|
const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
|
|
1663
2238
|
const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
|
|
2239
|
+
d.state = { ...d.state, autoPassed: null };
|
|
1664
2240
|
enterStep(d, suite, env, mission, index, { replay: false });
|
|
1665
2241
|
break;
|
|
1666
2242
|
}
|
|
1667
2243
|
case "sync": {
|
|
1668
2244
|
if (s.name === "pageOffer") {
|
|
1669
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
|
+
}
|
|
1670
2254
|
if (!pageStillHere(suite, s.page, env)) {
|
|
1671
2255
|
recordPage(d, suite, s.page, "skipped");
|
|
1672
2256
|
setScreen(d, { name: "idle" });
|
|
@@ -1675,8 +2259,18 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1675
2259
|
}
|
|
1676
2260
|
if (s.name !== "step")
|
|
1677
2261
|
break;
|
|
1678
|
-
if (s.
|
|
1679
|
-
//
|
|
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
|
+
}
|
|
2271
|
+
const read = readOf(s);
|
|
2272
|
+
if (read && !readStillHere(suite, read, env)) {
|
|
2273
|
+
// Leaving the page (or the section's root going) ends its guide, silently.
|
|
1680
2274
|
endPage(d, suite, "skipped");
|
|
1681
2275
|
break;
|
|
1682
2276
|
}
|
|
@@ -1709,7 +2303,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1709
2303
|
break;
|
|
1710
2304
|
}
|
|
1711
2305
|
}
|
|
1712
|
-
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page)) {
|
|
2306
|
+
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1713
2307
|
// The target is missing because the page is another one.
|
|
1714
2308
|
recoverOffRoute(d, suite, env, false);
|
|
1715
2309
|
break;
|
|
@@ -1825,7 +2419,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1825
2419
|
const cur = d.state.screen;
|
|
1826
2420
|
if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
|
|
1827
2421
|
break;
|
|
1828
|
-
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
|
|
2422
|
+
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page ?? cur.section)) {
|
|
1829
2423
|
recoverOffRoute(d, suite, env, false);
|
|
1830
2424
|
break;
|
|
1831
2425
|
}
|
|
@@ -1846,7 +2440,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1846
2440
|
if (!mission)
|
|
1847
2441
|
break;
|
|
1848
2442
|
const step = mission.steps[s.index];
|
|
1849
|
-
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
2443
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1850
2444
|
recoverOffRoute(d, suite, env, false);
|
|
1851
2445
|
break;
|
|
1852
2446
|
}
|
|
@@ -1863,7 +2457,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1863
2457
|
advance(d, suite, env);
|
|
1864
2458
|
}
|
|
1865
2459
|
else if (policy === "abort") {
|
|
1866
|
-
if (s
|
|
2460
|
+
if (readOf(s) || flowOf(s))
|
|
1867
2461
|
endPage(d, suite, "skipped");
|
|
1868
2462
|
else
|
|
1869
2463
|
exitMission(d);
|
|
@@ -1889,7 +2483,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1889
2483
|
if (s.name !== "step" || s.phase !== "missing")
|
|
1890
2484
|
break;
|
|
1891
2485
|
const mission = findMission(suite, s.mission);
|
|
1892
|
-
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
2486
|
+
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1893
2487
|
// Polling again cannot help on another page: go back to the step's.
|
|
1894
2488
|
recoverOffRoute(d, suite, env, true);
|
|
1895
2489
|
break;
|
|
@@ -1905,7 +2499,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1905
2499
|
case "exit":
|
|
1906
2500
|
if (s.name !== "step")
|
|
1907
2501
|
break;
|
|
1908
|
-
if (s
|
|
2502
|
+
if (readOf(s) || flowOf(s)) {
|
|
1909
2503
|
// ✕, Escape or ปิดคู่มือ: ends silently.
|
|
1910
2504
|
pushAbort(d, suite, s);
|
|
1911
2505
|
endPage(d, suite, "skipped");
|
|
@@ -1933,6 +2527,11 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1933
2527
|
case "endWithoutSave":
|
|
1934
2528
|
if (s.name !== "step" || !s.replay)
|
|
1935
2529
|
break;
|
|
2530
|
+
if (flowOf(s)) {
|
|
2531
|
+
pushAbort(d, suite, s);
|
|
2532
|
+
endPage(d, suite, "skipped");
|
|
2533
|
+
break;
|
|
2534
|
+
}
|
|
1936
2535
|
pushAbort(d, suite, s);
|
|
1937
2536
|
setScreen(d, { name: "idle" });
|
|
1938
2537
|
d.effects.push({ type: "toast", text: COPY.replayEnded });
|
|
@@ -1981,7 +2580,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1981
2580
|
break;
|
|
1982
2581
|
case "openPanel": {
|
|
1983
2582
|
const section = action.section ?? "all";
|
|
1984
|
-
const allowed = section
|
|
2583
|
+
const allowed = section !== "all" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
|
|
1985
2584
|
if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
|
|
1986
2585
|
break;
|
|
1987
2586
|
// The offer gives way to the panel, unrecorded.
|
|
@@ -2034,6 +2633,13 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2034
2633
|
break;
|
|
2035
2634
|
}
|
|
2036
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
|
+
}
|
|
2037
2643
|
if (pageOfferDue(state, suite, env) !== action.page)
|
|
2038
2644
|
break;
|
|
2039
2645
|
setScreen(d, { name: "pageOffer", page: action.page });
|
|
@@ -2043,14 +2649,42 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2043
2649
|
case "pageOfferStart":
|
|
2044
2650
|
if (s.name !== "pageOffer" || state.pageAudience === "none")
|
|
2045
2651
|
break;
|
|
2046
|
-
|
|
2652
|
+
if (s.flow)
|
|
2653
|
+
startFlow(d, suite, env, s.page, true);
|
|
2654
|
+
else
|
|
2655
|
+
startPage(d, suite, env, s.page, true);
|
|
2047
2656
|
break;
|
|
2048
2657
|
case "pageOfferLater":
|
|
2049
2658
|
if (s.name !== "pageOffer")
|
|
2050
2659
|
break;
|
|
2051
|
-
|
|
2660
|
+
if (s.flow)
|
|
2661
|
+
recordFlow(d, suite, s.page, "skipped");
|
|
2662
|
+
else
|
|
2663
|
+
recordPage(d, suite, s.page, "skipped");
|
|
2052
2664
|
setScreen(d, { name: "idle" });
|
|
2053
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
|
+
}
|
|
2054
2688
|
case "startPage": {
|
|
2055
2689
|
// Never over a mission step, a welcome, a gate or a celebration.
|
|
2056
2690
|
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
@@ -2064,7 +2698,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2064
2698
|
break;
|
|
2065
2699
|
}
|
|
2066
2700
|
case "back": {
|
|
2067
|
-
if (s.name !== "step" || !s
|
|
2701
|
+
if (s.name !== "step" || !readOf(s) || s.phase !== "ready" || s.confirmExit)
|
|
2068
2702
|
break;
|
|
2069
2703
|
const mission = findMission(suite, s.mission);
|
|
2070
2704
|
if (!mission)
|
|
@@ -2079,12 +2713,39 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2079
2713
|
break;
|
|
2080
2714
|
}
|
|
2081
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
|
+
}
|
|
2723
|
+
if (action.kind === "section") {
|
|
2724
|
+
const cur = d.state.sections?.[action.key];
|
|
2725
|
+
if (cur && cur.version > action.row.version)
|
|
2726
|
+
break;
|
|
2727
|
+
d.state = { ...d.state, sections: { ...d.state.sections, [action.key]: action.row } };
|
|
2728
|
+
break;
|
|
2729
|
+
}
|
|
2082
2730
|
const cur = d.state.pages[action.key];
|
|
2083
2731
|
if (cur && cur.version > action.row.version)
|
|
2084
2732
|
break;
|
|
2085
2733
|
d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
|
|
2086
2734
|
break;
|
|
2087
2735
|
}
|
|
2736
|
+
case "startSection": {
|
|
2737
|
+
// Never offered: only on request. Never over a mission step, a
|
|
2738
|
+
// welcome, a gate or a celebration; only on screen, only theirs.
|
|
2739
|
+
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
2740
|
+
break;
|
|
2741
|
+
if (s.name !== "idle" && s.name !== "pageOffer")
|
|
2742
|
+
break;
|
|
2743
|
+
if (!sectionsAvailable(suite, env).includes(action.section))
|
|
2744
|
+
break;
|
|
2745
|
+
const section = findSection(suite, action.section);
|
|
2746
|
+
startSection(d, suite, env, section.key, !sectionRecorded(state, section));
|
|
2747
|
+
break;
|
|
2748
|
+
}
|
|
2088
2749
|
case "idleCelebrate": {
|
|
2089
2750
|
if (s.name !== "idle" || state.panelOpen)
|
|
2090
2751
|
break;
|
|
@@ -2213,6 +2874,8 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
2213
2874
|
? data.page_audience
|
|
2214
2875
|
: "none";
|
|
2215
2876
|
const pages = parsePageRows(rows, suite.app);
|
|
2877
|
+
const sections = parsePageRows(rows, suite.app, "section");
|
|
2878
|
+
const flows = parsePageRows(rows, suite.app, "flow");
|
|
2216
2879
|
let row = null;
|
|
2217
2880
|
for (const r of rows) {
|
|
2218
2881
|
if (isRecord(r) && r.guide_key === suite.key) {
|
|
@@ -2233,15 +2896,15 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
2233
2896
|
progress = row;
|
|
2234
2897
|
newerOnServer = row.guide_version > suite.version;
|
|
2235
2898
|
}
|
|
2236
|
-
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages };
|
|
2899
|
+
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections, flows };
|
|
2237
2900
|
}
|
|
2238
|
-
/** The row key of a page guide
|
|
2239
|
-
function pageRowKey(app, key) {
|
|
2240
|
-
return `${app}
|
|
2901
|
+
/** The row key of a page guide `<app>.page.<key>`, a section guide `<app>.section.<key>`, or a flow `<app>.flow.<key>`. */
|
|
2902
|
+
function pageRowKey(app, key, kind = "page") {
|
|
2903
|
+
return `${app}.${kind}.${key}`;
|
|
2241
2904
|
}
|
|
2242
|
-
/** Page rows among the list's rows, by
|
|
2243
|
-
function parsePageRows(rows, app) {
|
|
2244
|
-
const prefix = pageRowKey(app, "");
|
|
2905
|
+
/** Page (or section) rows among the list's rows, by key. Off-shape rows are left out. */
|
|
2906
|
+
function parsePageRows(rows, app, kind = "page") {
|
|
2907
|
+
const prefix = pageRowKey(app, "", kind);
|
|
2245
2908
|
const out = {};
|
|
2246
2909
|
for (const r of rows) {
|
|
2247
2910
|
if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
|
|
@@ -2491,14 +3154,15 @@ async function storeGuides(adapter, suite, progress) {
|
|
|
2491
3154
|
return null;
|
|
2492
3155
|
}
|
|
2493
3156
|
/**
|
|
2494
|
-
* Writes a page guide's row (`<app>.page.<key>`)
|
|
3157
|
+
* Writes a page guide's row (`<app>.page.<key>`), or a section guide's
|
|
3158
|
+
* (`<app>.section.<key>`, kind "section"), once: the minimal body,
|
|
2495
3159
|
* with the same 409 handling as the tour's row. Another tab that already
|
|
2496
3160
|
* recorded this version wins (its row is returned, nothing is sent again);
|
|
2497
3161
|
* an older version's row is replaced with its rev; a newer one stops. Returns
|
|
2498
3162
|
* the stored row, or null when it could not be saved.
|
|
2499
3163
|
*/
|
|
2500
|
-
async function storePageRow(adapter, app, key, version, status, rev) {
|
|
2501
|
-
const guideKey = pageRowKey(app, key);
|
|
3164
|
+
async function storePageRow(adapter, app, key, version, status, rev, kind = "page") {
|
|
3165
|
+
const guideKey = pageRowKey(app, key, kind);
|
|
2502
3166
|
let at = rev;
|
|
2503
3167
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2504
3168
|
let res;
|
|
@@ -3416,7 +4080,10 @@ function CompleteModal(p) {
|
|
|
3416
4080
|
* corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
|
|
3417
4081
|
*/
|
|
3418
4082
|
function PageOfferCard(p) {
|
|
3419
|
-
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: "
|
|
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))) }));
|
|
3420
4087
|
}
|
|
3421
4088
|
function PageList({ items, onOpen }) {
|
|
3422
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))) }));
|
|
@@ -3443,12 +4110,16 @@ function QuestChip({ done, total, pulse, onOpen }) {
|
|
|
3443
4110
|
}
|
|
3444
4111
|
function QuestPanel(p) {
|
|
3445
4112
|
const ref = useAutoFocus();
|
|
3446
|
-
const pages = p.pages ?? [];
|
|
4113
|
+
const pages = p.only === "flows" ? [] : (p.pages ?? []);
|
|
4114
|
+
const flows = p.only === "pages" ? [] : (p.flows ?? []);
|
|
3447
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;
|
|
3448
4118
|
if (p.showMissions === false) {
|
|
3449
|
-
|
|
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] })] }));
|
|
3450
4121
|
}
|
|
3451
|
-
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] })] }));
|
|
3452
4123
|
}
|
|
3453
4124
|
function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
3454
4125
|
const v = item.view;
|
|
@@ -3586,11 +4257,10 @@ function writeMirror(orgId, suite, p) {
|
|
|
3586
4257
|
/* private mode: the server copy is enough */
|
|
3587
4258
|
}
|
|
3588
4259
|
}
|
|
3589
|
-
|
|
3590
|
-
|
|
3591
|
-
function readPagesMirror(orgId, suite) {
|
|
4260
|
+
const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind}s:${orgId}:${suite.app}`;
|
|
4261
|
+
function readPagesMirror(orgId, suite, kind = "page") {
|
|
3592
4262
|
try {
|
|
3593
|
-
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite));
|
|
4263
|
+
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
|
|
3594
4264
|
const parsed = raw ? JSON.parse(raw) : {};
|
|
3595
4265
|
const out = {};
|
|
3596
4266
|
for (const [k, v] of Object.entries(parsed ?? {})) {
|
|
@@ -3603,9 +4273,9 @@ function readPagesMirror(orgId, suite) {
|
|
|
3603
4273
|
return {};
|
|
3604
4274
|
}
|
|
3605
4275
|
}
|
|
3606
|
-
function writePagesMirror(orgId, suite, pages) {
|
|
4276
|
+
function writePagesMirror(orgId, suite, pages, kind = "page") {
|
|
3607
4277
|
try {
|
|
3608
|
-
sessionStorage.setItem(pagesMirrorKey(orgId, suite), JSON.stringify(pages));
|
|
4278
|
+
sessionStorage.setItem(pagesMirrorKey(orgId, suite, kind), JSON.stringify(pages));
|
|
3609
4279
|
}
|
|
3610
4280
|
catch {
|
|
3611
4281
|
/* private mode */
|
|
@@ -3621,6 +4291,8 @@ function mergePageRows(server, local) {
|
|
|
3621
4291
|
}
|
|
3622
4292
|
return out;
|
|
3623
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}`;
|
|
3624
4296
|
/* ------------------------------------------------------------------ */
|
|
3625
4297
|
/* One tab at a time */
|
|
3626
4298
|
/* ------------------------------------------------------------------ */
|
|
@@ -3733,7 +4405,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3733
4405
|
const [answer, setAnswer] = React.useState(null);
|
|
3734
4406
|
const missionsReady = config.ready !== false;
|
|
3735
4407
|
const pagesReady = config.pagesReady ?? missionsReady;
|
|
3736
|
-
const hasPages = React.useMemo(() => validPages(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]);
|
|
4410
|
+
const hasSections = React.useMemo(() => validSections(suite).length > 0, [suite]);
|
|
3737
4411
|
const enabled = primary && (missionsReady || (hasPages && pagesReady));
|
|
3738
4412
|
const loaded = React.useRef(false);
|
|
3739
4413
|
React.useEffect(() => {
|
|
@@ -3789,6 +4463,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3789
4463
|
pageAudience: pagesOn ? answer.pageAudience : "none",
|
|
3790
4464
|
pagesMode: answer.pagesMode,
|
|
3791
4465
|
pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
|
|
4466
|
+
sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
|
|
4467
|
+
flows: mergePageRows(answer.flows ?? {}, readPagesMirror(organizationId, suite, "flow")),
|
|
3792
4468
|
});
|
|
3793
4469
|
stateRef.current = s;
|
|
3794
4470
|
setState(s);
|
|
@@ -3797,8 +4473,12 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3797
4473
|
const pageRef = React.useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
|
|
3798
4474
|
pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
|
|
3799
4475
|
const targetElRef = React.useRef(null);
|
|
4476
|
+
// The section guide being opened (before its first step is on screen).
|
|
4477
|
+
const openingSection = React.useRef(null);
|
|
3800
4478
|
const env = React.useCallback(() => {
|
|
3801
4479
|
const pg = pageRef.current;
|
|
4480
|
+
const sc = stateRef.current?.screen;
|
|
4481
|
+
const sectionKey = (sc?.name === "step" ? sc.section : undefined) ?? openingSection.current;
|
|
3802
4482
|
return {
|
|
3803
4483
|
code,
|
|
3804
4484
|
pathname: pg.pathname,
|
|
@@ -3816,6 +4496,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3816
4496
|
sidebarOpen: pg.sidebarOpen,
|
|
3817
4497
|
targetValue: () => fieldValue(targetElRef.current),
|
|
3818
4498
|
dialogOpen: () => appDialogOpen(),
|
|
4499
|
+
values: getSectionGuideValues(sectionKey),
|
|
3819
4500
|
};
|
|
3820
4501
|
}, [code]);
|
|
3821
4502
|
/* ---------------- effects: save, announce, toast, navigate ---------------- */
|
|
@@ -3891,13 +4572,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3891
4572
|
}
|
|
3892
4573
|
else if (e.type === "savePage") {
|
|
3893
4574
|
// Once per page and version; the machine already holds it as written.
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
const
|
|
3897
|
-
|
|
4575
|
+
const kind = e.kind ?? "page";
|
|
4576
|
+
const cur = stateRef.current;
|
|
4577
|
+
const rows = cur ? (kind === "section" ? cur.sections : kind === "flow" ? cur.flows : cur.pages) : undefined;
|
|
4578
|
+
if (rows)
|
|
4579
|
+
writePagesMirror(organizationId, suite, rows, kind);
|
|
4580
|
+
const rev = rows?.[e.key]?.rev ?? 0;
|
|
4581
|
+
void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev, kind).then(row => {
|
|
3898
4582
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
3899
4583
|
if (row)
|
|
3900
|
-
dispatchRef.current({ type: "pageRow", key: e.key, row });
|
|
4584
|
+
dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
|
|
3901
4585
|
});
|
|
3902
4586
|
}
|
|
3903
4587
|
}
|
|
@@ -3987,7 +4671,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3987
4671
|
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
3988
4672
|
// A page guide's step runs while page guides are live; a mission's while
|
|
3989
4673
|
// the missions are. The offer card is watched the same way (left: ignored).
|
|
3990
|
-
const inStep = screen.name === "step" && (screen.page ? pagesLive : missionsLive);
|
|
4674
|
+
const inStep = screen.name === "step" && (screen.page || screen.section || screen.flow ? pagesLive : missionsLive);
|
|
3991
4675
|
const offerUp = pagesLive && screen.name === "pageOffer";
|
|
3992
4676
|
const watching = inStep || offerUp;
|
|
3993
4677
|
React.useEffect(() => {
|
|
@@ -4035,6 +4719,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4035
4719
|
const step = shownStep(raw, screen.nothingToSave);
|
|
4036
4720
|
return { mission, step, raw };
|
|
4037
4721
|
}, [state, screen, suite]);
|
|
4722
|
+
const valuesVersion = useSectionValuesVersion();
|
|
4038
4723
|
const stepCtx = React.useMemo(() => {
|
|
4039
4724
|
if (!state)
|
|
4040
4725
|
return null;
|
|
@@ -4043,7 +4728,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4043
4728
|
return guideContext(e, state.progress.context, params);
|
|
4044
4729
|
// pathname/search/factsKey make it current.
|
|
4045
4730
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4046
|
-
}, [state, stepInfo, env, code, pathname, search, factsKey]);
|
|
4731
|
+
}, [state, stepInfo, env, code, pathname, search, factsKey, valuesVersion]);
|
|
4047
4732
|
const target = stepInfo && stepCtx && screen.name === "step"
|
|
4048
4733
|
? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
|
|
4049
4734
|
: null;
|
|
@@ -4551,7 +5236,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4551
5236
|
/* ---------------- page guides: this page's, the offer, the list ---------------- */
|
|
4552
5237
|
// `when` anchors (LIFF's sub-screens) come and go without a route change:
|
|
4553
5238
|
// look again when the page changes.
|
|
4554
|
-
const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when), [suite]);
|
|
5239
|
+
const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
|
|
4555
5240
|
const [domTick, setDomTick] = React.useState(0);
|
|
4556
5241
|
React.useEffect(() => {
|
|
4557
5242
|
if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
|
|
@@ -4585,6 +5270,30 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4585
5270
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4586
5271
|
[pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
|
|
4587
5272
|
const listed = React.useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
|
|
5273
|
+
const sectionsNow = React.useMemo(() => pagesLive && hasState && hasSections
|
|
5274
|
+
? sectionsAvailable(suite, env()).map(k => ({ key: k, title: findSection(suite, k)?.title ?? "" }))
|
|
5275
|
+
: [],
|
|
5276
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5277
|
+
[pagesLive, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
|
|
5278
|
+
const sectionsSig = sectionsNow.map(x => x.key).join(",");
|
|
5279
|
+
const openSection = React.useCallback((key) => {
|
|
5280
|
+
openingSection.current = key;
|
|
5281
|
+
try {
|
|
5282
|
+
dispatchRef.current({ type: "startSection", section: key });
|
|
5283
|
+
}
|
|
5284
|
+
finally {
|
|
5285
|
+
openingSection.current = null;
|
|
5286
|
+
}
|
|
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 }), []);
|
|
4588
5297
|
const openPageHere = React.useCallback(() => {
|
|
4589
5298
|
const p = pageHere(suite, env());
|
|
4590
5299
|
if (p)
|
|
@@ -4595,8 +5304,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4595
5304
|
publishPageGuides(null, null, null);
|
|
4596
5305
|
return;
|
|
4597
5306
|
}
|
|
4598
|
-
publishPageGuides({
|
|
4599
|
-
|
|
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.
|
|
5315
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5316
|
+
}, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection, flowsSig, flowsListed.length, openFlowHere]);
|
|
4600
5317
|
React.useEffect(() => () => publishPageGuides(null, null, null), []);
|
|
4601
5318
|
// The first-visit offer: in "offer" mode, nothing else up, the welcome
|
|
4602
5319
|
// not due here, and a moment of nothing loading on this page.
|
|
@@ -4624,14 +5341,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4624
5341
|
if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
|
|
4625
5342
|
return;
|
|
4626
5343
|
let key = null;
|
|
5344
|
+
let flow = null;
|
|
4627
5345
|
try {
|
|
4628
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());
|
|
4629
5350
|
}
|
|
4630
5351
|
catch {
|
|
4631
5352
|
key = null;
|
|
5353
|
+
flow = null;
|
|
4632
5354
|
}
|
|
4633
5355
|
if (key)
|
|
4634
5356
|
dispatchRef.current({ type: "pageOffer", page: key });
|
|
5357
|
+
else if (flow)
|
|
5358
|
+
dispatchRef.current({ type: "pageOffer", page: flow, flow: true });
|
|
4635
5359
|
}, PAGE_POLL_MS);
|
|
4636
5360
|
return () => clearInterval(iv);
|
|
4637
5361
|
}, [offerWatch, suite, env, pathname, search]);
|
|
@@ -4683,6 +5407,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4683
5407
|
if (b)
|
|
4684
5408
|
return;
|
|
4685
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
|
+
}
|
|
4686
5418
|
const page = findPage(suite, pending.key);
|
|
4687
5419
|
if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
|
|
4688
5420
|
return;
|
|
@@ -4691,6 +5423,93 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4691
5423
|
}, PAGE_POLL_MS);
|
|
4692
5424
|
return () => clearInterval(iv);
|
|
4693
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]);
|
|
4694
5513
|
if (!live || !state || !layer || !ctxIdle)
|
|
4695
5514
|
return null;
|
|
4696
5515
|
/* ---------------- render ---------------- */
|
|
@@ -4713,16 +5532,24 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4713
5532
|
const missionNo = missionNumber(suite, mission.key, ctxIdle);
|
|
4714
5533
|
const kind = stepKind(step);
|
|
4715
5534
|
const bridge = screen.bridge;
|
|
4716
|
-
const pos = stepPosition(mission, screen.index, env(), state.progress.context);
|
|
5535
|
+
const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
|
|
4717
5536
|
const page = screen.page ? findPage(suite, screen.page) : undefined;
|
|
4718
|
-
const
|
|
5537
|
+
const section = screen.section ? findSection(suite, screen.section) : undefined;
|
|
5538
|
+
const flow = screen.flow ? findFlow(suite, screen.flow) : undefined;
|
|
5539
|
+
const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
|
|
5540
|
+
const pageBits = read
|
|
4719
5541
|
? {
|
|
4720
|
-
|
|
4721
|
-
exitLabel: "ปิดคู่มือ",
|
|
5542
|
+
...read,
|
|
4722
5543
|
showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
|
|
4723
5544
|
nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
|
|
4724
5545
|
}
|
|
4725
|
-
:
|
|
5546
|
+
: flow
|
|
5547
|
+
? {
|
|
5548
|
+
heading: `พาทำ · ${flow.title}`,
|
|
5549
|
+
exitLabel: "ออกจากการพาทำ",
|
|
5550
|
+
nextLabel: kind === "view" && pos.no >= pos.total ? "เสร็จ" : undefined,
|
|
5551
|
+
}
|
|
5552
|
+
: {};
|
|
4726
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: {
|
|
4727
5554
|
missionNo,
|
|
4728
5555
|
missionTitle: mission.title,
|
|
@@ -4739,6 +5566,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4739
5566
|
...pageBits,
|
|
4740
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" }) }));
|
|
4741
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
|
+
}
|
|
4742
5576
|
else if (screen.name === "pageOffer" && pagesLive) {
|
|
4743
5577
|
const page = findPage(suite, screen.page);
|
|
4744
5578
|
if (page) {
|
|
@@ -4782,12 +5616,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4782
5616
|
const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
|
|
4783
5617
|
// The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
|
|
4784
5618
|
// then the page guides they have.
|
|
4785
|
-
const panelMissions = missionsLive && total > 0 && state.panelSection
|
|
4786
|
-
const pageItems = pagesLive
|
|
4787
|
-
|
|
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" }))
|
|
4788
5623
|
: [];
|
|
4789
|
-
const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
|
|
4790
|
-
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);
|
|
4791
5626
|
}
|
|
4792
5627
|
/** A stable key for the facts object, so effects re-run when they change. */
|
|
4793
5628
|
function useFactsKey(facts) {
|
|
@@ -4801,28 +5636,134 @@ function useFactsKey(facts) {
|
|
|
4801
5636
|
}, [facts]);
|
|
4802
5637
|
}
|
|
4803
5638
|
|
|
5639
|
+
const DEFAULT_STYLE = {
|
|
5640
|
+
display: "inline-grid",
|
|
5641
|
+
placeItems: "center",
|
|
5642
|
+
width: 22,
|
|
5643
|
+
height: 22,
|
|
5644
|
+
padding: 0,
|
|
5645
|
+
border: 0,
|
|
5646
|
+
borderRadius: 999,
|
|
5647
|
+
background: "transparent",
|
|
5648
|
+
color: "inherit",
|
|
5649
|
+
opacity: 0.6,
|
|
5650
|
+
cursor: "pointer",
|
|
5651
|
+
flex: "none",
|
|
5652
|
+
};
|
|
5653
|
+
function SectionGuideButton({ guideKey, values, className }) {
|
|
5654
|
+
useSectionGuideValues(guideKey, values);
|
|
5655
|
+
const { sections } = usePageGuides();
|
|
5656
|
+
const here = (sections ?? []).find(s => s.key === guideKey);
|
|
5657
|
+
if (!here)
|
|
5658
|
+
return null;
|
|
5659
|
+
return (jsxRuntime.jsx("button", { type: "button", className: className, style: className ? undefined : DEFAULT_STYLE, "aria-label": `อธิบายส่วนนี้ ${here.title}`, title: "\u0E2D\u0E18\u0E34\u0E1A\u0E32\u0E22\u0E2A\u0E48\u0E27\u0E19\u0E19\u0E35\u0E49", "data-guide-section-button": guideKey, onClick: e => {
|
|
5660
|
+
// A header that opens or sorts on press must not hear it.
|
|
5661
|
+
e.stopPropagation();
|
|
5662
|
+
e.preventDefault();
|
|
5663
|
+
openSectionGuide(guideKey);
|
|
5664
|
+
}, children: jsxRuntime.jsxs("svg", { viewBox: "0 0 24 24", width: "16", height: "16", "aria-hidden": "true", fill: "none", stroke: "currentColor", strokeWidth: "2", strokeLinecap: "round", strokeLinejoin: "round", children: [jsxRuntime.jsx("circle", { cx: "12", cy: "12", r: "10" }), jsxRuntime.jsx("path", { d: "M9.09 9a3 3 0 0 1 5.83 1c0 2-3 3-3 3" }), jsxRuntime.jsx("path", { d: "M12 17h.01" })] }) }));
|
|
5665
|
+
}
|
|
5666
|
+
|
|
5667
|
+
/**
|
|
5668
|
+
* Small formatters for section guides' copy (shell 0.1.75): the app passes
|
|
5669
|
+
* its own computed numbers, and the copy says them the way the page does —
|
|
5670
|
+
* "ม.ค. รับ 500,000 − จ่าย 320,000 = คงเหลือ 180,000".
|
|
5671
|
+
*
|
|
5672
|
+
* Pure: the tests run it in Node.
|
|
5673
|
+
*/
|
|
5674
|
+
const THAI_MONTHS_SHORT = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."];
|
|
5675
|
+
const grouped = new Map();
|
|
5676
|
+
function nf(decimals) {
|
|
5677
|
+
let f = grouped.get(decimals);
|
|
5678
|
+
if (!f) {
|
|
5679
|
+
f = new Intl.NumberFormat("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
5680
|
+
grouped.set(decimals, f);
|
|
5681
|
+
}
|
|
5682
|
+
return f;
|
|
5683
|
+
}
|
|
5684
|
+
/**
|
|
5685
|
+
* A baht amount with thousands separators, no currency sign: 500000 →
|
|
5686
|
+
* "500,000"; 1234.5 → "1,234.50"; -320000 → "-320,000". Whole amounts
|
|
5687
|
+
* have no decimals unless `decimals` says so; others have two. Not a
|
|
5688
|
+
* finite number → "" (a step whose value is missing is skipped anyway).
|
|
5689
|
+
*/
|
|
5690
|
+
function formatBaht(n, opts = {}) {
|
|
5691
|
+
const v = typeof n === "string" && n.trim() !== "" ? Number(n) : n;
|
|
5692
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
5693
|
+
return "";
|
|
5694
|
+
const rounded = Math.round(v * 100) / 100;
|
|
5695
|
+
const decimals = opts.decimals ?? (Number.isInteger(rounded) ? 0 : 2);
|
|
5696
|
+
const out = nf(decimals).format(rounded);
|
|
5697
|
+
// No "-0".
|
|
5698
|
+
return out === "-0" || /^-0(\.0+)?$/.test(out) ? out.slice(1) : out;
|
|
5699
|
+
}
|
|
5700
|
+
/** The month's number 1–12 from "2026-01", "2026-01-15", a Date, or 1–12; null when none. */
|
|
5701
|
+
function monthOf(m) {
|
|
5702
|
+
if (m instanceof Date)
|
|
5703
|
+
return Number.isNaN(m.getTime()) ? null : { month: m.getMonth() + 1, year: m.getFullYear() };
|
|
5704
|
+
if (typeof m === "number")
|
|
5705
|
+
return Number.isInteger(m) && m >= 1 && m <= 12 ? { month: m } : null;
|
|
5706
|
+
if (typeof m === "string") {
|
|
5707
|
+
const iso = /^(\d{4})-(\d{1,2})(?:-\d{1,2})?/.exec(m.trim());
|
|
5708
|
+
if (iso) {
|
|
5709
|
+
const month = Number(iso[2]);
|
|
5710
|
+
return month >= 1 && month <= 12 ? { month, year: Number(iso[1]) } : null;
|
|
5711
|
+
}
|
|
5712
|
+
}
|
|
5713
|
+
return null;
|
|
5714
|
+
}
|
|
5715
|
+
/** "2026-01" (or a Date, or 1) → "ม.ค."; "" when it is not a month. */
|
|
5716
|
+
function thaiMonth(m) {
|
|
5717
|
+
const x = monthOf(m);
|
|
5718
|
+
return x ? THAI_MONTHS_SHORT[x.month - 1] : "";
|
|
5719
|
+
}
|
|
5720
|
+
/**
|
|
5721
|
+
* "2026-01" → "ม.ค. 69" (the Buddhist year's last two digits), or with
|
|
5722
|
+
* `{ year: "full" }` "ม.ค. 2569"; just the month when no year is known.
|
|
5723
|
+
*/
|
|
5724
|
+
function thaiMonthYear(m, opts = {}) {
|
|
5725
|
+
const x = monthOf(m);
|
|
5726
|
+
if (!x)
|
|
5727
|
+
return "";
|
|
5728
|
+
const name = THAI_MONTHS_SHORT[x.month - 1];
|
|
5729
|
+
if (x.year === undefined)
|
|
5730
|
+
return name;
|
|
5731
|
+
const be = x.year + 543;
|
|
5732
|
+
return `${name} ${opts.year === "full" ? be : String(be).slice(-2)}`;
|
|
5733
|
+
}
|
|
5734
|
+
|
|
4804
5735
|
exports.API_EVENT = API_EVENT;
|
|
4805
5736
|
exports.GUIDE_EVENT_PREFIX = GUIDE_EVENT_PREFIX;
|
|
4806
5737
|
exports.GUIDE_LAYER_ATTRIBUTE = GUIDE_LAYER_ATTRIBUTE;
|
|
4807
5738
|
exports.GuideErrorBoundary = GuideErrorBoundary;
|
|
4808
5739
|
exports.GuideProgressConflict = GuideProgressConflict;
|
|
4809
5740
|
exports.GuideRuntime = GuideRuntime;
|
|
5741
|
+
exports.SectionGuideButton = SectionGuideButton;
|
|
5742
|
+
exports.THAI_MONTHS_SHORT = THAI_MONTHS_SHORT;
|
|
4810
5743
|
exports.apiCallDetail = apiCallDetail;
|
|
4811
5744
|
exports.emitGuideEvent = emitGuideEvent;
|
|
4812
5745
|
exports.fillRoute = fillRoute;
|
|
5746
|
+
exports.formatBaht = formatBaht;
|
|
4813
5747
|
exports.guideAnchor = guideAnchor;
|
|
4814
5748
|
exports.guideMenuItems = guideMenuItems;
|
|
4815
5749
|
exports.guideMenuShown = guideMenuShown;
|
|
4816
5750
|
exports.loadGuides = loadGuides;
|
|
4817
5751
|
exports.matchApiPath = matchApiPath;
|
|
4818
5752
|
exports.matchRoute = matchRoute;
|
|
5753
|
+
exports.openFlow = openFlow;
|
|
5754
|
+
exports.openFlowsPanel = openFlowsPanel;
|
|
4819
5755
|
exports.openGuidePanel = openGuidePanel;
|
|
4820
5756
|
exports.openPageGuide = openPageGuide;
|
|
4821
5757
|
exports.openPagesPanel = openPagesPanel;
|
|
5758
|
+
exports.openSectionGuide = openSectionGuide;
|
|
4822
5759
|
exports.reportApiCall = reportApiCall;
|
|
4823
5760
|
exports.reportApiEvent = reportApiEvent;
|
|
5761
|
+
exports.setSectionGuideValues = setSectionGuideValues;
|
|
4824
5762
|
exports.storeGuides = storeGuides;
|
|
5763
|
+
exports.thaiMonth = thaiMonth;
|
|
5764
|
+
exports.thaiMonthYear = thaiMonthYear;
|
|
4825
5765
|
exports.useGuideEntry = useGuideEntry;
|
|
4826
5766
|
exports.usePageGuideEntry = usePageGuideEntry;
|
|
4827
5767
|
exports.usePageGuides = usePageGuides;
|
|
4828
|
-
|
|
5768
|
+
exports.useSectionGuideValues = useSectionGuideValues;
|
|
5769
|
+
//# sourceMappingURL=format-BVoVc7Yk.cjs.map
|