@eintrek/erp-shell 0.1.73 → 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.
@@ -357,15 +357,43 @@ function publishGuideEntry(next, openPanel) {
357
357
  emit();
358
358
  }
359
359
  }
360
+ const NO_PAGES = { page: null, listed: 0 };
361
+ let pages = NO_PAGES;
362
+ let openPageImpl = null;
363
+ let openPagesImpl = null;
364
+ /** Called by the host: this page's guide and the listed count, and how to open them. */
365
+ function publishPageGuides(next, openPage, openPages) {
366
+ const n = next ?? NO_PAGES;
367
+ openPageImpl = openPage;
368
+ openPagesImpl = openPages;
369
+ const same = n.listed === pages.listed && (n.page?.title ?? null) === (pages.page?.title ?? null);
370
+ if (!same) {
371
+ pages = n.page || n.listed ? { page: n.page ? { title: n.page.title } : null, listed: n.listed } : NO_PAGES;
372
+ emit();
373
+ }
374
+ }
375
+ function guideMenuItems(e, p = NO_PAGES) {
376
+ const items = [];
377
+ if (p.page)
378
+ items.push("page");
379
+ if (e && e.total > 0)
380
+ items.push("missions");
381
+ if (p.listed > 0)
382
+ items.push("pages");
383
+ if (items.length)
384
+ items.push("help");
385
+ return items;
386
+ }
360
387
  /**
361
- * Whether the "?" menu offers "ไกด์เริ่มต้น": a tour whose missions are all
362
- * hidden for this user (`visible`, e.g. Plannex without an employee record)
363
- * has nothing to open, and "0/0" led to an empty panel. The entry itself
364
- * stays published: apps wait on it before they load the facts that make
365
- * missions visible.
388
+ * Whether the "?" press opens a menu rather than the help dialog: a tour
389
+ * with missions shown, a guide for this page, or page guides to list. A tour
390
+ * whose missions are all hidden for this user (`visible`, e.g. Plannex
391
+ * without an employee record) has nothing to open, and "0/0" led to an
392
+ * empty panel. The entry itself stays published: apps wait on it before
393
+ * they load the facts that make missions visible.
366
394
  */
