@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
|
@@ -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,27 @@ 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: [] };
|
|
341
341
|
let pages = NO_PAGES;
|
|
342
342
|
let openPageImpl = null;
|
|
343
343
|
let openPagesImpl = null;
|
|
344
|
-
|
|
345
|
-
|
|
344
|
+
let openSectionImpl = null;
|
|
345
|
+
const sectionsKey = (x) => (x ?? []).map(s => `${s.key}\u0000${s.title}`).join("\u0001");
|
|
346
|
+
/** Called by the host: this page's guide, the listed count, the sections on screen, and how to open them. */
|
|
347
|
+
function publishPageGuides(next, openPage, openPages, openSection = null) {
|
|
346
348
|
const n = next ?? NO_PAGES;
|
|
347
349
|
openPageImpl = openPage;
|
|
348
350
|
openPagesImpl = openPages;
|
|
349
|
-
|
|
351
|
+
openSectionImpl = openSection;
|
|
352
|
+
const same = n.listed === pages.listed &&
|
|
353
|
+
(n.page?.title ?? null) === (pages.page?.title ?? null) &&
|
|
354
|
+
sectionsKey(n.sections) === sectionsKey(pages.sections);
|
|
350
355
|
if (!same) {
|
|
351
|
-
|
|
356
|
+
const sections = (n.sections ?? []).map(x => ({ key: x.key, title: x.title }));
|
|
357
|
+
pages =
|
|
358
|
+
n.page || n.listed || sections.length
|
|
359
|
+
? { page: n.page ? { title: n.page.title } : null, listed: n.listed, sections }
|
|
360
|
+
: NO_PAGES;
|
|
352
361
|
emit();
|
|
353
362
|
}
|
|
354
363
|
}
|
|
@@ -356,6 +365,8 @@ function guideMenuItems(e, p = NO_PAGES) {
|
|
|
356
365
|
const items = [];
|
|
357
366
|
if (p.page)
|
|
358
367
|
items.push("page");
|
|
368
|
+
if ((p.sections ?? []).length > 0)
|
|
369
|
+
items.push("sections");
|
|
359
370
|
if (e && e.total > 0)
|
|
360
371
|
items.push("missions");
|
|
361
372
|
if (p.listed > 0)
|
|
@@ -405,6 +416,79 @@ function usePageGuideEntry() {
|
|
|
405
416
|
const title = p.page?.title ?? null;
|
|
406
417
|
return useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
|
|
407
418
|
}
|
|
419
|
+
/**
|
|
420
|
+
* Opens a section guide ("อธิบายส่วนนี้"), when it is on screen here for this
|
|
421
|
+
* person. The values it shows are the ones the app last rendered
|
|
422
|
+
* (`useSectionGuideValues`).
|
|
423
|
+
*/
|
|
424
|
+
function openSectionGuide(key) {
|
|
425
|
+
openSectionImpl?.(key);
|
|
426
|
+
}
|
|
427
|
+
const sectionValues = new Map();
|
|
428
|
+
const valueListeners = new Set();
|
|
429
|
+
let valuesVersion = 0;
|
|
430
|
+
function valuesSig(v) {
|
|
431
|
+
try {
|
|
432
|
+
return JSON.stringify(v);
|
|
433
|
+
}
|
|
434
|
+
catch {
|
|
435
|
+
return String(Math.random());
|
|
436
|
+
}
|
|
437
|
+
}
|
|
438
|
+
/**
|
|
439
|
+
* Sets (or with undefined, clears) a section guide's live values. The
|
|
440
|
+
* engine reads the latest whenever it draws a step, so a page that
|
|
441
|
+
* re-renders mid-guide never leaves an old number on screen.
|
|
442
|
+
*/
|
|
443
|
+
function setSectionGuideValues(key, values) {
|
|
444
|
+
const prev = sectionValues.get(key);
|
|
445
|
+
if (values === undefined) {
|
|
446
|
+
if (!prev)
|
|
447
|
+
return;
|
|
448
|
+
sectionValues.delete(key);
|
|
449
|
+
}
|
|
450
|
+
else {
|
|
451
|
+
const sig = valuesSig(values);
|
|
452
|
+
if (prev && prev.sig === sig)
|
|
453
|
+
return;
|
|
454
|
+
sectionValues.set(key, { values, sig });
|
|
455
|
+
}
|
|
456
|
+
valuesVersion++;
|
|
457
|
+
valueListeners.forEach(l => l());
|
|
458
|
+
}
|
|
459
|
+
/** A section guide's live values now (empty when the app has given none). */
|
|
460
|
+
function getSectionGuideValues(key) {
|
|
461
|
+
return (key && sectionValues.get(key)?.values) || {};
|
|
462
|
+
}
|
|
463
|
+
/** Re-renders when any section guide's values change (the engine's own use). */
|
|
464
|
+
function useSectionValuesVersion() {
|
|
465
|
+
return useSyncExternalStore(l => {
|
|
466
|
+
valueListeners.add(l);
|
|
467
|
+
return () => {
|
|
468
|
+
valueListeners.delete(l);
|
|
469
|
+
};
|
|
470
|
+
}, () => valuesVersion, () => 0);
|
|
471
|
+
}
|
|
472
|
+
/**
|
|
473
|
+
* Hands a section guide the numbers its copy uses, from the component that
|
|
474
|
+
* computes them: `useSectionGuideValues("cash-flow", { month: "ม.ค.", cashIn,
|
|
475
|
+
* … })`. Call it on every render (the latest wins); they go when the
|
|
476
|
+
* component unmounts. undefined values: nothing.
|
|
477
|
+
*/
|
|
478
|
+
function useSectionGuideValues(key, values) {
|
|
479
|
+
const sig = values === undefined ? "" : valuesSig(values);
|
|
480
|
+
useEffect(() => {
|
|
481
|
+
if (values !== undefined)
|
|
482
|
+
setSectionGuideValues(key, values);
|
|
483
|
+
// sig stands for the values.
|
|
484
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
485
|
+
}, [key, sig]);
|
|
486
|
+
useEffect(() => {
|
|
487
|
+
if (values === undefined)
|
|
488
|
+
return;
|
|
489
|
+
return () => setSectionGuideValues(key, undefined);
|
|
490
|
+
}, [key, values === undefined]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
491
|
+
}
|
|
408
492
|
|
|
409
493
|
/** The synthetic mission key of a page guide: `page:<key>`. */
|
|
410
494
|
const PAGE_PREFIX = "page:";
|
|
@@ -583,6 +667,140 @@ function currentPage(suite, where, ctx) {
|
|
|
583
667
|
function listedPages(suite, ctx) {
|
|
584
668
|
return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
|
|
585
669
|
}
|
|
670
|
+
/* ------------------------------------------------------------------ */
|
|
671
|
+
/* Section guides ("อธิบายส่วนนี้", shell 0.1.75) */
|
|
672
|
+
/* ------------------------------------------------------------------ */
|
|
673
|
+
/** The synthetic mission key of a section guide: `section:<key>`. */
|
|
674
|
+
const SECTION_PREFIX = "section:";
|
|
675
|
+
function isSectionMissionKey(key) {
|
|
676
|
+
return !!key && key.startsWith(SECTION_PREFIX);
|
|
677
|
+
}
|
|
678
|
+
function sectionKeyOf(missionKey) {
|
|
679
|
+
return isSectionMissionKey(missionKey) ? missionKey.slice(SECTION_PREFIX.length) : undefined;
|
|
680
|
+
}
|
|
681
|
+
/** Why a section guide cannot run, or null when it can. */
|
|
682
|
+
function sectionProblem(app, section) {
|
|
683
|
+
if (!section || typeof section.key !== "string" || !PAGE_KEY_RE.test(section.key))
|
|
684
|
+
return "its key is not [a-z0-9-]";
|
|
685
|
+
if (`${app}.section.${section.key}`.length > ROW_KEY_MAX)
|
|
686
|
+
return `its row key is over ${ROW_KEY_MAX} characters`;
|
|
687
|
+
if (!Number.isInteger(section.version) || section.version < 1)
|
|
688
|
+
return "its version is not a whole number ≥ 1";
|
|
689
|
+
const routes = Array.isArray(section.route) ? section.route : [section.route];
|
|
690
|
+
if (!routes.length || routes.some(r => typeof r !== "string" || !r.startsWith("/")))
|
|
691
|
+
return "it has no route";
|
|
692
|
+
if (typeof section.anchor !== "string" || !section.anchor)
|
|
693
|
+
return "it has no anchor";
|
|
694
|
+
if (!Array.isArray(section.steps) || section.steps.length === 0)
|
|
695
|
+
return "it has no steps";
|
|
696
|
+
for (const st of section.steps) {
|
|
697
|
+
const kind = st.kind ?? "view";
|
|
698
|
+
if (kind !== "view")
|
|
699
|
+
return `step "${st.key}" is a ${kind} step (a section guide only reads)`;
|
|
700
|
+
}
|
|
701
|
+
return null;
|
|
702
|
+
}
|
|
703
|
+
const validSectionCache = new WeakMap();
|
|
704
|
+
/** The suite's section guides that can run (as validPages: the rest dropped, with a warning in development). */
|
|
705
|
+
function validSections(suite) {
|
|
706
|
+
const hit = validSectionCache.get(suite);
|
|
707
|
+
if (hit)
|
|
708
|
+
return hit;
|
|
709
|
+
const out = [];
|
|
710
|
+
const seen = new Set();
|
|
711
|
+
for (const section of suite.sections ?? []) {
|
|
712
|
+
let problem = sectionProblem(suite.app, section);
|
|
713
|
+
if (!problem && seen.has(section.key))
|
|
714
|
+
problem = "another section guide has the same key";
|
|
715
|
+
if (problem) {
|
|
716
|
+
const id = `${suite.key}:section:${section?.key}:${problem}`;
|
|
717
|
+
if (!warned.has(id)) {
|
|
718
|
+
warned.add(id);
|
|
719
|
+
devWarn(`section guide "${section?.key}" dropped: ${problem}`);
|
|
720
|
+
}
|
|
721
|
+
continue;
|
|
722
|
+
}
|
|
723
|
+
seen.add(section.key);
|
|
724
|
+
out.push(section);
|
|
725
|
+
}
|
|
726
|
+
validSectionCache.set(suite, out);
|
|
727
|
+
return out;
|
|
728
|
+
}
|
|
729
|
+
function findSection(suite, key) {
|
|
730
|
+
return key ? validSections(suite).find(s => s.key === key) : undefined;
|
|
731
|
+
}
|
|
732
|
+
/** A live value is missing: undefined, null, "" or a number that is not finite. */
|
|
733
|
+
function valueMissing(v) {
|
|
734
|
+
return v === undefined || v === null || v === "" || (typeof v === "number" && !Number.isFinite(v));
|
|
735
|
+
}
|
|
736
|
+
function textOf(t, ctx) {
|
|
737
|
+
try {
|
|
738
|
+
return typeof t === "function" ? t(ctx) : t;
|
|
739
|
+
}
|
|
740
|
+
catch {
|
|
741
|
+
return "";
|
|
742
|
+
}
|
|
743
|
+
}
|
|
744
|
+
const sectionMissionCache = new WeakMap();
|
|
745
|
+
/**
|
|
746
|
+
* The section guide as a mission the machine plays: `section:<key>`, read
|
|
747
|
+
* steps only, checkpoints unless they say otherwise. A step is skipped while
|
|
748
|
+
* a value it `needs` is missing, or when its title comes out empty.
|
|
749
|
+
*/
|
|
750
|
+
function sectionMission(section) {
|
|
751
|
+
const hit = sectionMissionCache.get(section);
|
|
752
|
+
if (hit)
|
|
753
|
+
return hit;
|
|
754
|
+
const steps = section.steps.map(st => {
|
|
755
|
+
const { route: _route, resumeUrl: _resume, ...rest } = st;
|
|
756
|
+
const own = st.skipIf;
|
|
757
|
+
return {
|
|
758
|
+
...rest,
|
|
759
|
+
kind: "view",
|
|
760
|
+
checkpoint: st.checkpoint ?? true,
|
|
761
|
+
skipIf: (ctx) => {
|
|
762
|
+
const values = ctx.values ?? {};
|
|
763
|
+
if ((st.needs ?? []).some(k => valueMissing(values[k])))
|
|
764
|
+
return true;
|
|
765
|
+
if (!textOf(st.title, ctx).trim())
|
|
766
|
+
return true;
|
|
767
|
+
return own ? own(ctx) : false;
|
|
768
|
+
},
|
|
769
|
+
};
|
|
770
|
+
});
|
|
771
|
+
const m = { key: SECTION_PREFIX + section.key, title: section.title, minutes: 1, steps };
|
|
772
|
+
sectionMissionCache.set(section, m);
|
|
773
|
+
return m;
|
|
774
|
+
}
|
|
775
|
+
/** Whether the section guide applies here: one of its routes matches and its root is on screen. */
|
|
776
|
+
function sectionApplies(section, where) {
|
|
777
|
+
const routes = Array.isArray(section.route) ? section.route : [section.route];
|
|
778
|
+
if (!routes.some(r => !!matchRoute(r, where.pathname, where.search, where.code)))
|
|
779
|
+
return false;
|
|
780
|
+
try {
|
|
781
|
+
return where.isVisible(section.anchor);
|
|
782
|
+
}
|
|
783
|
+
catch {
|
|
784
|
+
return false;
|
|
785
|
+
}
|
|
786
|
+
}
|
|
787
|
+
/** Whether this person has the section guide: `visible` (only true) and `requires`. */
|
|
788
|
+
function sectionShown(section, ctx) {
|
|
789
|
+
try {
|
|
790
|
+
if (section.visible && section.visible(ctx) !== true)
|
|
791
|
+
return false;
|
|
792
|
+
if (section.requires && !ctx.can(section.requires))
|
|
793
|
+
return false;
|
|
794
|
+
return true;
|
|
795
|
+
}
|
|
796
|
+
catch {
|
|
797
|
+
return false;
|
|
798
|
+
}
|
|
799
|
+
}
|
|
800
|
+
/** The section guides on screen here that this person has, in the suite's order. */
|
|
801
|
+
function sectionsHere(suite, where, ctx) {
|
|
802
|
+
return validSections(suite).filter(s => sectionApplies(s, where) && sectionShown(s, ctx));
|
|
803
|
+
}
|
|
586
804
|
|
|
587
805
|
/* ------------------------------------------------------------------ */
|
|
588
806
|
/* Copy */
|
|
@@ -597,6 +815,7 @@ const COPY = {
|
|
|
597
815
|
hiddenToast: "ซ่อนแล้ว",
|
|
598
816
|
aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
|
|
599
817
|
pageDone: "จบคู่มือหน้านี้",
|
|
818
|
+
sectionDone: "จบคำอธิบายส่วนนี้",
|
|
600
819
|
bridgeTitle: "กดปุ่มเมนู",
|
|
601
820
|
bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
|
|
602
821
|
};
|
|
@@ -609,8 +828,10 @@ function initialGuideState(audience, progress, pages) {
|
|
|
609
828
|
pageAudience: pages?.pageAudience ?? "none",
|
|
610
829
|
pagesMode: pages?.pagesMode ?? "off",
|
|
611
830
|
pages: pages?.pages ?? {},
|
|
831
|
+
sections: pages?.sections ?? {},
|
|
612
832
|
pageRun: null,
|
|
613
833
|
panelSection: "all",
|
|
834
|
+
autoPassed: null,
|
|
614
835
|
audience,
|
|
615
836
|
progress,
|
|
616
837
|
screen: { name: "idle" },
|
|
@@ -630,6 +851,10 @@ function findMission(suite, key) {
|
|
|
630
851
|
const page = findPage(suite, pageKeyOf(key));
|
|
631
852
|
return page ? pageMission(page) : undefined;
|
|
632
853
|
}
|
|
854
|
+
if (isSectionMissionKey(key)) {
|
|
855
|
+
const section = findSection(suite, sectionKeyOf(key));
|
|
856
|
+
return section ? sectionMission(section) : undefined;
|
|
857
|
+
}
|
|
633
858
|
return suite.missions.find(m => m.key === key);
|
|
634
859
|
}
|
|
635
860
|
/**
|
|
@@ -667,6 +892,7 @@ function guideContext(env, data, params = {}) {
|
|
|
667
892
|
facts: env.facts,
|
|
668
893
|
can: env.can,
|
|
669
894
|
visible: (anchor) => env.isVisible(anchor),
|
|
895
|
+
values: env.values ?? {},
|
|
670
896
|
};
|
|
671
897
|
}
|
|
672
898
|
function resolveText(t, ctx) {
|
|
@@ -980,7 +1206,7 @@ function announceStep(d, suite, env) {
|
|
|
980
1206
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
981
1207
|
const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
|
|
982
1208
|
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);
|
|
1209
|
+
const pos = stepPosition(mission, s.index, env, d.state.progress.context, autoPassedOf(d.state, mission.key));
|
|
984
1210
|
d.effects.push({
|
|
985
1211
|
type: "announce",
|
|
986
1212
|
text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
|
|
@@ -991,7 +1217,7 @@ function announceStep(d, suite, env) {
|
|
|
991
1217
|
* whose `requires` they lack or whose `skipIf` holds is left out (the step on
|
|
992
1218
|
* screen always counts).
|
|
993
1219
|
*/
|
|
994
|
-
function stepPosition(mission, index, env, context) {
|
|
1220
|
+
function stepPosition(mission, index, env, context, autoPassed = []) {
|
|
995
1221
|
let no = 0;
|
|
996
1222
|
let total = 0;
|
|
997
1223
|
mission.steps.forEach((step, i) => {
|
|
@@ -1001,6 +1227,12 @@ function stepPosition(mission, index, env, context) {
|
|
|
1001
1227
|
const ctx = guideContext(env, context, routeParamsFor(step, env));
|
|
1002
1228
|
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
1003
1229
|
return;
|
|
1230
|
+
// A step that passed by itself (shell ≥ 0.1.75): before this one, as
|
|
1231
|
+
// recorded; after it, one whose outcome already holds now.
|
|
1232
|
+
if (i < index && autoPassed.includes(i))
|
|
1233
|
+
return;
|
|
1234
|
+
if (i > index && wouldAutoPass(step, env))
|
|
1235
|
+
return;
|
|
1004
1236
|
}
|
|
1005
1237
|
total++;
|
|
1006
1238
|
if (i <= index)
|
|
@@ -1008,6 +1240,17 @@ function stepPosition(mission, index, env, context) {
|
|
|
1008
1240
|
});
|
|
1009
1241
|
return { no: Math.max(no, 1), total: Math.max(total, 1) };
|
|
1010
1242
|
}
|
|
1243
|
+
/** A step entered now would pass at once: a click or select step whose route or visible outcome holds. */
|
|
1244
|
+
function wouldAutoPass(step, env) {
|
|
1245
|
+
const kind = stepKind(step);
|
|
1246
|
+
if (step.autoPass === false || kind === "input" || kind === "view")
|
|
1247
|
+
return false;
|
|
1248
|
+
return safe(() => stateTriggerHolds(step, env) !== null, false);
|
|
1249
|
+
}
|
|
1250
|
+
/** The steps of this mission that passed by themselves in this run. */
|
|
1251
|
+
function autoPassedOf(state, mission) {
|
|
1252
|
+
return state.autoPassed?.mission === mission ? state.autoPassed.steps : [];
|
|
1253
|
+
}
|
|
1011
1254
|
/** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
|
|
1012
1255
|
function plainTitle(t) {
|
|
1013
1256
|
return t.replace(/\[([^\]]+)\]/g, "$1");
|
|
@@ -1020,6 +1263,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1020
1263
|
const steps = mission.steps;
|
|
1021
1264
|
let i = Math.max(0, index);
|
|
1022
1265
|
let firstPass = true;
|
|
1266
|
+
if (d.state.autoPassed?.mission !== mission.key)
|
|
1267
|
+
d.state = { ...d.state, autoPassed: { mission: mission.key, steps: [] } };
|
|
1023
1268
|
while (i < steps.length) {
|
|
1024
1269
|
const step = steps[i];
|
|
1025
1270
|
const params = routeParamsFor(step, env);
|
|
@@ -1041,6 +1286,9 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1041
1286
|
const held = stateTriggerHolds(step, env);
|
|
1042
1287
|
if (held) {
|
|
1043
1288
|
captureInto(d, step, held, ctx);
|
|
1289
|
+
const ap = d.state.autoPassed;
|
|
1290
|
+
if (!ap.steps.includes(i))
|
|
1291
|
+
d.state = { ...d.state, autoPassed: { mission: ap.mission, steps: [...ap.steps, i] } };
|
|
1044
1292
|
i++;
|
|
1045
1293
|
continue;
|
|
1046
1294
|
}
|
|
@@ -1054,9 +1302,10 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1054
1302
|
const step = steps[i];
|
|
1055
1303
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1056
1304
|
const page = pageKeyOf(mission.key);
|
|
1305
|
+
const section = sectionKeyOf(mission.key);
|
|
1057
1306
|
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);
|
|
1307
|
+
// A page or section guide is on its page: leaving it ends the guide (never "ทำต่อ").
|
|
1308
|
+
const away = !page && !section && !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
1060
1309
|
const bridge = !away && needsBridge(step, env, ctx);
|
|
1061
1310
|
const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
|
|
1062
1311
|
setScreen(d, {
|
|
@@ -1074,6 +1323,7 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
1074
1323
|
status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
|
|
1075
1324
|
replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
|
|
1076
1325
|
...(page ? { page } : {}),
|
|
1326
|
+
...(section ? { section } : {}),
|
|
1077
1327
|
});
|
|
1078
1328
|
if (!opts.replay) {
|
|
1079
1329
|
const cp = checkpointAtOrBefore(steps, i);
|
|
@@ -1238,9 +1488,9 @@ function advance(d, suite, env, capture) {
|
|
|
1238
1488
|
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
1239
1489
|
}
|
|
1240
1490
|
function finishMission(d, suite, env, mission, replay) {
|
|
1241
|
-
if (isPageMissionKey(mission.key)) {
|
|
1491
|
+
if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
|
|
1242
1492
|
endPage(d, suite, "completed");
|
|
1243
|
-
d.effects.push({ type: "toast", text: COPY.pageDone });
|
|
1493
|
+
d.effects.push({ type: "toast", text: isSectionMissionKey(mission.key) ? COPY.sectionDone : COPY.pageDone });
|
|
1244
1494
|
return;
|
|
1245
1495
|
}
|
|
1246
1496
|
if (replay) {
|
|
@@ -1344,7 +1594,8 @@ function startMission(d, suite, env, key, replay) {
|
|
|
1344
1594
|
// A mission already done is only ever replayed; its done status stays.
|
|
1345
1595
|
const isReplay = replay || entry?.status === "done";
|
|
1346
1596
|
const before = d.state.progress;
|
|
1347
|
-
|
|
1597
|
+
// A new run: what passed by itself is counted afresh.
|
|
1598
|
+
d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
|
|
1348
1599
|
if (d.state.progress.welcome === "not_seen")
|
|
1349
1600
|
withProgress(d, { welcome: "started" });
|
|
1350
1601
|
if (!isReplay && d.state.progress.mission_key !== key)
|
|
@@ -1503,14 +1754,72 @@ function recordPage(d, suite, key, status) {
|
|
|
1503
1754
|
d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
|
|
1504
1755
|
d.effects.push({ type: "savePage", key, version: page.version, status });
|
|
1505
1756
|
}
|
|
1506
|
-
/** The
|
|
1757
|
+
/** The section guide's row says this person has ended this version of it. */
|
|
1758
|
+
function sectionRecorded(state, section) {
|
|
1759
|
+
const row = state.sections?.[section.key];
|
|
1760
|
+
return !!row && row.version >= section.version;
|
|
1761
|
+
}
|
|
1762
|
+
/** Write a section guide's row (once per section and version). */
|
|
1763
|
+
function recordSection(d, suite, key, status) {
|
|
1764
|
+
const section = findSection(suite, key);
|
|
1765
|
+
if (!section || sectionRecorded(d.state, section))
|
|
1766
|
+
return;
|
|
1767
|
+
const prev = d.state.sections?.[key];
|
|
1768
|
+
d.state = { ...d.state, sections: { ...d.state.sections, [key]: { version: section.version, status, rev: prev?.rev ?? 0 } } };
|
|
1769
|
+
d.effects.push({ type: "savePage", key, version: section.version, status, kind: "section" });
|
|
1770
|
+
}
|
|
1771
|
+
/** The page or section guide on screen, or null. */
|
|
1772
|
+
function readOf(s) {
|
|
1773
|
+
if (s.name !== "step")
|
|
1774
|
+
return null;
|
|
1775
|
+
if (s.page)
|
|
1776
|
+
return { kind: "page", key: s.page };
|
|
1777
|
+
if (s.section)
|
|
1778
|
+
return { kind: "section", key: s.section };
|
|
1779
|
+
return null;
|
|
1780
|
+
}
|
|
1781
|
+
/** Whether a page or section guide still applies where the person is. */
|
|
1782
|
+
function readStillHere(suite, r, env) {
|
|
1783
|
+
if (r.kind === "page")
|
|
1784
|
+
return pageStillHere(suite, r.key, env);
|
|
1785
|
+
const section = findSection(suite, r.key);
|
|
1786
|
+
return !!section && sectionApplies(section, whereOf(env));
|
|
1787
|
+
}
|
|
1788
|
+
/** The page or section guide ends: its row written when it is due one; back to idle. */
|
|
1507
1789
|
function endPage(d, suite, how) {
|
|
1508
1790
|
const run = d.state.pageRun;
|
|
1509
|
-
if (run?.write)
|
|
1510
|
-
|
|
1791
|
+
if (run?.write) {
|
|
1792
|
+
if (run.kind === "section")
|
|
1793
|
+
recordSection(d, suite, run.key, how);
|
|
1794
|
+
else
|
|
1795
|
+
recordPage(d, suite, run.key, how);
|
|
1796
|
+
}
|
|
1511
1797
|
d.state = { ...d.state, pageRun: null, replayPage: null };
|
|
1512
1798
|
setScreen(d, { name: "idle" });
|
|
1513
1799
|
}
|
|
1800
|
+
/** The section guides on screen here that this person has (the "?" entries, the buttons). */
|
|
1801
|
+
function sectionsAvailable(suite, env) {
|
|
1802
|
+
return sectionsHere(suite, whereOf(env), guideContext(env, {})).map(x => x.key);
|
|
1803
|
+
}
|
|
1804
|
+
function startSection(d, suite, env, key, write) {
|
|
1805
|
+
const section = findSection(suite, key);
|
|
1806
|
+
if (!section)
|
|
1807
|
+
return;
|
|
1808
|
+
const mission = sectionMission(section);
|
|
1809
|
+
d.state = {
|
|
1810
|
+
...d.state,
|
|
1811
|
+
panelOpen: false,
|
|
1812
|
+
justDone: null,
|
|
1813
|
+
pageRun: { key, write, kind: "section" },
|
|
1814
|
+
autoPassed: null,
|
|
1815
|
+
replayPage: {
|
|
1816
|
+
mission: mission.key,
|
|
1817
|
+
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
1818
|
+
dialog: safe(() => env.dialogOpen?.() === true, false),
|
|
1819
|
+
},
|
|
1820
|
+
};
|
|
1821
|
+
enterStep(d, suite, env, mission, 0, { replay: true });
|
|
1822
|
+
}
|
|
1514
1823
|
function startPage(d, suite, env, key, write) {
|
|
1515
1824
|
const page = findPage(suite, key);
|
|
1516
1825
|
if (!page)
|
|
@@ -1520,7 +1829,8 @@ function startPage(d, suite, env, key, write) {
|
|
|
1520
1829
|
...d.state,
|
|
1521
1830
|
panelOpen: false,
|
|
1522
1831
|
justDone: null,
|
|
1523
|
-
pageRun: { key, write },
|
|
1832
|
+
pageRun: { key, write, kind: "page" },
|
|
1833
|
+
autoPassed: null,
|
|
1524
1834
|
// What is open now is the person's: ending the guide leaves it alone.
|
|
1525
1835
|
replayPage: {
|
|
1526
1836
|
mission: mission.key,
|
|
@@ -1620,7 +1930,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1620
1930
|
break;
|
|
1621
1931
|
// A mission always wins over a page guide: a running one ends (its row
|
|
1622
1932
|
// written if due), an offer goes unrecorded.
|
|
1623
|
-
if (s
|
|
1933
|
+
if (readOf(s))
|
|
1624
1934
|
endPage(d, suite, "skipped");
|
|
1625
1935
|
else if (s.name === "pageOffer")
|
|
1626
1936
|
setScreen(d, { name: "idle" });
|
|
@@ -1641,6 +1951,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1641
1951
|
const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
|
|
1642
1952
|
const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
|
|
1643
1953
|
const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
|
|
1954
|
+
d.state = { ...d.state, autoPassed: null };
|
|
1644
1955
|
enterStep(d, suite, env, mission, index, { replay: false });
|
|
1645
1956
|
break;
|
|
1646
1957
|
}
|
|
@@ -1655,8 +1966,9 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1655
1966
|
}
|
|
1656
1967
|
if (s.name !== "step")
|
|
1657
1968
|
break;
|
|
1658
|
-
|
|
1659
|
-
|
|
1969
|
+
const read = readOf(s);
|
|
1970
|
+
if (read && !readStillHere(suite, read, env)) {
|
|
1971
|
+
// Leaving the page (or the section's root going) ends its guide, silently.
|
|
1660
1972
|
endPage(d, suite, "skipped");
|
|
1661
1973
|
break;
|
|
1662
1974
|
}
|
|
@@ -1689,7 +2001,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1689
2001
|
break;
|
|
1690
2002
|
}
|
|
1691
2003
|
}
|
|
1692
|
-
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page)) {
|
|
2004
|
+
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1693
2005
|
// The target is missing because the page is another one.
|
|
1694
2006
|
recoverOffRoute(d, suite, env, false);
|
|
1695
2007
|
break;
|
|
@@ -1805,7 +2117,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1805
2117
|
const cur = d.state.screen;
|
|
1806
2118
|
if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
|
|
1807
2119
|
break;
|
|
1808
|
-
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
|
|
2120
|
+
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page ?? cur.section)) {
|
|
1809
2121
|
recoverOffRoute(d, suite, env, false);
|
|
1810
2122
|
break;
|
|
1811
2123
|
}
|
|
@@ -1826,7 +2138,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1826
2138
|
if (!mission)
|
|
1827
2139
|
break;
|
|
1828
2140
|
const step = mission.steps[s.index];
|
|
1829
|
-
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
2141
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1830
2142
|
recoverOffRoute(d, suite, env, false);
|
|
1831
2143
|
break;
|
|
1832
2144
|
}
|
|
@@ -1843,7 +2155,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1843
2155
|
advance(d, suite, env);
|
|
1844
2156
|
}
|
|
1845
2157
|
else if (policy === "abort") {
|
|
1846
|
-
if (s
|
|
2158
|
+
if (readOf(s))
|
|
1847
2159
|
endPage(d, suite, "skipped");
|
|
1848
2160
|
else
|
|
1849
2161
|
exitMission(d);
|
|
@@ -1869,7 +2181,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1869
2181
|
if (s.name !== "step" || s.phase !== "missing")
|
|
1870
2182
|
break;
|
|
1871
2183
|
const mission = findMission(suite, s.mission);
|
|
1872
|
-
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
2184
|
+
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1873
2185
|
// Polling again cannot help on another page: go back to the step's.
|
|
1874
2186
|
recoverOffRoute(d, suite, env, true);
|
|
1875
2187
|
break;
|
|
@@ -1885,7 +2197,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1885
2197
|
case "exit":
|
|
1886
2198
|
if (s.name !== "step")
|
|
1887
2199
|
break;
|
|
1888
|
-
if (s
|
|
2200
|
+
if (readOf(s)) {
|
|
1889
2201
|
// ✕, Escape or ปิดคู่มือ: ends silently.
|
|
1890
2202
|
pushAbort(d, suite, s);
|
|
1891
2203
|
endPage(d, suite, "skipped");
|
|
@@ -2044,7 +2356,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2044
2356
|
break;
|
|
2045
2357
|
}
|
|
2046
2358
|
case "back": {
|
|
2047
|
-
if (s.name !== "step" || !s
|
|
2359
|
+
if (s.name !== "step" || !readOf(s) || s.phase !== "ready" || s.confirmExit)
|
|
2048
2360
|
break;
|
|
2049
2361
|
const mission = findMission(suite, s.mission);
|
|
2050
2362
|
if (!mission)
|
|
@@ -2059,12 +2371,32 @@ function reduceGuide(state, action, suite, env) {
|
|
|
2059
2371
|
break;
|
|
2060
2372
|
}
|
|
2061
2373
|
case "pageRow": {
|
|
2374
|
+
if (action.kind === "section") {
|
|
2375
|
+
const cur = d.state.sections?.[action.key];
|
|
2376
|
+
if (cur && cur.version > action.row.version)
|
|
2377
|
+
break;
|
|
2378
|
+
d.state = { ...d.state, sections: { ...d.state.sections, [action.key]: action.row } };
|
|
2379
|
+
break;
|
|
2380
|
+
}
|
|
2062
2381
|
const cur = d.state.pages[action.key];
|
|
2063
2382
|
if (cur && cur.version > action.row.version)
|
|
2064
2383
|
break;
|
|
2065
2384
|
d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
|
|
2066
2385
|
break;
|
|
2067
2386
|
}
|
|
2387
|
+
case "startSection": {
|
|
2388
|
+
// Never offered: only on request. Never over a mission step, a
|
|
2389
|
+
// welcome, a gate or a celebration; only on screen, only theirs.
|
|
2390
|
+
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
2391
|
+
break;
|
|
2392
|
+
if (s.name !== "idle" && s.name !== "pageOffer")
|
|
2393
|
+
break;
|
|
2394
|
+
if (!sectionsAvailable(suite, env).includes(action.section))
|
|
2395
|
+
break;
|
|
2396
|
+
const section = findSection(suite, action.section);
|
|
2397
|
+
startSection(d, suite, env, section.key, !sectionRecorded(state, section));
|
|
2398
|
+
break;
|
|
2399
|
+
}
|
|
2068
2400
|
case "idleCelebrate": {
|
|
2069
2401
|
if (s.name !== "idle" || state.panelOpen)
|
|
2070
2402
|
break;
|
|
@@ -2193,6 +2525,7 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
2193
2525
|
? data.page_audience
|
|
2194
2526
|
: "none";
|
|
2195
2527
|
const pages = parsePageRows(rows, suite.app);
|
|
2528
|
+
const sections = parsePageRows(rows, suite.app, "section");
|
|
2196
2529
|
let row = null;
|
|
2197
2530
|
for (const r of rows) {
|
|
2198
2531
|
if (isRecord(r) && r.guide_key === suite.key) {
|
|
@@ -2213,15 +2546,15 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
2213
2546
|
progress = row;
|
|
2214
2547
|
newerOnServer = row.guide_version > suite.version;
|
|
2215
2548
|
}
|
|
2216
|
-
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages };
|
|
2549
|
+
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
|
|
2217
2550
|
}
|
|
2218
|
-
/** The row key of a page guide
|
|
2219
|
-
function pageRowKey(app, key) {
|
|
2220
|
-
return `${app}
|
|
2551
|
+
/** The row key of a page guide, `<app>.page.<key>`, or a section guide, `<app>.section.<key>`. */
|
|
2552
|
+
function pageRowKey(app, key, kind = "page") {
|
|
2553
|
+
return `${app}.${kind}.${key}`;
|
|
2221
2554
|
}
|
|
2222
|
-
/** Page rows among the list's rows, by
|
|
2223
|
-
function parsePageRows(rows, app) {
|
|
2224
|
-
const prefix = pageRowKey(app, "");
|
|
2555
|
+
/** Page (or section) rows among the list's rows, by key. Off-shape rows are left out. */
|
|
2556
|
+
function parsePageRows(rows, app, kind = "page") {
|
|
2557
|
+
const prefix = pageRowKey(app, "", kind);
|
|
2225
2558
|
const out = {};
|
|
2226
2559
|
for (const r of rows) {
|
|
2227
2560
|
if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
|
|
@@ -2471,14 +2804,15 @@ async function storeGuides(adapter, suite, progress) {
|
|
|
2471
2804
|
return null;
|
|
2472
2805
|
}
|
|
2473
2806
|
/**
|
|
2474
|
-
* Writes a page guide's row (`<app>.page.<key>`)
|
|
2807
|
+
* Writes a page guide's row (`<app>.page.<key>`), or a section guide's
|
|
2808
|
+
* (`<app>.section.<key>`, kind "section"), once: the minimal body,
|
|
2475
2809
|
* with the same 409 handling as the tour's row. Another tab that already
|
|
2476
2810
|
* recorded this version wins (its row is returned, nothing is sent again);
|
|
2477
2811
|
* an older version's row is replaced with its rev; a newer one stops. Returns
|
|
2478
2812
|
* the stored row, or null when it could not be saved.
|
|
2479
2813
|
*/
|
|
2480
|
-
async function storePageRow(adapter, app, key, version, status, rev) {
|
|
2481
|
-
const guideKey = pageRowKey(app, key);
|
|
2814
|
+
async function storePageRow(adapter, app, key, version, status, rev, kind = "page") {
|
|
2815
|
+
const guideKey = pageRowKey(app, key, kind);
|
|
2482
2816
|
let at = rev;
|
|
2483
2817
|
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2484
2818
|
let res;
|
|
@@ -3567,10 +3901,10 @@ function writeMirror(orgId, suite, p) {
|
|
|
3567
3901
|
}
|
|
3568
3902
|
}
|
|
3569
3903
|
/** Page rows written in this tab, kept for a remount within the GET's cache time. */
|
|
3570
|
-
const pagesMirrorKey = (orgId, suite) => `erp-guide
|
|
3571
|
-
function readPagesMirror(orgId, suite) {
|
|
3904
|
+
const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
|
|
3905
|
+
function readPagesMirror(orgId, suite, kind = "page") {
|
|
3572
3906
|
try {
|
|
3573
|
-
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite));
|
|
3907
|
+
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
|
|
3574
3908
|
const parsed = raw ? JSON.parse(raw) : {};
|
|
3575
3909
|
const out = {};
|
|
3576
3910
|
for (const [k, v] of Object.entries(parsed ?? {})) {
|
|
@@ -3583,9 +3917,9 @@ function readPagesMirror(orgId, suite) {
|
|
|
3583
3917
|
return {};
|
|
3584
3918
|
}
|
|
3585
3919
|
}
|
|
3586
|
-
function writePagesMirror(orgId, suite, pages) {
|
|
3920
|
+
function writePagesMirror(orgId, suite, pages, kind = "page") {
|
|
3587
3921
|
try {
|
|
3588
|
-
sessionStorage.setItem(pagesMirrorKey(orgId, suite), JSON.stringify(pages));
|
|
3922
|
+
sessionStorage.setItem(pagesMirrorKey(orgId, suite, kind), JSON.stringify(pages));
|
|
3589
3923
|
}
|
|
3590
3924
|
catch {
|
|
3591
3925
|
/* private mode */
|
|
@@ -3713,7 +4047,8 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3713
4047
|
const [answer, setAnswer] = useState(null);
|
|
3714
4048
|
const missionsReady = config.ready !== false;
|
|
3715
4049
|
const pagesReady = config.pagesReady ?? missionsReady;
|
|
3716
|
-
const hasPages = useMemo(() => validPages(suite).length > 0, [suite]);
|
|
4050
|
+
const hasPages = useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0, [suite]);
|
|
4051
|
+
const hasSections = useMemo(() => validSections(suite).length > 0, [suite]);
|
|
3717
4052
|
const enabled = primary && (missionsReady || (hasPages && pagesReady));
|
|
3718
4053
|
const loaded = useRef(false);
|
|
3719
4054
|
useEffect(() => {
|
|
@@ -3769,6 +4104,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3769
4104
|
pageAudience: pagesOn ? answer.pageAudience : "none",
|
|
3770
4105
|
pagesMode: answer.pagesMode,
|
|
3771
4106
|
pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
|
|
4107
|
+
sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
|
|
3772
4108
|
});
|
|
3773
4109
|
stateRef.current = s;
|
|
3774
4110
|
setState(s);
|
|
@@ -3777,8 +4113,12 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3777
4113
|
const pageRef = useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
|
|
3778
4114
|
pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
|
|
3779
4115
|
const targetElRef = useRef(null);
|
|
4116
|
+
// The section guide being opened (before its first step is on screen).
|
|
4117
|
+
const openingSection = useRef(null);
|
|
3780
4118
|
const env = useCallback(() => {
|
|
3781
4119
|
const pg = pageRef.current;
|
|
4120
|
+
const sc = stateRef.current?.screen;
|
|
4121
|
+
const sectionKey = (sc?.name === "step" ? sc.section : undefined) ?? openingSection.current;
|
|
3782
4122
|
return {
|
|
3783
4123
|
code,
|
|
3784
4124
|
pathname: pg.pathname,
|
|
@@ -3796,6 +4136,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3796
4136
|
sidebarOpen: pg.sidebarOpen,
|
|
3797
4137
|
targetValue: () => fieldValue(targetElRef.current),
|
|
3798
4138
|
dialogOpen: () => appDialogOpen(),
|
|
4139
|
+
values: getSectionGuideValues(sectionKey),
|
|
3799
4140
|
};
|
|
3800
4141
|
}, [code]);
|
|
3801
4142
|
/* ---------------- effects: save, announce, toast, navigate ---------------- */
|
|
@@ -3871,13 +4212,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3871
4212
|
}
|
|
3872
4213
|
else if (e.type === "savePage") {
|
|
3873
4214
|
// Once per page and version; the machine already holds it as written.
|
|
3874
|
-
|
|
3875
|
-
|
|
3876
|
-
|
|
3877
|
-
|
|
4215
|
+
const kind = e.kind ?? "page";
|
|
4216
|
+
const rows = stateRef.current ? (kind === "section" ? stateRef.current.sections : stateRef.current.pages) : undefined;
|
|
4217
|
+
if (rows)
|
|
4218
|
+
writePagesMirror(organizationId, suite, rows, kind);
|
|
4219
|
+
const rev = rows?.[e.key]?.rev ?? 0;
|
|
4220
|
+
void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev, kind).then(row => {
|
|
3878
4221
|
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
3879
4222
|
if (row)
|
|
3880
|
-
dispatchRef.current({ type: "pageRow", key: e.key, row });
|
|
4223
|
+
dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
|
|
3881
4224
|
});
|
|
3882
4225
|
}
|
|
3883
4226
|
}
|
|
@@ -3967,7 +4310,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3967
4310
|
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
3968
4311
|
// A page guide's step runs while page guides are live; a mission's while
|
|
3969
4312
|
// the missions are. The offer card is watched the same way (left: ignored).
|
|
3970
|
-
const inStep = screen.name === "step" && (screen.page ? pagesLive : missionsLive);
|
|
4313
|
+
const inStep = screen.name === "step" && (screen.page || screen.section ? pagesLive : missionsLive);
|
|
3971
4314
|
const offerUp = pagesLive && screen.name === "pageOffer";
|
|
3972
4315
|
const watching = inStep || offerUp;
|
|
3973
4316
|
useEffect(() => {
|
|
@@ -4015,6 +4358,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4015
4358
|
const step = shownStep(raw, screen.nothingToSave);
|
|
4016
4359
|
return { mission, step, raw };
|
|
4017
4360
|
}, [state, screen, suite]);
|
|
4361
|
+
const valuesVersion = useSectionValuesVersion();
|
|
4018
4362
|
const stepCtx = useMemo(() => {
|
|
4019
4363
|
if (!state)
|
|
4020
4364
|
return null;
|
|
@@ -4023,7 +4367,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4023
4367
|
return guideContext(e, state.progress.context, params);
|
|
4024
4368
|
// pathname/search/factsKey make it current.
|
|
4025
4369
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4026
|
-
}, [state, stepInfo, env, code, pathname, search, factsKey]);
|
|
4370
|
+
}, [state, stepInfo, env, code, pathname, search, factsKey, valuesVersion]);
|
|
4027
4371
|
const target = stepInfo && stepCtx && screen.name === "step"
|
|
4028
4372
|
? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
|
|
4029
4373
|
: null;
|
|
@@ -4531,7 +4875,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4531
4875
|
/* ---------------- page guides: this page's, the offer, the list ---------------- */
|
|
4532
4876
|
// `when` anchors (LIFF's sub-screens) come and go without a route change:
|
|
4533
4877
|
// look again when the page changes.
|
|
4534
|
-
const hasWhen = useMemo(() => validPages(suite).some(p => !!p.when), [suite]);
|
|
4878
|
+
const hasWhen = useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
|
|
4535
4879
|
const [domTick, setDomTick] = useState(0);
|
|
4536
4880
|
useEffect(() => {
|
|
4537
4881
|
if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
|
|
@@ -4565,6 +4909,21 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4565
4909
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4566
4910
|
[pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
|
|
4567
4911
|
const listed = useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
|
|
4912
|
+
const sectionsNow = useMemo(() => pagesLive && hasState && hasSections
|
|
4913
|
+
? sectionsAvailable(suite, env()).map(k => ({ key: k, title: findSection(suite, k)?.title ?? "" }))
|
|
4914
|
+
: [],
|
|
4915
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4916
|
+
[pagesLive, hasState, hasSections, suite, env, pathname, search, factsKey, domTick]);
|
|
4917
|
+
const sectionsSig = sectionsNow.map(x => x.key).join(",");
|
|
4918
|
+
const openSection = useCallback((key) => {
|
|
4919
|
+
openingSection.current = key;
|
|
4920
|
+
try {
|
|
4921
|
+
dispatchRef.current({ type: "startSection", section: key });
|
|
4922
|
+
}
|
|
4923
|
+
finally {
|
|
4924
|
+
openingSection.current = null;
|
|
4925
|
+
}
|
|
4926
|
+
}, []);
|
|
4568
4927
|
const openPageHere = useCallback(() => {
|
|
4569
4928
|
const p = pageHere(suite, env());
|
|
4570
4929
|
if (p)
|
|
@@ -4575,8 +4934,10 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4575
4934
|
publishPageGuides(null, null, null);
|
|
4576
4935
|
return;
|
|
4577
4936
|
}
|
|
4578
|
-
publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }));
|
|
4579
|
-
|
|
4937
|
+
publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length, sections: sectionsNow }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }), openSection);
|
|
4938
|
+
// sectionsSig stands for sectionsNow.
|
|
4939
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4940
|
+
}, [pagesLive, hasState, pageNow, listed.length, openPageHere, sectionsSig, openSection]);
|
|
4580
4941
|
useEffect(() => () => publishPageGuides(null, null, null), []);
|
|
4581
4942
|
// The first-visit offer: in "offer" mode, nothing else up, the welcome
|
|
4582
4943
|
// not due here, and a moment of nothing loading on this page.
|
|
@@ -4693,12 +5054,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
4693
5054
|
const missionNo = missionNumber(suite, mission.key, ctxIdle);
|
|
4694
5055
|
const kind = stepKind(step);
|
|
4695
5056
|
const bridge = screen.bridge;
|
|
4696
|
-
const pos = stepPosition(mission, screen.index, env(), state.progress.context);
|
|
5057
|
+
const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
|
|
4697
5058
|
const page = screen.page ? findPage(suite, screen.page) : undefined;
|
|
4698
|
-
const
|
|
5059
|
+
const section = screen.section ? findSection(suite, screen.section) : undefined;
|
|
5060
|
+
const read = page ? { heading: `คู่มือหน้านี้ · ${page.title}`, exitLabel: "ปิดคู่มือ" } : section ? { heading: `อธิบายส่วนนี้ · ${section.title}`, exitLabel: "ปิดคำอธิบาย" } : null;
|
|
5061
|
+
const pageBits = read
|
|
4699
5062
|
? {
|
|
4700
|
-
|
|
4701
|
-
exitLabel: "ปิดคู่มือ",
|
|
5063
|
+
...read,
|
|
4702
5064
|
showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
|
|
4703
5065
|
nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
|
|
4704
5066
|
}
|
|
@@ -4781,5 +5143,101 @@ function useFactsKey(facts) {
|
|
|
4781
5143
|
}, [facts]);
|
|
4782
5144
|
}
|
|
4783
5145
|
|
|
4784
|
-
|
|
4785
|
-
|
|
5146
|
+
const DEFAULT_STYLE = {
|
|
5147
|
+
display: "inline-grid",
|
|
5148
|
+
placeItems: "center",
|
|
5149
|
+
width: 22,
|
|
5150
|
+
height: 22,
|
|
5151
|
+
padding: 0,
|
|
5152
|
+
border: 0,
|
|
5153
|
+
borderRadius: 999,
|
|
5154
|
+
background: "transparent",
|
|
5155
|
+
color: "inherit",
|
|
5156
|
+
opacity: 0.6,
|
|
5157
|
+
cursor: "pointer",
|
|
5158
|
+
flex: "none",
|
|
5159
|
+
};
|
|
5160
|
+
function SectionGuideButton({ guideKey, values, className }) {
|
|
5161
|
+
useSectionGuideValues(guideKey, values);
|
|
5162
|
+
const { sections } = usePageGuides();
|
|
5163
|
+
const here = (sections ?? []).find(s => s.key === guideKey);
|
|
5164
|
+
if (!here)
|
|
5165
|
+
return null;
|
|
5166
|
+
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 => {
|
|
5167
|
+
// A header that opens or sorts on press must not hear it.
|
|
5168
|
+
e.stopPropagation();
|
|
5169
|
+
e.preventDefault();
|
|
5170
|
+
openSectionGuide(guideKey);
|
|
5171
|
+
}, 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" })] }) }));
|
|
5172
|
+
}
|
|
5173
|
+
|
|
5174
|
+
/**
|
|
5175
|
+
* Small formatters for section guides' copy (shell 0.1.75): the app passes
|
|
5176
|
+
* its own computed numbers, and the copy says them the way the page does —
|
|
5177
|
+
* "ม.ค. รับ 500,000 − จ่าย 320,000 = คงเหลือ 180,000".
|
|
5178
|
+
*
|
|
5179
|
+
* Pure: the tests run it in Node.
|
|
5180
|
+
*/
|
|
5181
|
+
const THAI_MONTHS_SHORT = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."];
|
|
5182
|
+
const grouped = new Map();
|
|
5183
|
+
function nf(decimals) {
|
|
5184
|
+
let f = grouped.get(decimals);
|
|
5185
|
+
if (!f) {
|
|
5186
|
+
f = new Intl.NumberFormat("en-US", { minimumFractionDigits: decimals, maximumFractionDigits: decimals });
|
|
5187
|
+
grouped.set(decimals, f);
|
|
5188
|
+
}
|
|
5189
|
+
return f;
|
|
5190
|
+
}
|
|
5191
|
+
/**
|
|
5192
|
+
* A baht amount with thousands separators, no currency sign: 500000 →
|
|
5193
|
+
* "500,000"; 1234.5 → "1,234.50"; -320000 → "-320,000". Whole amounts
|
|
5194
|
+
* have no decimals unless `decimals` says so; others have two. Not a
|
|
5195
|
+
* finite number → "" (a step whose value is missing is skipped anyway).
|
|
5196
|
+
*/
|
|
5197
|
+
function formatBaht(n, opts = {}) {
|
|
5198
|
+
const v = typeof n === "string" && n.trim() !== "" ? Number(n) : n;
|
|
5199
|
+
if (typeof v !== "number" || !Number.isFinite(v))
|
|
5200
|
+
return "";
|
|
5201
|
+
const rounded = Math.round(v * 100) / 100;
|
|
5202
|
+
const decimals = opts.decimals ?? (Number.isInteger(rounded) ? 0 : 2);
|
|
5203
|
+
const out = nf(decimals).format(rounded);
|
|
5204
|
+
// No "-0".
|
|
5205
|
+
return out === "-0" || /^-0(\.0+)?$/.test(out) ? out.slice(1) : out;
|
|
5206
|
+
}
|
|
5207
|
+
/** The month's number 1–12 from "2026-01", "2026-01-15", a Date, or 1–12; null when none. */
|
|
5208
|
+
function monthOf(m) {
|
|
5209
|
+
if (m instanceof Date)
|
|
5210
|
+
return Number.isNaN(m.getTime()) ? null : { month: m.getMonth() + 1, year: m.getFullYear() };
|
|
5211
|
+
if (typeof m === "number")
|
|
5212
|
+
return Number.isInteger(m) && m >= 1 && m <= 12 ? { month: m } : null;
|
|
5213
|
+
if (typeof m === "string") {
|
|
5214
|
+
const iso = /^(\d{4})-(\d{1,2})(?:-\d{1,2})?/.exec(m.trim());
|
|
5215
|
+
if (iso) {
|
|
5216
|
+
const month = Number(iso[2]);
|
|
5217
|
+
return month >= 1 && month <= 12 ? { month, year: Number(iso[1]) } : null;
|
|
5218
|
+
}
|
|
5219
|
+
}
|
|
5220
|
+
return null;
|
|
5221
|
+
}
|
|
5222
|
+
/** "2026-01" (or a Date, or 1) → "ม.ค."; "" when it is not a month. */
|
|
5223
|
+
function thaiMonth(m) {
|
|
5224
|
+
const x = monthOf(m);
|
|
5225
|
+
return x ? THAI_MONTHS_SHORT[x.month - 1] : "";
|
|
5226
|
+
}
|
|
5227
|
+
/**
|
|
5228
|
+
* "2026-01" → "ม.ค. 69" (the Buddhist year's last two digits), or with
|
|
5229
|
+
* `{ year: "full" }` "ม.ค. 2569"; just the month when no year is known.
|
|
5230
|
+
*/
|
|
5231
|
+
function thaiMonthYear(m, opts = {}) {
|
|
5232
|
+
const x = monthOf(m);
|
|
5233
|
+
if (!x)
|
|
5234
|
+
return "";
|
|
5235
|
+
const name = THAI_MONTHS_SHORT[x.month - 1];
|
|
5236
|
+
if (x.year === undefined)
|
|
5237
|
+
return name;
|
|
5238
|
+
const be = x.year + 543;
|
|
5239
|
+
return `${name} ${opts.year === "full" ? be : String(be).slice(-2)}`;
|
|
5240
|
+
}
|
|
5241
|
+
|
|
5242
|
+
export { API_EVENT as A, usePageGuides as B, useSectionGuideValues as C, 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, openGuidePanel as o, openPageGuide as p, openPagesPanel as q, openSectionGuide as r, reportApiCall as s, reportApiEvent as t, setSectionGuideValues as u, storeGuides as v, thaiMonth as w, thaiMonthYear as x, useGuideEntry as y, usePageGuideEntry as z };
|
|
5243
|
+
//# sourceMappingURL=format-DCTuLpBU.esm.js.map
|