@eintrek/erp-shell 0.1.73 → 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 +63 -0
- package/dist/chunks/{guide-runtime-CLVSNkTN.cjs → format-CVazK0hP.cjs} +1371 -59
- package/dist/chunks/format-CVazK0hP.cjs.map +1 -0
- package/dist/chunks/{guide-runtime-C7Z00QwM.esm.js → format-DCTuLpBU.esm.js} +1360 -61
- package/dist/chunks/format-DCTuLpBU.esm.js.map +1 -0
- package/dist/guide/core.d.ts +5 -3
- package/dist/guide/format.d.ts +26 -0
- package/dist/guide/guide-store.d.ts +72 -6
- package/dist/guide/guide-styles.d.ts +1 -1
- package/dist/guide/guide-ui.d.ts +38 -0
- package/dist/guide/machine.d.ts +139 -3
- package/dist/guide/pages.d.ts +79 -0
- package/dist/guide/progress-sync.d.ts +10 -1
- package/dist/guide/progress.d.ts +27 -0
- package/dist/guide/section-button.d.ts +12 -0
- package/dist/guide/types.d.ts +111 -0
- package/dist/guide-core.cjs +32 -19
- package/dist/guide-core.cjs.map +1 -1
- package/dist/guide-core.esm.js +1 -1
- package/dist/help/help-button.d.ts +6 -3
- package/dist/index.cjs +56 -34
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +5 -2
- package/dist/index.esm.js +22 -13
- 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-C7Z00QwM.esm.js.map +0 -1
- package/dist/chunks/guide-runtime-CLVSNkTN.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, 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,15 +337,54 @@ function publishGuideEntry(next, openPanel) {
|
|
|
337
337
|
emit();
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
|
+
const NO_PAGES = { page: null, listed: 0, sections: [] };
|
|
341
|
+
let pages = NO_PAGES;
|
|
342
|
+
let openPageImpl = null;
|
|
343
|
+
let openPagesImpl = null;
|
|
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) {
|
|
348
|
+
const n = next ?? NO_PAGES;
|
|
349
|
+
openPageImpl = openPage;
|
|
350
|
+
openPagesImpl = openPages;
|
|
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);
|
|
355
|
+
if (!same) {
|
|
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;
|
|
361
|
+
emit();
|
|
362
|
+
}
|
|
363
|
+
}
|
|
364
|
+
function guideMenuItems(e, p = NO_PAGES) {
|
|
365
|
+
const items = [];
|
|
366
|
+
if (p.page)
|
|
367
|
+
items.push("page");
|
|
368
|
+
if ((p.sections ?? []).length > 0)
|
|
369
|
+
items.push("sections");
|
|
370
|
+
if (e && e.total > 0)
|
|
371
|
+
items.push("missions");
|
|
372
|
+
if (p.listed > 0)
|
|
373
|
+
items.push("pages");
|
|
374
|
+
if (items.length)
|
|
375
|
+
items.push("help");
|
|
376
|
+
return items;
|
|
377
|
+
}
|
|
340
378
|
/**
|
|
341
|
-
* Whether the "?" menu
|
|
342
|
-
*
|
|
343
|
-
*
|
|
344
|
-
*
|
|
345
|
-
*
|
|
379
|
+
* Whether the "?" press opens a menu rather than the help dialog: a tour
|
|
380
|
+
* with missions shown, a guide for this page, or page guides to list. A tour
|
|
381
|
+
* whose missions are all hidden for this user (`visible`, e.g. Plannex
|
|
382
|
+
* without an employee record) has nothing to open, and "0/0" led to an
|
|
383
|
+
* empty panel. The entry itself stays published: apps wait on it before
|
|
384
|
+
* they load the facts that make missions visible.
|
|
346
385
|
*/
|
|
347
|
-
function guideMenuShown(e) {
|
|
348
|
-
return
|
|
386
|
+
function guideMenuShown(e, p = NO_PAGES) {
|
|
387
|
+
return guideMenuItems(e, p).length > 0;
|
|
349
388
|
}
|
|
350
389
|
/** Opens the tour's quest panel, when this user has a tour here. */
|
|
351
390
|
function openGuidePanel() {
|
|
@@ -355,6 +394,413 @@ function openGuidePanel() {
|
|
|
355
394
|
function useGuideEntry() {
|
|
356
395
|
return useSyncExternalStore(subscribe, () => entry, () => null);
|
|
357
396
|
}
|
|
397
|
+
/** Opens this page's guide ("คู่มือหน้านี้"), when there is one. */
|
|
398
|
+
function openPageGuide() {
|
|
399
|
+
openPageImpl?.();
|
|
400
|
+
}
|
|
401
|
+
/** Opens the panel on the page guides alone ("คู่มือทุกหน้า"). */
|
|
402
|
+
function openPagesPanel() {
|
|
403
|
+
openPagesImpl?.();
|
|
404
|
+
}
|
|
405
|
+
/** Page guides here (this page's, and the listed count); stable until they change. */
|
|
406
|
+
function usePageGuides() {
|
|
407
|
+
return useSyncExternalStore(subscribe, () => pages, () => NO_PAGES);
|
|
408
|
+
}
|
|
409
|
+
/**
|
|
410
|
+
* This page's guide: `{ title, open }`, or null when the page (or the
|
|
411
|
+
* sub-screen, `when`) has none for this person. For an app that draws its
|
|
412
|
+
* own "?" (LIFF's page head): draw it only when this is not null.
|
|
413
|
+
*/
|
|
414
|
+
function usePageGuideEntry() {
|
|
415
|
+
const p = usePageGuides();
|
|
416
|
+
const title = p.page?.title ?? null;
|
|
417
|
+
return useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
|
|
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
|
+
}
|
|
492
|
+
|
|
493
|
+
/** The synthetic mission key of a page guide: `page:<key>`. */
|
|
494
|
+
const PAGE_PREFIX = "page:";
|
|
495
|
+
/** A page guide's key: lower case, digits and hyphens. */
|
|
496
|
+
const PAGE_KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
497
|
+
/** The server's row key limit (`<app>.page.<key>`). */
|
|
498
|
+
const ROW_KEY_MAX = 64;
|
|
499
|
+
function isPageMissionKey(key) {
|
|
500
|
+
return !!key && key.startsWith(PAGE_PREFIX);
|
|
501
|
+
}
|
|
502
|
+
function pageKeyOf(missionKey) {
|
|
503
|
+
return isPageMissionKey(missionKey) ? missionKey.slice(PAGE_PREFIX.length) : undefined;
|
|
504
|
+
}
|
|
505
|
+
function devWarn(message) {
|
|
506
|
+
try {
|
|
507
|
+
// Written out in full so the app's bundler replaces it.
|
|
508
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV === "production")
|
|
509
|
+
return;
|
|
510
|
+
if (typeof console !== "undefined")
|
|
511
|
+
console.warn(`[erp-shell guide] ${message}`);
|
|
512
|
+
}
|
|
513
|
+
catch {
|
|
514
|
+
/* never breaks the app */
|
|
515
|
+
}
|
|
516
|
+
}
|
|
517
|
+
/** Why a page guide cannot run, or null when it can. */
|
|
518
|
+
function pageProblem(app, page) {
|
|
519
|
+
if (!page || typeof page.key !== "string" || !PAGE_KEY_RE.test(page.key))
|
|
520
|
+
return "its key is not [a-z0-9-]";
|
|
521
|
+
if (`${app}.page.${page.key}`.length > ROW_KEY_MAX)
|
|
522
|
+
return `its row key is over ${ROW_KEY_MAX} characters`;
|
|
523
|
+
if (!Number.isInteger(page.version) || page.version < 1)
|
|
524
|
+
return "its version is not a whole number ≥ 1";
|
|
525
|
+
const routes = Array.isArray(page.route) ? page.route : [page.route];
|
|
526
|
+
if (!routes.length || routes.some(r => typeof r !== "string" || !r.startsWith("/")))
|
|
527
|
+
return "it has no route";
|
|
528
|
+
if (!Array.isArray(page.steps) || page.steps.length === 0)
|
|
529
|
+
return "it has no steps";
|
|
530
|
+
for (const st of page.steps) {
|
|
531
|
+
const kind = st.kind ?? "click";
|
|
532
|
+
if (kind !== "view" && kind !== "click")
|
|
533
|
+
return `step "${st.key}" is a ${kind} step (a page guide never saves)`;
|
|
534
|
+
}
|
|
535
|
+
return null;
|
|
536
|
+
}
|
|
537
|
+
const validCache = new WeakMap();
|
|
538
|
+
const warned = new Set();
|
|
539
|
+
/**
|
|
540
|
+
* The suite's page guides that can run. A page guide with a commit, input or
|
|
541
|
+
* select step (it would save), a bad key or no route is dropped, with a
|
|
542
|
+
* warning in development; so is a second one with the same key.
|
|
543
|
+
*/
|
|
544
|
+
function validPages(suite) {
|
|
545
|
+
const hit = validCache.get(suite);
|
|
546
|
+
if (hit)
|
|
547
|
+
return hit;
|
|
548
|
+
const out = [];
|
|
549
|
+
const seen = new Set();
|
|
550
|
+
for (const page of suite.pages ?? []) {
|
|
551
|
+
let problem = pageProblem(suite.app, page);
|
|
552
|
+
if (!problem && seen.has(page.key))
|
|
553
|
+
problem = "another page guide has the same key";
|
|
554
|
+
if (problem) {
|
|
555
|
+
const id = `${suite.key}:${page?.key}:${problem}`;
|
|
556
|
+
if (!warned.has(id)) {
|
|
557
|
+
warned.add(id);
|
|
558
|
+
devWarn(`page guide "${page?.key}" dropped: ${problem}`);
|
|
559
|
+
}
|
|
560
|
+
continue;
|
|
561
|
+
}
|
|
562
|
+
seen.add(page.key);
|
|
563
|
+
out.push(page);
|
|
564
|
+
}
|
|
565
|
+
validCache.set(suite, out);
|
|
566
|
+
return out;
|
|
567
|
+
}
|
|
568
|
+
function findPage(suite, key) {
|
|
569
|
+
return key ? validPages(suite).find(p => p.key === key) : undefined;
|
|
570
|
+
}
|
|
571
|
+
const missionCache = new WeakMap();
|
|
572
|
+
/**
|
|
573
|
+
* The page guide as a mission the machine plays: `page:<key>`. Steps lose
|
|
574
|
+
* any `route` (the guide is the page's, and ends when it is left) and are
|
|
575
|
+
* checkpoints unless they say otherwise.
|
|
576
|
+
*/
|
|
577
|
+
function pageMission(page) {
|
|
578
|
+
const hit = missionCache.get(page);
|
|
579
|
+
if (hit)
|
|
580
|
+
return hit;
|
|
581
|
+
const steps = page.steps.map(st => {
|
|
582
|
+
const { route: _route, resumeUrl: _resume, ...rest } = st;
|
|
583
|
+
return { ...rest, checkpoint: st.checkpoint ?? true };
|
|
584
|
+
});
|
|
585
|
+
const m = { key: PAGE_PREFIX + page.key, title: page.title, minutes: 1, steps };
|
|
586
|
+
missionCache.set(page, m);
|
|
587
|
+
return m;
|
|
588
|
+
}
|
|
589
|
+
function routesOf(page) {
|
|
590
|
+
return Array.isArray(page.route) ? page.route : [page.route];
|
|
591
|
+
}
|
|
592
|
+
/** How specific a route pattern is: its `?k=v` constraints, then its static segments. */
|
|
593
|
+
function routeSpecificity(pattern) {
|
|
594
|
+
const q = pattern.indexOf("?");
|
|
595
|
+
const path = q < 0 ? pattern : pattern.slice(0, q);
|
|
596
|
+
const query = q < 0 ? 0 : [...new URLSearchParams(pattern.slice(q + 1)).keys()].length;
|
|
597
|
+
const statics = path.split("/").filter(seg => seg && !/^\[.*\]$/.test(seg) && !seg.startsWith(":")).length;
|
|
598
|
+
return [query, statics];
|
|
599
|
+
}
|
|
600
|
+
/**
|
|
601
|
+
* Whether the page guide applies here: one of its routes matches and its
|
|
602
|
+
* `when` anchor (if any) is on screen. The score ranks matches: `when`
|
|
603
|
+
* first, then query constraints, then static segments. Null when it does
|
|
604
|
+
* not apply.
|
|
605
|
+
*/
|
|
606
|
+
function pageMatchScore(page, where) {
|
|
607
|
+
let best = null;
|
|
608
|
+
for (const r of routesOf(page)) {
|
|
609
|
+
if (!matchRoute(r, where.pathname, where.search, where.code))
|
|
610
|
+
continue;
|
|
611
|
+
const s = routeSpecificity(r);
|
|
612
|
+
if (!best || s[0] > best[0] || (s[0] === best[0] && s[1] > best[1]))
|
|
613
|
+
best = s;
|
|
614
|
+
}
|
|
615
|
+
if (!best)
|
|
616
|
+
return null;
|
|
617
|
+
if (page.when) {
|
|
618
|
+
let on = false;
|
|
619
|
+
try {
|
|
620
|
+
on = where.isVisible(page.when);
|
|
621
|
+
}
|
|
622
|
+
catch {
|
|
623
|
+
on = false;
|
|
624
|
+
}
|
|
625
|
+
if (!on)
|
|
626
|
+
return null;
|
|
627
|
+
}
|
|
628
|
+
return [page.when ? 1 : 0, best[0], best[1]];
|
|
629
|
+
}
|
|
630
|
+
const after = (a, b) => a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2];
|
|
631
|
+
/** The most specific page guide that applies here, whoever it is for (first in the list on a tie). */
|
|
632
|
+
function matchingPage(suite, where) {
|
|
633
|
+
let best = null;
|
|
634
|
+
let bestScore = null;
|
|
635
|
+
for (const p of validPages(suite)) {
|
|
636
|
+
const s = pageMatchScore(p, where);
|
|
637
|
+
if (s && (!bestScore || after(s, bestScore))) {
|
|
638
|
+
best = p;
|
|
639
|
+
bestScore = s;
|
|
640
|
+
}
|
|
641
|
+
}
|
|
642
|
+
return best;
|
|
643
|
+
}
|
|
644
|
+
/** Whether this person has the page guide: `visible` (only true) and `requires`. */
|
|
645
|
+
function pageShown(page, ctx) {
|
|
646
|
+
try {
|
|
647
|
+
if (page.visible && page.visible(ctx) !== true)
|
|
648
|
+
return false;
|
|
649
|
+
if (page.requires && !ctx.can(page.requires))
|
|
650
|
+
return false;
|
|
651
|
+
return true;
|
|
652
|
+
}
|
|
653
|
+
catch {
|
|
654
|
+
return false;
|
|
655
|
+
}
|
|
656
|
+
}
|
|
657
|
+
/**
|
|
658
|
+
* This page's guide for this person: the most specific match, when it is
|
|
659
|
+
* theirs. A more specific guide they do not have is not replaced by a less
|
|
660
|
+
* specific one (it would describe what is not on screen).
|
|
661
|
+
*/
|
|
662
|
+
function currentPage(suite, where, ctx) {
|
|
663
|
+
const p = matchingPage(suite, where);
|
|
664
|
+
return p && pageShown(p, ctx) ? p : null;
|
|
665
|
+
}
|
|
666
|
+
/** The page guides this person has, for the panel's list (those with an `href`). */
|
|
667
|
+
function listedPages(suite, ctx) {
|
|
668
|
+
return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
|
|
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
|
+
}
|
|
358
804
|
|
|
359
805
|
/* ------------------------------------------------------------------ */
|
|
360
806
|
/* Copy */
|
|
@@ -368,6 +814,8 @@ const COPY = {
|
|
|
368
814
|
replayEnded: "จบการทบทวนแล้ว",
|
|
369
815
|
hiddenToast: "ซ่อนแล้ว",
|
|
370
816
|
aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
|
|
817
|
+
pageDone: "จบคู่มือหน้านี้",
|
|
818
|
+
sectionDone: "จบคำอธิบายส่วนนี้",
|
|
371
819
|
bridgeTitle: "กดปุ่มเมนู",
|
|
372
820
|
bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
|
|
373
821
|
};
|
|
@@ -375,8 +823,15 @@ const COPY = {
|
|
|
375
823
|
/* Helpers */
|
|
376
824
|
/* ------------------------------------------------------------------ */
|
|
377
825
|
const BRIDGE_TARGET = "shell.menu-toggle";
|
|
378
|
-
function initialGuideState(audience, progress) {
|
|
826
|
+
function initialGuideState(audience, progress, pages) {
|
|
379
827
|
return {
|
|
828
|
+
pageAudience: pages?.pageAudience ?? "none",
|
|
829
|
+
pagesMode: pages?.pagesMode ?? "off",
|
|
830
|
+
pages: pages?.pages ?? {},
|
|
831
|
+
sections: pages?.sections ?? {},
|
|
832
|
+
pageRun: null,
|
|
833
|
+
panelSection: "all",
|
|
834
|
+
autoPassed: null,
|
|
380
835
|
audience,
|
|
381
836
|
progress,
|
|
382
837
|
screen: { name: "idle" },
|
|
@@ -390,7 +845,17 @@ function initialGuideState(audience, progress) {
|
|
|
390
845
|
};
|
|
391
846
|
}
|
|
392
847
|
function findMission(suite, key) {
|
|
393
|
-
|
|
848
|
+
if (!key)
|
|
849
|
+
return undefined;
|
|
850
|
+
if (isPageMissionKey(key)) {
|
|
851
|
+
const page = findPage(suite, pageKeyOf(key));
|
|
852
|
+
return page ? pageMission(page) : undefined;
|
|
853
|
+
}
|
|
854
|
+
if (isSectionMissionKey(key)) {
|
|
855
|
+
const section = findSection(suite, sectionKeyOf(key));
|
|
856
|
+
return section ? sectionMission(section) : undefined;
|
|
857
|
+
}
|
|
858
|
+
return suite.missions.find(m => m.key === key);
|
|
394
859
|
}
|
|
395
860
|
/**
|
|
396
861
|
* Whether the user has this mission (GuideMission.visible). Without the
|
|
@@ -427,6 +892,7 @@ function guideContext(env, data, params = {}) {
|
|
|
427
892
|
facts: env.facts,
|
|
428
893
|
can: env.can,
|
|
429
894
|
visible: (anchor) => env.isVisible(anchor),
|
|
895
|
+
values: env.values ?? {},
|
|
430
896
|
};
|
|
431
897
|
}
|
|
432
898
|
function resolveText(t, ctx) {
|
|
@@ -553,8 +1019,10 @@ function placeRoute(steps, index) {
|
|
|
553
1019
|
return step.route;
|
|
554
1020
|
return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
|
|
555
1021
|
}
|
|
556
|
-
/** The user is not on the page the step happens on. */
|
|
557
|
-
function offRoute(steps, index, env) {
|
|
1022
|
+
/** The user is not on the page the step happens on (a page guide's step never is: leaving the page ends it). */
|
|
1023
|
+
function offRoute(steps, index, env, page) {
|
|
1024
|
+
if (page)
|
|
1025
|
+
return false;
|
|
558
1026
|
const home = stepHomeRoute(steps, index);
|
|
559
1027
|
return !!home && !matchRoute(home, env.pathname, env.search, env.code);
|
|
560
1028
|
}
|
|
@@ -697,7 +1165,8 @@ function welcomeDue(state, suite, ctx) {
|
|
|
697
1165
|
state.progress.welcome === "not_seen" &&
|
|
698
1166
|
state.progress.status === "in_progress" &&
|
|
699
1167
|
!state.progress.mission_key &&
|
|
700
|
-
|
|
1168
|
+
// The missions' welcome always wins over a page guide's offer.
|
|
1169
|
+
(state.screen.name === "idle" || state.screen.name === "pageOffer") &&
|
|
701
1170
|
(ctx ? shownMissions(suite, ctx).length : suite.missions.length) > 0);
|
|
702
1171
|
}
|
|
703
1172
|
/** Whether the floating "ภารกิจ n/N" chip shows. */
|
|
@@ -737,7 +1206,7 @@ function announceStep(d, suite, env) {
|
|
|
737
1206
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
738
1207
|
const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
|
|
739
1208
|
const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
|
|
740
|
-
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));
|
|
741
1210
|
d.effects.push({
|
|
742
1211
|
type: "announce",
|
|
743
1212
|
text: `ขั้นที่ ${pos.no} จาก ${pos.total}. ${title}${why ? `. ${why}` : ""}`,
|
|
@@ -748,7 +1217,7 @@ function announceStep(d, suite, env) {
|
|
|
748
1217
|
* whose `requires` they lack or whose `skipIf` holds is left out (the step on
|
|
749
1218
|
* screen always counts).
|
|
750
1219
|
*/
|
|
751
|
-
function stepPosition(mission, index, env, context) {
|
|
1220
|
+
function stepPosition(mission, index, env, context, autoPassed = []) {
|
|
752
1221
|
let no = 0;
|
|
753
1222
|
let total = 0;
|
|
754
1223
|
mission.steps.forEach((step, i) => {
|
|
@@ -758,6 +1227,12 @@ function stepPosition(mission, index, env, context) {
|
|
|
758
1227
|
const ctx = guideContext(env, context, routeParamsFor(step, env));
|
|
759
1228
|
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
760
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;
|
|
761
1236
|
}
|
|
762
1237
|
total++;
|
|
763
1238
|
if (i <= index)
|
|
@@ -765,6 +1240,17 @@ function stepPosition(mission, index, env, context) {
|
|
|
765
1240
|
});
|
|
766
1241
|
return { no: Math.max(no, 1), total: Math.max(total, 1) };
|
|
767
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
|
+
}
|
|
768
1254
|
/** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
|
|
769
1255
|
function plainTitle(t) {
|
|
770
1256
|
return t.replace(/\[([^\]]+)\]/g, "$1");
|
|
@@ -777,6 +1263,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
777
1263
|
const steps = mission.steps;
|
|
778
1264
|
let i = Math.max(0, index);
|
|
779
1265
|
let firstPass = true;
|
|
1266
|
+
if (d.state.autoPassed?.mission !== mission.key)
|
|
1267
|
+
d.state = { ...d.state, autoPassed: { mission: mission.key, steps: [] } };
|
|
780
1268
|
while (i < steps.length) {
|
|
781
1269
|
const step = steps[i];
|
|
782
1270
|
const params = routeParamsFor(step, env);
|
|
@@ -798,6 +1286,9 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
798
1286
|
const held = stateTriggerHolds(step, env);
|
|
799
1287
|
if (held) {
|
|
800
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] } };
|
|
801
1292
|
i++;
|
|
802
1293
|
continue;
|
|
803
1294
|
}
|
|
@@ -810,8 +1301,11 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
810
1301
|
}
|
|
811
1302
|
const step = steps[i];
|
|
812
1303
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1304
|
+
const page = pageKeyOf(mission.key);
|
|
1305
|
+
const section = sectionKeyOf(mission.key);
|
|
813
1306
|
const where = placeRoute(steps, i);
|
|
814
|
-
|
|
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);
|
|
815
1309
|
const bridge = !away && needsBridge(step, env, ctx);
|
|
816
1310
|
const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
|
|
817
1311
|
setScreen(d, {
|
|
@@ -828,6 +1322,8 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
828
1322
|
nothingToSave: nts,
|
|
829
1323
|
status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
|
|
830
1324
|
replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
|
|
1325
|
+
...(page ? { page } : {}),
|
|
1326
|
+
...(section ? { section } : {}),
|
|
831
1327
|
});
|
|
832
1328
|
if (!opts.replay) {
|
|
833
1329
|
const cp = checkpointAtOrBefore(steps, i);
|
|
@@ -992,6 +1488,11 @@ function advance(d, suite, env, capture) {
|
|
|
992
1488
|
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
993
1489
|
}
|
|
994
1490
|
function finishMission(d, suite, env, mission, replay) {
|
|
1491
|
+
if (isPageMissionKey(mission.key) || isSectionMissionKey(mission.key)) {
|
|
1492
|
+
endPage(d, suite, "completed");
|
|
1493
|
+
d.effects.push({ type: "toast", text: isSectionMissionKey(mission.key) ? COPY.sectionDone : COPY.pageDone });
|
|
1494
|
+
return;
|
|
1495
|
+
}
|
|
995
1496
|
if (replay) {
|
|
996
1497
|
setScreen(d, { name: "idle" });
|
|
997
1498
|
d.effects.push({ type: "toast", text: COPY.replayDone });
|
|
@@ -1093,7 +1594,8 @@ function startMission(d, suite, env, key, replay) {
|
|
|
1093
1594
|
// A mission already done is only ever replayed; its done status stays.
|
|
1094
1595
|
const isReplay = replay || entry?.status === "done";
|
|
1095
1596
|
const before = d.state.progress;
|
|
1096
|
-
|
|
1597
|
+
// A new run: what passed by itself is counted afresh.
|
|
1598
|
+
d.state = { ...d.state, panelOpen: false, justDone: null, autoPassed: null };
|
|
1097
1599
|
if (d.state.progress.welcome === "not_seen")
|
|
1098
1600
|
withProgress(d, { welcome: "started" });
|
|
1099
1601
|
if (!isReplay && d.state.progress.mission_key !== key)
|
|
@@ -1185,6 +1687,193 @@ function pushAbort(d, suite, s) {
|
|
|
1185
1687
|
if (actions.length)
|
|
1186
1688
|
d.effects.push({ type: "abort", actions });
|
|
1187
1689
|
}
|
|
1690
|
+
/* ------------------------------------------------------------------ */
|
|
1691
|
+
/* Page guides */
|
|
1692
|
+
/* ------------------------------------------------------------------ */
|
|
1693
|
+
function whereOf(env) {
|
|
1694
|
+
return { code: env.code, pathname: env.pathname, search: env.search, isVisible: a => env.isVisible(a) };
|
|
1695
|
+
}
|
|
1696
|
+
/** This page's guide for this person (the most specific match, if theirs). */
|
|
1697
|
+
function pageHere(suite, env) {
|
|
1698
|
+
return currentPage(suite, whereOf(env), guideContext(env, {}));
|
|
1699
|
+
}
|
|
1700
|
+
/** The page guide's row says this person has ended this version of it. */
|
|
1701
|
+
function pageRecorded(state, page) {
|
|
1702
|
+
const row = state.pages[page.key];
|
|
1703
|
+
return !!row && row.version >= page.version;
|
|
1704
|
+
}
|
|
1705
|
+
/** Whether the first step this person sees has its target on screen (or none). */
|
|
1706
|
+
function firstStepReady(suite, pageKey, env) {
|
|
1707
|
+
const page = findPage(suite, pageKey);
|
|
1708
|
+
if (!page)
|
|
1709
|
+
return false;
|
|
1710
|
+
const mission = pageMission(page);
|
|
1711
|
+
for (const step of mission.steps) {
|
|
1712
|
+
const ctx = guideContext(env, {});
|
|
1713
|
+
if (step.requires && !safe(() => env.can(step.requires), true))
|
|
1714
|
+
continue;
|
|
1715
|
+
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
1716
|
+
continue;
|
|
1717
|
+
return stepTargetVisible(step, env, ctx, false);
|
|
1718
|
+
}
|
|
1719
|
+
return true;
|
|
1720
|
+
}
|
|
1721
|
+
/**
|
|
1722
|
+
* The page guide to offer now, or null. Due when: pages mode is "offer" and
|
|
1723
|
+
* this person has page guides; nothing else is up (no step, card, panel or
|
|
1724
|
+
* celebration waiting); this page's guide is theirs, may be offered, and has
|
|
1725
|
+
* no row of this version; its first step's target is on screen; no app
|
|
1726
|
+
* dialog is open. The host adds the rest: the welcome not due on this
|
|
1727
|
+
* route, a moment of nothing loading, the primary tab, not hidden.
|
|
1728
|
+
*/
|
|
1729
|
+
function pageOfferDue(state, suite, env) {
|
|
1730
|
+
if (state.pagesMode !== "offer" || state.pageAudience === "none")
|
|
1731
|
+
return null;
|
|
1732
|
+
if (state.screen.name !== "idle" || state.panelOpen || (state.pendingCelebrate?.length ?? 0) > 0)
|
|
1733
|
+
return null;
|
|
1734
|
+
const page = pageHere(suite, env);
|
|
1735
|
+
if (!page || page.offer === false || pageRecorded(state, page))
|
|
1736
|
+
return null;
|
|
1737
|
+
if (!firstStepReady(suite, page.key, env))
|
|
1738
|
+
return null;
|
|
1739
|
+
if (safe(() => env.dialogOpen?.() === true, false))
|
|
1740
|
+
return null;
|
|
1741
|
+
return page.key;
|
|
1742
|
+
}
|
|
1743
|
+
/** Whether the page guide still applies where the person is (its route, its `when`). */
|
|
1744
|
+
function pageStillHere(suite, key, env) {
|
|
1745
|
+
const page = findPage(suite, key);
|
|
1746
|
+
return !!page && pageMatchScore(page, whereOf(env)) !== null;
|
|
1747
|
+
}
|
|
1748
|
+
/** Write a page guide's row (once per page and version): recorded here at once, saved by the host. */
|
|
1749
|
+
function recordPage(d, suite, key, status) {
|
|
1750
|
+
const page = findPage(suite, key);
|
|
1751
|
+
if (!page || pageRecorded(d.state, page))
|
|
1752
|
+
return;
|
|
1753
|
+
const prev = d.state.pages[key];
|
|
1754
|
+
d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
|
|
1755
|
+
d.effects.push({ type: "savePage", key, version: page.version, status });
|
|
1756
|
+
}
|
|
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. */
|
|
1789
|
+
function endPage(d, suite, how) {
|
|
1790
|
+
const run = d.state.pageRun;
|
|
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
|
+
}
|
|
1797
|
+
d.state = { ...d.state, pageRun: null, replayPage: null };
|
|
1798
|
+
setScreen(d, { name: "idle" });
|
|
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
|
+
}
|
|
1823
|
+
function startPage(d, suite, env, key, write) {
|
|
1824
|
+
const page = findPage(suite, key);
|
|
1825
|
+
if (!page)
|
|
1826
|
+
return;
|
|
1827
|
+
const mission = pageMission(page);
|
|
1828
|
+
d.state = {
|
|
1829
|
+
...d.state,
|
|
1830
|
+
panelOpen: false,
|
|
1831
|
+
justDone: null,
|
|
1832
|
+
pageRun: { key, write, kind: "page" },
|
|
1833
|
+
autoPassed: null,
|
|
1834
|
+
// What is open now is the person's: ending the guide leaves it alone.
|
|
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
|
+
}
|
|
1843
|
+
/** The step before this one that this person sees, or -1. */
|
|
1844
|
+
function previousShownIndex(mission, index, env, context) {
|
|
1845
|
+
for (let j = index - 1; j >= 0; j--) {
|
|
1846
|
+
const st = mission.steps[j];
|
|
1847
|
+
const ctx = guideContext(env, context, routeParamsFor(st, env));
|
|
1848
|
+
if (st.requires && !safe(() => env.can(st.requires), true))
|
|
1849
|
+
continue;
|
|
1850
|
+
if (st.skipIf && safe(() => st.skipIf(ctx), false))
|
|
1851
|
+
continue;
|
|
1852
|
+
return j;
|
|
1853
|
+
}
|
|
1854
|
+
return -1;
|
|
1855
|
+
}
|
|
1856
|
+
/**
|
|
1857
|
+
* Where ย้อนกลับ goes from a page guide's step, or null. The step before it
|
|
1858
|
+
* that this person sees; from inside what a click opened (not a
|
|
1859
|
+
* checkpoint) back to that click, closing it first (the step's abort,
|
|
1860
|
+
* Escape by default). A click step whose result is still on screen (a tab
|
|
1861
|
+
* switched) is passed over: entering it again would pass it at once.
|
|
1862
|
+
*/
|
|
1863
|
+
function backTarget(mission, index, env, context) {
|
|
1864
|
+
const cur = mission.steps[index];
|
|
1865
|
+
let prev = previousShownIndex(mission, index, env, context);
|
|
1866
|
+
if (prev < 0 || !cur)
|
|
1867
|
+
return null;
|
|
1868
|
+
if (cur.checkpoint === false) {
|
|
1869
|
+
const to = mission.steps[prev];
|
|
1870
|
+
return { index: prev, close: to.checkpoint !== false };
|
|
1871
|
+
}
|
|
1872
|
+
while (prev >= 0 && stepKind(mission.steps[prev]) !== "view" && stateTriggerHolds(mission.steps[prev], env)) {
|
|
1873
|
+
prev = previousShownIndex(mission, prev, env, context);
|
|
1874
|
+
}
|
|
1875
|
+
return prev >= 0 ? { index: prev, close: false } : null;
|
|
1876
|
+
}
|
|
1188
1877
|
function reduceGuide(state, action, suite, env) {
|
|
1189
1878
|
const d = { state, effects: [] };
|
|
1190
1879
|
const s = state.screen;
|
|
@@ -1196,6 +1885,9 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1196
1885
|
const gate = gateState(suite, ctxNow());
|
|
1197
1886
|
if (gate === undefined)
|
|
1198
1887
|
break;
|
|
1888
|
+
// The welcome wins: the page offer goes, unrecorded (it comes back on a later visit).
|
|
1889
|
+
if (s.name === "pageOffer")
|
|
1890
|
+
setScreen(d, { name: "idle" });
|
|
1199
1891
|
if (gate === false) {
|
|
1200
1892
|
if (!state.gateDismissed)
|
|
1201
1893
|
setScreen(d, { name: "gate" });
|
|
@@ -1236,6 +1928,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1236
1928
|
case "start":
|
|
1237
1929
|
if (state.audience === "none")
|
|
1238
1930
|
break;
|
|
1931
|
+
// A mission always wins over a page guide: a running one ends (its row
|
|
1932
|
+
// written if due), an offer goes unrecorded.
|
|
1933
|
+
if (readOf(s))
|
|
1934
|
+
endPage(d, suite, "skipped");
|
|
1935
|
+
else if (s.name === "pageOffer")
|
|
1936
|
+
setScreen(d, { name: "idle" });
|
|
1239
1937
|
if (s.name === "step" && !s.replay && s.mission !== action.mission) {
|
|
1240
1938
|
// Leaving one mission for another: the first is set aside.
|
|
1241
1939
|
exitMission(d);
|
|
@@ -1253,12 +1951,27 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1253
1951
|
const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
|
|
1254
1952
|
const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
|
|
1255
1953
|
const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
|
|
1954
|
+
d.state = { ...d.state, autoPassed: null };
|
|
1256
1955
|
enterStep(d, suite, env, mission, index, { replay: false });
|
|
1257
1956
|
break;
|
|
1258
1957
|
}
|
|
1259
1958
|
case "sync": {
|
|
1959
|
+
if (s.name === "pageOffer") {
|
|
1960
|
+
// Ignored: the person left the page (or its sub-screen). Recorded.
|
|
1961
|
+
if (!pageStillHere(suite, s.page, env)) {
|
|
1962
|
+
recordPage(d, suite, s.page, "skipped");
|
|
1963
|
+
setScreen(d, { name: "idle" });
|
|
1964
|
+
}
|
|
1965
|
+
break;
|
|
1966
|
+
}
|
|
1260
1967
|
if (s.name !== "step")
|
|
1261
1968
|
break;
|
|
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.
|
|
1972
|
+
endPage(d, suite, "skipped");
|
|
1973
|
+
break;
|
|
1974
|
+
}
|
|
1262
1975
|
const mission = findMission(suite, s.mission);
|
|
1263
1976
|
if (!mission)
|
|
1264
1977
|
break;
|
|
@@ -1288,7 +2001,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1288
2001
|
break;
|
|
1289
2002
|
}
|
|
1290
2003
|
}
|
|
1291
|
-
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env)) {
|
|
2004
|
+
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1292
2005
|
// The target is missing because the page is another one.
|
|
1293
2006
|
recoverOffRoute(d, suite, env, false);
|
|
1294
2007
|
break;
|
|
@@ -1404,7 +2117,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1404
2117
|
const cur = d.state.screen;
|
|
1405
2118
|
if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
|
|
1406
2119
|
break;
|
|
1407
|
-
if (!cur.bridge && offRoute(mission.steps, cur.index, env)) {
|
|
2120
|
+
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page ?? cur.section)) {
|
|
1408
2121
|
recoverOffRoute(d, suite, env, false);
|
|
1409
2122
|
break;
|
|
1410
2123
|
}
|
|
@@ -1425,7 +2138,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1425
2138
|
if (!mission)
|
|
1426
2139
|
break;
|
|
1427
2140
|
const step = mission.steps[s.index];
|
|
1428
|
-
if (!s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
2141
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1429
2142
|
recoverOffRoute(d, suite, env, false);
|
|
1430
2143
|
break;
|
|
1431
2144
|
}
|
|
@@ -1442,7 +2155,10 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1442
2155
|
advance(d, suite, env);
|
|
1443
2156
|
}
|
|
1444
2157
|
else if (policy === "abort") {
|
|
1445
|
-
|
|
2158
|
+
if (readOf(s))
|
|
2159
|
+
endPage(d, suite, "skipped");
|
|
2160
|
+
else
|
|
2161
|
+
exitMission(d);
|
|
1446
2162
|
d.effects.push({ type: "toast", text: COPY.aborted });
|
|
1447
2163
|
}
|
|
1448
2164
|
else if ((policy === "rewind" || (policy === "retry" && misses >= 2)) && cp < s.index) {
|
|
@@ -1465,7 +2181,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1465
2181
|
if (s.name !== "step" || s.phase !== "missing")
|
|
1466
2182
|
break;
|
|
1467
2183
|
const mission = findMission(suite, s.mission);
|
|
1468
|
-
if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
2184
|
+
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page ?? s.section)) {
|
|
1469
2185
|
// Polling again cannot help on another page: go back to the step's.
|
|
1470
2186
|
recoverOffRoute(d, suite, env, true);
|
|
1471
2187
|
break;
|
|
@@ -1481,6 +2197,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1481
2197
|
case "exit":
|
|
1482
2198
|
if (s.name !== "step")
|
|
1483
2199
|
break;
|
|
2200
|
+
if (readOf(s)) {
|
|
2201
|
+
// ✕, Escape or ปิดคู่มือ: ends silently.
|
|
2202
|
+
pushAbort(d, suite, s);
|
|
2203
|
+
endPage(d, suite, "skipped");
|
|
2204
|
+
break;
|
|
2205
|
+
}
|
|
1484
2206
|
if (s.replay) {
|
|
1485
2207
|
pushAbort(d, suite, s);
|
|
1486
2208
|
setScreen(d, { name: "idle" });
|
|
@@ -1549,11 +2271,17 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1549
2271
|
break;
|
|
1550
2272
|
setScreen(d, { name: "idle" });
|
|
1551
2273
|
break;
|
|
1552
|
-
case "openPanel":
|
|
1553
|
-
|
|
1554
|
-
|
|
1555
|
-
|
|
2274
|
+
case "openPanel": {
|
|
2275
|
+
const section = action.section ?? "all";
|
|
2276
|
+
const allowed = section === "pages" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
|
|
2277
|
+
if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
|
|
2278
|
+
break;
|
|
2279
|
+
// The offer gives way to the panel, unrecorded.
|
|
2280
|
+
if (s.name === "pageOffer")
|
|
2281
|
+
setScreen(d, { name: "idle" });
|
|
2282
|
+
d.state = { ...d.state, panelOpen: true, panelSection: section };
|
|
1556
2283
|
break;
|
|
2284
|
+
}
|
|
1557
2285
|
case "closePanel":
|
|
1558
2286
|
d.state = { ...d.state, panelOpen: false, justDone: null };
|
|
1559
2287
|
break;
|
|
@@ -1597,6 +2325,78 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1597
2325
|
passiveDone(d, suite, env, m);
|
|
1598
2326
|
break;
|
|
1599
2327
|
}
|
|
2328
|
+
case "pageOffer": {
|
|
2329
|
+
if (pageOfferDue(state, suite, env) !== action.page)
|
|
2330
|
+
break;
|
|
2331
|
+
setScreen(d, { name: "pageOffer", page: action.page });
|
|
2332
|
+
d.effects.push({ type: "announce", text: `คู่มือหน้านี้ ${findPage(suite, action.page)?.title ?? ""}` });
|
|
2333
|
+
break;
|
|
2334
|
+
}
|
|
2335
|
+
case "pageOfferStart":
|
|
2336
|
+
if (s.name !== "pageOffer" || state.pageAudience === "none")
|
|
2337
|
+
break;
|
|
2338
|
+
startPage(d, suite, env, s.page, true);
|
|
2339
|
+
break;
|
|
2340
|
+
case "pageOfferLater":
|
|
2341
|
+
if (s.name !== "pageOffer")
|
|
2342
|
+
break;
|
|
2343
|
+
recordPage(d, suite, s.page, "skipped");
|
|
2344
|
+
setScreen(d, { name: "idle" });
|
|
2345
|
+
break;
|
|
2346
|
+
case "startPage": {
|
|
2347
|
+
// Never over a mission step, a welcome, a gate or a celebration.
|
|
2348
|
+
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
2349
|
+
break;
|
|
2350
|
+
if (s.name !== "idle" && s.name !== "pageOffer")
|
|
2351
|
+
break;
|
|
2352
|
+
const page = pageHere(suite, env);
|
|
2353
|
+
if (!page || page.key !== action.page)
|
|
2354
|
+
break;
|
|
2355
|
+
startPage(d, suite, env, page.key, !pageRecorded(state, page));
|
|
2356
|
+
break;
|
|
2357
|
+
}
|
|
2358
|
+
case "back": {
|
|
2359
|
+
if (s.name !== "step" || !readOf(s) || s.phase !== "ready" || s.confirmExit)
|
|
2360
|
+
break;
|
|
2361
|
+
const mission = findMission(suite, s.mission);
|
|
2362
|
+
if (!mission)
|
|
2363
|
+
break;
|
|
2364
|
+
const to = backTarget(mission, s.index, env, d.state.progress.context);
|
|
2365
|
+
if (!to)
|
|
2366
|
+
break;
|
|
2367
|
+
// Close what the click opened, then show the click again.
|
|
2368
|
+
if (to.close)
|
|
2369
|
+
pushAbort(d, suite, s);
|
|
2370
|
+
enterStep(d, suite, env, mission, to.index, { replay: true, noAutoPass: true });
|
|
2371
|
+
break;
|
|
2372
|
+
}
|
|
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
|
+
}
|
|
2381
|
+
const cur = d.state.pages[action.key];
|
|
2382
|
+
if (cur && cur.version > action.row.version)
|
|
2383
|
+
break;
|
|
2384
|
+
d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
|
|
2385
|
+
break;
|
|
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
|
+
}
|
|
1600
2400
|
case "idleCelebrate": {
|
|
1601
2401
|
if (s.name !== "idle" || state.panelOpen)
|
|
1602
2402
|
break;
|
|
@@ -1720,6 +2520,12 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
1720
2520
|
if (typeof data.app === "string" && data.app !== suite.app)
|
|
1721
2521
|
return null;
|
|
1722
2522
|
const rows = Array.isArray(data.guides) ? data.guides : [];
|
|
2523
|
+
const pagesMode = data.pages_mode === "menu" || data.pages_mode === "offer" ? data.pages_mode : "off";
|
|
2524
|
+
const pageAudience = pagesMode !== "off" && (data.page_audience === "optional" || data.page_audience === "forced")
|
|
2525
|
+
? data.page_audience
|
|
2526
|
+
: "none";
|
|
2527
|
+
const pages = parsePageRows(rows, suite.app);
|
|
2528
|
+
const sections = parsePageRows(rows, suite.app, "section");
|
|
1723
2529
|
let row = null;
|
|
1724
2530
|
for (const r of rows) {
|
|
1725
2531
|
if (isRecord(r) && r.guide_key === suite.key) {
|
|
@@ -1740,7 +2546,58 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
1740
2546
|
progress = row;
|
|
1741
2547
|
newerOnServer = row.guide_version > suite.version;
|
|
1742
2548
|
}
|
|
1743
|
-
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer };
|
|
2549
|
+
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages, sections };
|
|
2550
|
+
}
|
|
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}`;
|
|
2554
|
+
}
|
|
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);
|
|
2558
|
+
const out = {};
|
|
2559
|
+
for (const r of rows) {
|
|
2560
|
+
if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
|
|
2561
|
+
continue;
|
|
2562
|
+
const row = parsePageRow(r);
|
|
2563
|
+
if (row)
|
|
2564
|
+
out[r.guide_key.slice(prefix.length)] = row;
|
|
2565
|
+
}
|
|
2566
|
+
return out;
|
|
2567
|
+
}
|
|
2568
|
+
/** One page row (a list entry, a PUT answer, a 409's `current`); null when off-shape. */
|
|
2569
|
+
function parsePageRow(raw) {
|
|
2570
|
+
if (!isRecord(raw))
|
|
2571
|
+
return null;
|
|
2572
|
+
const version = Number(raw.guide_version);
|
|
2573
|
+
const rev = Number(raw.rev);
|
|
2574
|
+
if (!Number.isInteger(version) || version < 1 || !Number.isInteger(rev) || rev < 0)
|
|
2575
|
+
return null;
|
|
2576
|
+
const status = ROW_STATUSES.includes(raw.status) ? raw.status : "in_progress";
|
|
2577
|
+
return { version, status, rev };
|
|
2578
|
+
}
|
|
2579
|
+
/**
|
|
2580
|
+
* A page row's PUT body: the least the server takes. Written once per page
|
|
2581
|
+
* and version (finished: completed; not now, exited or left: skipped).
|
|
2582
|
+
*/
|
|
2583
|
+
function pageBody(app, version, rev, status) {
|
|
2584
|
+
return {
|
|
2585
|
+
app,
|
|
2586
|
+
guide_version: version,
|
|
2587
|
+
rev,
|
|
2588
|
+
status,
|
|
2589
|
+
mission_key: null,
|
|
2590
|
+
step_key: null,
|
|
2591
|
+
step_index: 0,
|
|
2592
|
+
checkpoint_key: null,
|
|
2593
|
+
missions: {},
|
|
2594
|
+
context: {},
|
|
2595
|
+
welcome: "started",
|
|
2596
|
+
chip_hidden: false,
|
|
2597
|
+
last_error: null,
|
|
2598
|
+
skipped_step: null,
|
|
2599
|
+
restart: false,
|
|
2600
|
+
};
|
|
1744
2601
|
}
|
|
1745
2602
|
const TERMINAL = ["completed", "skipped", "aborted"];
|
|
1746
2603
|
/** The PUT body for the client's state. Drops keys the server would refuse. */
|
|
@@ -1946,6 +2803,48 @@ async function storeGuides(adapter, suite, progress) {
|
|
|
1946
2803
|
}
|
|
1947
2804
|
return null;
|
|
1948
2805
|
}
|
|
2806
|
+
/**
|
|
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,
|
|
2809
|
+
* with the same 409 handling as the tour's row. Another tab that already
|
|
2810
|
+
* recorded this version wins (its row is returned, nothing is sent again);
|
|
2811
|
+
* an older version's row is replaced with its rev; a newer one stops. Returns
|
|
2812
|
+
* the stored row, or null when it could not be saved.
|
|
2813
|
+
*/
|
|
2814
|
+
async function storePageRow(adapter, app, key, version, status, rev, kind = "page") {
|
|
2815
|
+
const guideKey = pageRowKey(app, key, kind);
|
|
2816
|
+
let at = rev;
|
|
2817
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2818
|
+
let res;
|
|
2819
|
+
try {
|
|
2820
|
+
res = await adapter.saveProgress({ app, guideKey, body: pageBody(app, version, at, status) });
|
|
2821
|
+
}
|
|
2822
|
+
catch (err) {
|
|
2823
|
+
const conflict = conflictOf(err);
|
|
2824
|
+
if (!conflict)
|
|
2825
|
+
return null;
|
|
2826
|
+
res = conflict;
|
|
2827
|
+
}
|
|
2828
|
+
if (!res || typeof res.status !== "number")
|
|
2829
|
+
return null;
|
|
2830
|
+
const payload = res.data;
|
|
2831
|
+
if (res.status >= 200 && res.status < 300)
|
|
2832
|
+
return parsePageRow(payload?.data ?? payload);
|
|
2833
|
+
if (res.status !== 409)
|
|
2834
|
+
return null;
|
|
2835
|
+
const current = parsePageRow(payload?.current);
|
|
2836
|
+
if (!current) {
|
|
2837
|
+
at = 0;
|
|
2838
|
+
continue;
|
|
2839
|
+
}
|
|
2840
|
+
if (current.version > version)
|
|
2841
|
+
return null;
|
|
2842
|
+
if (current.version === version && current.status !== "in_progress")
|
|
2843
|
+
return current;
|
|
2844
|
+
at = current.rev;
|
|
2845
|
+
}
|
|
2846
|
+
return null;
|
|
2847
|
+
}
|
|
1949
2848
|
|
|
1950
2849
|
/**
|
|
1951
2850
|
* The tour's DOM side: finding anchors, deciding whether one is really on
|
|
@@ -2465,6 +3364,12 @@ const GUIDE_CSS = `
|
|
|
2465
3364
|
.eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
|
2466
3365
|
.eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
|
|
2467
3366
|
.eg-q-f{border-top:1px solid var(--eg-line);padding:8px 12px;display:flex;justify-content:flex-end}
|
|
3367
|
+
.eg-q-sub{margin:6px 0 0;padding:12px 18px 2px;border-top:1px solid var(--eg-line);font-size:13px;font-weight:700;color:var(--eg-muted)}
|
|
3368
|
+
.eg-pg-item{padding:8px}
|
|
3369
|
+
.eg-pg-item .eg-t small.eg-new{color:var(--eg-strong);font-weight:600}
|
|
3370
|
+
.eg-card.eg-offer{left:auto;right:22px;transform:none;bottom:var(--erp-guide-bottom-offset,22px);width:min(320px,calc(100vw - 32px))}
|
|
3371
|
+
.eg-offer .eg-po-e{margin:0 0 2px;font-size:12px;font-weight:600;color:var(--eg-strong)}
|
|
3372
|
+
.eg-offer .eg-row .eg-btn{height:34px;padding:0 14px;font-size:13.5px}
|
|
2468
3373
|
.eg-toast{position:fixed;left:50%;bottom:calc(var(--erp-guide-bottom-offset,22px) + 62px);transform:translateX(-50%);background:#1c2634;color:#fff;padding:10px 16px;border-radius:10px;font-size:13.5px;box-shadow:var(--eg-shadow);max-width:calc(100vw - 32px);pointer-events:none}
|
|
2469
3374
|
svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
|
2470
3375
|
@media (max-width:767px){
|
|
@@ -2478,6 +3383,7 @@ svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;
|
|
|
2478
3383
|
.eg-panel{left:0;right:0;bottom:0;width:100%;max-height:85vh;border-radius:18px 18px 0 0;padding-bottom:env(safe-area-inset-bottom)}
|
|
2479
3384
|
.eg-q-item{padding:12px 8px}
|
|
2480
3385
|
.eg-card{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) - 4px)}
|
|
3386
|
+
.eg-card.eg-offer{left:16px;right:16px;width:auto;bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) - 4px)}
|
|
2481
3387
|
.eg-toast{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) + 60px)}
|
|
2482
3388
|
}
|
|
2483
3389
|
@keyframes eg-up{from{opacity:0;transform:translate(-50%,30px)}}
|
|
@@ -2575,6 +3481,14 @@ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
|
|
|
2575
3481
|
const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
|
|
2576
3482
|
return best >= 0 ? best : ok.indexOf(true);
|
|
2577
3483
|
}
|
|
3484
|
+
/** The way out of a step, as the bubble and the cards say it. */
|
|
3485
|
+
function exitLabelOf(b) {
|
|
3486
|
+
return b.exitLabel ?? (b.replay ? "จบการทบทวน" : "ออกจากไกด์");
|
|
3487
|
+
}
|
|
3488
|
+
/** The bubble's top line. */
|
|
3489
|
+
function headingOf(b) {
|
|
3490
|
+
return b.heading ?? `ภารกิจ ${b.missionNo} · ${b.missionTitle}${b.replay ? " (ทบทวน)" : ""}`;
|
|
3491
|
+
}
|
|
2578
3492
|
const BUBBLE_TITLE_ID = "eg-bubble-title";
|
|
2579
3493
|
const BUBBLE_WHY_ID = "eg-bubble-why";
|
|
2580
3494
|
function useNudge(nudge, ref) {
|
|
@@ -2612,7 +3526,7 @@ function StepLayer(p) {
|
|
|
2612
3526
|
const showBubble = p.phase === "ready" && (!!spot || !p.hasTarget);
|
|
2613
3527
|
const gap = p.showHand ? 34 : 14;
|
|
2614
3528
|
const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
|
|
2615
|
-
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}`;
|
|
3529
|
+
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}|${!!p.bubble.showBack}`;
|
|
2616
3530
|
// What the bubble sits beside: the target, with an open list over it (the
|
|
2617
3531
|
// list is what the person is choosing from now). What it must not cover:
|
|
2618
3532
|
// open lists, and the other anchors the step allows (a map step's
|
|
@@ -2729,13 +3643,13 @@ function StepLayer(p) {
|
|
|
2729
3643
|
top: pos?.top ?? 0,
|
|
2730
3644
|
width: phone ? viewport.width - 32 : undefined,
|
|
2731
3645
|
visibility: pos ? "visible" : "hidden",
|
|
2732
|
-
}, children: [jsx(BubbleBody, { ...p }), jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxs("div", { className: "eg-loading", children: [jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsx("div", { className: "eg-row", children: jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: p.bubble
|
|
3646
|
+
}, children: [jsx(BubbleBody, { ...p }), jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxs("div", { className: "eg-loading", children: [jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsx("div", { className: "eg-row", children: jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }) })] })) : null, p.phase === "missing" ? (jsxs("div", { className: "eg-card eg-err", role: "alertdialog", "aria-labelledby": "eg-missing-t", "data-eg-card": "", tabIndex: -1, children: [jsx("p", { className: "eg-b-t", id: "eg-missing-t", children: "\u0E44\u0E21\u0E48\u0E1E\u0E1A\u0E2A\u0E48\u0E27\u0E19\u0E19\u0E35\u0E49\u0E1A\u0E19\u0E2B\u0E19\u0E49\u0E32" }), jsxs("div", { className: "eg-row", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onRetry, children: "\u0E25\u0E2D\u0E07\u0E2D\u0E35\u0E01\u0E04\u0E23\u0E31\u0E49\u0E07" })] })] })) : null, p.phase === "away" ? (jsxs("div", { className: "eg-card", role: "dialog", "aria-modal": "false", "aria-labelledby": "eg-away-t", "data-eg-card": "", tabIndex: -1, children: [jsxs("div", { className: "eg-b-top", children: [jsx("span", { className: "eg-mis", children: headingOf(p.bubble) }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", p.bubble.index + 1, "/", p.bubble.total] })] }), jsx("p", { className: "eg-b-t", id: "eg-away-t", style: { marginTop: 8 }, children: jsx(GuideTitle, { text: p.bubble.title }) }), jsxs("div", { className: "eg-row", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onGoResume, children: "\u0E17\u0E33\u0E15\u0E48\u0E2D" })] })] })) : null, p.confirmExit ? (jsx(ConfirmModal, { title: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E44\u0E01\u0E14\u0E4C\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49?", line: p.reopenHint || REOPEN_HINT, leaveLabel: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E44\u0E01\u0E14\u0E4C", stayLabel: "\u0E17\u0E33\u0E15\u0E48\u0E2D", onLeave: p.onExitConfirm, onStay: p.onExitCancel })) : null] }));
|
|
2733
3647
|
}
|
|
2734
3648
|
function BubbleBody(p) {
|
|
2735
3649
|
const b = p.bubble;
|
|
2736
3650
|
const title = b.error ? b.error.title : b.title;
|
|
2737
3651
|
const why = b.error ? (b.error.detail ?? "") : b.why;
|
|
2738
|
-
return (jsxs(Fragment, { children: [jsxs("div", { className: "eg-b-top", children: [
|
|
3652
|
+
return (jsxs(Fragment, { children: [jsxs("div", { className: "eg-b-top", children: [jsx("span", { className: "eg-mis", children: headingOf(b) }), jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsx(GuideTitle, { text: title }) }), jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: jsx(GuideTitle, { text: why }) }), b.status && !b.error ? (jsx("p", { className: "eg-b-s", role: "status", "data-eg-status": "", children: b.status })) : null, jsxs("div", { className: "eg-b-f", children: [jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(b) }), jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showBack ? (jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onBack, children: "\u0E22\u0E49\u0E2D\u0E19\u0E01\u0E25\u0E31\u0E1A" })) : null, b.showEndWithoutSave ? (jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onEndWithoutSave, children: "\u0E08\u0E1A\u0E42\u0E14\u0E22\u0E44\u0E21\u0E48\u0E1A\u0E31\u0E19\u0E17\u0E36\u0E01" })) : null, b.showNext ? (jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: b.nextLabel ?? "ถัดไป" })) : null] })] })] }));
|
|
2739
3653
|
}
|
|
2740
3654
|
/** The fingertip at ≈62% across and down the target; upside down near the bottom. */
|
|
2741
3655
|
function handPosition(spot, viewportHeight) {
|
|
@@ -2809,6 +3723,19 @@ function CompleteModal(p) {
|
|
|
2809
3723
|
return (jsxs(Fragment, { children: [jsx("div", { className: "eg-scrim", "data-eg-block": "" }), jsxs("div", { className: "eg-modal eg-pop", role: "dialog", "aria-modal": "true", "aria-labelledby": "eg-f-t", "data-eg-modal": "", tabIndex: -1, children: [jsx(Confetti, { reduced: p.reduced }), jsx("div", { className: "eg-medal", children: jsx(IconTrophy, {}) }), jsxs("p", { className: "eg-eyebrow", children: ["\u0E04\u0E23\u0E1A ", p.total, " \u0E20\u0E32\u0E23\u0E01\u0E34\u0E08"] }), jsx("h2", { id: "eg-f-t", children: p.title }), p.subtitle ? jsx("p", { className: "eg-sub", children: p.subtitle }) : null, jsx("ol", { className: "eg-quests", children: p.missions.map((m, i) => (jsxs("li", { className: "eg-ok", children: [jsx("span", { className: "eg-n", children: jsx(IconCheck, {}) }), jsx("span", { className: "eg-t", children: m })] }, i))) }), jsxs("div", { className: "eg-actions", children: [jsx("button", { type: "button", ref: primary, className: "eg-btn eg-primary", onClick: p.onFinish, children: p.actionLabel }), p.secondary ? (jsx("a", { className: "eg-btn eg-ghost", href: p.secondary.href, style: { textDecoration: "none" }, children: p.secondary.label })) : null] })] })] }));
|
|
2810
3724
|
}
|
|
2811
3725
|
/* ------------------------------------------------------------------ */
|
|
3726
|
+
/* Page guides: the first-visit offer */
|
|
3727
|
+
/* ------------------------------------------------------------------ */
|
|
3728
|
+
/**
|
|
3729
|
+
* The offer of this page's guide on a first visit: a small card in the
|
|
3730
|
+
* corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
|
|
3731
|
+
*/
|
|
3732
|
+
function PageOfferCard(p) {
|
|
3733
|
+
return (jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsx("p", { className: "eg-po-e", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49" }), jsx("p", { className: "eg-b-t", id: "eg-po-t", children: p.title }), jsxs("p", { className: "eg-b-w", children: [p.steps, " \u0E02\u0E31\u0E49\u0E19"] }), jsxs("div", { className: "eg-row", children: [jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49" }), jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" })] })] }));
|
|
3734
|
+
}
|
|
3735
|
+
function PageList({ items, onOpen }) {
|
|
3736
|
+
return (jsx("ul", { className: "eg-q-list eg-pg-list", children: items.map(it => (jsxs("li", { className: "eg-q-item eg-pg-item", children: [jsxs("div", { className: "eg-t", children: [jsx("b", { children: it.title }), jsx("small", { className: it.seen ? undefined : "eg-new", children: it.seen ? "ดูแล้ว" : "ใหม่" })] }), jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onOpen(it.key), children: "\u0E40\u0E1B\u0E34\u0E14" })] }, it.key))) }));
|
|
3737
|
+
}
|
|
3738
|
+
/* ------------------------------------------------------------------ */
|
|
2812
3739
|
/* Quest chip and panel */
|
|
2813
3740
|
/* ------------------------------------------------------------------ */
|
|
2814
3741
|
function QuestChip({ done, total, pulse, onOpen }) {
|
|
@@ -2830,7 +3757,12 @@ function QuestChip({ done, total, pulse, onOpen }) {
|
|
|
2830
3757
|
}
|
|
2831
3758
|
function QuestPanel(p) {
|
|
2832
3759
|
const ref = useAutoFocus();
|
|
2833
|
-
|
|
3760
|
+
const pages = p.pages ?? [];
|
|
3761
|
+
const openPage = p.onOpenPage ?? (() => { });
|
|
3762
|
+
if (p.showMissions === false) {
|
|
3763
|
+
return (jsxs(Fragment, { children: [jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxs("div", { className: "eg-q-h", children: [jsx("h2", { id: "eg-q-t", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsx(IconX, {}) })] }), jsx(PageList, { items: pages, onOpen: openPage })] })] }));
|
|
3764
|
+
}
|
|
3765
|
+
return (jsxs(Fragment, { children: [jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxs("div", { className: "eg-q-h", children: [jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsx(IconX, {}) })] }), jsx("div", { className: "eg-prog", children: jsx("div", { className: "eg-bar", children: jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), pages.length ? (jsxs(Fragment, { children: [jsx("h3", { className: "eg-q-sub", id: "eg-q-pages", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsx(PageList, { items: pages, onOpen: openPage })] })) : null, p.canHideChip ? (jsx("div", { className: "eg-q-f", children: jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onHideChip, children: "\u0E0B\u0E48\u0E2D\u0E19\u0E1B\u0E38\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08" }) })) : null] })] }));
|
|
2834
3766
|
}
|
|
2835
3767
|
function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
2836
3768
|
const v = item.view;
|
|
@@ -2891,6 +3823,7 @@ class GuideErrorBoundary extends React.Component {
|
|
|
2891
3823
|
console.warn("[erp-shell guide] hidden after an error", error);
|
|
2892
3824
|
try {
|
|
2893
3825
|
publishGuideEntry(null, null);
|
|
3826
|
+
publishPageGuides(null, null, null);
|
|
2894
3827
|
}
|
|
2895
3828
|
catch {
|
|
2896
3829
|
/* ignore */
|
|
@@ -2967,6 +3900,41 @@ function writeMirror(orgId, suite, p) {
|
|
|
2967
3900
|
/* private mode: the server copy is enough */
|
|
2968
3901
|
}
|
|
2969
3902
|
}
|
|
3903
|
+
/** Page rows written in this tab, kept for a remount within the GET's cache time. */
|
|
3904
|
+
const pagesMirrorKey = (orgId, suite, kind = "page") => `erp-guide-${kind === "section" ? "sections" : "pages"}:${orgId}:${suite.app}`;
|
|
3905
|
+
function readPagesMirror(orgId, suite, kind = "page") {
|
|
3906
|
+
try {
|
|
3907
|
+
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite, kind));
|
|
3908
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
3909
|
+
const out = {};
|
|
3910
|
+
for (const [k, v] of Object.entries(parsed ?? {})) {
|
|
3911
|
+
if (v && Number.isInteger(v.version) && Number.isInteger(v.rev) && typeof v.status === "string")
|
|
3912
|
+
out[k] = v;
|
|
3913
|
+
}
|
|
3914
|
+
return out;
|
|
3915
|
+
}
|
|
3916
|
+
catch {
|
|
3917
|
+
return {};
|
|
3918
|
+
}
|
|
3919
|
+
}
|
|
3920
|
+
function writePagesMirror(orgId, suite, pages, kind = "page") {
|
|
3921
|
+
try {
|
|
3922
|
+
sessionStorage.setItem(pagesMirrorKey(orgId, suite, kind), JSON.stringify(pages));
|
|
3923
|
+
}
|
|
3924
|
+
catch {
|
|
3925
|
+
/* private mode */
|
|
3926
|
+
}
|
|
3927
|
+
}
|
|
3928
|
+
/** The server's page rows, with this tab's newer ones laid over. */
|
|
3929
|
+
function mergePageRows(server, local) {
|
|
3930
|
+
const out = { ...server };
|
|
3931
|
+
for (const [k, v] of Object.entries(local)) {
|
|
3932
|
+
const r = out[k];
|
|
3933
|
+
if (!r || v.version > r.version)
|
|
3934
|
+
out[k] = v;
|
|
3935
|
+
}
|
|
3936
|
+
return out;
|
|
3937
|
+
}
|
|
2970
3938
|
/* ------------------------------------------------------------------ */
|
|
2971
3939
|
/* One tab at a time */
|
|
2972
3940
|
/* ------------------------------------------------------------------ */
|
|
@@ -3054,6 +4022,12 @@ const SAVE_DEBOUNCE_MS = 600;
|
|
|
3054
4022
|
const IDLE_POLL_MS = 100;
|
|
3055
4023
|
/** A celebration earned outside the tour waits this long of nothing loading. */
|
|
3056
4024
|
const PASSIVE_IDLE_MS = 600;
|
|
4025
|
+
/** A page guide's offer waits this long of nothing loading. */
|
|
4026
|
+
const PAGE_OFFER_IDLE_MS = 600;
|
|
4027
|
+
/** How often the page guides look at the page while idle (offer due, `when`). */
|
|
4028
|
+
const PAGE_POLL_MS = 250;
|
|
4029
|
+
/** How long a page opened from the panel's list may take to arrive. */
|
|
4030
|
+
const PAGE_ARRIVE_MS = 8000;
|
|
3057
4031
|
function GuideRuntimeInner({ config, organizationId, code, routing, progress: progressAdapter, isBusy, menu, hidden, bottomOffset }) {
|
|
3058
4032
|
const suite = config.suite;
|
|
3059
4033
|
const pathname = routing.pathname || "/";
|
|
@@ -3068,9 +4042,14 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3068
4042
|
// No menu: never a menu step (the machine sees it as always open).
|
|
3069
4043
|
const sidebarOpen = menu ? !!menu.open : true;
|
|
3070
4044
|
const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
|
|
3071
|
-
// Progress: read once this tab runs the tour and the app is ready
|
|
4045
|
+
// Progress: read once this tab runs the tour and the app is ready (for
|
|
4046
|
+
// the missions, or for the page guides alone: config.pagesReady).
|
|
3072
4047
|
const [answer, setAnswer] = useState(null);
|
|
3073
|
-
const
|
|
4048
|
+
const missionsReady = config.ready !== false;
|
|
4049
|
+
const pagesReady = config.pagesReady ?? missionsReady;
|
|
4050
|
+
const hasPages = useMemo(() => validPages(suite).length > 0 || validSections(suite).length > 0, [suite]);
|
|
4051
|
+
const hasSections = useMemo(() => validSections(suite).length > 0, [suite]);
|
|
4052
|
+
const enabled = primary && (missionsReady || (hasPages && pagesReady));
|
|
3074
4053
|
const loaded = useRef(false);
|
|
3075
4054
|
useEffect(() => {
|
|
3076
4055
|
if (!enabled || loaded.current)
|
|
@@ -3092,7 +4071,11 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3092
4071
|
loaded.current = false;
|
|
3093
4072
|
};
|
|
3094
4073
|
}, [enabled, suite]);
|
|
3095
|
-
|
|
4074
|
+
// The missions run for their audience; the page guides for theirs (every
|
|
4075
|
+
// member while pages are on), each with its own ready.
|
|
4076
|
+
const missionsLive = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && missionsReady && !hidden;
|
|
4077
|
+
const pagesLive = !!answer && hasPages && answer.pageAudience !== "none" && primary && pagesReady && !hidden;
|
|
4078
|
+
const live = missionsLive || pagesLive;
|
|
3096
4079
|
/* ---------------- machine ---------------- */
|
|
3097
4080
|
const [state, setState] = useState(null);
|
|
3098
4081
|
const stateRef = useRef(null);
|
|
@@ -3100,7 +4083,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3100
4083
|
// is not sent and does not bump its rev.
|
|
3101
4084
|
const savedKey = useRef(null);
|
|
3102
4085
|
useEffect(() => {
|
|
3103
|
-
|
|
4086
|
+
const missionsOn = !!answer && answer.audience !== "none" && !answer.newerOnServer;
|
|
4087
|
+
const pagesOn = !!answer && hasPages && answer.pageAudience !== "none";
|
|
4088
|
+
if (!answer || (!missionsOn && !pagesOn)) {
|
|
3104
4089
|
stateRef.current = null;
|
|
3105
4090
|
setState(null);
|
|
3106
4091
|
return;
|
|
@@ -3114,16 +4099,26 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3114
4099
|
let progress = answer.progress;
|
|
3115
4100
|
if (mirror && furtherProgress(mirror, progress) === mirror)
|
|
3116
4101
|
progress = mergeProgress(mirror, progress);
|
|
3117
|
-
|
|
4102
|
+
// A tour newer on the server than this build's: the missions stay out.
|
|
4103
|
+
const s = initialGuideState(missionsOn ? answer.audience : "none", progress, {
|
|
4104
|
+
pageAudience: pagesOn ? answer.pageAudience : "none",
|
|
4105
|
+
pagesMode: answer.pagesMode,
|
|
4106
|
+
pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
|
|
4107
|
+
sections: mergePageRows(answer.sections ?? {}, readPagesMirror(organizationId, suite, "section")),
|
|
4108
|
+
});
|
|
3118
4109
|
stateRef.current = s;
|
|
3119
4110
|
setState(s);
|
|
3120
|
-
}, [answer, organizationId, suite]);
|
|
4111
|
+
}, [answer, organizationId, suite, hasPages]);
|
|
3121
4112
|
// The latest page facts, read by the machine through env.
|
|
3122
4113
|
const pageRef = useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
|
|
3123
4114
|
pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
|
|
3124
4115
|
const targetElRef = useRef(null);
|
|
4116
|
+
// The section guide being opened (before its first step is on screen).
|
|
4117
|
+
const openingSection = useRef(null);
|
|
3125
4118
|
const env = useCallback(() => {
|
|
3126
4119
|
const pg = pageRef.current;
|
|
4120
|
+
const sc = stateRef.current?.screen;
|
|
4121
|
+
const sectionKey = (sc?.name === "step" ? sc.section : undefined) ?? openingSection.current;
|
|
3127
4122
|
return {
|
|
3128
4123
|
code,
|
|
3129
4124
|
pathname: pg.pathname,
|
|
@@ -3141,6 +4136,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3141
4136
|
sidebarOpen: pg.sidebarOpen,
|
|
3142
4137
|
targetValue: () => fieldValue(targetElRef.current),
|
|
3143
4138
|
dialogOpen: () => appDialogOpen(),
|
|
4139
|
+
values: getSectionGuideValues(sectionKey),
|
|
3144
4140
|
};
|
|
3145
4141
|
}, [code]);
|
|
3146
4142
|
/* ---------------- effects: save, announce, toast, navigate ---------------- */
|
|
@@ -3214,6 +4210,19 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3214
4210
|
// After this render takes the guard away, put the page back.
|
|
3215
4211
|
runAbort(e.actions);
|
|
3216
4212
|
}
|
|
4213
|
+
else if (e.type === "savePage") {
|
|
4214
|
+
// Once per page and version; the machine already holds it as written.
|
|
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 => {
|
|
4221
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
4222
|
+
if (row)
|
|
4223
|
+
dispatchRef.current({ type: "pageRow", key: e.key, row, kind });
|
|
4224
|
+
});
|
|
4225
|
+
}
|
|
3217
4226
|
}
|
|
3218
4227
|
catch {
|
|
3219
4228
|
/* an effect never breaks the app */
|
|
@@ -3262,16 +4271,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3262
4271
|
const screen = state?.screen ?? { name: "idle" };
|
|
3263
4272
|
const resumed = useRef(false);
|
|
3264
4273
|
useEffect(() => {
|
|
3265
|
-
if (!
|
|
4274
|
+
if (!missionsLive || !state || resumed.current)
|
|
3266
4275
|
return;
|
|
3267
4276
|
resumed.current = true;
|
|
3268
4277
|
dispatch({ type: "resume" });
|
|
3269
|
-
}, [
|
|
4278
|
+
}, [missionsLive, state, dispatch]);
|
|
3270
4279
|
const welcomeRoutes = suite.welcomeRoutes ?? ["/[code]"];
|
|
3271
4280
|
const onWelcomeRoute = welcomeRoutes.some(r => !!matchRoute(r, pathname, search, code));
|
|
3272
4281
|
const factsKey = useFactsKey(config.facts);
|
|
3273
4282
|
const welcomeCtx = state ? guideContext(env(), state.progress.context) : null;
|
|
3274
|
-
const welcomeIsDue = !!state && !!welcomeCtx &&
|
|
4283
|
+
const welcomeIsDue = !!state && !!welcomeCtx && missionsLive && welcomeDue(state, suite, welcomeCtx);
|
|
3275
4284
|
const due = welcomeIsDue && onWelcomeRoute;
|
|
3276
4285
|
// After a moment of nothing loading (the page has settled).
|
|
3277
4286
|
useAfterIdle(due, WELCOME_DELAY_MS, busy, () => dispatchRef.current({ type: "welcome" }), [factsKey]);
|
|
@@ -3279,7 +4288,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3279
4288
|
const passiveNames = useMemo(() => passiveEventNames(suite), [suite]);
|
|
3280
4289
|
const hasPassive = useMemo(() => suite.missions.some(m => !!m.completeOn), [suite]);
|
|
3281
4290
|
useEffect(() => {
|
|
3282
|
-
if (!
|
|
4291
|
+
if (!missionsLive || !hasPassive)
|
|
3283
4292
|
return;
|
|
3284
4293
|
const onApi = (e) => {
|
|
3285
4294
|
const d = e.detail;
|
|
@@ -3297,15 +4306,19 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3297
4306
|
window.removeEventListener(API_EVENT, onApi);
|
|
3298
4307
|
handlers.forEach(([n, h]) => window.removeEventListener(n, h));
|
|
3299
4308
|
};
|
|
3300
|
-
}, [
|
|
4309
|
+
}, [missionsLive, hasPassive, passiveNames]);
|
|
3301
4310
|
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
3302
|
-
|
|
4311
|
+
// A page guide's step runs while page guides are live; a mission's while
|
|
4312
|
+
// the missions are. The offer card is watched the same way (left: ignored).
|
|
4313
|
+
const inStep = screen.name === "step" && (screen.page || screen.section ? pagesLive : missionsLive);
|
|
4314
|
+
const offerUp = pagesLive && screen.name === "pageOffer";
|
|
4315
|
+
const watching = inStep || offerUp;
|
|
3303
4316
|
useEffect(() => {
|
|
3304
|
-
if (
|
|
4317
|
+
if (watching)
|
|
3305
4318
|
dispatch({ type: "sync" });
|
|
3306
|
-
}, [
|
|
4319
|
+
}, [watching, pathname, search, sidebarOpen, factsKey, dispatch]);
|
|
3307
4320
|
useEffect(() => {
|
|
3308
|
-
if (!
|
|
4321
|
+
if (!watching || typeof MutationObserver === "undefined")
|
|
3309
4322
|
return;
|
|
3310
4323
|
let raf = 0;
|
|
3311
4324
|
const obs = new MutationObserver(records => {
|
|
@@ -3330,7 +4343,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3330
4343
|
if (raf)
|
|
3331
4344
|
cancelAnimationFrame(raf);
|
|
3332
4345
|
};
|
|
3333
|
-
}, [
|
|
4346
|
+
}, [watching]);
|
|
3334
4347
|
/* ---------------- the current step ---------------- */
|
|
3335
4348
|
const stepInfo = useMemo(() => {
|
|
3336
4349
|
if (!state || screen.name !== "step")
|
|
@@ -3345,6 +4358,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3345
4358
|
const step = shownStep(raw, screen.nothingToSave);
|
|
3346
4359
|
return { mission, step, raw };
|
|
3347
4360
|
}, [state, screen, suite]);
|
|
4361
|
+
const valuesVersion = useSectionValuesVersion();
|
|
3348
4362
|
const stepCtx = useMemo(() => {
|
|
3349
4363
|
if (!state)
|
|
3350
4364
|
return null;
|
|
@@ -3353,7 +4367,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3353
4367
|
return guideContext(e, state.progress.context, params);
|
|
3354
4368
|
// pathname/search/factsKey make it current.
|
|
3355
4369
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
3356
|
-
}, [state, stepInfo, env, code, pathname, search, factsKey]);
|
|
4370
|
+
}, [state, stepInfo, env, code, pathname, search, factsKey, valuesVersion]);
|
|
3357
4371
|
const target = stepInfo && stepCtx && screen.name === "step"
|
|
3358
4372
|
? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
|
|
3359
4373
|
: null;
|
|
@@ -3566,7 +4580,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3566
4580
|
};
|
|
3567
4581
|
}, [inStep, stepInfo]);
|
|
3568
4582
|
/* ---------------- the guard ---------------- */
|
|
3569
|
-
const modalScreen =
|
|
4583
|
+
const modalScreen = missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
|
|
3570
4584
|
const panelOpen = live && !!state?.panelOpen;
|
|
3571
4585
|
const guarding = inStep || modalScreen;
|
|
3572
4586
|
const guardingRef = useRef(guarding);
|
|
@@ -3843,7 +4857,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3843
4857
|
const total = shown.length;
|
|
3844
4858
|
// A celebration earned outside the tour: at the next idle moment — no
|
|
3845
4859
|
// step, card or panel up, the welcome not waiting, nothing loading.
|
|
3846
|
-
const passiveDue =
|
|
4860
|
+
const passiveDue = missionsLive &&
|
|
3847
4861
|
!!state &&
|
|
3848
4862
|
(state.pendingCelebrate?.length ?? 0) > 0 &&
|
|
3849
4863
|
screen.name === "idle" &&
|
|
@@ -3851,13 +4865,173 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3851
4865
|
!due;
|
|
3852
4866
|
useAfterIdle(passiveDue, PASSIVE_IDLE_MS, busy, () => dispatchRef.current({ type: "idleCelebrate" }), [state?.pendingCelebrate?.join(",")]);
|
|
3853
4867
|
useEffect(() => {
|
|
3854
|
-
if (!
|
|
4868
|
+
if (!missionsLive || !state) {
|
|
3855
4869
|
publishGuideEntry(null, null);
|
|
3856
4870
|
return;
|
|
3857
4871
|
}
|
|
3858
4872
|
publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
|
|
3859
|
-
}, [
|
|
4873
|
+
}, [missionsLive, state, done, total]);
|
|
3860
4874
|
useEffect(() => () => publishGuideEntry(null, null), []);
|
|
4875
|
+
/* ---------------- page guides: this page's, the offer, the list ---------------- */
|
|
4876
|
+
// `when` anchors (LIFF's sub-screens) come and go without a route change:
|
|
4877
|
+
// look again when the page changes.
|
|
4878
|
+
const hasWhen = useMemo(() => validPages(suite).some(p => !!p.when) || validSections(suite).length > 0, [suite]);
|
|
4879
|
+
const [domTick, setDomTick] = useState(0);
|
|
4880
|
+
useEffect(() => {
|
|
4881
|
+
if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
|
|
4882
|
+
return;
|
|
4883
|
+
let t = null;
|
|
4884
|
+
const obs = new MutationObserver(records => {
|
|
4885
|
+
if (records.every(r => inGuideLayer(r.target)))
|
|
4886
|
+
return;
|
|
4887
|
+
if (t)
|
|
4888
|
+
return;
|
|
4889
|
+
t = setTimeout(() => {
|
|
4890
|
+
t = null;
|
|
4891
|
+
setDomTick(n => n + 1);
|
|
4892
|
+
}, PAGE_POLL_MS);
|
|
4893
|
+
});
|
|
4894
|
+
obs.observe(document.body, {
|
|
4895
|
+
childList: true,
|
|
4896
|
+
subtree: true,
|
|
4897
|
+
attributes: true,
|
|
4898
|
+
attributeFilter: ["data-state", "hidden", "aria-hidden", "class", "style", "open"],
|
|
4899
|
+
});
|
|
4900
|
+
return () => {
|
|
4901
|
+
obs.disconnect();
|
|
4902
|
+
if (t)
|
|
4903
|
+
clearTimeout(t);
|
|
4904
|
+
};
|
|
4905
|
+
}, [pagesLive, hasWhen]);
|
|
4906
|
+
const hasState = !!state;
|
|
4907
|
+
const pageNow = useMemo(() => (pagesLive && hasState ? pageHere(suite, env()) : null),
|
|
4908
|
+
// pathname/search/factsKey/domTick make it current.
|
|
4909
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4910
|
+
[pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
|
|
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
|
+
}, []);
|
|
4927
|
+
const openPageHere = useCallback(() => {
|
|
4928
|
+
const p = pageHere(suite, env());
|
|
4929
|
+
if (p)
|
|
4930
|
+
dispatchRef.current({ type: "startPage", page: p.key });
|
|
4931
|
+
}, [suite, env]);
|
|
4932
|
+
useEffect(() => {
|
|
4933
|
+
if (!pagesLive || !hasState) {
|
|
4934
|
+
publishPageGuides(null, null, null);
|
|
4935
|
+
return;
|
|
4936
|
+
}
|
|
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]);
|
|
4941
|
+
useEffect(() => () => publishPageGuides(null, null, null), []);
|
|
4942
|
+
// The first-visit offer: in "offer" mode, nothing else up, the welcome
|
|
4943
|
+
// not due here, and a moment of nothing loading on this page.
|
|
4944
|
+
const offerWatch = pagesLive && !!state && state.pagesMode === "offer" && screen.name === "idle" && !state.panelOpen && !due;
|
|
4945
|
+
useEffect(() => {
|
|
4946
|
+
if (!offerWatch)
|
|
4947
|
+
return;
|
|
4948
|
+
let idleSince = null;
|
|
4949
|
+
const iv = setInterval(() => {
|
|
4950
|
+
const cur = stateRef.current;
|
|
4951
|
+
if (!cur)
|
|
4952
|
+
return;
|
|
4953
|
+
let b = false;
|
|
4954
|
+
try {
|
|
4955
|
+
b = busyRef.current();
|
|
4956
|
+
}
|
|
4957
|
+
catch {
|
|
4958
|
+
b = false;
|
|
4959
|
+
}
|
|
4960
|
+
if (b || document.visibilityState === "hidden") {
|
|
4961
|
+
idleSince = null;
|
|
4962
|
+
return;
|
|
4963
|
+
}
|
|
4964
|
+
idleSince ?? (idleSince = Date.now());
|
|
4965
|
+
if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
|
|
4966
|
+
return;
|
|
4967
|
+
let key = null;
|
|
4968
|
+
try {
|
|
4969
|
+
key = pageOfferDue(cur, suite, env());
|
|
4970
|
+
}
|
|
4971
|
+
catch {
|
|
4972
|
+
key = null;
|
|
4973
|
+
}
|
|
4974
|
+
if (key)
|
|
4975
|
+
dispatchRef.current({ type: "pageOffer", page: key });
|
|
4976
|
+
}, PAGE_POLL_MS);
|
|
4977
|
+
return () => clearInterval(iv);
|
|
4978
|
+
}, [offerWatch, suite, env, pathname, search]);
|
|
4979
|
+
// A page in the panel's list: start it here, or go there and start it on arrival.
|
|
4980
|
+
const [pending, setPending] = useState(null);
|
|
4981
|
+
const openListedPage = useCallback((key) => {
|
|
4982
|
+
const page = findPage(suite, key);
|
|
4983
|
+
if (!page)
|
|
4984
|
+
return;
|
|
4985
|
+
const e = env();
|
|
4986
|
+
if (pageHere(suite, e)?.key === key) {
|
|
4987
|
+
dispatchRef.current({ type: "startPage", page: key });
|
|
4988
|
+
return;
|
|
4989
|
+
}
|
|
4990
|
+
dispatchRef.current({ type: "closePanel" });
|
|
4991
|
+
const raw = resolveText(page.href, guideContext(e, {}));
|
|
4992
|
+
const href = raw ? (fillRoute(raw, { code }) ?? raw) : "";
|
|
4993
|
+
if (!href)
|
|
4994
|
+
return;
|
|
4995
|
+
setPending({ key, until: Date.now() + PAGE_ARRIVE_MS });
|
|
4996
|
+
try {
|
|
4997
|
+
if (/^https?:\/\//i.test(href))
|
|
4998
|
+
window.location.assign(href);
|
|
4999
|
+
else
|
|
5000
|
+
navigateRef.current(href);
|
|
5001
|
+
}
|
|
5002
|
+
catch {
|
|
5003
|
+
/* never breaks the app */
|
|
5004
|
+
}
|
|
5005
|
+
}, [suite, env, code]);
|
|
5006
|
+
useEffect(() => {
|
|
5007
|
+
if (!pending || !pagesLive)
|
|
5008
|
+
return;
|
|
5009
|
+
const iv = setInterval(() => {
|
|
5010
|
+
if (Date.now() > pending.until) {
|
|
5011
|
+
setPending(null);
|
|
5012
|
+
return;
|
|
5013
|
+
}
|
|
5014
|
+
const cur = stateRef.current;
|
|
5015
|
+
if (!cur || (cur.screen.name !== "idle" && cur.screen.name !== "pageOffer"))
|
|
5016
|
+
return;
|
|
5017
|
+
let b = false;
|
|
5018
|
+
try {
|
|
5019
|
+
b = busyRef.current();
|
|
5020
|
+
}
|
|
5021
|
+
catch {
|
|
5022
|
+
b = false;
|
|
5023
|
+
}
|
|
5024
|
+
if (b)
|
|
5025
|
+
return;
|
|
5026
|
+
const e = env();
|
|
5027
|
+
const page = findPage(suite, pending.key);
|
|
5028
|
+
if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
|
|
5029
|
+
return;
|
|
5030
|
+
setPending(null);
|
|
5031
|
+
dispatchRef.current({ type: "startPage", page: pending.key });
|
|
5032
|
+
}, PAGE_POLL_MS);
|
|
5033
|
+
return () => clearInterval(iv);
|
|
5034
|
+
}, [pending, pagesLive, suite, env, code]);
|
|
3861
5035
|
if (!live || !state || !layer || !ctxIdle)
|
|
3862
5036
|
return null;
|
|
3863
5037
|
/* ---------------- render ---------------- */
|
|
@@ -3866,31 +5040,53 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3866
5040
|
const gateHref = gate ? resolveText(gate.action.href, ctxIdle) : "";
|
|
3867
5041
|
const gateFailing = gate ? gateState(suite, ctxIdle) === false : false;
|
|
3868
5042
|
let content = null;
|
|
3869
|
-
if (screen.name === "welcome") {
|
|
5043
|
+
if (!missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete")) {
|
|
5044
|
+
content = null;
|
|
5045
|
+
}
|
|
5046
|
+
else if (screen.name === "welcome") {
|
|
3870
5047
|
content = (jsx(WelcomeModal, { title: resolveText(suite.welcome.title, ctxIdle), subtitle: resolveText(suite.welcome.subtitle, ctxIdle) || `มาตั้งค่าบริษัทให้พร้อมใน ${total} ภารกิจ`, missions: shown.map(m => ({ title: m.title, minutes: m.minutes })), confirm: screen.confirm, reopenHint: suite.reopenHint, onStart: () => dispatch({ type: "welcomeStart" }), onSkip: () => dispatch({ type: "welcomeSkip" }), onSkipConfirm: () => dispatch({ type: "welcomeSkipConfirm" }), onSkipCancel: () => dispatch({ type: "welcomeSkipCancel" }) }));
|
|
3871
5048
|
}
|
|
3872
5049
|
else if (screen.name === "gate" && gate) {
|
|
3873
5050
|
content = (jsx(GateModal, { title: gate.title, subtitle: gate.subtitle, actionLabel: gate.action.label, href: gateHref, onLater: () => dispatch({ type: "gateLater" }) }));
|
|
3874
5051
|
}
|
|
3875
|
-
else if (screen.name === "step" && stepInfo && stepCtx) {
|
|
5052
|
+
else if (screen.name === "step" && inStep && stepInfo && stepCtx) {
|
|
3876
5053
|
const { mission, step } = stepInfo;
|
|
3877
5054
|
const missionNo = missionNumber(suite, mission.key, ctxIdle);
|
|
3878
5055
|
const kind = stepKind(step);
|
|
3879
5056
|
const bridge = screen.bridge;
|
|
5057
|
+
const pos = stepPosition(mission, screen.index, env(), state.progress.context, autoPassedOf(state, mission.key));
|
|
5058
|
+
const page = screen.page ? findPage(suite, screen.page) : undefined;
|
|
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
|
|
5062
|
+
? {
|
|
5063
|
+
...read,
|
|
5064
|
+
showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
|
|
5065
|
+
nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
|
|
5066
|
+
}
|
|
5067
|
+
: {};
|
|
3880
5068
|
content = (jsx(StepLayer, { spot: layout.spot, holes: layout.holes, popovers: layout.popovers, covered: layout.covered, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
|
|
3881
5069
|
missionNo,
|
|
3882
5070
|
missionTitle: mission.title,
|
|
3883
5071
|
replay: screen.replay,
|
|
3884
5072
|
// Steps this person skips (requires / skipIf) are not counted.
|
|
3885
|
-
index:
|
|
3886
|
-
total:
|
|
5073
|
+
index: pos.no - 1,
|
|
5074
|
+
total: pos.total,
|
|
3887
5075
|
title: bridge ? "กดปุ่มเมนู" : resolveText(step.title, stepCtx),
|
|
3888
5076
|
why: bridge ? `เมนูทั้งหมดของ ${suite.appName} อยู่ในนี้` : resolveText(step.why, stepCtx),
|
|
3889
5077
|
error: screen.error,
|
|
3890
5078
|
status: bridge ? null : screen.status,
|
|
3891
5079
|
showNext: !bridge && (kind === "input" || kind === "view" || (kind === "select" && screen.replay && screen.replayNext)),
|
|
3892
5080
|
showEndWithoutSave: !bridge && screen.replay && kind === "commit",
|
|
3893
|
-
|
|
5081
|
+
...pageBits,
|
|
5082
|
+
}, hasTarget: bridge || !!step.target, targetPressable: targetPressable(step, bridge), phase: screen.phase, confirmExit: screen.confirmExit, reopenHint: suite.reopenHint, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onBack: () => dispatch({ type: "back" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
|
|
5083
|
+
}
|
|
5084
|
+
else if (screen.name === "pageOffer" && pagesLive) {
|
|
5085
|
+
const page = findPage(suite, screen.page);
|
|
5086
|
+
if (page) {
|
|
5087
|
+
const mission = findMission(suite, PAGE_PREFIX + page.key);
|
|
5088
|
+
content = (jsx(PageOfferCard, { title: page.title, steps: mission ? stepPosition(mission, 0, env(), {}).total : page.steps.length, onStart: () => dispatch({ type: "pageOfferStart" }), onLater: () => dispatch({ type: "pageOfferLater" }) }));
|
|
5089
|
+
}
|
|
3894
5090
|
}
|
|
3895
5091
|
else if (screen.name === "celebrate" && celebrateShown) {
|
|
3896
5092
|
const mission = findMission(suite, screen.mission);
|
|
@@ -3925,8 +5121,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3925
5121
|
.join(", ")
|
|
3926
5122
|
: undefined,
|
|
3927
5123
|
}));
|
|
3928
|
-
const showChip = chipVisible(state, suite, ctxIdle);
|
|
3929
|
-
|
|
5124
|
+
const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
|
|
5125
|
+
// The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
|
|
5126
|
+
// then the page guides they have.
|
|
5127
|
+
const panelMissions = missionsLive && total > 0 && state.panelSection !== "pages";
|
|
5128
|
+
const pageItems = pagesLive
|
|
5129
|
+
? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) }))
|
|
5130
|
+
: [];
|
|
5131
|
+
const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
|
|
5132
|
+
return createPortal(jsxs(Fragment, { children: [jsx("style", { children: GUIDE_CSS }), jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsx(QuestPanel, { items: items, done: done, total: total, justDone: state.justDone, gate: gate && gateFailing ? { label: gate.action.label, pending: gate.pending, href: gateHref } : null, canHideChip: !state.progress.chip_hidden && state.progress.status !== "completed", onClose: () => dispatch({ type: "closePanel" }), onStart: (key, replayIt) => dispatch({ type: "start", mission: key, replay: replayIt }), onDismiss: key => dispatch({ type: "dismissMission", mission: key }), onHideChip: () => dispatch({ type: "hideChip" }), pages: pageItems, showMissions: panelMissions, onOpenPage: openListedPage })) : null, toast ? jsx(GuideToast, { text: toast }) : null] }), layer);
|
|
3930
5133
|
}
|
|
3931
5134
|
/** A stable key for the facts object, so effects re-run when they change. */
|
|
3932
5135
|
function useFactsKey(facts) {
|
|
@@ -3940,5 +5143,101 @@ function useFactsKey(facts) {
|
|
|
3940
5143
|
}, [facts]);
|
|
3941
5144
|
}
|
|
3942
5145
|
|
|
3943
|
-
|
|
3944
|
-
|
|
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
|