367
- function guideMenuShown(e) {
368
- return !!e && e.total > 0;
395
+ function guideMenuShown(e, p = NO_PAGES) {
396
+ return guideMenuItems(e, p).length > 0;
369
397
  }
370
398
  /** Opens the tour's quest panel, when this user has a tour here. */
371
399
  function openGuidePanel() {
@@ -375,6 +403,206 @@ function openGuidePanel() {
375
403
  function useGuideEntry() {
376
404
  return React.useSyncExternalStore(subscribe, () => entry, () => null);
377
405
  }
406
+ /** Opens this page's guide ("คู่มือหน้านี้"), when there is one. */
407
+ function openPageGuide() {
408
+ openPageImpl?.();
409
+ }
410
+ /** Opens the panel on the page guides alone ("คู่มือทุกหน้า"). */
411
+ function openPagesPanel() {
412
+ openPagesImpl?.();
413
+ }
414
+ /** Page guides here (this page's, and the listed count); stable until they change. */
415
+ function usePageGuides() {
416
+ return React.useSyncExternalStore(subscribe, () => pages, () => NO_PAGES);
417
+ }
418
+ /**
419
+ * This page's guide: `{ title, open }`, or null when the page (or the
420
+ * sub-screen, `when`) has none for this person. For an app that draws its
421
+ * own "?" (LIFF's page head): draw it only when this is not null.
422
+ */
423
+ function usePageGuideEntry() {
424
+ const p = usePageGuides();
425
+ const title = p.page?.title ?? null;
426
+ return React.useMemo(() => (title === null ? null : { title, open: openPageGuide }), [title]);
427
+ }
428
+
429
+ /** The synthetic mission key of a page guide: `page:<key>`. */
430
+ const PAGE_PREFIX = "page:";
431
+ /** A page guide's key: lower case, digits and hyphens. */
432
+ const PAGE_KEY_RE = /^[a-z0-9][a-z0-9-]*$/;
433
+ /** The server's row key limit (`<app>.page.<key>`). */
434
+ const ROW_KEY_MAX = 64;
435
+ function isPageMissionKey(key) {
436
+ return !!key && key.startsWith(PAGE_PREFIX);
437
+ }
438
+ function pageKeyOf(missionKey) {
439
+ return isPageMissionKey(missionKey) ? missionKey.slice(PAGE_PREFIX.length) : undefined;
440
+ }
441
+ function devWarn(message) {
442
+ try {
443
+ // Written out in full so the app's bundler replaces it.
444
+ if (typeof process !== "undefined" && process.env.NODE_ENV === "production")
445
+ return;
446
+ if (typeof console !== "undefined")
447
+ console.warn(`[erp-shell guide] ${message}`);
448
+ }
449
+ catch {
450
+ /* never breaks the app */
451
+ }
452
+ }
453
+ /** Why a page guide cannot run, or null when it can. */
454
+ function pageProblem(app, page) {
455
+ if (!page || typeof page.key !== "string" || !PAGE_KEY_RE.test(page.key))
456
+ return "its key is not [a-z0-9-]";
457
+ if (`${app}.page.${page.key}`.length > ROW_KEY_MAX)
458
+ return `its row key is over ${ROW_KEY_MAX} characters`;
459
+ if (!Number.isInteger(page.version) || page.version < 1)
460
+ return "its version is not a whole number ≥ 1";
461
+ const routes = Array.isArray(page.route) ? page.route : [page.route];
462
+ if (!routes.length || routes.some(r => typeof r !== "string" || !r.startsWith("/")))
463
+ return "it has no route";
464
+ if (!Array.isArray(page.steps) || page.steps.length === 0)
465
+ return "it has no steps";
466
+ for (const st of page.steps) {
467
+ const kind = st.kind ?? "click";
468
+ if (kind !== "view" && kind !== "click")
469
+ return `step "${st.key}" is a ${kind} step (a page guide never saves)`;
470
+ }
471
+ return null;
472
+ }
473
+ const validCache = new WeakMap();
474
+ const warned = new Set();
475
+ /**
476
+ * The suite's page guides that can run. A page guide with a commit, input or
477
+ * select step (it would save), a bad key or no route is dropped, with a
478
+ * warning in development; so is a second one with the same key.
479
+ */
480
+ function validPages(suite) {
481
+ const hit = validCache.get(suite);
482
+ if (hit)
483
+ return hit;
484
+ const out = [];
485
+ const seen = new Set();
486
+ for (const page of suite.pages ?? []) {
487
+ let problem = pageProblem(suite.app, page);
488
+ if (!problem && seen.has(page.key))
489
+ problem = "another page guide has the same key";
490
+ if (problem) {
491
+ const id = `${suite.key}:${page?.key}:${problem}`;
492
+ if (!warned.has(id)) {
493
+ warned.add(id);
494
+ devWarn(`page guide "${page?.key}" dropped: ${problem}`);
495
+ }
496
+ continue;
497
+ }
498
+ seen.add(page.key);
499
+ out.push(page);
500
+ }
501
+ validCache.set(suite, out);
502
+ return out;
503
+ }
504
+ function findPage(suite, key) {
505
+ return key ? validPages(suite).find(p => p.key === key) : undefined;
506
+ }
507
+ const missionCache = new WeakMap();
508
+ /**
509
+ * The page guide as a mission the machine plays: `page:<key>`. Steps lose
510
+ * any `route` (the guide is the page's, and ends when it is left) and are
511
+ * checkpoints unless they say otherwise.
512
+ */
513
+ function pageMission(page) {
514
+ const hit = missionCache.get(page);
515
+ if (hit)
516
+ return hit;
517
+ const steps = page.steps.map(st => {
518
+ const { route: _route, resumeUrl: _resume, ...rest } = st;
519
+ return { ...rest, checkpoint: st.checkpoint ?? true };
520
+ });
521
+ const m = { key: PAGE_PREFIX + page.key, title: page.title, minutes: 1, steps };
522
+ missionCache.set(page, m);
523
+ return m;
524
+ }
525
+ function routesOf(page) {
526
+ return Array.isArray(page.route) ? page.route : [page.route];
527
+ }
528
+ /** How specific a route pattern is: its `?k=v` constraints, then its static segments. */
529
+ function routeSpecificity(pattern) {
530
+ const q = pattern.indexOf("?");
531
+ const path = q < 0 ? pattern : pattern.slice(0, q);
532
+ const query = q < 0 ? 0 : [...new URLSearchParams(pattern.slice(q + 1)).keys()].length;
533
+ const statics = path.split("/").filter(seg => seg && !/^\[.*\]$/.test(seg) && !seg.startsWith(":")).length;
534
+ return [query, statics];
535
+ }
536
+ /**
537
+ * Whether the page guide applies here: one of its routes matches and its
538
+ * `when` anchor (if any) is on screen. The score ranks matches: `when`
539
+ * first, then query constraints, then static segments. Null when it does
540
+ * not apply.
541
+ */
542
+ function pageMatchScore(page, where) {
543
+ let best = null;
544
+ for (const r of routesOf(page)) {
545
+ if (!matchRoute(r, where.pathname, where.search, where.code))
546
+ continue;
547
+ const s = routeSpecificity(r);
548
+ if (!best || s[0] > best[0] || (s[0] === best[0] && s[1] > best[1]))
549
+ best = s;
550
+ }
551
+ if (!best)
552
+ return null;
553
+ if (page.when) {
554
+ let on = false;
555
+ try {
556
+ on = where.isVisible(page.when);
557
+ }
558
+ catch {
559
+ on = false;
560
+ }
561
+ if (!on)
562
+ return null;
563
+ }
564
+ return [page.when ? 1 : 0, best[0], best[1]];
565
+ }
566
+ const after = (a, b) => a[0] !== b[0] ? a[0] > b[0] : a[1] !== b[1] ? a[1] > b[1] : a[2] > b[2];
567
+ /** The most specific page guide that applies here, whoever it is for (first in the list on a tie). */
568
+ function matchingPage(suite, where) {
569
+ let best = null;
570
+ let bestScore = null;
571
+ for (const p of validPages(suite)) {
572
+ const s = pageMatchScore(p, where);
573
+ if (s && (!bestScore || after(s, bestScore))) {
574
+ best = p;
575
+ bestScore = s;
576
+ }
577
+ }
578
+ return best;
579
+ }
580
+ /** Whether this person has the page guide: `visible` (only true) and `requires`. */
581
+ function pageShown(page, ctx) {
582
+ try {
583
+ if (page.visible && page.visible(ctx) !== true)
584
+ return false;
585
+ if (page.requires && !ctx.can(page.requires))
586
+ return false;
587
+ return true;
588
+ }
589
+ catch {
590
+ return false;
591
+ }
592
+ }
593
+ /**
594
+ * This page's guide for this person: the most specific match, when it is
595
+ * theirs. A more specific guide they do not have is not replaced by a less
596
+ * specific one (it would describe what is not on screen).
597
+ */
598
+ function currentPage(suite, where, ctx) {
599
+ const p = matchingPage(suite, where);
600
+ return p && pageShown(p, ctx) ? p : null;
601
+ }
602
+ /** The page guides this person has, for the panel's list (those with an `href`). */
603
+ function listedPages(suite, ctx) {
604
+ return validPages(suite).filter(p => p.href !== undefined && pageShown(p, ctx));
605
+ }
378
606
 
379
607
  /* ------------------------------------------------------------------ */
380
608
  /* Copy */
@@ -388,6 +616,7 @@ const COPY = {
388
616
  replayEnded: "จบการทบทวนแล้ว",
389
617
  hiddenToast: "ซ่อนแล้ว",
390
618
  aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
619
+ pageDone: "จบคู่มือหน้านี้",
391
620
  bridgeTitle: "กดปุ่มเมนู",
392
621
  bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
393
622
  };
@@ -395,8 +624,13 @@ const COPY = {
395
624
  /* Helpers */
396
625
  /* ------------------------------------------------------------------ */
397
626
  const BRIDGE_TARGET = "shell.menu-toggle";
398
- function initialGuideState(audience, progress) {
627
+ function initialGuideState(audience, progress, pages) {
399
628
  return {
629
+ pageAudience: pages?.pageAudience ?? "none",
630
+ pagesMode: pages?.pagesMode ?? "off",
631
+ pages: pages?.pages ?? {},
632
+ pageRun: null,
633
+ panelSection: "all",
400
634
  audience,
401
635
  progress,
402
636
  screen: { name: "idle" },
@@ -410,7 +644,13 @@ function initialGuideState(audience, progress) {
410
644
  };
411
645
  }
412
646
  function findMission(suite, key) {
413
- return key ? suite.missions.find(m => m.key === key) : undefined;
647
+ if (!key)
648
+ return undefined;
649
+ if (isPageMissionKey(key)) {
650
+ const page = findPage(suite, pageKeyOf(key));
651
+ return page ? pageMission(page) : undefined;
652
+ }
653
+ return suite.missions.find(m => m.key === key);
414
654
  }
415
655
  /**
416
656
  * Whether the user has this mission (GuideMission.visible). Without the
@@ -573,8 +813,10 @@ function placeRoute(steps, index) {
573
813
  return step.route;
574
814
  return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
575
815
  }
576
- /** The user is not on the page the step happens on. */
577
- function offRoute(steps, index, env) {
816
+ /** The user is not on the page the step happens on (a page guide's step never is: leaving the page ends it). */
817
+ function offRoute(steps, index, env, page) {
818
+ if (page)
819
+ return false;
578
820
  const home = stepHomeRoute(steps, index);
579
821
  return !!home && !matchRoute(home, env.pathname, env.search, env.code);
580
822
  }
@@ -717,7 +959,8 @@ function welcomeDue(state, suite, ctx) {
717
959
  state.progress.welcome === "not_seen" &&
718
960
  state.progress.status === "in_progress" &&
719
961
  !state.progress.mission_key &&
720
- state.screen.name === "idle" &&
962
+ // The missions' welcome always wins over a page guide's offer.
963
+ (state.screen.name === "idle" || state.screen.name === "pageOffer") &&
721
964
  (ctx ? shownMissions(suite, ctx).length : suite.missions.length) > 0);
722
965
  }
723
966
  /** Whether the floating "ภารกิจ n/N" chip shows. */
@@ -830,8 +1073,10 @@ function enterStep(d, suite, env, mission, index, opts) {
830
1073
  }
831
1074
  const step = steps[i];
832
1075
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1076
+ const page = pageKeyOf(mission.key);
833
1077
  const where = placeRoute(steps, i);
834
- const away = !!where && !matchRoute(where, env.pathname, env.search, env.code);
1078
+ // A page guide is on its page: leaving it ends the guide (never "ทำต่อ").
1079
+ const away = !page && !!where && !matchRoute(where, env.pathname, env.search, env.code);
835
1080
  const bridge = !away && needsBridge(step, env, ctx);
836
1081
  const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
837
1082
  setScreen(d, {
@@ -848,6 +1093,7 @@ function enterStep(d, suite, env, mission, index, opts) {
848
1093
  nothingToSave: nts,
849
1094
  status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
850
1095
  replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
1096
+ ...(page ? { page } : {}),
851
1097
  });
852
1098
  if (!opts.replay) {
853
1099
  const cp = checkpointAtOrBefore(steps, i);
@@ -1012,6 +1258,11 @@ function advance(d, suite, env, capture) {
1012
1258
  enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
1013
1259
  }
1014
1260
  function finishMission(d, suite, env, mission, replay) {
1261
+ if (isPageMissionKey(mission.key)) {
1262
+ endPage(d, suite, "completed");
1263
+ d.effects.push({ type: "toast", text: COPY.pageDone });
1264
+ return;
1265
+ }
1015
1266
  if (replay) {
1016
1267
  setScreen(d, { name: "idle" });
1017
1268
  d.effects.push({ type: "toast", text: COPY.replayDone });
@@ -1205,6 +1456,134 @@ function pushAbort(d, suite, s) {
1205
1456
  if (actions.length)
1206
1457
  d.effects.push({ type: "abort", actions });
1207
1458
  }
1459
+ /* ------------------------------------------------------------------ */
1460
+ /* Page guides */
1461
+ /* ------------------------------------------------------------------ */
1462
+ function whereOf(env) {
1463
+ return { code: env.code, pathname: env.pathname, search: env.search, isVisible: a => env.isVisible(a) };
1464
+ }
1465
+ /** This page's guide for this person (the most specific match, if theirs). */
1466
+ function pageHere(suite, env) {
1467
+ return currentPage(suite, whereOf(env), guideContext(env, {}));
1468
+ }
1469
+ /** The page guide's row says this person has ended this version of it. */
1470
+ function pageRecorded(state, page) {
1471
+ const row = state.pages[page.key];
1472
+ return !!row && row.version >= page.version;
1473
+ }
1474
+ /** Whether the first step this person sees has its target on screen (or none). */
1475
+ function firstStepReady(suite, pageKey, env) {
1476
+ const page = findPage(suite, pageKey);
1477
+ if (!page)
1478
+ return false;
1479
+ const mission = pageMission(page);
1480
+ for (const step of mission.steps) {
1481
+ const ctx = guideContext(env, {});
1482
+ if (step.requires && !safe(() => env.can(step.requires), true))
1483
+ continue;
1484
+ if (step.skipIf && safe(() => step.skipIf(ctx), false))
1485
+ continue;
1486
+ return stepTargetVisible(step, env, ctx, false);
1487
+ }
1488
+ return true;
1489
+ }
1490
+ /**
1491
+ * The page guide to offer now, or null. Due when: pages mode is "offer" and
1492
+ * this person has page guides; nothing else is up (no step, card, panel or
1493
+ * celebration waiting); this page's guide is theirs, may be offered, and has
1494
+ * no row of this version; its first step's target is on screen; no app
1495
+ * dialog is open. The host adds the rest: the welcome not due on this
1496
+ * route, a moment of nothing loading, the primary tab, not hidden.
1497
+ */
1498
+ function pageOfferDue(state, suite, env) {
1499
+ if (state.pagesMode !== "offer" || state.pageAudience === "none")
1500
+ return null;
1501
+ if (state.screen.name !== "idle" || state.panelOpen || (state.pendingCelebrate?.length ?? 0) > 0)
1502
+ return null;
1503
+ const page = pageHere(suite, env);
1504
+ if (!page || page.offer === false || pageRecorded(state, page))
1505
+ return null;
1506
+ if (!firstStepReady(suite, page.key, env))
1507
+ return null;
1508
+ if (safe(() => env.dialogOpen?.() === true, false))
1509
+ return null;
1510
+ return page.key;
1511
+ }
1512
+ /** Whether the page guide still applies where the person is (its route, its `when`). */
1513
+ function pageStillHere(suite, key, env) {
1514
+ const page = findPage(suite, key);
1515
+ return !!page && pageMatchScore(page, whereOf(env)) !== null;
1516
+ }
1517
+ /** Write a page guide's row (once per page and version): recorded here at once, saved by the host. */
1518
+ function recordPage(d, suite, key, status) {
1519
+ const page = findPage(suite, key);
1520
+ if (!page || pageRecorded(d.state, page))
1521
+ return;
1522
+ const prev = d.state.pages[key];
1523
+ d.state = { ...d.state, pages: { ...d.state.pages, [key]: { version: page.version, status, rev: prev?.rev ?? 0 } } };
1524
+ d.effects.push({ type: "savePage", key, version: page.version, status });
1525
+ }
1526
+ /** The page guide ends: its row written when it is due one; back to idle. */
1527
+ function endPage(d, suite, how) {
1528
+ const run = d.state.pageRun;
1529
+ if (run?.write)
1530
+ recordPage(d, suite, run.key, how);
1531
+ d.state = { ...d.state, pageRun: null, replayPage: null };
1532
+ setScreen(d, { name: "idle" });
1533
+ }
1534
+ function startPage(d, suite, env, key, write) {
1535
+ const page = findPage(suite, key);
1536
+ if (!page)
1537
+ return;
1538
+ const mission = pageMission(page);
1539
+ d.state = {
1540
+ ...d.state,
1541
+ panelOpen: false,
1542
+ justDone: null,
1543
+ pageRun: { key, write },
1544
+ // What is open now is the person's: ending the guide leaves it alone.
1545
+ replayPage: {
1546
+ mission: mission.key,
1547
+ anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
1548
+ dialog: safe(() => env.dialogOpen?.() === true, false),
1549
+ },
1550
+ };
1551
+ enterStep(d, suite, env, mission, 0, { replay: true });
1552
+ }
1553
+ /** The step before this one that this person sees, or -1. */
1554
+ function previousShownIndex(mission, index, env, context) {
1555
+ for (let j = index - 1; j >= 0; j--) {
1556
+ const st = mission.steps[j];
1557
+ const ctx = guideContext(env, context, routeParamsFor(st, env));
1558
+ if (st.requires && !safe(() => env.can(st.requires), true))
1559
+ continue;
1560
+ if (st.skipIf && safe(() => st.skipIf(ctx), false))
1561
+ continue;
1562
+ return j;
1563
+ }
1564
+ return -1;
1565
+ }
1566
+ /**
1567
+ * Where ย้อนกลับ goes from a page guide's step, or null. The step before it
1568
+ * that this person sees; from inside what a click opened (not a
1569
+ * checkpoint) back to that click, closing it first (the step's abort,
1570
+ * Escape by default). A click step whose result is still on screen (a tab
1571
+ * switched) is passed over: entering it again would pass it at once.
1572
+ */
1573
+ function backTarget(mission, index, env, context) {
1574
+ const cur = mission.steps[index];
1575
+ let prev = previousShownIndex(mission, index, env, context);
1576
+ if (prev < 0 || !cur)
1577
+ return null;
1578
+ if (cur.checkpoint === false) {
1579
+ const to = mission.steps[prev];
1580
+ return { index: prev, close: to.checkpoint !== false };
1581
+ }
1582
+ while (prev >= 0 && stepKind(mission.steps[prev]) !== "view" && stateTriggerHolds(mission.steps[prev], env)) {
1583
+ prev = previousShownIndex(mission, prev, env, context);
1584
+ }
1585
+ return prev >= 0 ? { index: prev, close: false } : null;
1586
+ }
1208
1587
  function reduceGuide(state, action, suite, env) {
1209
1588
  const d = { state, effects: [] };
1210
1589
  const s = state.screen;
@@ -1216,6 +1595,9 @@ function reduceGuide(state, action, suite, env) {
1216
1595
  const gate = gateState(suite, ctxNow());
1217
1596
  if (gate === undefined)
1218
1597
  break;
1598
+ // The welcome wins: the page offer goes, unrecorded (it comes back on a later visit).
1599
+ if (s.name === "pageOffer")
1600
+ setScreen(d, { name: "idle" });
1219
1601
  if (gate === false) {
1220
1602
  if (!state.gateDismissed)
1221
1603
  setScreen(d, { name: "gate" });
@@ -1256,6 +1638,12 @@ function reduceGuide(state, action, suite, env) {
1256
1638
  case "start":
1257
1639
  if (state.audience === "none")
1258
1640
  break;
1641
+ // A mission always wins over a page guide: a running one ends (its row
1642
+ // written if due), an offer goes unrecorded.
1643
+ if (s.name === "step" && s.page)
1644
+ endPage(d, suite, "skipped");
1645
+ else if (s.name === "pageOffer")
1646
+ setScreen(d, { name: "idle" });
1259
1647
  if (s.name === "step" && !s.replay && s.mission !== action.mission) {
1260
1648
  // Leaving one mission for another: the first is set aside.
1261
1649
  exitMission(d);
@@ -1277,8 +1665,21 @@ function reduceGuide(state, action, suite, env) {
1277
1665
  break;
1278
1666
  }
1279
1667
  case "sync": {
1668
+ if (s.name === "pageOffer") {
1669
+ // Ignored: the person left the page (or its sub-screen). Recorded.
1670
+ if (!pageStillHere(suite, s.page, env)) {
1671
+ recordPage(d, suite, s.page, "skipped");
1672
+ setScreen(d, { name: "idle" });
1673
+ }
1674
+ break;
1675
+ }
1280
1676
  if (s.name !== "step")
1281
1677
  break;
1678
+ if (s.page && !pageStillHere(suite, s.page, env)) {
1679
+ // Leaving the page ends its guide, silently.
1680
+ endPage(d, suite, "skipped");
1681
+ break;
1682
+ }
1282
1683
  const mission = findMission(suite, s.mission);
1283
1684
  if (!mission)
1284
1685
  break;
@@ -1308,7 +1709,7 @@ function reduceGuide(state, action, suite, env) {
1308
1709
  break;
1309
1710
  }
1310
1711
  }
1311
- if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env)) {
1712
+ if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env, s.page)) {
1312
1713
  // The target is missing because the page is another one.
1313
1714
  recoverOffRoute(d, suite, env, false);
1314
1715
  break;
@@ -1424,7 +1825,7 @@ function reduceGuide(state, action, suite, env) {
1424
1825
  const cur = d.state.screen;
1425
1826
  if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
1426
1827
  break;
1427
- if (!cur.bridge && offRoute(mission.steps, cur.index, env)) {
1828
+ if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
1428
1829
  recoverOffRoute(d, suite, env, false);
1429
1830
  break;
1430
1831
  }
@@ -1445,7 +1846,7 @@ function reduceGuide(state, action, suite, env) {
1445
1846
  if (!mission)
1446
1847
  break;
1447
1848
  const step = mission.steps[s.index];
1448
- if (!s.bridge && offRoute(mission.steps, s.index, env)) {
1849
+ if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
1449
1850
  recoverOffRoute(d, suite, env, false);
1450
1851
  break;
1451
1852
  }
@@ -1462,7 +1863,10 @@ function reduceGuide(state, action, suite, env) {
1462
1863
  advance(d, suite, env);
1463
1864
  }
1464
1865
  else if (policy === "abort") {
1465
- exitMission(d);
1866
+ if (s.page)
1867
+ endPage(d, suite, "skipped");
1868
+ else
1869
+ exitMission(d);
1466
1870
  d.effects.push({ type: "toast", text: COPY.aborted });
1467
1871
  }
1468
1872
  else if ((policy === "rewind" || (policy === "retry" && misses >= 2)) && cp < s.index) {
@@ -1485,7 +1889,7 @@ function reduceGuide(state, action, suite, env) {
1485
1889
  if (s.name !== "step" || s.phase !== "missing")
1486
1890
  break;
1487
1891
  const mission = findMission(suite, s.mission);
1488
- if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
1892
+ if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
1489
1893
  // Polling again cannot help on another page: go back to the step's.
1490
1894
  recoverOffRoute(d, suite, env, true);
1491
1895
  break;
@@ -1501,6 +1905,12 @@ function reduceGuide(state, action, suite, env) {
1501
1905
  case "exit":
1502
1906
  if (s.name !== "step")
1503
1907
  break;
1908
+ if (s.page) {
1909
+ // ✕, Escape or ปิดคู่มือ: ends silently.
1910
+ pushAbort(d, suite, s);
1911
+ endPage(d, suite, "skipped");
1912
+ break;
1913
+ }
1504
1914
  if (s.replay) {
1505
1915
  pushAbort(d, suite, s);
1506
1916
  setScreen(d, { name: "idle" });
@@ -1569,11 +1979,17 @@ function reduceGuide(state, action, suite, env) {
1569
1979
  break;
1570
1980
  setScreen(d, { name: "idle" });
1571
1981
  break;
1572
- case "openPanel":
1573
- if (state.audience !== "none" && s.name === "idle") {
1574
- d.state = { ...d.state, panelOpen: true };
1575
- }
1982
+ case "openPanel": {
1983
+ const section = action.section ?? "all";
1984
+ const allowed = section === "pages" ? state.pageAudience !== "none" : state.audience !== "none" || state.pageAudience !== "none";
1985
+ if (!allowed || (s.name !== "idle" && s.name !== "pageOffer"))
1986
+ break;
1987
+ // The offer gives way to the panel, unrecorded.
1988
+ if (s.name === "pageOffer")
1989
+ setScreen(d, { name: "idle" });
1990
+ d.state = { ...d.state, panelOpen: true, panelSection: section };
1576
1991
  break;
1992
+ }
1577
1993
  case "closePanel":
1578
1994
  d.state = { ...d.state, panelOpen: false, justDone: null };
1579
1995
  break;
@@ -1617,6 +2033,58 @@ function reduceGuide(state, action, suite, env) {
1617
2033
  passiveDone(d, suite, env, m);
1618
2034
  break;
1619
2035
  }
2036
+ case "pageOffer": {
2037
+ if (pageOfferDue(state, suite, env) !== action.page)
2038
+ break;
2039
+ setScreen(d, { name: "pageOffer", page: action.page });
2040
+ d.effects.push({ type: "announce", text: `คู่มือหน้านี้ ${findPage(suite, action.page)?.title ?? ""}` });
2041
+ break;
2042
+ }
2043
+ case "pageOfferStart":
2044
+ if (s.name !== "pageOffer" || state.pageAudience === "none")
2045
+ break;
2046
+ startPage(d, suite, env, s.page, true);
2047
+ break;
2048
+ case "pageOfferLater":
2049
+ if (s.name !== "pageOffer")
2050
+ break;
2051
+ recordPage(d, suite, s.page, "skipped");
2052
+ setScreen(d, { name: "idle" });
2053
+ break;
2054
+ case "startPage": {
2055
+ // Never over a mission step, a welcome, a gate or a celebration.
2056
+ if (state.pageAudience === "none" || state.pagesMode === "off")
2057
+ break;
2058
+ if (s.name !== "idle" && s.name !== "pageOffer")
2059
+ break;
2060
+ const page = pageHere(suite, env);
2061
+ if (!page || page.key !== action.page)
2062
+ break;
2063
+ startPage(d, suite, env, page.key, !pageRecorded(state, page));
2064
+ break;
2065
+ }
2066
+ case "back": {
2067
+ if (s.name !== "step" || !s.page || s.phase !== "ready" || s.confirmExit)
2068
+ break;
2069
+ const mission = findMission(suite, s.mission);
2070
+ if (!mission)
2071
+ break;
2072
+ const to = backTarget(mission, s.index, env, d.state.progress.context);
2073
+ if (!to)
2074
+ break;
2075
+ // Close what the click opened, then show the click again.
2076
+ if (to.close)
2077
+ pushAbort(d, suite, s);
2078
+ enterStep(d, suite, env, mission, to.index, { replay: true, noAutoPass: true });
2079
+ break;
2080
+ }
2081
+ case "pageRow": {
2082
+ const cur = d.state.pages[action.key];
2083
+ if (cur && cur.version > action.row.version)
2084
+ break;
2085
+ d.state = { ...d.state, pages: { ...d.state.pages, [action.key]: action.row } };
2086
+ break;
2087
+ }
1620
2088
  case "idleCelebrate": {
1621
2089
  if (s.name !== "idle" || state.panelOpen)
1622
2090
  break;
@@ -1740,6 +2208,11 @@ function parseGuidesAnswer(payload, suite) {
1740
2208
  if (typeof data.app === "string" && data.app !== suite.app)
1741
2209
  return null;
1742
2210
  const rows = Array.isArray(data.guides) ? data.guides : [];
2211
+ const pagesMode = data.pages_mode === "menu" || data.pages_mode === "offer" ? data.pages_mode : "off";
2212
+ const pageAudience = pagesMode !== "off" && (data.page_audience === "optional" || data.page_audience === "forced")
2213
+ ? data.page_audience
2214
+ : "none";
2215
+ const pages = parsePageRows(rows, suite.app);
1743
2216
  let row = null;
1744
2217
  for (const r of rows) {
1745
2218
  if (isRecord(r) && r.guide_key === suite.key) {
@@ -1760,7 +2233,58 @@ function parseGuidesAnswer(payload, suite) {
1760
2233
  progress = row;
1761
2234
  newerOnServer = row.guide_version > suite.version;
1762
2235
  }
1763
- return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer };
2236
+ return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer, pagesMode, pageAudience, pages };
2237
+ }
2238
+ /** The row key of a page guide: `<app>.page.<key>`. */
2239
+ function pageRowKey(app, key) {
2240
+ return `${app}.page.${key}`;
2241
+ }
2242
+ /** Page rows among the list's rows, by page key. Off-shape rows are left out. */
2243
+ function parsePageRows(rows, app) {
2244
+ const prefix = pageRowKey(app, "");
2245
+ const out = {};
2246
+ for (const r of rows) {
2247
+ if (!isRecord(r) || typeof r.guide_key !== "string" || !r.guide_key.startsWith(prefix))
2248
+ continue;
2249
+ const row = parsePageRow(r);
2250
+ if (row)
2251
+ out[r.guide_key.slice(prefix.length)] = row;
2252
+ }
2253
+ return out;
2254
+ }
2255
+ /** One page row (a list entry, a PUT answer, a 409's `current`); null when off-shape. */
2256
+ function parsePageRow(raw) {
2257
+ if (!isRecord(raw))
2258
+ return null;
2259
+ const version = Number(raw.guide_version);
2260
+ const rev = Number(raw.rev);
2261
+ if (!Number.isInteger(version) || version < 1 || !Number.isInteger(rev) || rev < 0)
2262
+ return null;
2263
+ const status = ROW_STATUSES.includes(raw.status) ? raw.status : "in_progress";
2264
+ return { version, status, rev };
2265
+ }
2266
+ /**
2267
+ * A page row's PUT body: the least the server takes. Written once per page
2268
+ * and version (finished: completed; not now, exited or left: skipped).
2269
+ */
2270
+ function pageBody(app, version, rev, status) {
2271
+ return {
2272
+ app,
2273
+ guide_version: version,
2274
+ rev,
2275
+ status,
2276
+ mission_key: null,
2277
+ step_key: null,
2278
+ step_index: 0,
2279
+ checkpoint_key: null,
2280
+ missions: {},
2281
+ context: {},
2282
+ welcome: "started",
2283
+ chip_hidden: false,
2284
+ last_error: null,
2285
+ skipped_step: null,
2286
+ restart: false,
2287
+ };
1764
2288
  }
1765
2289
  const TERMINAL = ["completed", "skipped", "aborted"];
1766
2290
  /** The PUT body for the client's state. Drops keys the server would refuse. */
@@ -1966,6 +2490,47 @@ async function storeGuides(adapter, suite, progress) {
1966
2490
  }
1967
2491
  return null;
1968
2492
  }
2493
+ /**
2494
+ * Writes a page guide's row (`<app>.page.<key>`) once: the minimal body,
2495
+ * with the same 409 handling as the tour's row. Another tab that already
2496
+ * recorded this version wins (its row is returned, nothing is sent again);
2497
+ * an older version's row is replaced with its rev; a newer one stops. Returns
2498
+ * the stored row, or null when it could not be saved.
2499
+ */
2500
+ async function storePageRow(adapter, app, key, version, status, rev) {
2501
+ const guideKey = pageRowKey(app, key);
2502
+ let at = rev;
2503
+ for (let attempt = 0; attempt < 2; attempt++) {
2504
+ let res;
2505
+ try {
2506
+ res = await adapter.saveProgress({ app, guideKey, body: pageBody(app, version, at, status) });
2507
+ }
2508
+ catch (err) {
2509
+ const conflict = conflictOf(err);
2510
+ if (!conflict)
2511
+ return null;
2512
+ res = conflict;
2513
+ }
2514
+ if (!res || typeof res.status !== "number")
2515
+ return null;
2516
+ const payload = res.data;
2517
+ if (res.status >= 200 && res.status < 300)
2518
+ return parsePageRow(payload?.data ?? payload);
2519
+ if (res.status !== 409)
2520
+ return null;
2521
+ const current = parsePageRow(payload?.current);
2522
+ if (!current) {
2523
+ at = 0;
2524
+ continue;
2525
+ }
2526
+ if (current.version > version)
2527
+ return null;
2528
+ if (current.version === version && current.status !== "in_progress")
2529
+ return current;
2530
+ at = current.rev;
2531
+ }
2532
+ return null;
2533
+ }
1969
2534
 
1970
2535
  /**
1971
2536
  * The tour's DOM side: finding anchors, deciding whether one is really on
@@ -2485,6 +3050,12 @@ const GUIDE_CSS = `
2485
3050
  .eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
2486
3051
  .eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
2487
3052
  .eg-q-f{border-top:1px solid var(--eg-line);padding:8px 12px;display:flex;justify-content:flex-end}
3053
+ .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)}
3054
+ .eg-pg-item{padding:8px}
3055
+ .eg-pg-item .eg-t small.eg-new{color:var(--eg-strong);font-weight:600}
3056
+ .eg-card.eg-offer{left:auto;right:22px;transform:none;bottom:var(--erp-guide-bottom-offset,22px);width:min(320px,calc(100vw - 32px))}
3057
+ .eg-offer .eg-po-e{margin:0 0 2px;font-size:12px;font-weight:600;color:var(--eg-strong)}
3058
+ .eg-offer .eg-row .eg-btn{height:34px;padding:0 14px;font-size:13.5px}
2488
3059
  .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}
2489
3060
  svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
2490
3061
  @media (max-width:767px){
@@ -2498,6 +3069,7 @@ svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;
2498
3069
  .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)}
2499
3070
  .eg-q-item{padding:12px 8px}
2500
3071
  .eg-card{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) - 4px)}
3072
+ .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)}
2501
3073
  .eg-toast{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) + 60px)}
2502
3074
  }
2503
3075
  @keyframes eg-up{from{opacity:0;transform:translate(-50%,30px)}}
@@ -2595,6 +3167,14 @@ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
2595
3167
  const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
2596
3168
  return best >= 0 ? best : ok.indexOf(true);
2597
3169
  }
3170
+ /** The way out of a step, as the bubble and the cards say it. */
3171
+ function exitLabelOf(b) {
3172
+ return b.exitLabel ?? (b.replay ? "จบการทบทวน" : "ออกจากไกด์");
3173
+ }
3174
+ /** The bubble's top line. */
3175
+ function headingOf(b) {
3176
+ return b.heading ?? `ภารกิจ ${b.missionNo} · ${b.missionTitle}${b.replay ? " (ทบทวน)" : ""}`;
3177
+ }
2598
3178
  const BUBBLE_TITLE_ID = "eg-bubble-title";
2599
3179
  const BUBBLE_WHY_ID = "eg-bubble-why";
2600
3180
  function useNudge(nudge, ref) {
@@ -2632,7 +3212,7 @@ function StepLayer(p) {
2632
3212
  const showBubble = p.phase === "ready" && (!!spot || !p.hasTarget);
2633
3213
  const gap = p.showHand ? 34 : 14;
2634
3214
  const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
2635
- const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}`;
3215
+ const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}|${!!p.bubble.showBack}`;
2636
3216
  // What the bubble sits beside: the target, with an open list over it (the
2637
3217
  // list is what the person is choosing from now). What it must not cover:
2638
3218
  // open lists, and the other anchors the step allows (a map step's
@@ -2749,13 +3329,13 @@ function StepLayer(p) {
2749
3329
  top: pos?.top ?? 0,
2750
3330
  width: phone ? viewport.width - 32 : undefined,
2751
3331
  visibility: pos ? "visible" : "hidden",
2752
- }, children: [jsxRuntime.jsx(BubbleBody, { ...p }), jsxRuntime.jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxRuntime.jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxRuntime.jsxs("div", { className: "eg-loading", children: [jsxRuntime.jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsxRuntime.jsx("div", { className: "eg-row", children: jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: p.bubble.replay ? "จบการทบทวน" : "ออกจากไกด์" }) })] })) : null, p.phase === "missing" ? (jsxRuntime.jsxs("div", { className: "eg-card eg-err", role: "alertdialog", "aria-labelledby": "eg-missing-t", "data-eg-card": "", tabIndex: -1, children: [jsxRuntime.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" }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: p.bubble.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxRuntime.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" ? (jsxRuntime.jsxs("div", { className: "eg-card", role: "dialog", "aria-modal": "false", "aria-labelledby": "eg-away-t", "data-eg-card": "", tabIndex: -1, children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", p.bubble.missionNo, " \u00B7 ", p.bubble.missionTitle, p.bubble.replay ? " (ทบทวน)" : ""] }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", p.bubble.index + 1, "/", p.bubble.total] })] }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-away-t", style: { marginTop: 8 }, children: jsxRuntime.jsx(GuideTitle, { text: p.bubble.title }) }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: p.bubble.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onGoResume, children: "\u0E17\u0E33\u0E15\u0E48\u0E2D" })] })] })) : null, p.confirmExit ? (jsxRuntime.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] }));
3332
+ }, children: [jsxRuntime.jsx(BubbleBody, { ...p }), jsxRuntime.jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxRuntime.jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxRuntime.jsxs("div", { className: "eg-loading", children: [jsxRuntime.jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsxRuntime.jsx("div", { className: "eg-row", children: jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }) })] })) : null, p.phase === "missing" ? (jsxRuntime.jsxs("div", { className: "eg-card eg-err", role: "alertdialog", "aria-labelledby": "eg-missing-t", "data-eg-card": "", tabIndex: -1, children: [jsxRuntime.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" }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsxRuntime.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" ? (jsxRuntime.jsxs("div", { className: "eg-card", role: "dialog", "aria-modal": "false", "aria-labelledby": "eg-away-t", "data-eg-card": "", tabIndex: -1, children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsx("span", { className: "eg-mis", children: headingOf(p.bubble) }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", p.bubble.index + 1, "/", p.bubble.total] })] }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-away-t", style: { marginTop: 8 }, children: jsxRuntime.jsx(GuideTitle, { text: p.bubble.title }) }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(p.bubble) }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onGoResume, children: "\u0E17\u0E33\u0E15\u0E48\u0E2D" })] })] })) : null, p.confirmExit ? (jsxRuntime.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] }));
2753
3333
  }
2754
3334
  function BubbleBody(p) {
2755
3335
  const b = p.bubble;
2756
3336
  const title = b.error ? b.error.title : b.title;
2757
3337
  const why = b.error ? (b.error.detail ?? "") : b.why;
2758
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsxRuntime.jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsxRuntime.jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsxRuntime.jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsxRuntime.jsx(GuideTitle, { text: title }) }), jsxRuntime.jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: jsxRuntime.jsx(GuideTitle, { text: why }) }), b.status && !b.error ? (jsxRuntime.jsx("p", { className: "eg-b-s", role: "status", "data-eg-status": "", children: b.status })) : null, jsxRuntime.jsxs("div", { className: "eg-b-f", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxRuntime.jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showEndWithoutSave ? (jsxRuntime.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 ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
3338
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsx("span", { className: "eg-mis", children: headingOf(b) }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsxRuntime.jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsxRuntime.jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsxRuntime.jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsxRuntime.jsx(GuideTitle, { text: title }) }), jsxRuntime.jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: jsxRuntime.jsx(GuideTitle, { text: why }) }), b.status && !b.error ? (jsxRuntime.jsx("p", { className: "eg-b-s", role: "status", "data-eg-status": "", children: b.status })) : null, jsxRuntime.jsxs("div", { className: "eg-b-f", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: exitLabelOf(b) }), jsxRuntime.jsxs("span", { style: { display: "flex", gap: 6 }, children: [b.showBack ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onBack, children: "\u0E22\u0E49\u0E2D\u0E19\u0E01\u0E25\u0E31\u0E1A" })) : null, b.showEndWithoutSave ? (jsxRuntime.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 ? (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onNext, children: b.nextLabel ?? "ถัดไป" })) : null] })] })] }));
2759
3339
  }
