@eintrek/erp-shell 0.1.72 → 0.1.74
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 +54 -0
- package/dist/chunks/{guide-runtime-B-3myqxe.esm.js → guide-runtime-Dl8V1iy9.esm.js} +963 -52
- package/dist/chunks/guide-runtime-Dl8V1iy9.esm.js.map +1 -0
- package/dist/chunks/{guide-runtime-DfnNdrjZ.cjs → guide-runtime-YS2nUhwS.cjs} +967 -50
- package/dist/chunks/guide-runtime-YS2nUhwS.cjs.map +1 -0
- package/dist/guide/core.d.ts +3 -3
- package/dist/guide/guide-store.d.ts +41 -0
- package/dist/guide/guide-styles.d.ts +1 -1
- package/dist/guide/guide-ui.d.ts +40 -0
- package/dist/guide/machine.d.ts +118 -2
- package/dist/guide/pages.d.ts +56 -0
- package/dist/guide/progress-sync.d.ts +9 -1
- package/dist/guide/progress.d.ts +25 -0
- package/dist/guide/types.d.ts +72 -0
- package/dist/guide-core.cjs +7 -1
- package/dist/guide-core.cjs.map +1 -1
- package/dist/guide-core.esm.js +1 -1
- package/dist/help/help-button.d.ts +5 -3
- package/dist/index.cjs +22 -10
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.ts +3 -2
- package/dist/index.esm.js +19 -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-B-3myqxe.esm.js.map +0 -1
- package/dist/chunks/guide-runtime-DfnNdrjZ.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, useEffect, useLayoutEffect, useCallback
|
|
4
|
+
import { useSyncExternalStore, useMemo, useRef, useState, useEffect, 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,6 +337,44 @@ function publishGuideEntry(next, openPanel) {
|
|
|
337
337
|
emit();
|
|
338
338
|
}
|
|
339
339
|
}
|
|
340
|
+
const NO_PAGES = { page: null, listed: 0 };
|
|
341
|
+
let pages = NO_PAGES;
|
|
342
|
+
let openPageImpl = null;
|
|
343
|
+
let openPagesImpl = null;
|
|
344
|
+
/** Called by the host: this page's guide and the listed count, and how to open them. */
|
|
345
|
+
function publishPageGuides(next, openPage, openPages) {
|
|
346
|
+
const n = next ?? NO_PAGES;
|
|
347
|
+
openPageImpl = openPage;
|
|
348
|
+
openPagesImpl = openPages;
|
|
349
|
+
const same = n.listed === pages.listed && (n.page?.title ?? null) === (pages.page?.title ?? null);
|
|
350
|
+
if (!same) {
|
|
351
|
+
pages = n.page || n.listed ? { page: n.page ? { title: n.page.title } : null, listed: n.listed } : NO_PAGES;
|
|
352
|
+
emit();
|
|
353
|
+
}
|
|
354
|
+
}
|
|
355
|
+
function guideMenuItems(e, p = NO_PAGES) {
|
|
356
|
+
const items = [];
|
|
357
|
+
if (p.page)
|
|
358
|
+
items.push("page");
|
|
359
|
+
if (e && e.total > 0)
|
|
360
|
+
items.push("missions");
|
|
361
|
+
if (p.listed > 0)
|
|
362
|
+
items.push("pages");
|
|
363
|
+
if (items.length)
|
|
364
|
+
items.push("help");
|
|
365
|
+
return items;
|
|
366
|
+
}
|
|
367
|
+
/**
|
|
368
|
+
* Whether the "?" press opens a menu rather than the help dialog: a tour
|
|
369
|
+
* with missions shown, a guide for this page, or page guides to list. A tour
|
|
370
|
+
* whose missions are all hidden for this user (`visible`, e.g. Plannex
|
|
371
|
+
* without an employee record) has nothing to open, and "0/0" led to an
|
|
372
|
+
* empty panel. The entry itself stays published: apps wait on it before
|
|
373
|
+
* they load the facts that make missions visible.
|
|
374
|
+
*/
|
|
375
|
+
function guideMenuShown(e, p = NO_PAGES) {
|
|
376
|
+
return guideMenuItems(e, p).length > 0;
|
|
377
|
+
}
|
|
340
378
|
/** Opens the tour's quest panel, when this user has a tour here. */
|
|
341
379
|
function openGuidePanel() {
|
|
342
380
|
openPanelImpl?.();
|
|
@@ -345,6 +383,206 @@ function openGuidePanel() {
|
|
|
345
383
|
function useGuideEntry() {
|
|
346
384
|
return useSyncExternalStore(subscribe, () => entry, () => null);
|
|
347
385
|
}
|
|
386
|
+
/** Opens this page's guide ("คู่มือหน้านี้"), when there is one. */
|
|
387
|
+
function openPageGuide() {
|
|
388
|
+
openPageImpl?.();
|
|
389
|
+
}
|
|
390
|
+
/** Opens the panel on the page guides alone ("คู่มือทุกหน้า"). */
|
|
391
|
+
function openPagesPanel() {
|
|
392
|
+
openPagesImpl?.();
|
|
393
|
+
}
|
|
394
|
+
/** Page guides here (this page's, and the listed count); stable until they change. */
|
|
395
|
+
function usePageGuides() {
|
|
396
|
+
return useSyncExternalStore(subscribe, () => pages, () => NO_PAGES);
|
|
397
|
+
}
|
|
398
|
+
/**
|
|
399
|
+
* This page's guide: `{ title, open }`, or null when the page (or the
|
|
400
|
+
* sub-screen, `when`) has none for this person. For an app that draws its
|
|
401
|
+
* own "?" (LIFF's page head): draw it only when this is not null.
|
|
402
|
+
*/
|
|
403
|
+
function usePageGuideEntry() {
|
|
404
|
+
const p = usePageGuides();
|
|
405
|
+
const title = p.page?.title ?? null;
|
|
406
|
+
return useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
|
|
407
|
+
}
|
|
408
|
+
|
|
409
|
+
/** The synthetic mission key of a page guide: `page:<key>`. */
|
|
410
|
+
const PAGE_PREFIX = "page:";
|
|
411
|
+
/** A page guide's key: lower case, digits and hyphens. */
|
|
412
|
+
const PAGE_KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
|
|
413
|
+
/** The server's row key limit (`<app>.page.<key>`). */
|
|
414
|
+
const ROW_KEY_MAX = 64;
|
|
415
|
+
function isPageMissionKey(key) {
|
|
416
|
+
return !!key && key.startsWith(PAGE_PREFIX);
|
|
417
|
+
}
|
|
418
|
+
function pageKeyOf(missionKey) {
|
|
419
|
+
return isPageMissionKey(missionKey) ? missionKey.slice(PAGE_PREFIX.length) : undefined;
|
|
420
|
+
}
|
|
421
|
+
function devWarn(message) {
|
|
422
|
+
try {
|
|
423
|
+
// Written out in full so the app's bundler replaces it.
|
|
424
|
+
if (typeof process !== "undefined" && process.env.NODE_ENV === "production")
|
|
425
|
+
return;
|
|
426
|
+
if (typeof console !== "undefined")
|
|
427
|
+
console.warn(`[erp-shell guide] ${message}`);
|
|
428
|
+
}
|
|
429
|
+
catch {
|
|
430
|
+
/* never breaks the app */
|
|
431
|
+
}
|
|
432
|
+
}
|
|
433
|
+
/** Why a page guide cannot run, or null when it can. */
|
|
434
|
+
function pageProblem(app, page) {
|
|
435
|
+
if (!page || typeof page.key !== "string" || !PAGE_KEY_RE.test(page.key))
|
|
436
|
+
return "its key is not [a-z0-9-]";
|
|
437
|
+
if (`${app}.page.${page.key}`.length > ROW_KEY_MAX)
|
|
438
|
+
return `its row key is over ${ROW_KEY_MAX} characters`;
|
|
439
|
+
if (!Number.isInteger(page.version) || page.version < 1)
|
|
440
|
+
return "its version is not a whole number ≥ 1";
|
|
441
|
+
const routes = Array.isArray(page.route) ? page.route : [page.route];
|
|
442
|
+
if (!routes.length || routes.some(r => typeof r !== "string" || !r.startsWith("/")))
|
|
443
|
+
return "it has no route";
|
|
444
|
+
if (!Array.isArray(page.steps) || page.steps.length === 0)
|
|
445
|
+
return "it has no steps";
|
|
446
|
+
for (const st of page.steps) {
|
|
447
|
+
const kind = st.kind ?? "click";
|
|
448
|
+
if (kind !== "view" && kind !== "click")
|
|
449
|
+
return `step "${st.key}" is a ${kind} step (a page guide never saves)`;
|
|
450
|
+
}
|
|
451
|
+
return null;
|
|
452
|
+
}
|
|
453
|
+
const validCache = new WeakMap();
|
|
454
|
+
const warned = new Set();
|
|
455
|
+
/**
|
|
456
|
+
* The suite's page guides that can run. A page guide with a commit, input or
|
|
457
|
+
* select step (it would save), a bad key or no route is dropped, with a
|
|
458
|
+
* warning in development; so is a second one with the same key.
|
|
459
|
+
*/
|
|
460
|
+
function validPages(suite) {
|
|
461
|
+
const hit = validCache.get(suite);
|
|
462
|
+
if (hit)
|
|
463
|
+
return hit;
|
|
464
|
+
const out = [];
|
|
465
|
+
const seen = new Set();
|
|
466
|
+
for (const page of suite.pages ?? []) {
|
|
467
|
+
let problem = pageProblem(suite.app, page);
|
|
468
|
+
if (!problem && seen.has(page.key))
|
|
469
|
+
problem = "another page guide has the same key";
|
|
470
|
+
if (problem) {
|
|
471
|
+
const id = `${suite.key}:${page?.key}:${problem}`;
|
|
472
|
+
if (!warned.has(id)) {
|
|
473
|
+
warned.add(id);
|
|
474
|
+
devWarn(`page guide "${page?.key}" dropped: ${problem}`);
|
|
475
|
+
}
|
|
476
|
+
continue;
|
|
477
|
+
}
|
|
478
|
+
seen.add(page.key);
|
|
479
|
+
out.push(page);
|
|
480
|
+
}
|
|
481
|
+
validCache.set(suite, out);
|
|
482
|
+
return out;
|
|
483
|
+
}
|
|
484
|
+
function findPage(suite, key) {
|
|
485
|
+
return key ? validPages(suite).find(p => p.key === key) : undefined;
|
|
486
|
+
}
|
|
487
|
+
const missionCache = new WeakMap();
|
|
488
|
+
/**
|
|
489
|
+
* The page guide as a mission the machine plays: `page:<key>`. Steps lose
|
|
490
|
+
* any `route` (the guide is the page's, and ends when it is left) and are
|
|
491
|
+
* checkpoints unless they say otherwise.
|
|
492
|
+
*/
|
|
493
|
+
function pageMission(page) {
|
|
494
|
+
const hit = missionCache.get(page);
|
|
495
|
+
if (hit)
|
|
496
|
+
return hit;
|
|
497
|
+
const steps = page.steps.map(st => {
|
|
498
|
+
const { route: _route, resumeUrl: _resume, ...rest } = st;
|
|
499
|
+
return { ...rest, checkpoint: st.checkpoint ?? true };
|
|
500
|
+
});
|
|
501
|
+
const m = { key: PAGE_PREFIX + page.key, title: page.title, minutes: 1, steps };
|
|
502
|
+
missionCache.set(page, m);
|
|
503
|
+
return m;
|
|
504
|
+
}
|
|
505
|
+
function routesOf(page) {
|
|
506
|
+
return Array.isArray(page.route) ? page.route : [page.route];
|
|
507
|
+
}
|
|
508
|
+
/** How specific a route pattern is: its `?k=v` constraints, then its static segments. */
|
|
509
|
+
function routeSpecificity(pattern) {
|
|
510
|
+
const q = pattern.indexOf("?");
|
|
511
|
+
const path = q < 0 ? pattern : pattern.slice(0, q);
|
|
512
|
+
const query = q < 0 ? 0 : [...new URLSearchParams(pattern.slice(q + 1)).keys()].length;
|
|
513
|
+
const statics = path.split("/").filter(seg => seg && !/^\[.*\]$/.test(seg) && !seg.startsWith(":")).length;
|
|
514
|
+
return [query, statics];
|
|
515
|
+
}
|
|
516
|
+
/**
|
|
517
|
+
* Whether the page guide applies here: one of its routes matches and its
|
|
518
|
+
* `when` anchor (if any) is on screen. The score ranks matches: `when`
|
|
519
|
+
* first, then query constraints, then static segments. Null when it does
|
|
520
|
+
* not apply.
|
|
521
|
+
*/
|
|
522
|
+
function pageMatchScore(page, where) {
|
|
523
|
+
let best = null;
|
|
524
|
+
for (const r of routesOf(page)) {
|
|
525
|
+
if (!matchRoute(r, where.pathname, where.search, where.code))
|
|
526
|
+
continue;
|
|
527
|
+
const s = routeSpecificity(r);
|
|
528
|
+
if (!best || s[0] > best[0] || (s[0] === best[0] && s[1] > best[1]))
|
|
529
|
+
best = s;
|
|
530
|
+
}
|
|
531
|
+
if (!best)
|
|
532
|
+
return null;
|
|
533
|
+
if (page.when) {
|
|
534
|
+
let on = false;
|
|
535
|
+
try {
|
|
536
|
+
on = where.isVisible(page.when);
|
|
537
|
+
}
|
|
538
|
+
catch {
|
|
539
|
+
on = false;
|
|
540
|
+
}
|
|
541
|
+
if (!on)
|
|
542
|
+
return null;
|
|
543
|
+
}
|
|
544
|
+
return [page.when ? 1 : 0, best[0], best[1]];
|
|
545
|
+
}
|
|
546
|
+
const after = (a, b) => a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2];
|
|
547
|
+
/** The most specific page guide that applies here, whoever it is for (first in the list on a tie). */
|
|
548
|
+
function matchingPage(suite, where) {
|
|
549
|
+
let best = null;
|
|
550
|
+
let bestScore = null;
|
|
551
|
+
for (const p of validPages(suite)) {
|
|
552
|
+
const s = pageMatchScore(p, where);
|
|
553
|
+
if (s && (!bestScore || after(s, bestScore))) {
|
|
554
|
+
best = p;
|
|
555
|
+
bestScore = s;
|
|
556
|
+
}
|
|
557
|
+
}
|
|
558
|
+
return best;
|
|
559
|
+
}
|
|
560
|
+
/** Whether this person has the page guide: `visible` (only true) and `requires`. */
|
|
561
|
+
function pageShown(page, ctx) {
|
|
562
|
+
try {
|
|
563
|
+
if (page.visible && page.visible(ctx) !== true)
|
|
564
|
+
return false;
|
|
565
|
+
if (page.requires && !ctx.can(page.requires))
|
|
566
|
+
return false;
|
|
567
|
+
return true;
|
|
568
|
+
}
|
|
569
|
+
catch {
|
|
570
|
+
return false;
|
|
571
|
+
}
|
|
572
|
+
}
|
|
573
|
+
/**
|
|
574
|
+
* This page's guide for this person: the most specific match, when it is
|
|
575
|
+
* theirs. A more specific guide they do not have is not replaced by a less
|
|
576
|
+
* specific one (it would describe what is not on screen).
|
|
577
|
+
*/
|
|
578
|
+
function currentPage(suite, where, ctx) {
|
|
579
|
+
const p = matchingPage(suite, where);
|
|
580
|
+
return p && pageShown(p, ctx) ? p : null;
|
|
581
|
+
}
|
|
582
|
+
/** The page guides this person has, for the panel's list (those with an `href`). */
|
|
583
|
+
function listedPages(suite, ctx) {
|
|
584
|
+
return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
|
|
585
|
+
}
|
|
348
586
|
|
|
349
587
|
/* ------------------------------------------------------------------ */
|
|
350
588
|
/* Copy */
|
|
@@ -358,6 +596,7 @@ const COPY = {
|
|
|
358
596
|
replayEnded: "จบการทบทวนแล้ว",
|
|
359
597
|
hiddenToast: "ซ่อนแล้ว",
|
|
360
598
|
aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
|
|
599
|
+
pageDone: "จบคู่มือหน้านี้",
|
|
361
600
|
bridgeTitle: "กดปุ่มเมนู",
|
|
362
601
|
bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
|
|
363
602
|
};
|
|
@@ -365,8 +604,13 @@ const COPY = {
|
|
|
365
604
|
/* Helpers */
|
|
366
605
|
/* ------------------------------------------------------------------ */
|
|
367
606
|
const BRIDGE_TARGET = "shell.menu-toggle";
|
|
368
|
-
function initialGuideState(audience, progress) {
|
|
607
|
+
function initialGuideState(audience, progress, pages) {
|
|
369
608
|
return {
|
|
609
|
+
pageAudience: pages?.pageAudience ?? "none",
|
|
610
|
+
pagesMode: pages?.pagesMode ?? "off",
|
|
611
|
+
pages: pages?.pages ?? {},
|
|
612
|
+
pageRun: null,
|
|
613
|
+
panelSection: "all",
|
|
370
614
|
audience,
|
|
371
615
|
progress,
|
|
372
616
|
screen: { name: "idle" },
|
|
@@ -380,7 +624,13 @@ function initialGuideState(audience, progress) {
|
|
|
380
624
|
};
|
|
381
625
|
}
|
|
382
626
|
function findMission(suite, key) {
|
|
383
|
-
|
|
627
|
+
if (!key)
|
|
628
|
+
return undefined;
|
|
629
|
+
if (isPageMissionKey(key)) {
|
|
630
|
+
const page = findPage(suite, pageKeyOf(key));
|
|
631
|
+
return page ? pageMission(page) : undefined;
|
|
632
|
+
}
|
|
633
|
+
return suite.missions.find(m => m.key === key);
|
|
384
634
|
}
|
|
385
635
|
/**
|
|
386
636
|
* Whether the user has this mission (GuideMission.visible). Without the
|
|
@@ -543,8 +793,10 @@ function placeRoute(steps, index) {
|
|
|
543
793
|
return step.route;
|
|
544
794
|
return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
|
|
545
795
|
}
|
|
546
|
-
/** The user is not on the page the step happens on. */
|
|
547
|
-
function offRoute(steps, index, env) {
|
|
796
|
+
/** The user is not on the page the step happens on (a page guide's step never is: leaving the page ends it). */
|
|
797
|
+
function offRoute(steps, index, env, page) {
|
|
798
|
+
if (page)
|
|
799
|
+
return false;
|
|
548
800
|
const home = stepHomeRoute(steps, index);
|
|
549
801
|
return !!home && !matchRoute(home, env.pathname, env.search, env.code);
|
|
550
802
|
}
|
|
@@ -687,7 +939,8 @@ function welcomeDue(state, suite, ctx) {
|
|
|
687
939
|
state.progress.welcome === "not_seen" &&
|
|
688
940
|
state.progress.status === "in_progress" &&
|
|
689
941
|
!state.progress.mission_key &&
|
|
690
|
-
|
|
942
|
+
// The missions' welcome always wins over a page guide's offer.
|
|
943
|
+
(state.screen.name === "idle" || state.screen.name === "pageOffer") &&
|
|
691
944
|
(ctx ? shownMissions(suite, ctx).length : suite.missions.length) > 0);
|
|
692
945
|
}
|
|
693
946
|
/** Whether the floating "ภารกิจ n/N" chip shows. */
|
|
@@ -800,9 +1053,12 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
800
1053
|
}
|
|
801
1054
|
const step = steps[i];
|
|
802
1055
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1056
|
+
const page = pageKeyOf(mission.key);
|
|
803
1057
|
const where = placeRoute(steps, i);
|
|
804
|
-
|
|
1058
|
+
// A page guide is on its page: leaving it ends the guide (never "ทำต่อ").
|
|
1059
|
+
const away = !page && !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
805
1060
|
const bridge = !away && needsBridge(step, env, ctx);
|
|
1061
|
+
const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
|
|
806
1062
|
setScreen(d, {
|
|
807
1063
|
name: "step",
|
|
808
1064
|
mission: mission.key,
|
|
@@ -814,7 +1070,10 @@ function enterStep(d, suite, env, mission, index, opts) {
|
|
|
814
1070
|
confirmExit: false,
|
|
815
1071
|
misses: 0,
|
|
816
1072
|
armed: hiddenAnchorShown(step, env),
|
|
817
|
-
nothingToSave:
|
|
1073
|
+
nothingToSave: nts,
|
|
1074
|
+
status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
|
|
1075
|
+
replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
|
|
1076
|
+
...(page ? { page } : {}),
|
|
818
1077
|
});
|
|
819
1078
|
if (!opts.replay) {
|
|
820
1079
|
const cp = checkpointAtOrBefore(steps, i);
|
|
@@ -857,6 +1116,54 @@ function setNothingToSave(d, suite, env, on) {
|
|
|
857
1116
|
if (stepTargetVisible(step, env, ctx, s.bridge))
|
|
858
1117
|
announceStep(d, suite, env);
|
|
859
1118
|
}
|
|
1119
|
+
/** The step's status line (GuideStep.status): trimmed text, or null. */
|
|
1120
|
+
function stepStatus(step, ctx) {
|
|
1121
|
+
if (!step.status)
|
|
1122
|
+
return null;
|
|
1123
|
+
const text = safe(() => step.status(ctx), undefined);
|
|
1124
|
+
return typeof text === "string" && text.trim() ? text.trim() : null;
|
|
1125
|
+
}
|
|
1126
|
+
/**
|
|
1127
|
+
* In a replay, whether a "select" step may go on with ถัดไป: the next step
|
|
1128
|
+
* this person sees has its target on the page already (or there is none).
|
|
1129
|
+
* A pick that makes the next step's target (a save shown only once the
|
|
1130
|
+
* right value is chosen) is still needed.
|
|
1131
|
+
*/
|
|
1132
|
+
function replayCanGoOn(mission, index, env, context, nothingToSave = false) {
|
|
1133
|
+
if (stepKind(shownStep(mission.steps[index], nothingToSave)) !== "select")
|
|
1134
|
+
return false;
|
|
1135
|
+
for (let i = index + 1; i < mission.steps.length; i++) {
|
|
1136
|
+
const next = mission.steps[i];
|
|
1137
|
+
const ctx = guideContext(env, context, routeParamsFor(next, env));
|
|
1138
|
+
if (next.requires && !safe(() => env.can(next.requires), true))
|
|
1139
|
+
continue;
|
|
1140
|
+
if (next.skipIf && safe(() => next.skipIf(ctx), false))
|
|
1141
|
+
continue;
|
|
1142
|
+
if (!next.target)
|
|
1143
|
+
return true;
|
|
1144
|
+
if (stepTargetVisible(next, env, ctx, false))
|
|
1145
|
+
return true;
|
|
1146
|
+
// A commit step with nothing to save is read on its saved state.
|
|
1147
|
+
return nothingToSaveHolds(next, env, ctx);
|
|
1148
|
+
}
|
|
1149
|
+
return true;
|
|
1150
|
+
}
|
|
1151
|
+
/** Keep the status line and a replay's ถัดไป current (the page changed). */
|
|
1152
|
+
function syncStatus(d, suite, env) {
|
|
1153
|
+
const s = d.state.screen;
|
|
1154
|
+
if (s.name !== "step")
|
|
1155
|
+
return;
|
|
1156
|
+
const mission = findMission(suite, s.mission);
|
|
1157
|
+
if (!mission)
|
|
1158
|
+
return;
|
|
1159
|
+
const step = shownStep(mission.steps[s.index], s.nothingToSave);
|
|
1160
|
+
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
1161
|
+
const off = s.phase === "away" || s.bridge;
|
|
1162
|
+
const status = off ? null : stepStatus(step, ctx);
|
|
1163
|
+
const replayNext = !off && s.replay && replayCanGoOn(mission, s.index, env, d.state.progress.context, s.nothingToSave);
|
|
1164
|
+
if (status !== s.status || replayNext !== s.replayNext)
|
|
1165
|
+
setScreen(d, { ...s, status, replayNext });
|
|
1166
|
+
}
|
|
860
1167
|
function markReadyIfVisible(d, suite, env) {
|
|
861
1168
|
const s = d.state.screen;
|
|
862
1169
|
if (s.name !== "step" || s.phase !== "waiting")
|
|
@@ -931,6 +1238,11 @@ function advance(d, suite, env, capture) {
|
|
|
931
1238
|
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
932
1239
|
}
|
|
933
1240
|
function finishMission(d, suite, env, mission, replay) {
|
|
1241
|
+
if (isPageMissionKey(mission.key)) {
|
|
1242
|
+
endPage(d, suite, "completed");
|
|
1243
|
+
d.effects.push({ type: "toast", text: COPY.pageDone });
|
|
1244
|
+
return;
|
|
1245
|
+
}
|
|
934
1246
|
if (replay) {
|
|
935
1247
|
setScreen(d, { name: "idle" });
|
|
936
1248
|
d.effects.push({ type: "toast", text: COPY.replayDone });
|
|
@@ -1124,6 +1436,134 @@ function pushAbort(d, suite, s) {
|
|
|
1124
1436
|
if (actions.length)
|
|
1125
1437
|
d.effects.push({ type: "abort", actions });
|
|
1126
1438
|
}
|
|
1439
|
+
/* ------------------------------------------------------------------ */
|
|
1440
|
+
/* Page guides */
|
|
1441
|
+
/* ------------------------------------------------------------------ */
|
|
1442
|
+
function whereOf(env) {
|
|
1443
|
+
return { code: env.code, pathname: env.pathname, search: env.search, isVisible: a => env.isVisible(a) };
|
|
1444
|
+
}
|
|
1445
|
+
/** This page's guide for this person (the most specific match, if theirs). */
|
|
1446
|
+
function pageHere(suite, env) {
|
|
1447
|
+
return currentPage(suite, whereOf(env), guideContext(env, {}));
|
|
1448
|
+
}
|
|
1449
|
+
/** The page guide's row says this person has ended this version of it. */
|
|
1450
|
+
function pageRecorded(state, page) {
|
|
1451
|
+
const row = state.pages[page.key];
|
|
1452
|
+
return !!row && row.version >= page.version;
|
|
1453
|
+
}
|
|
1454
|
+
/** Whether the first step this person sees has its target on screen (or none). */
|
|
1455
|
+
function firstStepReady(suite, pageKey, env) {
|
|
1456
|
+
const page = findPage(suite, pageKey);
|
|
1457
|
+
if (!page)
|
|
1458
|
+
return false;
|
|
1459
|
+
const mission = pageMission(page);
|
|
1460
|
+
for (const step of mission.steps) {
|
|
1461
|
+
const ctx = guideContext(env, {});
|
|
1462
|
+
if (step.requires && !safe(() => env.can(step.requires), true))
|
|
1463
|
+
continue;
|
|
1464
|
+
if (step.skipIf && safe(() => step.skipIf(ctx), false))
|
|
1465
|
+
continue;
|
|
1466
|
+
return stepTargetVisible(step, env, ctx, false);
|
|
1467
|
+
}
|
|
1468
|
+
return true;
|
|
1469
|
+
}
|
|
1470
|
+
/**
|
|
1471
|
+
* The page guide to offer now, or null. Due when: pages mode is "offer" and
|
|
1472
|
+
* this person has page guides; nothing else is up (no step, card, panel or
|
|
1473
|
+
* celebration waiting); this page's guide is theirs, may be offered, and has
|
|
1474
|
+
* no row of this version; its first step's target is on screen; no app
|
|
1475
|
+
* dialog is open. The host adds the rest: the welcome not due on this
|
|
1476
|
+
* route, a moment of nothing loading, the primary tab, not hidden.
|
|
1477
|
+
*/
|
|
1478
|
+
function pageOfferDue(state, suite, env) {
|
|
1479
|
+
if (state.pagesMode !== "offer" || state.pageAudience === "none")
|
|
1480
|
+
return null;
|
|
1481
|
+
if (state.screen.name !== "idle" || state.panelOpen || (state.pendingCelebrate?.length ?? 0) > 0)
|
|
1482
|
+
return null;
|
|
1483
|
+
const page = pageHere(suite, env);
|
|
1484
|
+
if (!page || page.offer === false || pageRecorded(state, page))
|
|
1485
|
+
return null;
|
|
1486
|
+
if (!firstStepReady(suite, page.key, env))
|
|
1487
|
+
return null;
|
|
1488
|
+
if (safe(() => env.dialogOpen?.() === true, false))
|
|
1489
|
+
return null;
|
|
1490
|
+
return page.key;
|
|
1491
|
+
}
|
|
1492
|
+
/** Whether the page guide still applies where the person is (its route, its `when`). */
|
|
1493
|
+
function pageStillHere(suite, key, env) {
|
|
1494
|
+
const page = findPage(suite, key);
|
|
1495
|
+
return !!page && pageMatchScore(page, whereOf(env)) !== null;
|
|
1496
|
+
}
|
|
1497
|
+
/** Write a page guide's row (once per page and version): recorded here at once, saved by the host. */
|
|
1498
|
+
function recordPage(d, suite, key, status) {
|
|
1499
|
+
const page = findPage(suite, key);
|
|
1500
|
+
if (!page || pageRecorded(d.state, page))
|
|
1501
|
+
return;
|
|
1502
|
+
const prev = d.state.pages[key];
|
|
1503
|
+
d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
|
|
1504
|
+
d.effects.push({ type: "savePage", key, version: page.version, status });
|
|
1505
|
+
}
|
|
1506
|
+
/** The page guide ends: its row written when it is due one; back to idle. */
|
|
1507
|
+
function endPage(d, suite, how) {
|
|
1508
|
+
const run = d.state.pageRun;
|
|
1509
|
+
if (run?.write)
|
|
1510
|
+
recordPage(d, suite, run.key, how);
|
|
1511
|
+
d.state = { ...d.state, pageRun: null, replayPage: null };
|
|
1512
|
+
setScreen(d, { name: "idle" });
|
|
1513
|
+
}
|
|
1514
|
+
function startPage(d, suite, env, key, write) {
|
|
1515
|
+
const page = findPage(suite, key);
|
|
1516
|
+
if (!page)
|
|
1517
|
+
return;
|
|
1518
|
+
const mission = pageMission(page);
|
|
1519
|
+
d.state = {
|
|
1520
|
+
...d.state,
|
|
1521
|
+
panelOpen: false,
|
|
1522
|
+
justDone: null,
|
|
1523
|
+
pageRun: { key, write },
|
|
1524
|
+
// What is open now is the person's: ending the guide leaves it alone.
|
|
1525
|
+
replayPage: {
|
|
1526
|
+
mission: mission.key,
|
|
1527
|
+
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
1528
|
+
dialog: safe(() => env.dialogOpen?.() === true, false),
|
|
1529
|
+
},
|
|
1530
|
+
};
|
|
1531
|
+
enterStep(d, suite, env, mission, 0, { replay: true });
|
|
1532
|
+
}
|
|
1533
|
+
/** The step before this one that this person sees, or -1. */
|
|
1534
|
+
function previousShownIndex(mission, index, env, context) {
|
|
1535
|
+
for (let j = index - 1; j >= 0; j--) {
|
|
1536
|
+
const st = mission.steps[j];
|
|
1537
|
+
const ctx = guideContext(env, context, routeParamsFor(st, env));
|
|
1538
|
+
if (st.requires && !safe(() => env.can(st.requires), true))
|
|
1539
|
+
continue;
|
|
1540
|
+
if (st.skipIf && safe(() => st.skipIf(ctx), false))
|
|
1541
|
+
continue;
|
|
1542
|
+
return j;
|
|
1543
|
+
}
|
|
1544
|
+
return -1;
|
|
1545
|
+
}
|
|
1546
|
+
/**
|
|
1547
|
+
* Where ย้อนกลับ goes from a page guide's step, or null. The step before it
|
|
1548
|
+
* that this person sees; from inside what a click opened (not a
|
|
1549
|
+
* checkpoint) back to that click, closing it first (the step's abort,
|
|
1550
|
+
* Escape by default). A click step whose result is still on screen (a tab
|
|
1551
|
+
* switched) is passed over: entering it again would pass it at once.
|
|
1552
|
+
*/
|
|
1553
|
+
function backTarget(mission, index, env, context) {
|
|
1554
|
+
const cur = mission.steps[index];
|
|
1555
|
+
let prev = previousShownIndex(mission, index, env, context);
|
|
1556
|
+
if (prev < 0 || !cur)
|
|
1557
|
+
return null;
|
|
1558
|
+
if (cur.checkpoint === false) {
|
|
1559
|
+
const to = mission.steps[prev];
|
|
1560
|
+
return { index: prev, close: to.checkpoint !== false };
|
|
1561
|
+
}
|
|
1562
|
+
while (prev >= 0 && stepKind(mission.steps[prev]) !== "view" && stateTriggerHolds(mission.steps[prev], env)) {
|
|
1563
|
+
prev = previousShownIndex(mission, prev, env, context);
|
|
1564
|
+
}
|
|
1565
|
+
return prev >= 0 ? { index: prev, close: false } : null;
|
|
1566
|
+
}
|
|
1127
1567
|
function reduceGuide(state, action, suite, env) {
|
|
1128
1568
|
const d = { state, effects: [] };
|
|
1129
1569
|
const s = state.screen;
|
|
@@ -1135,6 +1575,9 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1135
1575
|
const gate = gateState(suite, ctxNow());
|
|
1136
1576
|
if (gate === undefined)
|
|
1137
1577
|
break;
|
|
1578
|
+
// The welcome wins: the page offer goes, unrecorded (it comes back on a later visit).
|
|
1579
|
+
if (s.name === "pageOffer")
|
|
1580
|
+
setScreen(d, { name: "idle" });
|
|
1138
1581
|
if (gate === false) {
|
|
1139
1582
|
if (!state.gateDismissed)
|
|
1140
1583
|
setScreen(d, { name: "gate" });
|
|
@@ -1175,6 +1618,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1175
1618
|
case "start":
|
|
1176
1619
|
if (state.audience === "none")
|
|
1177
1620
|
break;
|
|
1621
|
+
// A mission always wins over a page guide: a running one ends (its row
|
|
1622
|
+
// written if due), an offer goes unrecorded.
|
|
1623
|
+
if (s.name === "step" && s.page)
|
|
1624
|
+
endPage(d, suite, "skipped");
|
|
1625
|
+
else if (s.name === "pageOffer")
|
|
1626
|
+
setScreen(d, { name: "idle" });
|
|
1178
1627
|
if (s.name === "step" && !s.replay && s.mission !== action.mission) {
|
|
1179
1628
|
// Leaving one mission for another: the first is set aside.
|
|
1180
1629
|
exitMission(d);
|
|
@@ -1196,8 +1645,21 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1196
1645
|
break;
|
|
1197
1646
|
}
|
|
1198
1647
|
case "sync": {
|
|
1648
|
+
if (s.name === "pageOffer") {
|
|
1649
|
+
// Ignored: the person left the page (or its sub-screen). Recorded.
|
|
1650
|
+
if (!pageStillHere(suite, s.page, env)) {
|
|
1651
|
+
recordPage(d, suite, s.page, "skipped");
|
|
1652
|
+
setScreen(d, { name: "idle" });
|
|
1653
|
+
}
|
|
1654
|
+
break;
|
|
1655
|
+
}
|
|
1199
1656
|
if (s.name !== "step")
|
|
1200
1657
|
break;
|
|
1658
|
+
if (s.page && !pageStillHere(suite, s.page, env)) {
|
|
1659
|
+
// Leaving the page ends its guide, silently.
|
|
1660
|
+
endPage(d, suite, "skipped");
|
|
1661
|
+
break;
|
|
1662
|
+
}
|
|
1201
1663
|
const mission = findMission(suite, s.mission);
|
|
1202
1664
|
if (!mission)
|
|
1203
1665
|
break;
|
|
@@ -1227,7 +1689,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1227
1689
|
break;
|
|
1228
1690
|
}
|
|
1229
1691
|
}
|
|
1230
|
-
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env)) {
|
|
1692
|
+
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page)) {
|
|
1231
1693
|
// The target is missing because the page is another one.
|
|
1232
1694
|
recoverOffRoute(d, suite, env, false);
|
|
1233
1695
|
break;
|
|
@@ -1242,6 +1704,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1242
1704
|
else if (!cur.bridge && needsBridge(step, env, ctx)) {
|
|
1243
1705
|
setScreen(d, { ...cur, bridge: true, phase: "waiting" });
|
|
1244
1706
|
}
|
|
1707
|
+
syncStatus(d, suite, env);
|
|
1245
1708
|
markReadyIfVisible(d, suite, env);
|
|
1246
1709
|
break;
|
|
1247
1710
|
}
|
|
@@ -1304,6 +1767,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1304
1767
|
advance(d, suite, env);
|
|
1305
1768
|
break;
|
|
1306
1769
|
}
|
|
1770
|
+
if (kind === "select") {
|
|
1771
|
+
// A replay goes on without a pick when the next step is on the page.
|
|
1772
|
+
if (s.replay && replayCanGoOn(mission, s.index, env, d.state.progress.context, s.nothingToSave))
|
|
1773
|
+
advance(d, suite, env);
|
|
1774
|
+
break;
|
|
1775
|
+
}
|
|
1307
1776
|
if (kind !== "input")
|
|
1308
1777
|
break;
|
|
1309
1778
|
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
@@ -1336,7 +1805,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1336
1805
|
const cur = d.state.screen;
|
|
1337
1806
|
if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
|
|
1338
1807
|
break;
|
|
1339
|
-
if (!cur.bridge && offRoute(mission.steps, cur.index, env)) {
|
|
1808
|
+
if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
|
|
1340
1809
|
recoverOffRoute(d, suite, env, false);
|
|
1341
1810
|
break;
|
|
1342
1811
|
}
|
|
@@ -1357,7 +1826,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1357
1826
|
if (!mission)
|
|
1358
1827
|
break;
|
|
1359
1828
|
const step = mission.steps[s.index];
|
|
1360
|
-
if (!s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
1829
|
+
if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
1361
1830
|
recoverOffRoute(d, suite, env, false);
|
|
1362
1831
|
break;
|
|
1363
1832
|
}
|
|
@@ -1374,7 +1843,10 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1374
1843
|
advance(d, suite, env);
|
|
1375
1844
|
}
|
|
1376
1845
|
else if (policy === "abort") {
|
|
1377
|
-
|
|
1846
|
+
if (s.page)
|
|
1847
|
+
endPage(d, suite, "skipped");
|
|
1848
|
+
else
|
|
1849
|
+
exitMission(d);
|
|
1378
1850
|
d.effects.push({ type: "toast", text: COPY.aborted });
|
|
1379
1851
|
}
|
|
1380
1852
|
else if ((policy === "rewind" || (policy === "retry" && misses >= 2)) && cp < s.index) {
|
|
@@ -1397,7 +1869,7 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1397
1869
|
if (s.name !== "step" || s.phase !== "missing")
|
|
1398
1870
|
break;
|
|
1399
1871
|
const mission = findMission(suite, s.mission);
|
|
1400
|
-
if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
1872
|
+
if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
|
|
1401
1873
|
// Polling again cannot help on another page: go back to the step's.
|
|
1402
1874
|
recoverOffRoute(d, suite, env, true);
|
|
1403
1875
|
break;
|
|
@@ -1413,6 +1885,12 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1413
1885
|
case "exit":
|
|
1414
1886
|
if (s.name !== "step")
|
|
1415
1887
|
break;
|
|
1888
|
+
if (s.page) {
|
|
1889
|
+
// ✕, Escape or ปิดคู่มือ: ends silently.
|
|
1890
|
+
pushAbort(d, suite, s);
|
|
1891
|
+
endPage(d, suite, "skipped");
|
|
1892
|
+
break;
|
|
1893
|
+
}
|
|
1416
1894
|
if (s.replay) {
|
|
1417
1895
|
pushAbort(d, suite, s);
|
|
1418
1896
|
setScreen(d, { name: "idle" });
|
|
@@ -1481,11 +1959,17 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1481
1959
|
break;
|
|
1482
1960
|
setScreen(d, { name: "idle" });
|
|
1483
1961
|
break;
|
|
1484
|
-
case "openPanel":
|
|
1485
|
-
|
|
1486
|
-
|
|
1487
|
-
|
|
1962
|
+
case "openPanel": {
|
|
1963
|
+
const section = action.section ?? "all";
|
|
1964
|
+
const allowed = section === "pages" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
|
|
1965
|
+
if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
|
|
1966
|
+
break;
|
|
1967
|
+
// The offer gives way to the panel, unrecorded.
|
|
1968
|
+
if (s.name === "pageOffer")
|
|
1969
|
+
setScreen(d, { name: "idle" });
|
|
1970
|
+
d.state = { ...d.state, panelOpen: true, panelSection: section };
|
|
1488
1971
|
break;
|
|
1972
|
+
}
|
|
1489
1973
|
case "closePanel":
|
|
1490
1974
|
d.state = { ...d.state, panelOpen: false, justDone: null };
|
|
1491
1975
|
break;
|
|
@@ -1529,6 +2013,58 @@ function reduceGuide(state, action, suite, env) {
|
|
|
1529
2013
|
passiveDone(d, suite, env, m);
|
|
1530
2014
|
break;
|
|
1531
2015
|
}
|
|
2016
|
+
case "pageOffer": {
|
|
2017
|
+
if (pageOfferDue(state, suite, env) !== action.page)
|
|
2018
|
+
break;
|
|
2019
|
+
setScreen(d, { name: "pageOffer", page: action.page });
|
|
2020
|
+
d.effects.push({ type: "announce", text: `คู่มือหน้านี้ ${findPage(suite, action.page)?.title ?? ""}` });
|
|
2021
|
+
break;
|
|
2022
|
+
}
|
|
2023
|
+
case "pageOfferStart":
|
|
2024
|
+
if (s.name !== "pageOffer" || state.pageAudience === "none")
|
|
2025
|
+
break;
|
|
2026
|
+
startPage(d, suite, env, s.page, true);
|
|
2027
|
+
break;
|
|
2028
|
+
case "pageOfferLater":
|
|
2029
|
+
if (s.name !== "pageOffer")
|
|
2030
|
+
break;
|
|
2031
|
+
recordPage(d, suite, s.page, "skipped");
|
|
2032
|
+
setScreen(d, { name: "idle" });
|
|
2033
|
+
break;
|
|
2034
|
+
case "startPage": {
|
|
2035
|
+
// Never over a mission step, a welcome, a gate or a celebration.
|
|
2036
|
+
if (state.pageAudience === "none" || state.pagesMode === "off")
|
|
2037
|
+
break;
|
|
2038
|
+
if (s.name !== "idle" && s.name !== "pageOffer")
|
|
2039
|
+
break;
|
|
2040
|
+
const page = pageHere(suite, env);
|
|
2041
|
+
if (!page || page.key !== action.page)
|
|
2042
|
+
break;
|
|
2043
|
+
startPage(d, suite, env, page.key, !pageRecorded(state, page));
|
|
2044
|
+
break;
|
|
2045
|
+
}
|
|
2046
|
+
case "back": {
|
|
2047
|
+
if (s.name !== "step" || !s.page || s.phase !== "ready" || s.confirmExit)
|
|
2048
|
+
break;
|
|
2049
|
+
const mission = findMission(suite, s.mission);
|
|
2050
|
+
if (!mission)
|
|
2051
|
+
break;
|
|
2052
|
+
const to = backTarget(mission, s.index, env, d.state.progress.context);
|
|
2053
|
+
if (!to)
|
|
2054
|
+
break;
|
|
2055
|
+
// Close what the click opened, then show the click again.
|
|
2056
|
+
if (to.close)
|
|
2057
|
+
pushAbort(d, suite, s);
|
|
2058
|
+
enterStep(d, suite, env, mission, to.index, { replay: true, noAutoPass: true });
|
|
2059
|
+
break;
|
|
2060
|
+
}
|
|
2061
|
+
case "pageRow": {
|
|
2062
|
+
const cur = d.state.pages[action.key];
|
|
2063
|
+
if (cur && cur.version > action.row.version)
|
|
2064
|
+
break;
|
|
2065
|
+
d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
|
|
2066
|
+
break;
|
|
2067
|
+
}
|
|
1532
2068
|
case "idleCelebrate": {
|
|
1533
2069
|
if (s.name !== "idle" || state.panelOpen)
|
|
1534
2070
|
break;
|
|
@@ -1652,6 +2188,11 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
1652
2188
|
if (typeof data.app === "string" && data.app !== suite.app)
|
|
1653
2189
|
return null;
|
|
1654
2190
|
const rows = Array.isArray(data.guides) ? data.guides : [];
|
|
2191
|
+
const pagesMode = data.pages_mode === "menu" || data.pages_mode === "offer" ? data.pages_mode : "off";
|
|
2192
|
+
const pageAudience = pagesMode !== "off" && (data.page_audience === "optional" || data.page_audience === "forced")
|
|
2193
|
+
? data.page_audience
|
|
2194
|
+
: "none";
|
|
2195
|
+
const pages = parsePageRows(rows, suite.app);
|
|
1655
2196
|
let row = null;
|
|
1656
2197
|
for (const r of rows) {
|
|
1657
2198
|
if (isRecord(r) && r.guide_key === suite.key) {
|
|
@@ -1672,7 +2213,58 @@ function parseGuidesAnswer(payload, suite) {
|
|
|
1672
2213
|
progress = row;
|
|
1673
2214
|
newerOnServer = row.guide_version > suite.version;
|
|
1674
2215
|
}
|
|
1675
|
-
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer };
|
|
2216
|
+
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages };
|
|
2217
|
+
}
|
|
2218
|
+
/** The row key of a page guide: `<app>.page.<key>`. */
|
|
2219
|
+
function pageRowKey(app, key) {
|
|
2220
|
+
return `${app}.page.${key}`;
|
|
2221
|
+
}
|
|
2222
|
+
/** Page rows among the list's rows, by page key. Off-shape rows are left out. */
|
|
2223
|
+
function parsePageRows(rows, app) {
|
|
2224
|
+
const prefix = pageRowKey(app, "");
|
|
2225
|
+
const out = {};
|
|
2226
|
+
for (const r of rows) {
|
|
2227
|
+
if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
|
|
2228
|
+
continue;
|
|
2229
|
+
const row = parsePageRow(r);
|
|
2230
|
+
if (row)
|
|
2231
|
+
out[r.guide_key.slice(prefix.length)] = row;
|
|
2232
|
+
}
|
|
2233
|
+
return out;
|
|
2234
|
+
}
|
|
2235
|
+
/** One page row (a list entry, a PUT answer, a 409's `current`); null when off-shape. */
|
|
2236
|
+
function parsePageRow(raw) {
|
|
2237
|
+
if (!isRecord(raw))
|
|
2238
|
+
return null;
|
|
2239
|
+
const version = Number(raw.guide_version);
|
|
2240
|
+
const rev = Number(raw.rev);
|
|
2241
|
+
if (!Number.isInteger(version) || version < 1 || !Number.isInteger(rev) || rev < 0)
|
|
2242
|
+
return null;
|
|
2243
|
+
const status = ROW_STATUSES.includes(raw.status) ? raw.status : "in_progress";
|
|
2244
|
+
return { version, status, rev };
|
|
2245
|
+
}
|
|
2246
|
+
/**
|
|
2247
|
+
* A page row's PUT body: the least the server takes. Written once per page
|
|
2248
|
+
* and version (finished: completed; not now, exited or left: skipped).
|
|
2249
|
+
*/
|
|
2250
|
+
function pageBody(app, version, rev, status) {
|
|
2251
|
+
return {
|
|
2252
|
+
app,
|
|
2253
|
+
guide_version: version,
|
|
2254
|
+
rev,
|
|
2255
|
+
status,
|
|
2256
|
+
mission_key: null,
|
|
2257
|
+
step_key: null,
|
|
2258
|
+
step_index: 0,
|
|
2259
|
+
checkpoint_key: null,
|
|
2260
|
+
missions: {},
|
|
2261
|
+
context: {},
|
|
2262
|
+
welcome: "started",
|
|
2263
|
+
chip_hidden: false,
|
|
2264
|
+
last_error: null,
|
|
2265
|
+
skipped_step: null,
|
|
2266
|
+
restart: false,
|
|
2267
|
+
};
|
|
1676
2268
|
}
|
|
1677
2269
|
const TERMINAL = ["completed", "skipped", "aborted"];
|
|
1678
2270
|
/** The PUT body for the client's state. Drops keys the server would refuse. */
|
|
@@ -1878,6 +2470,47 @@ async function storeGuides(adapter, suite, progress) {
|
|
|
1878
2470
|
}
|
|
1879
2471
|
return null;
|
|
1880
2472
|
}
|
|
2473
|
+
/**
|
|
2474
|
+
* Writes a page guide's row (`<app>.page.<key>`) once: the minimal body,
|
|
2475
|
+
* with the same 409 handling as the tour's row. Another tab that already
|
|
2476
|
+
* recorded this version wins (its row is returned, nothing is sent again);
|
|
2477
|
+
* an older version's row is replaced with its rev; a newer one stops. Returns
|
|
2478
|
+
* the stored row, or null when it could not be saved.
|
|
2479
|
+
*/
|
|
2480
|
+
async function storePageRow(adapter, app, key, version, status, rev) {
|
|
2481
|
+
const guideKey = pageRowKey(app, key);
|
|
2482
|
+
let at = rev;
|
|
2483
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
2484
|
+
let res;
|
|
2485
|
+
try {
|
|
2486
|
+
res = await adapter.saveProgress({ app, guideKey, body: pageBody(app, version, at, status) });
|
|
2487
|
+
}
|
|
2488
|
+
catch (err) {
|
|
2489
|
+
const conflict = conflictOf(err);
|
|
2490
|
+
if (!conflict)
|
|
2491
|
+
return null;
|
|
2492
|
+
res = conflict;
|
|
2493
|
+
}
|
|
2494
|
+
if (!res || typeof res.status !== "number")
|
|
2495
|
+
return null;
|
|
2496
|
+
const payload = res.data;
|
|
2497
|
+
if (res.status >= 200 && res.status < 300)
|
|
2498
|
+
return parsePageRow(payload?.data ?? payload);
|
|
2499
|
+
if (res.status !== 409)
|
|
2500
|
+
return null;
|
|
2501
|
+
const current = parsePageRow(payload?.current);
|
|
2502
|
+
if (!current) {
|
|
2503
|
+
at = 0;
|
|
2504
|
+
continue;
|
|
2505
|
+
}
|
|
2506
|
+
if (current.version > version)
|
|
2507
|
+
return null;
|
|
2508
|
+
if (current.version === version && current.status !== "in_progress")
|
|
2509
|
+
return current;
|
|
2510
|
+
at = current.rev;
|
|
2511
|
+
}
|
|
2512
|
+
return null;
|
|
2513
|
+
}
|
|
1881
2514
|
|
|
1882
2515
|
/**
|
|
1883
2516
|
* The tour's DOM side: finding anchors, deciding whether one is really on
|
|
@@ -2310,6 +2943,7 @@ const GUIDE_CSS = `
|
|
|
2310
2943
|
.eg-b-t{margin:0;font-size:16px;font-weight:600;line-height:1.45}
|
|
2311
2944
|
.eg-b-w{margin:2px 0 0;font-size:13.5px;color:var(--eg-muted)}
|
|
2312
2945
|
.eg-bubble.eg-err .eg-b-t{color:var(--eg-danger)}
|
|
2946
|
+
.eg-b-s{margin:6px 0 0;font-size:13px;font-weight:600;color:var(--eg-danger)}
|
|
2313
2947
|
.eg-k{display:inline-block;background:var(--eg-tint);color:var(--eg-strong);border-radius:7px;padding:0 7px;font-weight:600}
|
|
2314
2948
|
.eg-b-f{display:flex;align-items:center;justify-content:space-between;margin-top:12px;gap:8px}
|
|
2315
2949
|
.eg-b-f .eg-btn{height:34px;padding:0 14px;font-size:13.5px}
|
|
@@ -2396,6 +3030,12 @@ const GUIDE_CSS = `
|
|
|
2396
3030
|
.eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
|
2397
3031
|
.eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
|
|
2398
3032
|
.eg-q-f{border-top:1px solid var(--eg-line);padding:8px 12px;display:flex;justify-content:flex-end}
|
|
3033
|
+
.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)}
|
|
3034
|
+
.eg-pg-item{padding:8px}
|
|
3035
|
+
.eg-pg-item .eg-t small.eg-new{color:var(--eg-strong);font-weight:600}
|
|
3036
|
+
.eg-card.eg-offer{left:auto;right:22px;transform:none;bottom:var(--erp-guide-bottom-offset,22px);width:min(320px,calc(100vw - 32px))}
|
|
3037
|
+
.eg-offer .eg-po-e{margin:0 0 2px;font-size:12px;font-weight:600;color:var(--eg-strong)}
|
|
3038
|
+
.eg-offer .eg-row .eg-btn{height:34px;padding:0 14px;font-size:13.5px}
|
|
2399
3039
|
.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}
|
|
2400
3040
|
svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
|
2401
3041
|
@media (max-width:767px){
|
|
@@ -2409,6 +3049,7 @@ svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;
|
|
|
2409
3049
|
.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)}
|
|
2410
3050
|
.eg-q-item{padding:12px 8px}
|
|
2411
3051
|
.eg-card{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) - 4px)}
|
|
3052
|
+
.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)}
|
|
2412
3053
|
.eg-toast{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) + 60px)}
|
|
2413
3054
|
}
|
|
2414
3055
|
@keyframes eg-up{from{opacity:0;transform:translate(-50%,30px)}}
|
|
@@ -2506,6 +3147,14 @@ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
|
|
|
2506
3147
|
const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
|
|
2507
3148
|
return best >= 0 ? best : ok.indexOf(true);
|
|
2508
3149
|
}
|
|
3150
|
+
/** The way out of a step, as the bubble and the cards say it. */
|
|
3151
|
+
function exitLabelOf(b) {
|
|
3152
|
+
return b.exitLabel ?? (b.replay ? "จบการทบทวน" : "ออกจากไกด์");
|
|
3153
|
+
}
|
|
3154
|
+
/** The bubble's top line. */
|
|
3155
|
+
function headingOf(b) {
|
|
3156
|
+
return b.heading ?? `ภารกิจ ${b.missionNo} · ${b.missionTitle}${b.replay ? " (ทบทวน)" : ""}`;
|
|
3157
|
+
}
|
|
2509
3158
|
const BUBBLE_TITLE_ID = "eg-bubble-title";
|
|
2510
3159
|
const BUBBLE_WHY_ID = "eg-bubble-why";
|
|
2511
3160
|
function useNudge(nudge, ref) {
|
|
@@ -2543,7 +3192,7 @@ function StepLayer(p) {
|
|
|
2543
3192
|
const showBubble = p.phase === "ready" && (!!spot || !p.hasTarget);
|
|
2544
3193
|
const gap = p.showHand ? 34 : 14;
|
|
2545
3194
|
const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
|
|
2546
|
-
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.showNext}`;
|
|
3195
|
+
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}|${!!p.bubble.showBack}`;
|
|
2547
3196
|
// What the bubble sits beside: the target, with an open list over it (the
|
|
2548
3197
|
// list is what the person is choosing from now). What it must not cover:
|
|
2549
3198
|
// open lists, and the other anchors the step allows (a map step's
|
|
@@ -2660,13 +3309,13 @@ function StepLayer(p) {
|
|
|
2660
3309
|
top: pos?.top ?? 0,
|
|
2661
3310
|
width: phone ? viewport.width - 32 : undefined,
|
|
2662
3311
|
visibility: pos ? "visible" : "hidden",
|
|
2663
|
-
}, 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
|
|
3312
|
+
}, 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] }));
|
|
2664
3313
|
}
|
|
2665
3314
|
function BubbleBody(p) {
|
|
2666
3315
|
const b = p.bubble;
|
|
2667
3316
|
const title = b.error ? b.error.title : b.title;
|
|
2668
3317
|
const why = b.error ? (b.error.detail ?? "") : b.why;
|
|
2669
|
-
return (jsxs(Fragment, { children: [jsxs("div", { className: "eg-b-top", children: [
|
|
3318
|
+
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] })] })] }));
|
|
2670
3319
|
}
|
|
2671
3320
|
/** The fingertip at ≈62% across and down the target; upside down near the bottom. */
|
|
2672
3321
|
function handPosition(spot, viewportHeight) {
|
|
@@ -2740,6 +3389,19 @@ function CompleteModal(p) {
|
|
|
2740
3389
|
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] })] })] }));
|
|
2741
3390
|
}
|
|
2742
3391
|
/* ------------------------------------------------------------------ */
|
|
3392
|
+
/* Page guides: the first-visit offer */
|
|
3393
|
+
/* ------------------------------------------------------------------ */
|
|
3394
|
+
/**
|
|
3395
|
+
* The offer of this page's guide on a first visit: a small card in the
|
|
3396
|
+
* corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
|
|
3397
|
+
*/
|
|
3398
|
+
function PageOfferCard(p) {
|
|
3399
|
+
return (jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsx("p", { className: "eg-po-e", children: "\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" })] })] }));
|
|
3400
|
+
}
|
|
3401
|
+
function PageList({ items, onOpen }) {
|
|
3402
|
+
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))) }));
|
|
3403
|
+
}
|
|
3404
|
+
/* ------------------------------------------------------------------ */
|
|
2743
3405
|
/* Quest chip and panel */
|
|
2744
3406
|
/* ------------------------------------------------------------------ */
|
|
2745
3407
|
function QuestChip({ done, total, pulse, onOpen }) {
|
|
@@ -2761,7 +3423,12 @@ function QuestChip({ done, total, pulse, onOpen }) {
|
|
|
2761
3423
|
}
|
|
2762
3424
|
function QuestPanel(p) {
|
|
2763
3425
|
const ref = useAutoFocus();
|
|
2764
|
-
|
|
3426
|
+
const pages = p.pages ?? [];
|
|
3427
|
+
const openPage = p.onOpenPage ?? (() => { });
|
|
3428
|
+
if (p.showMissions === false) {
|
|
3429
|
+
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 })] })] }));
|
|
3430
|
+
}
|
|
3431
|
+
return (jsxs(Fragment, { children: [jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxs("div", { className: "eg-q-h", children: [jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsx(IconX, {}) })] }), jsx("div", { className: "eg-prog", children: jsx("div", { className: "eg-bar", children: jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), pages.length ? (jsxs(Fragment, { children: [jsx("h3", { className: "eg-q-sub", id: "eg-q-pages", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsx(PageList, { items: pages, onOpen: openPage })] })) : null, p.canHideChip ? (jsx("div", { className: "eg-q-f", children: jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onHideChip, children: "\u0E0B\u0E48\u0E2D\u0E19\u0E1B\u0E38\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08" }) })) : null] })] }));
|
|
2765
3432
|
}
|
|
2766
3433
|
function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
2767
3434
|
const v = item.view;
|
|
@@ -2822,6 +3489,7 @@ class GuideErrorBoundary extends React.Component {
|
|
|
2822
3489
|
console.warn("[erp-shell guide] hidden after an error", error);
|
|
2823
3490
|
try {
|
|
2824
3491
|
publishGuideEntry(null, null);
|
|
3492
|
+
publishPageGuides(null, null, null);
|
|
2825
3493
|
}
|
|
2826
3494
|
catch {
|
|
2827
3495
|
/* ignore */
|
|
@@ -2898,6 +3566,41 @@ function writeMirror(orgId, suite, p) {
|
|
|
2898
3566
|
/* private mode: the server copy is enough */
|
|
2899
3567
|
}
|
|
2900
3568
|
}
|
|
3569
|
+
/** Page rows written in this tab, kept for a remount within the GET's cache time. */
|
|
3570
|
+
const pagesMirrorKey = (orgId, suite) => `erp-guide-pages:${orgId}:${suite.app}`;
|
|
3571
|
+
function readPagesMirror(orgId, suite) {
|
|
3572
|
+
try {
|
|
3573
|
+
const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite));
|
|
3574
|
+
const parsed = raw ? JSON.parse(raw) : {};
|
|
3575
|
+
const out = {};
|
|
3576
|
+
for (const [k, v] of Object.entries(parsed ?? {})) {
|
|
3577
|
+
if (v && Number.isInteger(v.version) && Number.isInteger(v.rev) && typeof v.status === "string")
|
|
3578
|
+
out[k] = v;
|
|
3579
|
+
}
|
|
3580
|
+
return out;
|
|
3581
|
+
}
|
|
3582
|
+
catch {
|
|
3583
|
+
return {};
|
|
3584
|
+
}
|
|
3585
|
+
}
|
|
3586
|
+
function writePagesMirror(orgId, suite, pages) {
|
|
3587
|
+
try {
|
|
3588
|
+
sessionStorage.setItem(pagesMirrorKey(orgId, suite), JSON.stringify(pages));
|
|
3589
|
+
}
|
|
3590
|
+
catch {
|
|
3591
|
+
/* private mode */
|
|
3592
|
+
}
|
|
3593
|
+
}
|
|
3594
|
+
/** The server's page rows, with this tab's newer ones laid over. */
|
|
3595
|
+
function mergePageRows(server, local) {
|
|
3596
|
+
const out = { ...server };
|
|
3597
|
+
for (const [k, v] of Object.entries(local)) {
|
|
3598
|
+
const r = out[k];
|
|
3599
|
+
if (!r || v.version > r.version)
|
|
3600
|
+
out[k] = v;
|
|
3601
|
+
}
|
|
3602
|
+
return out;
|
|
3603
|
+
}
|
|
2901
3604
|
/* ------------------------------------------------------------------ */
|
|
2902
3605
|
/* One tab at a time */
|
|
2903
3606
|
/* ------------------------------------------------------------------ */
|
|
@@ -2985,6 +3688,12 @@ const SAVE_DEBOUNCE_MS = 600;
|
|
|
2985
3688
|
const IDLE_POLL_MS = 100;
|
|
2986
3689
|
/** A celebration earned outside the tour waits this long of nothing loading. */
|
|
2987
3690
|
const PASSIVE_IDLE_MS = 600;
|
|
3691
|
+
/** A page guide's offer waits this long of nothing loading. */
|
|
3692
|
+
const PAGE_OFFER_IDLE_MS = 600;
|
|
3693
|
+
/** How often the page guides look at the page while idle (offer due, `when`). */
|
|
3694
|
+
const PAGE_POLL_MS = 250;
|
|
3695
|
+
/** How long a page opened from the panel's list may take to arrive. */
|
|
3696
|
+
const PAGE_ARRIVE_MS = 8000;
|
|
2988
3697
|
function GuideRuntimeInner({ config, organizationId, code, routing, progress: progressAdapter, isBusy, menu, hidden, bottomOffset }) {
|
|
2989
3698
|
const suite = config.suite;
|
|
2990
3699
|
const pathname = routing.pathname || "/";
|
|
@@ -2999,9 +3708,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
2999
3708
|
// No menu: never a menu step (the machine sees it as always open).
|
|
3000
3709
|
const sidebarOpen = menu ? !!menu.open : true;
|
|
3001
3710
|
const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
|
|
3002
|
-
// Progress: read once this tab runs the tour and the app is ready
|
|
3711
|
+
// Progress: read once this tab runs the tour and the app is ready (for
|
|
3712
|
+
// the missions, or for the page guides alone: config.pagesReady).
|
|
3003
3713
|
const [answer, setAnswer] = useState(null);
|
|
3004
|
-
const
|
|
3714
|
+
const missionsReady = config.ready !== false;
|
|
3715
|
+
const pagesReady = config.pagesReady ?? missionsReady;
|
|
3716
|
+
const hasPages = useMemo(() => validPages(suite).length > 0, [suite]);
|
|
3717
|
+
const enabled = primary && (missionsReady || (hasPages && pagesReady));
|
|
3005
3718
|
const loaded = useRef(false);
|
|
3006
3719
|
useEffect(() => {
|
|
3007
3720
|
if (!enabled || loaded.current)
|
|
@@ -3023,7 +3736,11 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3023
3736
|
loaded.current = false;
|
|
3024
3737
|
};
|
|
3025
3738
|
}, [enabled, suite]);
|
|
3026
|
-
|
|
3739
|
+
// The missions run for their audience; the page guides for theirs (every
|
|
3740
|
+
// member while pages are on), each with its own ready.
|
|
3741
|
+
const missionsLive = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && missionsReady && !hidden;
|
|
3742
|
+
const pagesLive = !!answer && hasPages && answer.pageAudience !== "none" && primary && pagesReady && !hidden;
|
|
3743
|
+
const live = missionsLive || pagesLive;
|
|
3027
3744
|
/* ---------------- machine ---------------- */
|
|
3028
3745
|
const [state, setState] = useState(null);
|
|
3029
3746
|
const stateRef = useRef(null);
|
|
@@ -3031,7 +3748,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3031
3748
|
// is not sent and does not bump its rev.
|
|
3032
3749
|
const savedKey = useRef(null);
|
|
3033
3750
|
useEffect(() => {
|
|
3034
|
-
|
|
3751
|
+
const missionsOn = !!answer && answer.audience !== "none" && !answer.newerOnServer;
|
|
3752
|
+
const pagesOn = !!answer && hasPages && answer.pageAudience !== "none";
|
|
3753
|
+
if (!answer || (!missionsOn && !pagesOn)) {
|
|
3035
3754
|
stateRef.current = null;
|
|
3036
3755
|
setState(null);
|
|
3037
3756
|
return;
|
|
@@ -3045,10 +3764,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3045
3764
|
let progress = answer.progress;
|
|
3046
3765
|
if (mirror && furtherProgress(mirror, progress) === mirror)
|
|
3047
3766
|
progress = mergeProgress(mirror, progress);
|
|
3048
|
-
|
|
3767
|
+
// A tour newer on the server than this build's: the missions stay out.
|
|
3768
|
+
const s = initialGuideState(missionsOn ? answer.audience : "none", progress, {
|
|
3769
|
+
pageAudience: pagesOn ? answer.pageAudience : "none",
|
|
3770
|
+
pagesMode: answer.pagesMode,
|
|
3771
|
+
pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
|
|
3772
|
+
});
|
|
3049
3773
|
stateRef.current = s;
|
|
3050
3774
|
setState(s);
|
|
3051
|
-
}, [answer, organizationId, suite]);
|
|
3775
|
+
}, [answer, organizationId, suite, hasPages]);
|
|
3052
3776
|
// The latest page facts, read by the machine through env.
|
|
3053
3777
|
const pageRef = useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
|
|
3054
3778
|
pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
|
|
@@ -3145,6 +3869,17 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3145
3869
|
// After this render takes the guard away, put the page back.
|
|
3146
3870
|
runAbort(e.actions);
|
|
3147
3871
|
}
|
|
3872
|
+
else if (e.type === "savePage") {
|
|
3873
|
+
// Once per page and version; the machine already holds it as written.
|
|
3874
|
+
if (stateRef.current)
|
|
3875
|
+
writePagesMirror(organizationId, suite, stateRef.current.pages);
|
|
3876
|
+
const rev = stateRef.current?.pages[e.key]?.rev ?? 0;
|
|
3877
|
+
void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev).then(row => {
|
|
3878
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
3879
|
+
if (row)
|
|
3880
|
+
dispatchRef.current({ type: "pageRow", key: e.key, row });
|
|
3881
|
+
});
|
|
3882
|
+
}
|
|
3148
3883
|
}
|
|
3149
3884
|
catch {
|
|
3150
3885
|
/* an effect never breaks the app */
|
|
@@ -3193,16 +3928,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3193
3928
|
const screen = state?.screen ?? { name: "idle" };
|
|
3194
3929
|
const resumed = useRef(false);
|
|
3195
3930
|
useEffect(() => {
|
|
3196
|
-
if (!
|
|
3931
|
+
if (!missionsLive || !state || resumed.current)
|
|
3197
3932
|
return;
|
|
3198
3933
|
resumed.current = true;
|
|
3199
3934
|
dispatch({ type: "resume" });
|
|
3200
|
-
}, [
|
|
3935
|
+
}, [missionsLive, state, dispatch]);
|
|
3201
3936
|
const welcomeRoutes = suite.welcomeRoutes ?? ["/[code]"];
|
|
3202
3937
|
const onWelcomeRoute = welcomeRoutes.some(r => !!matchRoute(r, pathname, search, code));
|
|
3203
3938
|
const factsKey = useFactsKey(config.facts);
|
|
3204
3939
|
const welcomeCtx = state ? guideContext(env(), state.progress.context) : null;
|
|
3205
|
-
const welcomeIsDue = !!state && !!welcomeCtx &&
|
|
3940
|
+
const welcomeIsDue = !!state && !!welcomeCtx && missionsLive && welcomeDue(state, suite, welcomeCtx);
|
|
3206
3941
|
const due = welcomeIsDue && onWelcomeRoute;
|
|
3207
3942
|
// After a moment of nothing loading (the page has settled).
|
|
3208
3943
|
useAfterIdle(due, WELCOME_DELAY_MS, busy, () => dispatchRef.current({ type: "welcome" }), [factsKey]);
|
|
@@ -3210,7 +3945,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3210
3945
|
const passiveNames = useMemo(() => passiveEventNames(suite), [suite]);
|
|
3211
3946
|
const hasPassive = useMemo(() => suite.missions.some(m => !!m.completeOn), [suite]);
|
|
3212
3947
|
useEffect(() => {
|
|
3213
|
-
if (!
|
|
3948
|
+
if (!missionsLive || !hasPassive)
|
|
3214
3949
|
return;
|
|
3215
3950
|
const onApi = (e) => {
|
|
3216
3951
|
const d = e.detail;
|
|
@@ -3228,15 +3963,19 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3228
3963
|
window.removeEventListener(API_EVENT, onApi);
|
|
3229
3964
|
handlers.forEach(([n, h]) => window.removeEventListener(n, h));
|
|
3230
3965
|
};
|
|
3231
|
-
}, [
|
|
3966
|
+
}, [missionsLive, hasPassive, passiveNames]);
|
|
3232
3967
|
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
3233
|
-
|
|
3968
|
+
// A page guide's step runs while page guides are live; a mission's while
|
|
3969
|
+
// the missions are. The offer card is watched the same way (left: ignored).
|
|
3970
|
+
const inStep = screen.name === "step" && (screen.page ? pagesLive : missionsLive);
|
|
3971
|
+
const offerUp = pagesLive && screen.name === "pageOffer";
|
|
3972
|
+
const watching = inStep || offerUp;
|
|
3234
3973
|
useEffect(() => {
|
|
3235
|
-
if (
|
|
3974
|
+
if (watching)
|
|
3236
3975
|
dispatch({ type: "sync" });
|
|
3237
|
-
}, [
|
|
3976
|
+
}, [watching, pathname, search, sidebarOpen, factsKey, dispatch]);
|
|
3238
3977
|
useEffect(() => {
|
|
3239
|
-
if (!
|
|
3978
|
+
if (!watching || typeof MutationObserver === "undefined")
|
|
3240
3979
|
return;
|
|
3241
3980
|
let raf = 0;
|
|
3242
3981
|
const obs = new MutationObserver(records => {
|
|
@@ -3261,7 +4000,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3261
4000
|
if (raf)
|
|
3262
4001
|
cancelAnimationFrame(raf);
|
|
3263
4002
|
};
|
|
3264
|
-
}, [
|
|
4003
|
+
}, [watching]);
|
|
3265
4004
|
/* ---------------- the current step ---------------- */
|
|
3266
4005
|
const stepInfo = useMemo(() => {
|
|
3267
4006
|
if (!state || screen.name !== "step")
|
|
@@ -3497,7 +4236,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3497
4236
|
};
|
|
3498
4237
|
}, [inStep, stepInfo]);
|
|
3499
4238
|
/* ---------------- the guard ---------------- */
|
|
3500
|
-
const modalScreen =
|
|
4239
|
+
const modalScreen = missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
|
|
3501
4240
|
const panelOpen = live && !!state?.panelOpen;
|
|
3502
4241
|
const guarding = inStep || modalScreen;
|
|
3503
4242
|
const guardingRef = useRef(guarding);
|
|
@@ -3774,7 +4513,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3774
4513
|
const total = shown.length;
|
|
3775
4514
|
// A celebration earned outside the tour: at the next idle moment — no
|
|
3776
4515
|
// step, card or panel up, the welcome not waiting, nothing loading.
|
|
3777
|
-
const passiveDue =
|
|
4516
|
+
const passiveDue = missionsLive &&
|
|
3778
4517
|
!!state &&
|
|
3779
4518
|
(state.pendingCelebrate?.length ?? 0) > 0 &&
|
|
3780
4519
|
screen.name === "idle" &&
|
|
@@ -3782,13 +4521,156 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3782
4521
|
!due;
|
|
3783
4522
|
useAfterIdle(passiveDue, PASSIVE_IDLE_MS, busy, () => dispatchRef.current({ type: "idleCelebrate" }), [state?.pendingCelebrate?.join(",")]);
|
|
3784
4523
|
useEffect(() => {
|
|
3785
|
-
if (!
|
|
4524
|
+
if (!missionsLive || !state) {
|
|
3786
4525
|
publishGuideEntry(null, null);
|
|
3787
4526
|
return;
|
|
3788
4527
|
}
|
|
3789
4528
|
publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
|
|
3790
|
-
}, [
|
|
4529
|
+
}, [missionsLive, state, done, total]);
|
|
3791
4530
|
useEffect(() => () => publishGuideEntry(null, null), []);
|
|
4531
|
+
/* ---------------- page guides: this page's, the offer, the list ---------------- */
|
|
4532
|
+
// `when` anchors (LIFF's sub-screens) come and go without a route change:
|
|
4533
|
+
// look again when the page changes.
|
|
4534
|
+
const hasWhen = useMemo(() => validPages(suite).some(p => !!p.when), [suite]);
|
|
4535
|
+
const [domTick, setDomTick] = useState(0);
|
|
4536
|
+
useEffect(() => {
|
|
4537
|
+
if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
|
|
4538
|
+
return;
|
|
4539
|
+
let t = null;
|
|
4540
|
+
const obs = new MutationObserver(records => {
|
|
4541
|
+
if (records.every(r => inGuideLayer(r.target)))
|
|
4542
|
+
return;
|
|
4543
|
+
if (t)
|
|
4544
|
+
return;
|
|
4545
|
+
t = setTimeout(() => {
|
|
4546
|
+
t = null;
|
|
4547
|
+
setDomTick(n => n + 1);
|
|
4548
|
+
}, PAGE_POLL_MS);
|
|
4549
|
+
});
|
|
4550
|
+
obs.observe(document.body, {
|
|
4551
|
+
childList: true,
|
|
4552
|
+
subtree: true,
|
|
4553
|
+
attributes: true,
|
|
4554
|
+
attributeFilter: ["data-state", "hidden", "aria-hidden", "class", "style", "open"],
|
|
4555
|
+
});
|
|
4556
|
+
return () => {
|
|
4557
|
+
obs.disconnect();
|
|
4558
|
+
if (t)
|
|
4559
|
+
clearTimeout(t);
|
|
4560
|
+
};
|
|
4561
|
+
}, [pagesLive, hasWhen]);
|
|
4562
|
+
const hasState = !!state;
|
|
4563
|
+
const pageNow = useMemo(() => (pagesLive && hasState ? pageHere(suite, env()) : null),
|
|
4564
|
+
// pathname/search/factsKey/domTick make it current.
|
|
4565
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
4566
|
+
[pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
|
|
4567
|
+
const listed = useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
|
|
4568
|
+
const openPageHere = useCallback(() => {
|
|
4569
|
+
const p = pageHere(suite, env());
|
|
4570
|
+
if (p)
|
|
4571
|
+
dispatchRef.current({ type: "startPage", page: p.key });
|
|
4572
|
+
}, [suite, env]);
|
|
4573
|
+
useEffect(() => {
|
|
4574
|
+
if (!pagesLive || !hasState) {
|
|
4575
|
+
publishPageGuides(null, null, null);
|
|
4576
|
+
return;
|
|
4577
|
+
}
|
|
4578
|
+
publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }));
|
|
4579
|
+
}, [pagesLive, hasState, pageNow, listed.length, openPageHere]);
|
|
4580
|
+
useEffect(() => () => publishPageGuides(null, null, null), []);
|
|
4581
|
+
// The first-visit offer: in "offer" mode, nothing else up, the welcome
|
|
4582
|
+
// not due here, and a moment of nothing loading on this page.
|
|
4583
|
+
const offerWatch = pagesLive && !!state && state.pagesMode === "offer" && screen.name === "idle" && !state.panelOpen && !due;
|
|
4584
|
+
useEffect(() => {
|
|
4585
|
+
if (!offerWatch)
|
|
4586
|
+
return;
|
|
4587
|
+
let idleSince = null;
|
|
4588
|
+
const iv = setInterval(() => {
|
|
4589
|
+
const cur = stateRef.current;
|
|
4590
|
+
if (!cur)
|
|
4591
|
+
return;
|
|
4592
|
+
let b = false;
|
|
4593
|
+
try {
|
|
4594
|
+
b = busyRef.current();
|
|
4595
|
+
}
|
|
4596
|
+
catch {
|
|
4597
|
+
b = false;
|
|
4598
|
+
}
|
|
4599
|
+
if (b || document.visibilityState === "hidden") {
|
|
4600
|
+
idleSince = null;
|
|
4601
|
+
return;
|
|
4602
|
+
}
|
|
4603
|
+
idleSince ?? (idleSince = Date.now());
|
|
4604
|
+
if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
|
|
4605
|
+
return;
|
|
4606
|
+
let key = null;
|
|
4607
|
+
try {
|
|
4608
|
+
key = pageOfferDue(cur, suite, env());
|
|
4609
|
+
}
|
|
4610
|
+
catch {
|
|
4611
|
+
key = null;
|
|
4612
|
+
}
|
|
4613
|
+
if (key)
|
|
4614
|
+
dispatchRef.current({ type: "pageOffer", page: key });
|
|
4615
|
+
}, PAGE_POLL_MS);
|
|
4616
|
+
return () => clearInterval(iv);
|
|
4617
|
+
}, [offerWatch, suite, env, pathname, search]);
|
|
4618
|
+
// A page in the panel's list: start it here, or go there and start it on arrival.
|
|
4619
|
+
const [pending, setPending] = useState(null);
|
|
4620
|
+
const openListedPage = useCallback((key) => {
|
|
4621
|
+
const page = findPage(suite, key);
|
|
4622
|
+
if (!page)
|
|
4623
|
+
return;
|
|
4624
|
+
const e = env();
|
|
4625
|
+
if (pageHere(suite, e)?.key === key) {
|
|
4626
|
+
dispatchRef.current({ type: "startPage", page: key });
|
|
4627
|
+
return;
|
|
4628
|
+
}
|
|
4629
|
+
dispatchRef.current({ type: "closePanel" });
|
|
4630
|
+
const raw = resolveText(page.href, guideContext(e, {}));
|
|
4631
|
+
const href = raw ? (fillRoute(raw, { code }) ?? raw) : "";
|
|
4632
|
+
if (!href)
|
|
4633
|
+
return;
|
|
4634
|
+
setPending({ key, until: Date.now() + PAGE_ARRIVE_MS });
|
|
4635
|
+
try {
|
|
4636
|
+
if (/^https?:\/\//i.test(href))
|
|
4637
|
+
window.location.assign(href);
|
|
4638
|
+
else
|
|
4639
|
+
navigateRef.current(href);
|
|
4640
|
+
}
|
|
4641
|
+
catch {
|
|
4642
|
+
/* never breaks the app */
|
|
4643
|
+
}
|
|
4644
|
+
}, [suite, env, code]);
|
|
4645
|
+
useEffect(() => {
|
|
4646
|
+
if (!pending || !pagesLive)
|
|
4647
|
+
return;
|
|
4648
|
+
const iv = setInterval(() => {
|
|
4649
|
+
if (Date.now() > pending.until) {
|
|
4650
|
+
setPending(null);
|
|
4651
|
+
return;
|
|
4652
|
+
}
|
|
4653
|
+
const cur = stateRef.current;
|
|
4654
|
+
if (!cur || (cur.screen.name !== "idle" && cur.screen.name !== "pageOffer"))
|
|
4655
|
+
return;
|
|
4656
|
+
let b = false;
|
|
4657
|
+
try {
|
|
4658
|
+
b = busyRef.current();
|
|
4659
|
+
}
|
|
4660
|
+
catch {
|
|
4661
|
+
b = false;
|
|
4662
|
+
}
|
|
4663
|
+
if (b)
|
|
4664
|
+
return;
|
|
4665
|
+
const e = env();
|
|
4666
|
+
const page = findPage(suite, pending.key);
|
|
4667
|
+
if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
|
|
4668
|
+
return;
|
|
4669
|
+
setPending(null);
|
|
4670
|
+
dispatchRef.current({ type: "startPage", page: pending.key });
|
|
4671
|
+
}, PAGE_POLL_MS);
|
|
4672
|
+
return () => clearInterval(iv);
|
|
4673
|
+
}, [pending, pagesLive, suite, env, code]);
|
|
3792
4674
|
if (!live || !state || !layer || !ctxIdle)
|
|
3793
4675
|
return null;
|
|
3794
4676
|
/* ---------------- render ---------------- */
|
|
@@ -3797,30 +4679,52 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3797
4679
|
const gateHref = gate ? resolveText(gate.action.href, ctxIdle) : "";
|
|
3798
4680
|
const gateFailing = gate ? gateState(suite, ctxIdle) === false : false;
|
|
3799
4681
|
let content = null;
|
|
3800
|
-
if (screen.name === "welcome") {
|
|
4682
|
+
if (!missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete")) {
|
|
4683
|
+
content = null;
|
|
4684
|
+
}
|
|
4685
|
+
else if (screen.name === "welcome") {
|
|
3801
4686
|
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" }) }));
|
|
3802
4687
|
}
|
|
3803
4688
|
else if (screen.name === "gate" && gate) {
|
|
3804
4689
|
content = (jsx(GateModal, { title: gate.title, subtitle: gate.subtitle, actionLabel: gate.action.label, href: gateHref, onLater: () => dispatch({ type: "gateLater" }) }));
|
|
3805
4690
|
}
|
|
3806
|
-
else if (screen.name === "step" && stepInfo && stepCtx) {
|
|
4691
|
+
else if (screen.name === "step" && inStep && stepInfo && stepCtx) {
|
|
3807
4692
|
const { mission, step } = stepInfo;
|
|
3808
4693
|
const missionNo = missionNumber(suite, mission.key, ctxIdle);
|
|
3809
4694
|
const kind = stepKind(step);
|
|
3810
4695
|
const bridge = screen.bridge;
|
|
4696
|
+
const pos = stepPosition(mission, screen.index, env(), state.progress.context);
|
|
4697
|
+
const page = screen.page ? findPage(suite, screen.page) : undefined;
|
|
4698
|
+
const pageBits = page
|
|
4699
|
+
? {
|
|
4700
|
+
heading: `คู่มือหน้านี้ · ${page.title}`,
|
|
4701
|
+
exitLabel: "ปิดคู่มือ",
|
|
4702
|
+
showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
|
|
4703
|
+
nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
|
|
4704
|
+
}
|
|
4705
|
+
: {};
|
|
3811
4706
|
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: {
|
|
3812
4707
|
missionNo,
|
|
3813
4708
|
missionTitle: mission.title,
|
|
3814
4709
|
replay: screen.replay,
|
|
3815
4710
|
// Steps this person skips (requires / skipIf) are not counted.
|
|
3816
|
-
index:
|
|
3817
|
-
total:
|
|
4711
|
+
index: pos.no - 1,
|
|
4712
|
+
total: pos.total,
|
|
3818
4713
|
title: bridge ? "กดปุ่มเมนู" : resolveText(step.title, stepCtx),
|
|
3819
4714
|
why: bridge ? `เมนูทั้งหมดของ ${suite.appName} อยู่ในนี้` : resolveText(step.why, stepCtx),
|
|
3820
4715
|
error: screen.error,
|
|
3821
|
-
|
|
4716
|
+
status: bridge ? null : screen.status,
|
|
4717
|
+
showNext: !bridge && (kind === "input" || kind === "view" || (kind === "select" && screen.replay && screen.replayNext)),
|
|
3822
4718
|
showEndWithoutSave: !bridge && screen.replay && kind === "commit",
|
|
3823
|
-
|
|
4719
|
+
...pageBits,
|
|
4720
|
+
}, hasTarget: bridge || !!step.target, targetPressable: targetPressable(step, bridge), phase: screen.phase, confirmExit: screen.confirmExit, reopenHint: suite.reopenHint, nudge: state.nudge, moving: moving, onExit: () => dispatch({ type: "exit" }), onNext: () => dispatch({ type: "next" }), onBack: () => dispatch({ type: "back" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
|
|
4721
|
+
}
|
|
4722
|
+
else if (screen.name === "pageOffer" && pagesLive) {
|
|
4723
|
+
const page = findPage(suite, screen.page);
|
|
4724
|
+
if (page) {
|
|
4725
|
+
const mission = findMission(suite, PAGE_PREFIX + page.key);
|
|
4726
|
+
content = (jsx(PageOfferCard, { title: page.title, steps: mission ? stepPosition(mission, 0, env(), {}).total : page.steps.length, onStart: () => dispatch({ type: "pageOfferStart" }), onLater: () => dispatch({ type: "pageOfferLater" }) }));
|
|
4727
|
+
}
|
|
3824
4728
|
}
|
|
3825
4729
|
else if (screen.name === "celebrate" && celebrateShown) {
|
|
3826
4730
|
const mission = findMission(suite, screen.mission);
|
|
@@ -3855,8 +4759,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
|
|
|
3855
4759
|
.join(", ")
|
|
3856
4760
|
: undefined,
|
|
3857
4761
|
}));
|
|
3858
|
-
const showChip = chipVisible(state, suite, ctxIdle);
|
|
3859
|
-
|
|
4762
|
+
const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
|
|
4763
|
+
// The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
|
|
4764
|
+
// then the page guides they have.
|
|
4765
|
+
const panelMissions = missionsLive && total > 0 && state.panelSection !== "pages";
|
|
4766
|
+
const pageItems = pagesLive
|
|
4767
|
+
? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) }))
|
|
4768
|
+
: [];
|
|
4769
|
+
const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
|
|
4770
|
+
return createPortal(jsxs(Fragment, { children: [jsx("style", { children: GUIDE_CSS }), jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsx(QuestPanel, { items: items, done: done, total: total, justDone: state.justDone, gate: gate && gateFailing ? { label: gate.action.label, pending: gate.pending, href: gateHref } : null, canHideChip: !state.progress.chip_hidden && state.progress.status !== "completed", onClose: () => dispatch({ type: "closePanel" }), onStart: (key, replayIt) => dispatch({ type: "start", mission: key, replay: replayIt }), onDismiss: key => dispatch({ type: "dismissMission", mission: key }), onHideChip: () => dispatch({ type: "hideChip" }), pages: pageItems, showMissions: panelMissions, onOpenPage: openListedPage })) : null, toast ? jsx(GuideToast, { text: toast }) : null] }), layer);
|
|
3860
4771
|
}
|
|
3861
4772
|
/** A stable key for the facts object, so effects re-run when they change. */
|
|
3862
4773
|
function useFactsKey(facts) {
|
|
@@ -3870,5 +4781,5 @@ function useFactsKey(facts) {
|
|
|
3870
4781
|
}, [facts]);
|
|
3871
4782
|
}
|
|
3872
4783
|
|
|
3873
|
-
export { API_EVENT as A, GUIDE_EVENT_PREFIX as G, GUIDE_LAYER_ATTRIBUTE as a, GuideErrorBoundary as b, GuideProgressConflict as c, GuideRuntime as d, apiCallDetail as e, emitGuideEvent as f, fillRoute as g, guideAnchor as h,
|
|
3874
|
-
//# sourceMappingURL=guide-runtime-
|
|
4784
|
+
export { API_EVENT as A, GUIDE_EVENT_PREFIX as G, GUIDE_LAYER_ATTRIBUTE as a, GuideErrorBoundary as b, GuideProgressConflict as c, GuideRuntime as d, apiCallDetail as e, emitGuideEvent as f, fillRoute as g, guideAnchor as h, guideMenuItems as i, guideMenuShown as j, matchRoute as k, loadGuides as l, matchApiPath as m, openPageGuide as n, openGuidePanel as o, openPagesPanel as p, reportApiEvent as q, reportApiCall as r, storeGuides as s, usePageGuideEntry as t, useGuideEntry as u, usePageGuides as v };
|
|
4785
|
+
//# sourceMappingURL=guide-runtime-Dl8V1iy9.esm.js.map
|