@eintrek/erp-shell 0.1.74 → 0.1.75
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 +25 -0
- package/dist/chunks/{guide-runtime-YS2nUhwS.cjs → format-CVazK0hP.cjs} +523 -57
- package/dist/chunks/format-CVazK0hP.cjs.map +1 -0
- package/dist/chunks/{guide-runtime-Dl8V1iy9.esm.js → format-DCTuLpBU.esm.js} +517 -59
- package/dist/chunks/format-DCTuLpBU.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 +37 -4
- package/dist/guide/machine.d.ts +42 -5
- package/dist/guide/pages.d.ts +24 -1
- package/dist/guide/progress-sync.d.ts +3 -2
- package/dist/guide/progress.d.ts +6 -4
- package/dist/guide/section-button.d.ts +12 -0
- package/dist/guide/types.d.ts +47 -0
- package/dist/guide-core.cjs +32 -24
- package/dist/guide-core.cjs.map +1 -1
- package/dist/guide-core.esm.js +1 -1
- package/dist/help/help-button.d.ts +2 -1
- package/dist/index.cjs +46 -35
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +4 -2
- package/dist/index.esm.js +10 -7
- 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,27 @@ 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: [] };
|
|
361
361
|
let pages = NO_PAGES;
|
|
362
362
|
let openPageImpl = null;
|
|
363
363
|
let openPagesImpl = null;
|
|
364
|
-
|
|
365
|
-
|
|
364
|
+
let openSectionImpl = null;
|
|
365
|
+
const sectionsKey = (x) => (x ?? []).map(s => `${s.key}\u0000${s.title}`).join("\u0001");
|
|
366
|
+
/** Called by the host: this page's guide, the listed count, the sections on screen, and how to open them. */
|
|
367
|
+
function publishPageGuides(next, openPage, openPages, openSection = null) {
|
|
366
368
|
const n = next ?? NO_PAGES;
|
|
367
369
|
openPageImpl = openPage;
|
|
368
370
|
openPagesImpl = openPages;
|
|
369
|
-
|
|
371
|
+
openSectionImpl = openSection;
|
|
372
|
+
const same = n.listed === pages.listed &&
|
|
373
|
+
(n.page?.title ?? null) === (pages.page?.title ?? null) &&
|
|
374
|
+
sectionsKey(n.sections) === sectionsKey(pages.sections);
|
|
370
375
|
if (!same) {
|
|
371
|
-
|
|
376
|
+
const sections = (n.sections ?? []).map(x => ({ key: x.key, title: x.title }));
|
|
377
|
+
pages =
|
|
378
|
+
n.page || n.listed || sections.length
|
|
379
|
+
? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections }
|
|
380
|
+
: NO_PAGES;
|
|
372
381
|
emit();
|
|
373
382
|
}
|
|
374
383
|
}
|
|
@@ -376,6 +385,8 @@ function guideMenuItems(e, p = NO_PAGES) {
|
|
|
376
385
|
const items = [];
|
|
377
386
|
if (p.page)
|
|
378
387
|
items.push("page");
|
|
388
|
+
if ((p.sections ?? []).length > 0)
|
|
389
|
+
items.push("sections");
|
|
379
390
|
if (e && e.total > 0)
|
|
380
391
|
items.push("missions");
|
|
381
392
|
if (p.listed > 0)
|
|
@@ -425,6 +436,79 @@ function usePageGuideEntry() {
|
|
|
425
436
|
const title = p.page?.title ?? null;
|
|
426
437
|
return React.useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
|
|
427
438
|
}
|
|
439
|
+
/**
|
|
440
|
+
* Opens a section guide ("อธิบายส่วนนี้"), when it is on screen here for this
|
|
441
|
+
* person. The values it shows are the ones the app last rendered
|
|
442
|
+
* (`useSectionGuideValues`).
|
|
443
|
+
*/
|
|
444
|
+
function openSectionGuide(key) {
|
|
445
|
+
openSectionImpl?.(key);
|
|
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 React.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
|
+
React.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
|
+
React.useEffect(() => {
|
|
507
|
+
if (values === undefined)
|
|
508
|
+
return;
|
|
509
|
+
return () => setSectionGuideValues(key, undefined);
|
|
510
|
+
}, [key, values === undefined]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
511
|
+
}
|
|
428
512
|
|
|
429
513
|
/** The synthetic mission key of a page guide: `page:<key>`. */
|
|
430
514
|
const PAGE_PREFIX = "page:";
|
|
@@ -603,6 +687,140 @@ function currentPage(suite, where, ctx) {
|
|
|
603
687
|
function listedPages(suite, ctx) {
|
|
604
688
|
return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
|
|
605
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
|
+
}
|
|
606
824
|
|
|
607
825
|
/* ------------------------------------------------------------------ */
|
|
608
826
|
/* Copy */
|
|
@@ -617,6 +835,7 @@ const COPY = {
|
|
|
617
835
|
hiddenToast: "ซ่อนแล้ว",
|
|
618
836
|
aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
|
|
619
837
|
pageDone: "จบคู่มือหน้านี้",
|
|
838
|
+
sectionDone: "จบคำอธิบายส่วนนี้",
|
|
620
839
|
bridgeTitle: "กดปุ่มเมนู",
|
|
621
840
|
bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
|
|
622
841
|
};
|
|
@@ -629,8 +848,10 @@ function initialGuideState(audience, progress, pages) {
|
|
|
629
848
|
pageAudience: pages?.pageAudience ?? "none",
|
|
630
849
|
pagesMode: pages?.pagesMode ?? "off",
|
|
631
850
|
pages: pages?.pages ?? {},
|
|
851
|
+
sections: pages?.sections ?? {},
|
|
632
852
|
pageRun: null,
|
|
633
853
|
panelSection: "all",
|
|
854
|
+
autoPassed: null,
|
|
634
855
|
audience,
|
|
635
856
|
progress,
|
|
636
857
|
screen: { name: "idle" },
|
|
@@ -650,6 +871,10 @@ function findMission(suite, key) {
|
|
|
650
871
|
const page = findPage(suite, pageKeyOf(key));
|
|
651
872
|
return page ? pageMission(page) : undefined;
|
|
652
873
|
}
|
|
874
|
+
if (isSectionMissionKey(key)) {
|
|
875
|
+
const section = findSection(suite, sectionKeyOf(key));
|
|
876
|
+
return section ? sectionMission(section) : undefined;
|
|
877
|
+
}
|
|
653
878
|
return suite.missions.find(m => m.key === key);
|
|
654
879
|
}
|
|
655
880
|
/**
|
|
@@ -687,6 +912,7 @@ function guideContext(env, data, params = {}) {
|
|
|
687
912
|
facts: env.facts,
|
|
688
913
|
can: env.can,
|
|
689
914
|
visible: (anchor) => env.isVisible(anchor),
|
|
915
|
+
values: env.values ?? {},
|
|
690
916
|
};
|
|
691
917
|
}
|
|
692
918
|
function resolveText(t, ctx) {
|
|
@@ -1000,7 +1226,7 @@ function announceStep(d, suite, env) {
|
|
|
1000
1226
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1001
1227
|
const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
|
|
1002
1228
|
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);
|
|
1229
|
+
const pos = stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
|
|
1004
1230
|
d.effects.push({
|
|
1005
1231
|
type: "announce",
|
|
1006
1232
|
text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
|
|
@@ -1011,7 +1237,7 @@ function announceStep(d, suite, env) {
|
|
|
1011
1237
|
* whose `requires` they lack or whose `skipIf` holds is left out (the step on
|
|
1012
1238
|
* screen always counts).
|
|
1013
1239
|
*/
|
|
1014
|
-
function stepPosition(mission, index, env, context) {
|
|
1240
|
+
function stepPosition(mission, index, env, context, autoPassed = []) {
|
|
1015
1241
|
let no = 0;
|
|
1016
1242
|
let total = 0;
|
|
1017
1243
|
mission.steps.forEach((step, i) => {
|
|
@@ -1021,6 +1247,12 @@ function stepPosition(mission, index, env, context) {
|
|
|
1021
1247
|
const ctx = guideContext(env, context, routeParamsFor(step, env));
|
|
1022
1248
|
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
1023
1249
|
return;
|
|
1250
|
+
// A step that passed by itself (shell ≥ 0.1.75): before this one, as
|
|
1251
|
+
// recorded; after it, one whose outcome already holds now.
|
|
1252
|
+
if (i < index && autoPassed.includes(i))
|
|
1253
|
+
return;
|
|
1254
|
+
if (i > index && wouldAutoPass(step, env))
|
|
1255
|
+
return;
|
|
1024
1256
|
}
|
|
1025
1257
|
total++;
|
|
1026
1258
|
if (i <= index)
|
|
@@ -1028,6 +1260,17 @@ function stepPosition(mission, index, env, context) {
|
|
|
1028
1260
|
});
|
|
1029
1261
|
return { no: Math.max(no, 1), total: Math.max(total, 1) };
|
|
1030
1262
|
}
|
|
1263
|
+
/** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
|
|
1264
|
+
function wouldAutoPass(step, env) {
|
|
1265
|
+
const kind = stepKind(step);
|
|
1266
|
+
if (step.autoPass === false || kind === "input" || kind === "view")
|
|
1267
|
+
return false;
|
|
1268
|
+
return safe(() => stateTriggerHolds(step, env) !== null, false);
|
|
1269
|
+
}
|
|
1270
|
+
/** The steps of this mission that passed by themselves in this run. */
|
|
1271
|
+
function autoPassedOf(state, mission) {
|
|
1272
|
+
return state.autoPassed?.mission === mission ? state.autoPassed.steps : [];
|
|
1273
|
+
}
|
|
1031
1274
|
/** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
|
|
1032
1275
|
function plainTitle(t) {
|
|
1033
1276
|
return t.replace(/\[([^\]]+)\]/g, "$1");
|
|
@@ -1040,6 +1283,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1040
1283
|
const steps = mission.steps;
|
|
1041
1284
|
let i = Math.max(0, index);
|
|
1042
1285
|
let firstPass = true;
|
|
1286
|
+
if (d.state.autoPassed?.mission !== mission.key)
|
|
1287
|
+
d.state = { ...d.state, autoPassed: { mission: mission.key, steps: [] } };
|
|
1043
1288
|
while (i < steps.length) {
|
|
1044
1289
|
const step = steps[i];
|
|
1045
1290
|
const params = routeParamsFor(step, env);
|
|
@@ -1061,6 +1306,9 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1061
1306
|
const held = stateTriggerHolds(step, env);
|
|
1062
1307
|
if (held) {
|
|
1063
1308
|
captureInto(d, step, held, ctx);
|
|
1309
|
+
const ap = d.state.autoPassed;
|
|
1310
|
+
if (!ap.steps.includes(i))
|
|
1311
|
+
d.state = { ...d.state, autoPassed: { mission: ap.mission, steps: [...ap.steps, i] } };
|
|
1064
1312
|
i++;
|
|
1065
1313
|
continue;
|
|
1066
1314
|
}
|
|
@@ -1074,9 +1322,10 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1074
1322
|
const step = steps[i];
|
|
1075
1323
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1076
1324
|
const page = pageKeyOf(mission.key);
|
|
1325
|
+
const section = sectionKeyOf(mission.key);
|
|
1077
1326
|
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);
|
|
1327
|
+
// A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
|
|
1328
|
+
const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
1080
1329
|
const bridge = !away && needsBridge(step, env, ctx);
|
|
1081
1330
|
const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
|
|
1082
1331
|
setScreen(d, {
|
|
@@ -1094,6 +1343,7 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1094
1343
|
status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
|
|
1095
1344
|
replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
|
|
1096
1345
|
...(page ? { page } : {}),
|
|
1346
|
+
...(section ? { section } : {}),
|
|
1097
1347
|
});
|
|
1098
1348
|
if (!opts.replay) {
|
|
1099
1349
|
const cp = checkpointAtOrBefore(steps, i);
|
|
@@ -1258,9 +1508,9 @@ function advance(d, suite, env, capture) {
|
|
|
1258
1508
|
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
1259
1509
|
}
|
|
1260
1510
|
function finishMission(d, suite, env, mission, replay) {
|
|
1261
|
-
if (isPageMissionKey(mission.key)) {
|
|
1511
|
+
if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
|
|
1262
1512
|
endPage(d, suite, "completed");
|
|
1263
|
-
d.effects.push({ type: "toast", text: COPY.pageDone });
|
|
1513
|
+
d.effects.push({ type: "toast", text: isSectionMissionKey(mission.key) ? COPY.sectionDone : COPY.pageDone });
|
|
1264
1514
|
return;
|
|
1265
1515
|
}
|
|
1266
1516
|
if (replay) {
|
|
@@ -1364,7 +1614,8 @@ function startMission(d, suite, env, key, replay) {
|
|
|
1364
1614
|
// A mission already done is only ever replayed; its done status stays.
|
|
1365
1615
|
const isReplay = replay || entry?.status === "done";
|
|
1366
1616
|
const before = d.state.progress;
|
|
1367
|
-
|
|
1617
|
+
// A new run: what passed by itself is counted afresh.
|
|
1618
|
+
d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
|
|
1368
1619
|
if (d.state.progress.welcome === "not_seen")
|
|
1369
1620
|
withProgress(d, { welcome: "started" });
|
|
1370
1621
|
if (!isReplay && d.state.progress.mission_key !== key)
|
|
@@ -1523,14 +1774,72 @@ function recordPage(d, suite, key, status) {
|
|
|
1523
1774
|
d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
|
|
1524
1775
|
d.effects.push({ type: "savePage", key, version: page.version, status });
|
|
1525
1776
|
}
|
|
1526
|
-
/** The
|
|
1777
|
+
/** The section guide's row says this person has ended this version of it. */
|
|
1778
|
+
function sectionRecorded(state, section) {
|
|
1779
|
+
const row = state.sections?.[section.key];
|
|
1780
|
+
return !!row && row.version >= section.version;
|
|
1781
|
+
}
|
|
1782
|
+
/** Write a section guide's row (once per section and version). */
|
|
1783
|
+
function recordSection(d, suite, key, status) {
|
|
1784
|
+
const section = findSection(suite, key);
|
|
1785
|
+
if (!section || sectionRecorded(d.state, section))
|
|
1786
|
+
return;
|
|
1787
|
+
const prev = d.state.sections?.[key];
|
|
1788
|
+
d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
|
|
1789
|
+
d.effects.push({ type: "savePage", key, version: section.version, status, kind: "section" });
|
|
1790
|
+
}
|
|
1791
|
+
/** The page or section guide on screen, or null. */
|
|
1792
|
+
function readOf(s) {
|
|
1793
|
+
if (s.name !== "step")
|
|
1794
|
+
return null;
|
|
1795
|
+
if (s.page)
|
|
1796
|
+
return { kind: "page", key: s.page };
|
|
1797
|
+
if (s.section)
|
|
1798
|
+
return { kind: "section", key: s.section };
|
|
1799
|
+
return null;
|
|
1800
|
+
}
|
|
1801
|
+
/** Whether a page or section guide still applies where the person is. */
|
|
1802
|
+
function readStillHere(suite, r, env) {
|
|
1803
|
+
if (r.kind === "page")
|
|
1804
|
+
return pageStillHere(suite, r.key, env);
|
|
1805
|
+
const section = findSection(suite, r.key);
|
|
1806
|
+
return !!section && sectionApplies(section, whereOf(env));
|
|
1807
|
+
}
|
|
1808
|
+
/** The page or section guide ends: its row written when it is due one; back to idle. */
|
|
1527
1809
|
function endPage(d, suite, how) {
|
|
1528
1810
|
const run = d.state.pageRun;
|
|
1529
|
-
if (run?.write)
|
|
1530
|
-
|
|
1811
|
+
if (run?.write) {
|
|
1812
|
+
if (run.kind === "section")
|
|
1813
|
+
recordSection(d, suite, run.key, how);
|
|
1814
|
+
else
|
|
1815
|
+
recordPage(d, suite, run.key, how);
|
|
1816
|
+
}
|
|
1531
1817
|
d.state = { ...d.state, pageRun: null, replayPage: null };
|
|
1532
1818
|
setScreen(d, { name: "idle" });
|
|
1533
1819
|
}
|
|
1820
|
+
/** The section guides on screen here that this person has (the "?" entries, the buttons). */
|
|
1821
|
+
function sectionsAvailable(suite, env) {
|
|
1822
|
+
return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
|
|
1823
|
+
}
|
|
1824
|
+
function startSection(d, suite, env, key, write) {
|
|
1825
|
+
const section = findSection(suite, key);
|
|
1826
|
+
if (!section)
|
|
1827
|
+
return;
|
|
1828
|
+
const mission = sectionMission(section);
|
|
1829
|
+
d.state = {
|
|
1830
|
+
...d.state,
|
|
1831
|
+
panelOpen: false,
|
|
1832
|
+
justDone: null,
|
|
1833
|
+
pageRun: { key, write, kind: "section" },
|
|
1834
|
+
autoPassed: null,
|
|
1835
|
+
replayPage: {
|
|
1836
|
+
mission: mission.key,
|
|
1837
|
+
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
1838
|
+
dialog: safe(() => env.dialogOpen?.() === true, false),
|
|
1839
|
+
},
|
|
1840
|
+
};
|
|
1841
|
+
enterStep(d, suite, env, mission, 0, { replay: true });
|
|
1842
|
+
}
|
|
1534
1843
|
function startPage(d, suite, env, key, write) {
|
|
1535
1844
|
const page = findPage(suite, key);
|
|
1536
1845
|
if (!page)
|
|
@@ -1540,7 +1849,8 @@ function startPage(d, suite, env, key, write) {
|
|
|
1540
1849
|
...d.state,
|
|
1541
1850
|
panelOpen: false,
|
|
1542
1851
|
justDone: null,
|
|
1543
|
-
pageRun: { key, write },
|
|
1852
|
+
pageRun: { key, write, kind: "page" },
|
|
1853
|
+
autoPassed: null,
|
|
1544
1854
|
// What is open now is the person's: ending the guide leaves it alone.
|
|
1545
1855
|
replayPage: {
|
|
1546
1856
|
mission: mission.key,
|
|
@@ -1640,7 +1950,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1640
1950
|
break;
|
|
1641
1951
|
// A mission always wins over a page guide: a running one ends (its row
|
|
1642
1952
|
// written if due), an offer goes unrecorded.
|
|
1643
|
-
if (s
|
|
1953
|
+
if (readOf(s))
|
|
1644
1954
|
endPage(d, suite, "skipped");
|
|
1645
1955
|
else if (s.name === "pageOffer")
|
|
1646
1956
|
setScreen(d, { name: "idle" });
|
|
@@ -1661,6 +1971,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1661
1971
|
const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
|
|
1662
1972
|
const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
|
|
1663
1973
|
const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
|
|
1974
|
+
d.state = { ...d.state, autoPassed: null };
|
|
1664
1975
|
enterStep(d, suite, env, mission, index, { replay: false });
|
|
1665
1976
|
break;
|
|
1666
1977
|
}
|
|
@@ -1675,8 +1986,9 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1675
1986
|
}
|
|
1676
1987
|
if (s.name !== "step")
|
|
1677
1988
|
break;
|
|
1678
|
-
|
|
1679
|
-
|
|
1989
|
+
const read = readOf(s);
|
|
1990
|
+
if (read && !readStillHere(suite, read, env)) {
|
|
1991
|
+
// Leaving the page (or the section's root going) ends its guide, silently.
|
|
1680
1992
|
endPage(d, suite, "skipped");
|
|
1681
1993
|
break;
|
|
1682
1994
|
}
|
|
@@ -1709,7 +2021,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1709
2021
|
break;
|
|
1710
2022
|
}
|
|
1711
2023
|
}
|
|
1712
|
-
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page)) {
|
|
2024
|
+
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1713
2025
|
// The target is missing because the page is another one.
|
|
1714
2026
|
recoverOffRoute(d, suite, env, false);
|
|
1715
2027
|
break;
|
|
@@ -1825,7 +2137,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1825
2137
|
const cur = d.state.screen;
|
|
1826
2138
|
if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
|
|
1827
2139
|
break;
|
|
1828
|
-
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
|
|
2140
|
+
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page ?? cur.section)) {
|
|
1829
2141
|
recoverOffRoute(d, suite, env, false);
|
|
1830
2142
|
break;
|
|
1831
2143
|
}
|
|
@@ -1846,7 +2158,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1846
2158
|
if (!mission)
|
|
1847
2159
|
break;
|
|
1848
2160
|
const step = mission.steps[s.index];
|
|
1849
|
-
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
2161
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1850
2162
|
recoverOffRoute(d, suite, env, false);
|
|
1851
2163
|
break;
|
|
1852
2164
|
}
|
|
@@ -1863,7 +2175,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1863
2175
|
advance(d, suite, env);
|
|
1864
2176
|
}
|
|
1865
2177
|
else if (policy === "abort") {
|
|
1866
|
-
if (s
|
|
2178
|
+
if (readOf(s))
|
|
1867
2179
|
endPage(d, suite, "skipped");
|
|
1868
2180
|
else
|
|
1869
2181
|
exitMission(d);
|
|
@@ -1889,7 +2201,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1889
2201
|
if (s.name !== "step" || s.phase !== "missing")
|
|
1890
2202
|
break;
|
|
1891
2203
|
const mission = findMission(suite, s.mission);
|
|
1892
|
-
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
2204
|
+
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1893
2205
|
// Polling again cannot help on another page: go back to the step's.
|
|
1894
2206
|
recoverOffRoute(d, suite, env, true);
|
|
1895
2207
|
break;
|
|
@@ -1905,7 +2217,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1905
2217
|
case "exit":
|
|
1906
2218
|
if (s.name !== "step")
|
|
1907
2219
|
break;
|
|
1908
|
-
if (s
|
|
2220
|
+
if (readOf(s)) {
|
|
1909
2221
|
// ✕, Escape or ปิดคู่มือ: ends silently.
|
|
1910
2222
|
pushAbort(d, suite, s);
|
|
1911
2223
|
endPage(d, suite, "skipped");
|
|
@@ -2064,7 +2376,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2064
2376
|
break;
|
|
2065
2377
|
}
|
|
2066
2378
|
case "back": {
|
|
2067
|
-
if (s.name !== "step" || !s
|
|
2379
|
+
if (s.name !== "step" || !readOf(s) || s.phase !== "ready" || s.confirmExit)
|
|
2068
2380
|
break;
|
|
2069
2381
|
const mission = findMission(suite, s.mission);
|
|
2070
2382
|
if (!mission)
|
|
@@ -2079,12 +2391,32 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2079
2391
|
break;
|
|
2080
2392
|
}
|
|
2081
2393
|
case "pageRow": {
|
|
2394
|
+
if (action.kind === "section") {
|
|
2395
|
+
const cur = d.state.sections?.[action.key];
|
|
2396
|
+
if (cur && cur.version > action.row.version)
|
|
2397
|
+
break;
|
|
2398
|
+
d.state = { ...d.state, sections: { ...d.state.sections, [action.key]: action.row } };
|
|
2399
|
+
break;
|
|
2400
|
+
}
|
|
2082
2401
|
const cur = d.state.pages[action.key];
|
|
2083
2402
|
if (cur && cur.version > action.row.version)
|
|
2084
2403
|
break;
|
|
2085
2404
|
d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
|
|
2086
2405
|
break;
|
|
2087
2406
|
}
|
|
2407
|
+
case "startSection": {
|
|
2408
|
+
// Never offered: only on request. Never over a mission step, a
|
|
2409
|
+
// welcome, a gate or a celebration; only on screen, only theirs.
|
|
2410
|
+
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
2411
|
+
break;
|
|
2412
|
+
if (s.name !== "idle" && s.name !== "pageOffer")
|
|
2413
|
+
break;
|
|
2414
|
+
if (!sectionsAvailable(suite, env).includes(action.section))
|
|
2415
|
+
break;
|
|
2416
|
+
const section = findSection(suite, action.section);
|
|
2417
|
+
startSection(d, suite, env, section.key, !sectionRecorded(state, section));
|
|
2418
|
+
break;
|
|
2419
|
+
}
|
|
2088
2420
|
case "idleCelebrate": {
|
|
2089
2421
|
if (s.name !== "idle" || state.panelOpen)
|
|
2090
2422
|
break;
|
|
@@ -2213,6 +2545,7 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
2213
2545
|
? data.page_audience
|
|
2214
2546
|
: "none";
|
|
2215
2547
|
const pages = parsePageRows(rows, suite.app);
|
|
2548
|
+
const sections = parsePageRows(rows, suite.app, "section");
|
|
2216
2549
|
let row = null;
|
|
2217
2550
|
for (const r of rows) {
|
|
2218
2551
|
if (isRecord(r) && r.guide_key === suite.key) {
|
|
@@ -2233,15 +2566,15 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
2233
2566
|
progress = row;
|
|
2234
2567
|
newerOnServer = row.guide_version > suite.version;
|
|
2235
2568
|
}
|
|
2236
|
-
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages };
|
|
2569
|
+
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
|
|
2237
2570
|
}
|
|
2238
|
-
/** The row key of a page guide
|
|
2239
|
-
function pageRowKey(app, key) {
|
|
2240
|
-
return `${app}
|
|
2571
|
+
/** The row key of a page guide, `<app>.page.<key>`, or a section guide, `<app>.section.<key>`. */
|
|
2572
|
+
function pageRowKey(app, key, kind = "page") {
|
|
2573
|
+
return `${app}.${kind}.${key}`;
|
|
2241
2574
|
}
|
|
2242
|
-
/** Page rows among the list's rows, by
|
|
2243
|
-
function parsePageRows(rows, app) {
|
|
2244
|
-
const prefix = pageRowKey(app, "");
|
|
2575
|
+
/** Page (or section) rows among the list's rows, by key. Off-shape rows are left out. */
|
|
2576
|
+
function parsePageRows(rows, app, kind = "page") {
|
|
2577
|
+
const prefix = pageRowKey(app, "", kind);
|
|
2245
2578
|
const out = {};
|
|
2246
2579
|
for (const r of rows) {
|
|
2247
2580
|
if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
|
|
@@ -2491,14 +2824,15 @@ async function storeGuides(adapter, suite, progress) {
|
|
|
2491
2824
|
return null;
|
|
2492
2825
|
}
|
|
2493
2826
|
/**
|
|
2494
|
-
* Writes a page guide's row (`<app>.page.<key>`)
|
|
2827
|
+
* Writes a page guide's row (`<app>.page.<key>`), or a section guide's
|
|
2828
|
+
* (`<app>.section.<key>`, kind "section"), once: the minimal body,
|
|
2495
2829
|
* with the same 409 handling as the tour's row. Another tab that already
|
|
2496
2830
|
* recorded this version wins (its row is returned, nothing is sent again);
|
|
2497
2831
|
* an older version's row is replaced with its rev; a newer one stops. Returns
|
|
2498
2832
|
* the stored row, or null when it could not be saved.
|
|
2499
2833
|
*/
|
|
2500
|
-
async function storePageRow(adapter, app, key, version, status, rev) {
|
|
2501
|
-
const guideKey = pageRowKey(app, key);
|
|
2834
|
+
async function storePageRow(adapter, app, key, version, status, rev, kind = "page") {
|
|
2835
|
+
const guideKey = pageRowKey(app, key, kind);
|
|
2502
2836
|
let at = rev;
|
|
2503
2837
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2504
2838
|
let res;
|
|
@@ -3587,10 +3921,10 @@ function writeMirror(orgId, suite, p) {
|
|
|
3587
3921
|
}
|
|
3588
3922
|
}
|
|
3589
3923
|
/** Page rows written in this tab, kept for a remount within the GET's cache time. */
|
|
3590
|
-
const pagesMirrorKey = (orgId, suite) => `erp-guide
|
|
3591
|
-
function readPagesMirror(orgId, suite) {
|
|
3924
|
+
const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
|
|
3925
|
+
function readPagesMirror(orgId, suite, kind = "page") {
|
|
3592
3926
|
try {
|
|
3593
|
-
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite));
|
|
3927
|
+
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
|
|
3594
3928
|
const parsed = raw ? JSON.parse(raw) : {};
|
|
3595
3929
|
const out = {};
|
|
3596
3930
|
for (const [k, v] of Object.entries(parsed ?? {})) {
|
|
@@ -3603,9 +3937,9 @@ function readPagesMirror(orgId, suite) {
|
|
|
3603
3937
|
return {};
|
|
3604
3938
|
}
|
|
3605
3939
|
}
|
|
3606
|
-
function writePagesMirror(orgId, suite, pages) {
|
|
3940
|
+
function writePagesMirror(orgId, suite, pages, kind = "page") {
|
|
3607
3941
|
try {
|
|
3608
|
-
sessionStorage.setItem(pagesMirrorKey(orgId, suite), JSON.stringify(pages));
|
|
3942
|
+
sessionStorage.setItem(pagesMirrorKey(orgId, suite, kind), JSON.stringify(pages));
|
|
3609
3943
|
}
|
|
3610
3944
|
catch {
|
|
3611
3945
|
/* private mode */
|
|
@@ -3733,7 +4067,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3733
4067
|
const [answer, setAnswer] = React.useState(null);
|
|
3734
4068
|
const missionsReady = config.ready !== false;
|
|
3735
4069
|
const pagesReady = config.pagesReady ?? missionsReady;
|
|
3736
|
-
const hasPages = React.useMemo(() => validPages(suite).length > 0, [suite]);
|
|
4070
|
+
const hasPages = React.useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0, [suite]);
|
|
4071
|
+
const hasSections = React.useMemo(() => validSections(suite).length > 0, [suite]);
|
|
3737
4072
|
const enabled = primary && (missionsReady || (hasPages && pagesReady));
|
|
3738
4073
|
const loaded = React.useRef(false);
|
|
3739
4074
|
React.useEffect(() => {
|
|
@@ -3789,6 +4124,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3789
4124
|
pageAudience: pagesOn ? answer.pageAudience : "none",
|
|
3790
4125
|
pagesMode: answer.pagesMode,
|
|
3791
4126
|
pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
|
|
4127
|
+
sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
|
|
3792
4128
|
});
|
|
3793
4129
|
stateRef.current = s;
|
|
3794
4130
|
setState(s);
|
|
@@ -3797,8 +4133,12 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3797
4133
|
const pageRef = React.useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
|
|
3798
4134
|
pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
|
|
3799
4135
|
const targetElRef = React.useRef(null);
|
|
4136
|
+
// The section guide being opened (before its first step is on screen).
|
|
4137
|
+
const openingSection = React.useRef(null);
|
|
3800
4138
|
const env = React.useCallback(() => {
|
|
3801
4139
|
const pg = pageRef.current;
|
|
4140
|
+
const sc = stateRef.current?.screen;
|
|
4141
|
+
const sectionKey = (sc?.name === "step" ? sc.section : undefined) ?? openingSection.current;
|
|
3802
4142
|
return {
|
|
3803
4143
|
code,
|
|
3804
4144
|
pathname: pg.pathname,
|
|
@@ -3816,6 +4156,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3816
4156
|
sidebarOpen: pg.sidebarOpen,
|
|
3817
4157
|
targetValue: () => fieldValue(targetElRef.current),
|
|
3818
4158
|
dialogOpen: () => appDialogOpen(),
|
|
4159
|
+
values: getSectionGuideValues(sectionKey),
|
|
3819
4160
|
};
|
|
3820
4161
|
}, [code]);
|
|
3821
4162
|
/* ---------------- effects: save, announce, toast, navigate ---------------- */
|
|
@@ -3891,13 +4232,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3891
4232
|
}
|
|
3892
4233
|
else if (e.type === "savePage") {
|
|
3893
4234
|
// Once per page and version; the machine already holds it as written.
|
|
3894
|
-
|
|
3895
|
-
|
|
3896
|
-
|
|
3897
|
-
|
|
4235
|
+
const kind = e.kind ?? "page";
|
|
4236
|
+
const rows = stateRef.current ? (kind === "section" ? stateRef.current.sections : stateRef.current.pages) : undefined;
|
|
4237
|
+
if (rows)
|
|
4238
|
+
writePagesMirror(organizationId, suite, rows, kind);
|
|
4239
|
+
const rev = rows?.[e.key]?.rev ?? 0;
|
|
4240
|
+
void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev, kind).then(row => {
|
|
3898
4241
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
3899
4242
|
if (row)
|
|
3900
|
-
dispatchRef.current({ type: "pageRow", key: e.key, row });
|
|
4243
|
+
dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
|
|
3901
4244
|
});
|
|
3902
4245
|
}
|
|
3903
4246
|
}
|
|
@@ -3987,7 +4330,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3987
4330
|
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
3988
4331
|
// A page guide's step runs while page guides are live; a mission's while
|
|
3989
4332
|
// the missions are. The offer card is watched the same way (left: ignored).
|
|
3990
|
-
const inStep = screen.name === "step" && (screen.page ? pagesLive : missionsLive);
|
|
4333
|
+
const inStep = screen.name === "step" && (screen.page || screen.section ? pagesLive : missionsLive);
|
|
3991
4334
|
const offerUp = pagesLive && screen.name === "pageOffer";
|
|
3992
4335
|
const watching = inStep || offerUp;
|
|
3993
4336
|
React.useEffect(() => {
|
|
@@ -4035,6 +4378,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4035
4378
|
const step = shownStep(raw, screen.nothingToSave);
|
|
4036
4379
|
return { mission, step, raw };
|
|
4037
4380
|
}, [state, screen, suite]);
|
|
4381
|
+
const valuesVersion = useSectionValuesVersion();
|
|
4038
4382
|
const stepCtx = React.useMemo(() => {
|
|
4039
4383
|
if (!state)
|
|
4040
4384
|
return null;
|
|
@@ -4043,7 +4387,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4043
4387
|
return guideContext(e, state.progress.context, params);
|
|
4044
4388
|
// pathname/search/factsKey make it current.
|
|
4045
4389
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4046
|
-
}, [state, stepInfo, env, code, pathname, search, factsKey]);
|
|
4390
|
+
}, [state, stepInfo, env, code, pathname, search, factsKey, valuesVersion]);
|
|
4047
4391
|
const target = stepInfo && stepCtx && screen.name === "step"
|
|
4048
4392
|
? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
|
|
4049
4393
|
: null;
|
|
@@ -4551,7 +4895,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4551
4895
|
/* ---------------- page guides: this page's, the offer, the list ---------------- */
|
|
4552
4896
|
// `when` anchors (LIFF's sub-screens) come and go without a route change:
|
|
4553
4897
|
// look again when the page changes.
|
|
4554
|
-
const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when), [suite]);
|
|
4898
|
+
const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
|
|
4555
4899
|
const [domTick, setDomTick] = React.useState(0);
|
|
4556
4900
|
React.useEffect(() => {
|
|
4557
4901
|
if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
|
|
@@ -4585,6 +4929,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4585
4929
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4586
4930
|
[pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
|
|
4587
4931
|
const listed = React.useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
|
|
4932
|
+
const sectionsNow = React.useMemo(() => pagesLive && hasState && hasSections
|
|
4933
|
+
? sectionsAvailable(suite, env()).map(k => ({ key: k, title: findSection(suite, k)?.title ?? "" }))
|
|
4934
|
+
: [],
|
|
4935
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4936
|
+
[pagesLive, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
|
|
4937
|
+
const sectionsSig = sectionsNow.map(x => x.key).join(",");
|
|
4938
|
+
const openSection = React.useCallback((key) => {
|
|
4939
|
+
openingSection.current = key;
|
|
4940
|
+
try {
|
|
4941
|
+
dispatchRef.current({ type: "startSection", section: key });
|
|
4942
|
+
}
|
|
4943
|
+
finally {
|
|
4944
|
+
openingSection.current = null;
|
|
4945
|
+
}
|
|
4946
|
+
}, []);
|
|
4588
4947
|
const openPageHere = React.useCallback(() => {
|
|
4589
4948
|
const p = pageHere(suite, env());
|
|
4590
4949
|
if (p)
|
|
@@ -4595,8 +4954,10 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4595
4954
|
publishPageGuides(null, null, null);
|
|
4596
4955
|
return;
|
|
4597
4956
|
}
|
|
4598
|
-
publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }));
|
|
4599
|
-
|
|
4957
|
+
publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length, sections: sectionsNow }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection);
|
|
4958
|
+
// sectionsSig stands for sectionsNow.
|
|
4959
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4960
|
+
}, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection]);
|
|
4600
4961
|
React.useEffect(() => () => publishPageGuides(null, null, null), []);
|
|
4601
4962
|
// The first-visit offer: in "offer" mode, nothing else up, the welcome
|
|
4602
4963
|
// not due here, and a moment of nothing loading on this page.
|
|
@@ -4713,12 +5074,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4713
5074
|
const missionNo = missionNumber(suite, mission.key, ctxIdle);
|
|
4714
5075
|
const kind = stepKind(step);
|
|
4715
5076
|
const bridge = screen.bridge;
|
|
4716
|
-
const pos = stepPosition(mission, screen.index, env(), state.progress.context);
|
|
5077
|
+
const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
|
|
4717
5078
|
const page = screen.page ? findPage(suite, screen.page) : undefined;
|
|
4718
|
-
const
|
|
5079
|
+
const section = screen.section ? findSection(suite, screen.section) : undefined;
|
|
5080
|
+
const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
|
|
5081
|
+
const pageBits = read
|
|
4719
5082
|
? {
|
|
4720
|
-
|
|
4721
|
-
exitLabel: "ปิดคู่มือ",
|
|
5083
|
+
...read,
|
|
4722
5084
|
showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
|
|
4723
5085
|
nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
|
|
4724
5086
|
}
|
|
@@ -4801,15 +5163,114 @@ function useFactsKey(facts) {
|
|
|
4801
5163
|
}, [facts]);
|
|
4802
5164
|
}
|
|
4803
5165
|
|
|
5166
|
+
const DEFAULT_STYLE = {
|
|
5167
|
+
display: "inline-grid",
|
|
5168
|
+
placeItems: "center",
|
|
5169
|
+
width: 22,
|
|
5170
|
+
height: 22,
|
|
5171
|
+
padding: 0,
|
|
5172
|
+
border: 0,
|
|
5173
|
+
borderRadius: 999,
|
|
5174
|
+
background: "transparent",
|
|
5175
|
+
color: "inherit",
|
|
5176
|
+
opacity: 0.6,
|
|
5177
|
+
cursor: "pointer",
|
|
5178
|
+
flex: "none",
|
|
5179
|
+
};
|
|
5180
|
+
function SectionGuideButton({ guideKey, values, className }) {
|
|
5181
|
+
useSectionGuideValues(guideKey, values);
|
|
5182
|
+
const { sections } = usePageGuides();
|
|
5183
|
+
const here = (sections ?? []).find(s => s.key === guideKey);
|
|
5184
|
+
if (!here)
|
|
5185
|
+
return null;
|
|
5186
|
+
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 => {
|
|
5187
|
+
// A header that opens or sorts on press must not hear it.
|
|
5188
|
+
e.stopPropagation();
|
|
5189
|
+
e.preventDefault();
|
|
5190
|
+
openSectionGuide(guideKey);
|
|
5191
|
+
}, 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" })] }) }));
|
|
5192
|
+
}
|
|
5193
|
+
|
|
5194
|
+
/**
|
|
5195
|
+
* Small formatters for section guides' copy (shell 0.1.75): the app passes
|
|
5196
|
+
* its own computed numbers, and the copy says them the way the page does —
|
|
5197
|
+
* "ม.ค. รับ 500,000 − จ่าย 320,000 = คงเหลือ 180,000".
|
|
5198
|
+
*
|
|
5199
|
+
* Pure: the tests run it in Node.
|
|
5200
|
+
*/
|
|
5201
|
+
const THAI_MONTHS_SHORT = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."];
|
|
5202
|
+
const grouped = new Map();
|
|
5203
|
+
function nf(decimals) {
|
|
5204
|
+
let f = grouped.get(decimals);
|
|
5205
|
+
if (!f) {
|
|
5206
|
+
f = new Intl.NumberFormat("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
5207
|
+
grouped.set(decimals, f);
|
|
5208
|
+
}
|
|
5209
|
+
return f;
|
|
5210
|
+
}
|
|
5211
|
+
/**
|
|
5212
|
+
* A baht amount with thousands separators, no currency sign: 500000 →
|
|
5213
|
+
* "500,000"; 1234.5 → "1,234.50"; -320000 → "-320,000". Whole amounts
|
|
5214
|
+
* have no decimals unless `decimals` says so; others have two. Not a
|
|
5215
|
+
* finite number → "" (a step whose value is missing is skipped anyway).
|
|
5216
|
+
*/
|
|
5217
|
+
function formatBaht(n, opts = {}) {
|
|
5218
|
+
const v = typeof n === "string" && n.trim() !== "" ? Number(n) : n;
|
|
5219
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
5220
|
+
return "";
|
|
5221
|
+
const rounded = Math.round(v * 100) / 100;
|
|
5222
|
+
const decimals = opts.decimals ?? (Number.isInteger(rounded) ? 0 : 2);
|
|
5223
|
+
const out = nf(decimals).format(rounded);
|
|
5224
|
+
// No "-0".
|
|
5225
|
+
return out === "-0" || /^-0(\.0+)?$/.test(out) ? out.slice(1) : out;
|
|
5226
|
+
}
|
|
5227
|
+
/** The month's number 1–12 from "2026-01", "2026-01-15", a Date, or 1–12; null when none. */
|
|
5228
|
+
function monthOf(m) {
|
|
5229
|
+
if (m instanceof Date)
|
|
5230
|
+
return Number.isNaN(m.getTime()) ? null : { month: m.getMonth() + 1, year: m.getFullYear() };
|
|
5231
|
+
if (typeof m === "number")
|
|
5232
|
+
return Number.isInteger(m) && m >= 1 && m <= 12 ? { month: m } : null;
|
|
5233
|
+
if (typeof m === "string") {
|
|
5234
|
+
const iso = /^(\d{4})-(\d{1,2})(?:-\d{1,2})?/.exec(m.trim());
|
|
5235
|
+
if (iso) {
|
|
5236
|
+
const month = Number(iso[2]);
|
|
5237
|
+
return month >= 1 && month <= 12 ? { month, year: Number(iso[1]) } : null;
|
|
5238
|
+
}
|
|
5239
|
+
}
|
|
5240
|
+
return null;
|
|
5241
|
+
}
|
|
5242
|
+
/** "2026-01" (or a Date, or 1) → "ม.ค."; "" when it is not a month. */
|
|
5243
|
+
function thaiMonth(m) {
|
|
5244
|
+
const x = monthOf(m);
|
|
5245
|
+
return x ? THAI_MONTHS_SHORT[x.month - 1] : "";
|
|
5246
|
+
}
|
|
5247
|
+
/**
|
|
5248
|
+
* "2026-01" → "ม.ค. 69" (the Buddhist year's last two digits), or with
|
|
5249
|
+
* `{ year: "full" }` "ม.ค. 2569"; just the month when no year is known.
|
|
5250
|
+
*/
|
|
5251
|
+
function thaiMonthYear(m, opts = {}) {
|
|
5252
|
+
const x = monthOf(m);
|
|
5253
|
+
if (!x)
|
|
5254
|
+
return "";
|
|
5255
|
+
const name = THAI_MONTHS_SHORT[x.month - 1];
|
|
5256
|
+
if (x.year === undefined)
|
|
5257
|
+
return name;
|
|
5258
|
+
const be = x.year + 543;
|
|
5259
|
+
return `${name} ${opts.year === "full" ? be : String(be).slice(-2)}`;
|
|
5260
|
+
}
|
|
5261
|
+
|
|
4804
5262
|
exports.API_EVENT = API_EVENT;
|
|
4805
5263
|
exports.GUIDE_EVENT_PREFIX = GUIDE_EVENT_PREFIX;
|
|
4806
5264
|
exports.GUIDE_LAYER_ATTRIBUTE = GUIDE_LAYER_ATTRIBUTE;
|
|
4807
5265
|
exports.GuideErrorBoundary = GuideErrorBoundary;
|
|
4808
5266
|
exports.GuideProgressConflict = GuideProgressConflict;
|
|
4809
5267
|
exports.GuideRuntime = GuideRuntime;
|
|
5268
|
+
exports.SectionGuideButton = SectionGuideButton;
|
|
5269
|
+
exports.THAI_MONTHS_SHORT = THAI_MONTHS_SHORT;
|
|
4810
5270
|
exports.apiCallDetail = apiCallDetail;
|
|
4811
5271
|
exports.emitGuideEvent = emitGuideEvent;
|
|
4812
5272
|
exports.fillRoute = fillRoute;
|
|
5273
|
+
exports.formatBaht = formatBaht;
|
|
4813
5274
|
exports.guideAnchor = guideAnchor;
|
|
4814
5275
|
exports.guideMenuItems = guideMenuItems;
|
|
4815
5276
|
exports.guideMenuShown = guideMenuShown;
|
|
@@ -4819,10 +5280,15 @@ exports.matchRoute = matchRoute;
|
|
|
4819
5280
|
exports.openGuidePanel = openGuidePanel;
|
|
4820
5281
|
exports.openPageGuide = openPageGuide;
|
|
4821
5282
|
exports.openPagesPanel = openPagesPanel;
|
|
5283
|
+
exports.openSectionGuide = openSectionGuide;
|
|
4822
5284
|
exports.reportApiCall = reportApiCall;
|
|
4823
5285
|
exports.reportApiEvent = reportApiEvent;
|
|
5286
|
+
exports.setSectionGuideValues = setSectionGuideValues;
|
|
4824
5287
|
exports.storeGuides = storeGuides;
|
|
5288
|
+
exports.thaiMonth = thaiMonth;
|
|
5289
|
+
exports.thaiMonthYear = thaiMonthYear;
|
|
4825
5290
|
exports.useGuideEntry = useGuideEntry;
|
|
4826
5291
|
exports.usePageGuideEntry = usePageGuideEntry;
|
|
4827
5292
|
exports.usePageGuides = usePageGuides;
|
|
4828
|
-
|
|
5293
|
+
exports.useSectionGuideValues = useSectionGuideValues;
|
|
5294
|
+
//# sourceMappingURL=format-CVazK0hP.cjs.map
|