2760
3340
  /** The fingertip at ≈62% across and down the target; upside down near the bottom. */
2761
3341
  function handPosition(spot, viewportHeight) {
@@ -2829,6 +3409,19 @@ function CompleteModal(p) {
2829
3409
  return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-scrim", "data-eg-block": "" }), jsxRuntime.jsxs("div", { className: "eg-modal eg-pop", role: "dialog", "aria-modal": "true", "aria-labelledby": "eg-f-t", "data-eg-modal": "", tabIndex: -1, children: [jsxRuntime.jsx(Confetti, { reduced: p.reduced }), jsxRuntime.jsx("div", { className: "eg-medal", children: jsxRuntime.jsx(IconTrophy, {}) }), jsxRuntime.jsxs("p", { className: "eg-eyebrow", children: ["\u0E04\u0E23\u0E1A ", p.total, " \u0E20\u0E32\u0E23\u0E01\u0E34\u0E08"] }), jsxRuntime.jsx("h2", { id: "eg-f-t", children: p.title }), p.subtitle ? jsxRuntime.jsx("p", { className: "eg-sub", children: p.subtitle }) : null, jsxRuntime.jsx("ol", { className: "eg-quests", children: p.missions.map((m, i) => (jsxRuntime.jsxs("li", { className: "eg-ok", children: [jsxRuntime.jsx("span", { className: "eg-n", children: jsxRuntime.jsx(IconCheck, {}) }), jsxRuntime.jsx("span", { className: "eg-t", children: m })] }, i))) }), jsxRuntime.jsxs("div", { className: "eg-actions", children: [jsxRuntime.jsx("button", { type: "button", ref: primary, className: "eg-btn eg-primary", onClick: p.onFinish, children: p.actionLabel }), p.secondary ? (jsxRuntime.jsx("a", { className: "eg-btn eg-ghost", href: p.secondary.href, style: { textDecoration: "none" }, children: p.secondary.label })) : null] })] })] }));
2830
3410
  }
2831
3411
  /* ------------------------------------------------------------------ */
3412
+ /* Page guides: the first-visit offer */
3413
+ /* ------------------------------------------------------------------ */
3414
+ /**
3415
+ * The offer of this page's guide on a first visit: a small card in the
3416
+ * corner, not modal (the page stays usable; focus is not taken). Shell ≥ 0.1.74.
3417
+ */
3418
+ function PageOfferCard(p) {
3419
+ return (jsxRuntime.jsxs("section", { className: "eg-card eg-offer", role: "region", "aria-labelledby": "eg-po-t", "data-eg-offer": "", children: [jsxRuntime.jsx("p", { className: "eg-po-e", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E2B\u0E19\u0E49\u0E32\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("p", { className: "eg-b-t", id: "eg-po-t", children: p.title }), jsxRuntime.jsxs("p", { className: "eg-b-w", children: [p.steps, " \u0E02\u0E31\u0E49\u0E19"] }), jsxRuntime.jsxs("div", { className: "eg-row", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E21\u0E48\u0E15\u0E2D\u0E19\u0E19\u0E35\u0E49" }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" })] })] }));
3420
+ }
3421
+ function PageList({ items, onOpen }) {
3422
+ return (jsxRuntime.jsx("ul", { className: "eg-q-list eg-pg-list", children: items.map(it => (jsxRuntime.jsxs("li", { className: "eg-q-item eg-pg-item", children: [jsxRuntime.jsxs("div", { className: "eg-t", children: [jsxRuntime.jsx("b", { children: it.title }), jsxRuntime.jsx("small", { className: it.seen ? undefined : "eg-new", children: it.seen ? "ดูแล้ว" : "ใหม่" })] }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onOpen(it.key), children: "\u0E40\u0E1B\u0E34\u0E14" })] }, it.key))) }));
3423
+ }
3424
+ /* ------------------------------------------------------------------ */
2832
3425
  /* Quest chip and panel */
