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