2833
3426
  /* ------------------------------------------------------------------ */
2834
3427
  function QuestChip({ done, total, pulse, onOpen }) {
@@ -2850,7 +3443,12 @@ function QuestChip({ done, total, pulse, onOpen }) {
2850
3443
  }
2851
3444
  function QuestPanel(p) {
2852
3445
  const ref = useAutoFocus();
2853
- return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx("div", { className: "eg-prog", children: jsxRuntime.jsx("div", { className: "eg-bar", children: jsxRuntime.jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsxRuntime.jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsxRuntime.jsx(QuestRow, { item: it, n: i + 1, just: p.justDone === it.key, gate: p.gate, onStart: p.onStart, onDismiss: p.onDismiss }, it.key))) }), p.canHideChip ? (jsxRuntime.jsx("div", { className: "eg-q-f", children: jsxRuntime.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] })] }));
3446
+ const pages = p.pages ?? [];
3447
+ const openPage = p.onOpenPage ?? (() => { });
3448
+ if (p.showMissions === false) {
3449
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E04\u0E39\u0E48\u0E21\u0E37\u0E2D\u0E41\u0E15\u0E48\u0E25\u0E30\u0E2B\u0E19\u0E49\u0E32" }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })] }));
3450
+ }
3451
+ return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-panel-bd", onClick: p.onClose }), jsxRuntime.jsxs("section", { ref: ref, className: "eg-panel", role: "dialog", "aria-labelledby": "eg-q-t", tabIndex: -1, "data-eg-panel": "", children: [jsxRuntime.jsxs("div", { className: "eg-q-h", children: [jsxRuntime.jsx("h2", { id: "eg-q-t", children: "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsxs("span", { className: "eg-n", children: [p.done, "/", p.total] }), jsxRuntime.jsx("button", { type: "button", className: "eg-x", onClick: p.onClose, "aria-label": "\u0E1B\u0E34\u0E14", children: jsxRuntime.jsx(IconX, {}) })] }), jsxRuntime.jsx("div", { className: "eg-prog", children: jsxRuntime.jsx("div", { className: "eg-bar", children: jsxRuntime.jsx("i", { style: { width: `${p.total ? (p.done / p.total) * 100 : 0}%` } }) }) }), jsxRuntime.jsx("ol", { className: "eg-q-list", children: p.items.map((it, i) => (jsxRuntime.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 ? (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.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" }), jsxRuntime.jsx(PageList, { items: pages, onOpen: openPage })] })) : null, p.canHideChip ? (jsxRuntime.jsx("div", { className: "eg-q-f", children: jsxRuntime.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] })] }));
2854
3452
  }
2855
3453
  function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
2856
3454
  const v = item.view;
@@ -2911,6 +3509,7 @@ class GuideErrorBoundary extends React__namespace.Component {
2911
3509
  console.warn("[erp-shell guide] hidden after an error", error);
2912
3510
  try {
2913
3511
  publishGuideEntry(null, null);
3512
+ publishPageGuides(null, null, null);
2914
3513
  }
2915
3514
  catch {
2916
3515
  /* ignore */
@@ -2987,6 +3586,41 @@ function writeMirror(orgId, suite, p) {
2987
3586
  /* private mode: the server copy is enough */
2988
3587
  }
2989
3588
  }
3589
+ /** Page rows written in this tab, kept for a remount within the GET's cache time. */
3590
+ const pagesMirrorKey = (orgId, suite) => `erp-guide-pages:${orgId}:${suite.app}`;
3591
+ function readPagesMirror(orgId, suite) {
3592
+ try {
3593
+ const raw = sessionStorage.getItem(pagesMirrorKey(orgId, suite));
3594
+ const parsed = raw ? JSON.parse(raw) : {};
3595
+ const out = {};
3596
+ for (const [k, v] of Object.entries(parsed ?? {})) {
3597
+ if (v && Number.isInteger(v.version) && Number.isInteger(v.rev) && typeof v.status === "string")
3598
+ out[k] = v;
3599
+ }
3600
+ return out;
3601
+ }
3602
+ catch {
3603
+ return {};
3604
+ }
3605
+ }
3606
+ function writePagesMirror(orgId, suite, pages) {
3607
+ try {
3608
+ sessionStorage.setItem(pagesMirrorKey(orgId, suite), JSON.stringify(pages));
3609
+ }
3610
+ catch {
3611
+ /* private mode */
3612
+ }
3613
+ }
3614
+ /** The server's page rows, with this tab's newer ones laid over. */
3615
+ function mergePageRows(server, local) {
3616
+ const out = { ...server };
3617
+ for (const [k, v] of Object.entries(local)) {
3618
+ const r = out[k];
3619
+ if (!r || v.version > r.version)
3620
+ out[k] = v;
3621
+ }
3622
+ return out;
3623
+ }
2990
3624
  /* ------------------------------------------------------------------ */
2991
3625
  /* One tab at a time */
2992
3626
  /* ------------------------------------------------------------------ */
@@ -3074,6 +3708,12 @@ const SAVE_DEBOUNCE_MS = 600;
3074
3708
  const IDLE_POLL_MS = 100;
3075
3709
  /** A celebration earned outside the tour waits this long of nothing loading. */
3076
3710
  const PASSIVE_IDLE_MS = 600;
3711
+ /** A page guide's offer waits this long of nothing loading. */
3712
+ const PAGE_OFFER_IDLE_MS = 600;
3713
+ /** How often the page guides look at the page while idle (offer due, `when`). */
3714
+ const PAGE_POLL_MS = 250;
3715
+ /** How long a page opened from the panel's list may take to arrive. */
3716
+ const PAGE_ARRIVE_MS = 8000;
3077
3717
  function GuideRuntimeInner({ config, organizationId, code, routing, progress: progressAdapter, isBusy, menu, hidden, bottomOffset }) {
3078
3718
  const suite = config.suite;
3079
3719
  const pathname = routing.pathname || "/";
@@ -3088,9 +3728,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3088
3728
  // No menu: never a menu step (the machine sees it as always open).
3089
3729
  const sidebarOpen = menu ? !!menu.open : true;
3090
3730
  const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
3091
- // Progress: read once this tab runs the tour and the app is ready.
3731
+ // Progress: read once this tab runs the tour and the app is ready (for
3732
+ // the missions, or for the page guides alone: config.pagesReady).
3092
3733
  const [answer, setAnswer] = React.useState(null);
3093
- const enabled = primary && config.ready !== false;
3734
+ const missionsReady = config.ready !== false;
3735
+ const pagesReady = config.pagesReady ?? missionsReady;
3736
+ const hasPages = React.useMemo(() => validPages(suite).length > 0, [suite]);
3737
+ const enabled = primary && (missionsReady || (hasPages && pagesReady));
3094
3738
  const loaded = React.useRef(false);
3095
3739
  React.useEffect(() => {
3096
3740
  if (!enabled || loaded.current)
@@ -3112,7 +3756,11 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3112
3756
  loaded.current = false;
3113
3757
  };
3114
3758
  }, [enabled, suite]);
3115
- const live = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && config.ready !== false && !hidden;
3759
+ // The missions run for their audience; the page guides for theirs (every
3760
+ // member while pages are on), each with its own ready.
3761
+ const missionsLive = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && missionsReady && !hidden;
3762
+ const pagesLive = !!answer && hasPages && answer.pageAudience !== "none" && primary && pagesReady && !hidden;
3763
+ const live = missionsLive || pagesLive;
3116
3764
  /* ---------------- machine ---------------- */
3117
3765
  const [state, setState] = React.useState(null);
3118
3766
  const stateRef = React.useRef(null);
@@ -3120,7 +3768,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3120
3768
  // is not sent and does not bump its rev.
3121
3769
  const savedKey = React.useRef(null);
3122
3770
  React.useEffect(() => {
3123
- if (!answer || answer.audience === "none" || answer.newerOnServer) {
3771
+ const missionsOn = !!answer && answer.audience !== "none" && !answer.newerOnServer;
3772
+ const pagesOn = !!answer && hasPages && answer.pageAudience !== "none";
3773
+ if (!answer || (!missionsOn && !pagesOn)) {
3124
3774
  stateRef.current = null;
3125
3775
  setState(null);
3126
3776
  return;
@@ -3134,10 +3784,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3134
3784
  let progress = answer.progress;
3135
3785
  if (mirror && furtherProgress(mirror, progress) === mirror)
3136
3786
  progress = mergeProgress(mirror, progress);
3137
- const s = initialGuideState(answer.audience, progress);
3787
+ // A tour newer on the server than this build's: the missions stay out.
3788
+ const s = initialGuideState(missionsOn ? answer.audience : "none", progress, {
3789
+ pageAudience: pagesOn ? answer.pageAudience : "none",
3790
+ pagesMode: answer.pagesMode,
3791
+ pages: mergePageRows(answer.pages, readPagesMirror(organizationId, suite)),
3792
+ });
3138
3793
  stateRef.current = s;
3139
3794
  setState(s);
3140
- }, [answer, organizationId, suite]);
3795
+ }, [answer, organizationId, suite, hasPages]);
3141
3796
  // The latest page facts, read by the machine through env.
3142
3797
  const pageRef = React.useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
3143
3798
  pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
@@ -3234,6 +3889,17 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3234
3889
  // After this render takes the guard away, put the page back.
3235
3890
  runAbort(e.actions);
3236
3891
  }
3892
+ else if (e.type === "savePage") {
3893
+ // Once per page and version; the machine already holds it as written.
3894
+ if (stateRef.current)
3895
+ writePagesMirror(organizationId, suite, stateRef.current.pages);
3896
+ const rev = stateRef.current?.pages[e.key]?.rev ?? 0;
3897
+ void storePageRow(adapterRef.current, suite.app, e.key, e.version, e.status, rev).then(row => {
3898
+ // eslint-disable-next-line @typescript-eslint/no-use-before-define
3899
+ if (row)
3900
+ dispatchRef.current({ type: "pageRow", key: e.key, row });
3901
+ });
3902
+ }
3237
3903
  }
3238
3904
  catch {
3239
3905
  /* an effect never breaks the app */
@@ -3282,16 +3948,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3282
3948
  const screen = state?.screen ?? { name: "idle" };
3283
3949
  const resumed = React.useRef(false);
3284
3950
  React.useEffect(() => {
3285
- if (!live || !state || resumed.current)
3951
+ if (!missionsLive || !state || resumed.current)
3286
3952
  return;
3287
3953
  resumed.current = true;
3288
3954
  dispatch({ type: "resume" });
3289
- }, [live, state, dispatch]);
3955
+ }, [missionsLive, state, dispatch]);
3290
3956
  const welcomeRoutes = suite.welcomeRoutes ?? ["/[code]"];
3291
3957
  const onWelcomeRoute = welcomeRoutes.some(r => !!matchRoute(r, pathname, search, code));
3292
3958
  const factsKey = useFactsKey(config.facts);
3293
3959
  const welcomeCtx = state ? guideContext(env(), state.progress.context) : null;
3294
- const welcomeIsDue = !!state && !!welcomeCtx && live && welcomeDue(state, suite, welcomeCtx);
3960
+ const welcomeIsDue = !!state && !!welcomeCtx && missionsLive && welcomeDue(state, suite, welcomeCtx);
3295
3961
  const due = welcomeIsDue && onWelcomeRoute;
3296
3962
  // After a moment of nothing loading (the page has settled).
3297
3963
  useAfterIdle(due, WELCOME_DELAY_MS, busy, () => dispatchRef.current({ type: "welcome" }), [factsKey]);
@@ -3299,7 +3965,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3299
3965
  const passiveNames = React.useMemo(() => passiveEventNames(suite), [suite]);
3300
3966
  const hasPassive = React.useMemo(() => suite.missions.some(m => !!m.completeOn), [suite]);
3301
3967
  React.useEffect(() => {
3302
- if (!live || !hasPassive)
3968
+ if (!missionsLive || !hasPassive)
3303
3969
  return;
3304
3970
  const onApi = (e) => {
3305
3971
  const d = e.detail;
@@ -3317,15 +3983,19 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3317
3983
  window.removeEventListener(API_EVENT, onApi);
3318
3984
  handlers.forEach(([n, h]) => window.removeEventListener(n, h));
3319
3985
  };
3320
- }, [live, hasPassive, passiveNames]);
3986
+ }, [missionsLive, hasPassive, passiveNames]);
3321
3987
  /* ---------------- sync on route, sidebar, facts, DOM ---------------- */
3322
- const inStep = live && screen.name === "step";
3988
+ // A page guide's step runs while page guides are live; a mission's while
3989
+ // the missions are. The offer card is watched the same way (left: ignored).
3990
+ const inStep = screen.name === "step" && (screen.page ? pagesLive : missionsLive);
3991
+ const offerUp = pagesLive && screen.name === "pageOffer";
3992
+ const watching = inStep || offerUp;
3323
3993
  React.useEffect(() => {
3324
- if (inStep)
3994
+ if (watching)
3325
3995
  dispatch({ type: "sync" });
3326
- }, [inStep, pathname, search, sidebarOpen, factsKey, dispatch]);
3996
+ }, [watching, pathname, search, sidebarOpen, factsKey, dispatch]);
3327
3997
  React.useEffect(() => {
3328
- if (!inStep || typeof MutationObserver === "undefined")
3998
+ if (!watching || typeof MutationObserver === "undefined")
3329
3999
  return;
3330
4000
  let raf = 0;
3331
4001
  const obs = new MutationObserver(records => {
@@ -3350,7 +4020,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3350
4020
  if (raf)
3351
4021
  cancelAnimationFrame(raf);
3352
4022
  };
3353
- }, [inStep]);
4023
+ }, [watching]);
3354
4024
  /* ---------------- the current step ---------------- */
3355
4025
  const stepInfo = React.useMemo(() => {
3356
4026
  if (!state || screen.name !== "step")
@@ -3586,7 +4256,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3586
4256
  };
3587
4257
  }, [inStep, stepInfo]);
3588
4258
  /* ---------------- the guard ---------------- */
3589
- const modalScreen = live && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
4259
+ const modalScreen = missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
3590
4260
  const panelOpen = live && !!state?.panelOpen;
3591
4261
  const guarding = inStep || modalScreen;
3592
4262
  const guardingRef = React.useRef(guarding);
@@ -3863,7 +4533,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3863
4533
  const total = shown.length;
3864
4534
  // A celebration earned outside the tour: at the next idle moment — no
3865
4535
  // step, card or panel up, the welcome not waiting, nothing loading.
3866
- const passiveDue = live &&
4536
+ const passiveDue = missionsLive &&
3867
4537
  !!state &&
3868
4538
  (state.pendingCelebrate?.length ?? 0) > 0 &&
3869
4539
  screen.name === "idle" &&
@@ -3871,13 +4541,156 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3871
4541
  !due;
3872
4542
  useAfterIdle(passiveDue, PASSIVE_IDLE_MS, busy, () => dispatchRef.current({ type: "idleCelebrate" }), [state?.pendingCelebrate?.join(",")]);
3873
4543
  React.useEffect(() => {
3874
- if (!live || !state) {
4544
+ if (!missionsLive || !state) {
3875
4545
  publishGuideEntry(null, null);
3876
4546
  return;
3877
4547
  }
3878
4548
  publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
3879
- }, [live, state, done, total]);
4549
+ }, [missionsLive, state, done, total]);
3880
4550
  React.useEffect(() => () => publishGuideEntry(null, null), []);
4551
+ /* ---------------- page guides: this page's, the offer, the list ---------------- */
4552
+ // `when` anchors (LIFF's sub-screens) come and go without a route change:
4553
+ // look again when the page changes.
4554
+ const hasWhen = React.useMemo(() => validPages(suite).some(p => !!p.when), [suite]);
4555
+ const [domTick, setDomTick] = React.useState(0);
4556
+ React.useEffect(() => {
4557
+ if (!pagesLive || !hasWhen || typeof MutationObserver === "undefined")
4558
+ return;
4559
+ let t = null;
4560
+ const obs = new MutationObserver(records => {
4561
+ if (records.every(r => inGuideLayer(r.target)))
4562
+ return;
4563
+ if (t)
4564
+ return;
4565
+ t = setTimeout(() => {
4566
+ t = null;
4567
+ setDomTick(n => n + 1);
4568
+ }, PAGE_POLL_MS);
4569
+ });
4570
+ obs.observe(document.body, {
4571
+ childList: true,
4572
+ subtree: true,
4573
+ attributes: true,
4574
+ attributeFilter: ["data-state", "hidden", "aria-hidden", "class", "style", "open"],
4575
+ });
4576
+ return () => {
4577
+ obs.disconnect();
4578
+ if (t)
4579
+ clearTimeout(t);
4580
+ };
4581
+ }, [pagesLive, hasWhen]);
4582
+ const hasState = !!state;
4583
+ const pageNow = React.useMemo(() => (pagesLive && hasState ? pageHere(suite, env()) : null),
4584
+ // pathname/search/factsKey/domTick make it current.
4585
+ // eslint-disable-next-line react-hooks/exhaustive-deps
4586
+ [pagesLive, hasState, suite, env, pathname, search, factsKey, domTick]);
4587
+ const listed = React.useMemo(() => (pagesLive && ctxIdle ? listedPages(suite, ctxIdle) : []), [pagesLive, suite, ctxIdle]);
4588
+ const openPageHere = React.useCallback(() => {
4589
+ const p = pageHere(suite, env());
4590
+ if (p)
4591
+ dispatchRef.current({ type: "startPage", page: p.key });
4592
+ }, [suite, env]);
4593
+ React.useEffect(() => {
4594
+ if (!pagesLive || !hasState) {
4595
+ publishPageGuides(null, null, null);
4596
+ return;
4597
+ }
4598
+ publishPageGuides({ page: pageNow ? { title: pageNow.title } : null, listed: listed.length }, openPageHere, () => dispatchRef.current({ type: "openPanel", section: "pages" }));
4599
+ }, [pagesLive, hasState, pageNow, listed.length, openPageHere]);
4600
+ React.useEffect(() => () => publishPageGuides(null, null, null), []);
4601
+ // The first-visit offer: in "offer" mode, nothing else up, the welcome
4602
+ // not due here, and a moment of nothing loading on this page.
4603
+ const offerWatch = pagesLive && !!state && state.pagesMode === "offer" && screen.name === "idle" && !state.panelOpen && !due;
4604
+ React.useEffect(() => {
4605
+ if (!offerWatch)
4606
+ return;
4607
+ let idleSince = null;
4608
+ const iv = setInterval(() => {
4609
+ const cur = stateRef.current;
4610
+ if (!cur)
4611
+ return;
4612
+ let b = false;
4613
+ try {
4614
+ b = busyRef.current();
4615
+ }
4616
+ catch {
4617
+ b = false;
4618
+ }
4619
+ if (b || document.visibilityState === "hidden") {
4620
+ idleSince = null;
4621
+ return;
4622
+ }
4623
+ idleSince ?? (idleSince = Date.now());
4624
+ if (Date.now() - idleSince < PAGE_OFFER_IDLE_MS)
4625
+ return;
4626
+ let key = null;
4627
+ try {
4628
+ key = pageOfferDue(cur, suite, env());
4629
+ }
4630
+ catch {
4631
+ key = null;
4632
+ }
4633
+ if (key)
4634
+ dispatchRef.current({ type: "pageOffer", page: key });
4635
+ }, PAGE_POLL_MS);
4636
+ return () => clearInterval(iv);
4637
+ }, [offerWatch, suite, env, pathname, search]);
4638
+ // A page in the panel's list: start it here, or go there and start it on arrival.
4639
+ const [pending, setPending] = React.useState(null);
4640
+ const openListedPage = React.useCallback((key) => {
4641
+ const page = findPage(suite, key);
4642
+ if (!page)
4643
+ return;
4644
+ const e = env();
4645
+ if (pageHere(suite, e)?.key === key) {
4646
+ dispatchRef.current({ type: "startPage", page: key });
4647
+ return;
4648
+ }
4649
+ dispatchRef.current({ type: "closePanel" });
4650
+ const raw = resolveText(page.href, guideContext(e, {}));
4651
+ const href = raw ? (fillRoute(raw, { code }) ?? raw) : "";
4652
+ if (!href)
4653
+ return;
4654
+ setPending({ key, until: Date.now() + PAGE_ARRIVE_MS });
4655
+ try {
4656
+ if (/^https?:\/\//i.test(href))
4657
+ window.location.assign(href);
4658
+ else
4659
+ navigateRef.current(href);
4660
+ }
4661
+ catch {
4662
+ /* never breaks the app */
4663
+ }
4664
+ }, [suite, env, code]);
4665
+ React.useEffect(() => {
4666
+ if (!pending || !pagesLive)
4667
+ return;
4668
+ const iv = setInterval(() => {
4669
+ if (Date.now() > pending.until) {
4670
+ setPending(null);
4671
+ return;
4672
+ }
4673
+ const cur = stateRef.current;
4674
+ if (!cur || (cur.screen.name !== "idle" && cur.screen.name !== "pageOffer"))
4675
+ return;
4676
+ let b = false;
4677
+ try {
4678
+ b = busyRef.current();
4679
+ }
4680
+ catch {
4681
+ b = false;
4682
+ }
4683
+ if (b)
4684
+ return;
4685
+ const e = env();
4686
+ const page = findPage(suite, pending.key);
4687
+ if (!page || !pageMatchScore(page, { code, pathname: e.pathname, search: e.search, isVisible: a => e.isVisible(a) }))
4688
+ return;
4689
+ setPending(null);
4690
+ dispatchRef.current({ type: "startPage", page: pending.key });
4691
+ }, PAGE_POLL_MS);
4692
+ return () => clearInterval(iv);
4693
+ }, [pending, pagesLive, suite, env, code]);
3881
4694
  if (!live || !state || !layer || !ctxIdle)
3882
4695
  return null;
3883
4696
  /* ---------------- render ---------------- */
@@ -3886,31 +4699,52 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3886
4699
  const gateHref = gate ? resolveText(gate.action.href, ctxIdle) : "";
3887
4700
  const gateFailing = gate ? gateState(suite, ctxIdle) === false : false;
3888
4701
  let content = null;
3889
- if (screen.name === "welcome") {
4702
+ if (!missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete")) {
4703
+ content = null;
4704
+ }
4705
+ else if (screen.name === "welcome") {
3890
4706
  content = (jsxRuntime.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" }) }));
3891
4707
  }
3892
4708
  else if (screen.name === "gate" && gate) {
3893
4709
  content = (jsxRuntime.jsx(GateModal, { title: gate.title, subtitle: gate.subtitle, actionLabel: gate.action.label, href: gateHref, onLater: () => dispatch({ type: "gateLater" }) }));
3894
4710
  }
3895
- else if (screen.name === "step" && stepInfo && stepCtx) {
4711
+ else if (screen.name === "step" && inStep && stepInfo && stepCtx) {
3896
4712
  const { mission, step } = stepInfo;
3897
4713
  const missionNo = missionNumber(suite, mission.key, ctxIdle);
3898
4714
  const kind = stepKind(step);
3899
4715
  const bridge = screen.bridge;
4716
+ const pos = stepPosition(mission, screen.index, env(), state.progress.context);
4717
+ const page = screen.page ? findPage(suite, screen.page) : undefined;
4718
+ const pageBits = page
4719
+ ? {
4720
+ heading: `คู่มือหน้านี้ · ${page.title}`,
4721
+ exitLabel: "ปิดคู่มือ",
4722
+ showBack: kind === "view" && !!backTarget(mission, screen.index, env(), state.progress.context),
4723
+ nextLabel: pos.no >= pos.total ? "เสร็จ" : undefined,
4724
+ }
4725
+ : {};
3900
4726
  content = (jsxRuntime.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: {
3901
4727
  missionNo,
3902
4728
  missionTitle: mission.title,
3903
4729
  replay: screen.replay,
3904
4730
  // Steps this person skips (requires / skipIf) are not counted.
3905
- index: stepPosition(mission, screen.index, env(), state.progress.context).no - 1,
3906
- total: stepPosition(mission, screen.index, env(), state.progress.context).total,
4731
+ index: pos.no - 1,
4732
+ total: pos.total,
3907
4733
  title: bridge ? "กดปุ่มเมนู" : resolveText(step.title, stepCtx),
3908
4734
  why: bridge ? `เมนูทั้งหมดของ ${suite.appName} อยู่ในนี้` : resolveText(step.why, stepCtx),
3909
4735
  error: screen.error,
3910
4736
  status: bridge ? null : screen.status,
3911
4737
  showNext: !bridge && (kind === "input" || kind === "view" || (kind === "select" && screen.replay && screen.replayNext)),
3912
4738
  showEndWithoutSave: !bridge && screen.replay && kind === "commit",
3913
- }, 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" }), onEndWithoutSave: () => dispatch({ type: "endWithoutSave" }), onRetry: () => dispatch({ type: "retry" }), onGoResume: () => dispatch({ type: "goResume" }), onExitConfirm: () => dispatch({ type: "exitConfirm" }), onExitCancel: () => dispatch({ type: "exitCancel" }) }));
4739
+ ...pageBits,
4740
+ }, 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" }) }));
4741
+ }
4742
+ else if (screen.name === "pageOffer" && pagesLive) {
4743
+ const page = findPage(suite, screen.page);
4744
+ if (page) {
4745
+ const mission = findMission(suite, PAGE_PREFIX + page.key);
4746
+ content = (jsxRuntime.jsx(PageOfferCard, { title: page.title, steps: mission ? stepPosition(mission, 0, env(), {}).total : page.steps.length, onStart: () => dispatch({ type: "pageOfferStart" }), onLater: () => dispatch({ type: "pageOfferLater" }) }));
4747
+ }
3914
4748
  }
3915
4749
  else if (screen.name === "celebrate" && celebrateShown) {
3916
4750
  const mission = findMission(suite, screen.mission);
@@ -3945,8 +4779,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3945
4779
  .join(", ")
3946
4780
  : undefined,
3947
4781
  }));
3948
- const showChip = chipVisible(state, suite, ctxIdle);
3949
- return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("style", { children: GUIDE_CSS }), jsxRuntime.jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsxRuntime.jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, state.panelOpen && screen.name === "idle" ? (jsxRuntime.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" }) })) : null, toast ? jsxRuntime.jsx(GuideToast, { text: toast }) : null] }), layer);
4782
+ const showChip = missionsLive && chipVisible(state, suite, ctxIdle);
4783
+ // The panel: the missions (when this person has them, and not "คู่มือทุกหน้า"),
4784
+ // then the page guides they have.
4785
+ const panelMissions = missionsLive && total > 0 && state.panelSection !== "pages";
4786
+ const pageItems = pagesLive
4787
+ ? listed.map(p => ({ key: p.key, title: p.title, seen: pageRecorded(state, p) }))
4788
+ : [];
4789
+ const panelShown = state.panelOpen && screen.name === "idle" && (panelMissions || pageItems.length > 0);
4790
+ return reactDom.createPortal(jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("style", { children: GUIDE_CSS }), jsxRuntime.jsx("div", { className: "eg-sr", "aria-live": "polite", role: "status", children: liveText }), content, showChip ? jsxRuntime.jsx(QuestChip, { done: done, total: total, pulse: state.chipPulse, onOpen: () => dispatch({ type: "openPanel" }) }) : null, panelShown ? (jsxRuntime.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 ? jsxRuntime.jsx(GuideToast, { text: toast }) : null] }), layer);
3950
4791
  }
3951
4792
  /** A stable key for the facts object, so effects re-run when they change. */
3952
4793
  function useFactsKey(facts) {
@@ -3970,13 +4811,18 @@ exports.apiCallDetail = apiCallDetail;
3970
4811
  exports.emitGuideEvent = emitGuideEvent;
3971
4812
  exports.fillRoute = fillRoute;
3972
4813
  exports.guideAnchor = guideAnchor;
4814
+ exports.guideMenuItems = guideMenuItems;
3973
4815
  exports.guideMenuShown = guideMenuShown;
3974
4816
  exports.loadGuides = loadGuides;
3975
4817
  exports.matchApiPath = matchApiPath;
3976
4818
  exports.matchRoute = matchRoute;
3977
4819
  exports.openGuidePanel = openGuidePanel;
4820
+ exports.openPageGuide = openPageGuide;
4821
+ exports.openPagesPanel = openPagesPanel;
3978
4822
  exports.reportApiCall = reportApiCall;
3979
4823
  exports.reportApiEvent = reportApiEvent;
3980
4824
  exports.storeGuides = storeGuides;
3981
4825
  exports.useGuideEntry = useGuideEntry;
3982
- //# sourceMappingURL=guide-runtime-CLVSNkTN.cjs.map
4826
+ exports.usePageGuideEntry = usePageGuideEntry;
4827
+ exports.usePageGuides = usePageGuides;
4828
+ //# sourceMappingURL=guide-runtime-YS2nUhwS.cjs.map