@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.
@@ -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, useMemo } from 'react';
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,15 +337,43 @@ 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
+ }
340
367
  /**
341
- * Whether the "?" menu offers "ไกด์เริ่มต้น": a tour whose missions are all
342
- * hidden for this user (`visible`, e.g. Plannex without an employee record)
343
- * has nothing to open, and "0/0" led to an empty panel. The entry itself
344
- * stays published: apps wait on it before they load the facts that make
345
- * missions visible.
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.
346
374
  */
347
- function guideMenuShown(e) {
348
- return !!e && e.total > 0;
375
+ function guideMenuShown(e, p = NO_PAGES) {
376
+ return guideMenuItems(e, p).length > 0;
349
377
  }
350
378
  /** Opens the tour's quest panel, when this user has a tour here. */
351
379
  function openGuidePanel() {
@@ -355,6 +383,206 @@ function openGuidePanel() {
355
383
  function useGuideEntry() {
356
384
  return useSyncExternalStore(subscribe, () => entry, () => null);
357
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
+ }
358
586
 
359
587
  /* ------------------------------------------------------------------ */
360
588
  /* Copy */
@@ -368,6 +596,7 @@ const COPY = {
368
596
  replayEnded: "จบการทบทวนแล้ว",
369
597
  hiddenToast: "ซ่อนแล้ว",
370
598
  aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
599
+ pageDone: "จบคู่มือหน้านี้",
371
600
  bridgeTitle: "กดปุ่มเมนู",
372
601
  bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
373
602
  };
@@ -375,8 +604,13 @@ const COPY = {
375
604
  /* Helpers */
376
605
  /* ------------------------------------------------------------------ */
377
606
  const BRIDGE_TARGET = "shell.menu-toggle";
378
- function initialGuideState(audience, progress) {
607
+ function initialGuideState(audience, progress, pages) {
379
608
  return {
609
+ pageAudience: pages?.pageAudience ?? "none",
610
+ pagesMode: pages?.pagesMode ?? "off",
611
+ pages: pages?.pages ?? {},
612
+ pageRun: null,
613
+ panelSection: "all",
380
614
  audience,
381
615
  progress,
382
616
  screen: { name: "idle" },
@@ -390,7 +624,13 @@ function initialGuideState(audience, progress) {
390
624
  };
391
625
  }
392
626
  function findMission(suite, key) {
393
- return key ? suite.missions.find(m => m.key === key) : undefined;
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);
394
634
  }
395
635
  /**
396
636
  * Whether the user has this mission (GuideMission.visible). Without the
@@ -553,8 +793,10 @@ function placeRoute(steps, index) {
553
793
  return step.route;
554
794
  return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
555
795
  }
556
- /** The user is not on the page the step happens on. */
557
- 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;
558
800
  const home = stepHomeRoute(steps, index);
559
801
  return !!home && !matchRoute(home, env.pathname, env.search, env.code);
560
802
  }
@@ -697,7 +939,8 @@ function welcomeDue(state, suite, ctx) {
697
939
  state.progress.welcome === "not_seen" &&
698
940
  state.progress.status === "in_progress" &&
699
941
  !state.progress.mission_key &&
700
- state.screen.name === "idle" &&
942
+ // The missions' welcome always wins over a page guide's offer.
943
+ (state.screen.name === "idle" || state.screen.name === "pageOffer") &&
701
944
  (ctx ? shownMissions(suite, ctx).length : suite.missions.length) > 0);
702
945
  }
703
946
  /** Whether the floating "ภารกิจ n/N" chip shows. */
@@ -810,8 +1053,10 @@ function enterStep(d, suite, env, mission, index, opts) {
810
1053
  }
811
1054
  const step = steps[i];
812
1055
  const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
1056
+ const page = pageKeyOf(mission.key);
813
1057
  const where = placeRoute(steps, i);
814
- const away = !!where && !matchRoute(where, env.pathname, env.search, env.code);
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);
815
1060
  const bridge = !away && needsBridge(step, env, ctx);
816
1061
  const nts = !away && !bridge && nothingToSaveHolds(step, env, ctx);
817
1062
  setScreen(d, {
@@ -828,6 +1073,7 @@ function enterStep(d, suite, env, mission, index, opts) {
828
1073
  nothingToSave: nts,
829
1074
  status: away || bridge ? null : stepStatus(shownStep(step, nts), ctx),
830
1075
  replayNext: !away && !bridge && opts.replay && replayCanGoOn(mission, i, env, d.state.progress.context, nts),
1076
+ ...(page ? { page } : {}),
831
1077
  });
832
1078
  if (!opts.replay) {
833
1079
  const cp = checkpointAtOrBefore(steps, i);
@@ -992,6 +1238,11 @@ function advance(d, suite, env, capture) {
992
1238
  enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
993
1239
  }
994
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
+ }
995
1246
  if (replay) {
996
1247
  setScreen(d, { name: "idle" });
997
1248
  d.effects.push({ type: "toast", text: COPY.replayDone });
@@ -1185,6 +1436,134 @@ function pushAbort(d, suite, s) {
1185
1436
  if (actions.length)
1186
1437
  d.effects.push({ type: "abort", actions });
1187
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
+ }
1188
1567
  function reduceGuide(state, action, suite, env) {
1189
1568
  const d = { state, effects: [] };
1190
1569
  const s = state.screen;
@@ -1196,6 +1575,9 @@ function reduceGuide(state, action, suite, env) {
1196
1575
  const gate = gateState(suite, ctxNow());
1197
1576
  if (gate === undefined)
1198
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" });
1199
1581
  if (gate === false) {
1200
1582
  if (!state.gateDismissed)
1201
1583
  setScreen(d, { name: "gate" });
@@ -1236,6 +1618,12 @@ function reduceGuide(state, action, suite, env) {
1236
1618
  case "start":
1237
1619
  if (state.audience === "none")
1238
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" });
1239
1627
  if (s.name === "step" && !s.replay && s.mission !== action.mission) {
1240
1628
  // Leaving one mission for another: the first is set aside.
1241
1629
  exitMission(d);
@@ -1257,8 +1645,21 @@ function reduceGuide(state, action, suite, env) {
1257
1645
  break;
1258
1646
  }
1259
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
+ }
1260
1656
  if (s.name !== "step")
1261
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
+ }
1262
1663
  const mission = findMission(suite, s.mission);
1263
1664
  if (!mission)
1264
1665
  break;
@@ -1288,7 +1689,7 @@ function reduceGuide(state, action, suite, env) {
1288
1689
  break;
1289
1690
  }
1290
1691
  }
1291
- 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)) {
1292
1693
  // The target is missing because the page is another one.
1293
1694
  recoverOffRoute(d, suite, env, false);
1294
1695
  break;
@@ -1404,7 +1805,7 @@ function reduceGuide(state, action, suite, env) {
1404
1805
  const cur = d.state.screen;
1405
1806
  if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
1406
1807
  break;
1407
- if (!cur.bridge && offRoute(mission.steps, cur.index, env)) {
1808
+ if (!cur.bridge && offRoute(mission.steps, cur.index, env, cur.page)) {
1408
1809
  recoverOffRoute(d, suite, env, false);
1409
1810
  break;
1410
1811
  }
@@ -1425,7 +1826,7 @@ function reduceGuide(state, action, suite, env) {
1425
1826
  if (!mission)
1426
1827
  break;
1427
1828
  const step = mission.steps[s.index];
1428
- if (!s.bridge && offRoute(mission.steps, s.index, env)) {
1829
+ if (!s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
1429
1830
  recoverOffRoute(d, suite, env, false);
1430
1831
  break;
1431
1832
  }
@@ -1442,7 +1843,10 @@ function reduceGuide(state, action, suite, env) {
1442
1843
  advance(d, suite, env);
1443
1844
  }
1444
1845
  else if (policy === "abort") {
1445
- exitMission(d);
1846
+ if (s.page)
1847
+ endPage(d, suite, "skipped");
1848
+ else
1849
+ exitMission(d);
1446
1850
  d.effects.push({ type: "toast", text: COPY.aborted });
1447
1851
  }
1448
1852
  else if ((policy === "rewind" || (policy === "retry" && misses >= 2)) && cp < s.index) {
@@ -1465,7 +1869,7 @@ function reduceGuide(state, action, suite, env) {
1465
1869
  if (s.name !== "step" || s.phase !== "missing")
1466
1870
  break;
1467
1871
  const mission = findMission(suite, s.mission);
1468
- if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
1872
+ if (mission && !s.bridge && offRoute(mission.steps, s.index, env, s.page)) {
1469
1873
  // Polling again cannot help on another page: go back to the step's.
1470
1874
  recoverOffRoute(d, suite, env, true);
1471
1875
  break;
@@ -1481,6 +1885,12 @@ function reduceGuide(state, action, suite, env) {
1481
1885
  case "exit":
1482
1886
  if (s.name !== "step")
1483
1887
  break;
1888
+ if (s.page) {
1889
+ // ✕, Escape or ปิดคู่มือ: ends silently.
1890
+ pushAbort(d, suite, s);
1891
+ endPage(d, suite, "skipped");
1892
+ break;
1893
+ }
1484
1894
  if (s.replay) {
1485
1895
  pushAbort(d, suite, s);
1486
1896
  setScreen(d, { name: "idle" });
@@ -1549,11 +1959,17 @@ function reduceGuide(state, action, suite, env) {
1549
1959
  break;
1550
1960
  setScreen(d, { name: "idle" });
1551
1961
  break;
1552
- case "openPanel":
1553
- if (state.audience !== "none" && s.name === "idle") {
1554
- d.state = { ...d.state, panelOpen: true };
1555
- }
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 };
1556
1971
  break;
1972
+ }
1557
1973
  case "closePanel":
1558
1974
  d.state = { ...d.state, panelOpen: false, justDone: null };
1559
1975
  break;
@@ -1597,6 +2013,58 @@ function reduceGuide(state, action, suite, env) {
1597
2013
  passiveDone(d, suite, env, m);
1598
2014
  break;
1599
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
+ }
1600
2068
  case "idleCelebrate": {
1601
2069
  if (s.name !== "idle" || state.panelOpen)
1602
2070
  break;
@@ -1720,6 +2188,11 @@ function parseGuidesAnswer(payload, suite) {
1720
2188
  if (typeof data.app === "string" && data.app !== suite.app)
1721
2189
  return null;
1722
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);
1723
2196
  let row = null;
1724
2197
  for (const r of rows) {
1725
2198
  if (isRecord(r) && r.guide_key === suite.key) {
@@ -1740,7 +2213,58 @@ function parseGuidesAnswer(payload, suite) {
1740
2213
  progress = row;
1741
2214
  newerOnServer = row.guide_version > suite.version;
1742
2215
  }
1743
- 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
+ };
1744
2268
  }
1745
2269
  const TERMINAL = ["completed", "skipped", "aborted"];
1746
2270
  /** The PUT body for the client's state. Drops keys the server would refuse. */
@@ -1946,6 +2470,47 @@ async function storeGuides(adapter, suite, progress) {
1946
2470
  }
1947
2471
  return null;
1948
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
+ }
1949
2514
 
1950
2515
  /**
1951
2516
  * The tour's DOM side: finding anchors, deciding whether one is really on
@@ -2465,6 +3030,12 @@ const GUIDE_CSS = `
2465
3030
  .eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
2466
3031
  .eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
2467
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}
2468
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}
2469
3040
  svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
2470
3041
  @media (max-width:767px){
@@ -2478,6 +3049,7 @@ svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;
2478
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)}
2479
3050
  .eg-q-item{padding:12px 8px}
2480
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)}
2481
3053
  .eg-toast{bottom:calc(var(--erp-guide-bottom-offset,calc(16px + env(safe-area-inset-bottom))) + 60px)}
2482
3054
  }
2483
3055
  @keyframes eg-up{from{opacity:0;transform:translate(-50%,30px)}}
@@ -2575,6 +3147,14 @@ function pickPlacement(results, bw, bh, anchor, clear, bounds) {
2575
3147
  const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
2576
3148
  return best >= 0 ? best : ok.indexOf(true);
2577
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
+ }
2578
3158
  const BUBBLE_TITLE_ID = "eg-bubble-title";
2579
3159
  const BUBBLE_WHY_ID = "eg-bubble-why";
2580
3160
  function useNudge(nudge, ref) {
@@ -2612,7 +3192,7 @@ function StepLayer(p) {
2612
3192
  const showBubble = p.phase === "ready" && (!!spot || !p.hasTarget);
2613
3193
  const gap = p.showHand ? 34 : 14;
2614
3194
  const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
2615
- const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}`;
3195
+ const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.status ?? ""}|${p.bubble.showNext}|${!!p.bubble.showBack}`;
2616
3196
  // What the bubble sits beside: the target, with an open list over it (the
2617
3197
  // list is what the person is choosing from now). What it must not cover:
2618
3198
  // open lists, and the other anchors the step allows (a map step's
@@ -2729,13 +3309,13 @@ function StepLayer(p) {
2729
3309
  top: pos?.top ?? 0,
2730
3310
  width: phone ? viewport.width - 32 : undefined,
2731
3311
  visibility: pos ? "visible" : "hidden",
2732
- }, children: [jsx(BubbleBody, { ...p }), jsx("span", { ref: arrowRef, className: "eg-arrow" + (pos?.side === "bottom" ? " eg-top" : ""), style: arrowStyle ?? { display: "none" }, "aria-hidden": "true" })] })) : null, p.phase === "waiting" ? (jsxs("div", { className: "eg-card", role: "status", "data-eg-card": "", children: [jsxs("div", { className: "eg-loading", children: [jsx("span", { className: "eg-spin", "aria-hidden": "true" }), "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14\u2026"] }), jsx("div", { className: "eg-row", children: jsx("button", { type: "button", className: "eg-exit", onClick: p.onExit, children: p.bubble.replay ? "จบการทบทวน" : "ออกจากไกด์" }) })] })) : 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: p.bubble.replay ? "จบการทบทวน" : "ออกจากไกด์" }), 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: [jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", p.bubble.missionNo, " \u00B7 ", p.bubble.missionTitle, p.bubble.replay ? " (ทบทวน)" : ""] }), 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: p.bubble.replay ? "จบการทบทวน" : "ออกจากไกด์" }), 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] }));
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] }));
2733
3313
  }
2734
3314
  function BubbleBody(p) {
2735
3315
  const b = p.bubble;
2736
3316
  const title = b.error ? b.error.title : b.title;
2737
3317
  const why = b.error ? (b.error.detail ?? "") : b.why;
2738
- return (jsxs(Fragment, { children: [jsxs("div", { className: "eg-b-top", children: [jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), 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: b.replay ? "จบการทบทวน" : "ออกจากไกด์" }), jsxs("span", { style: { display: "flex", gap: 6 }, children: [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: "\u0E16\u0E31\u0E14\u0E44\u0E1B" })) : null] })] })] }));
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] })] })] }));
2739
3319
  }
2740
3320
  /** The fingertip at ≈62% across and down the target; upside down near the bottom. */
2741
3321
  function handPosition(spot, viewportHeight) {
@@ -2809,6 +3389,19 @@ function CompleteModal(p) {
2809
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] })] })] }));
2810
3390
  }
2811
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
+ /* ------------------------------------------------------------------ */
2812
3405
  /* Quest chip and panel */
2813
3406
  /* ------------------------------------------------------------------ */
2814
3407
  function QuestChip({ done, total, pulse, onOpen }) {
@@ -2830,7 +3423,12 @@ function QuestChip({ done, total, pulse, onOpen }) {
2830
3423
  }
2831
3424
  function QuestPanel(p) {
2832
3425
  const ref = useAutoFocus();
2833
- 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))) }), 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] })] }));
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] })] }));
2834
3432
  }
2835
3433
  function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
2836
3434
  const v = item.view;
@@ -2891,6 +3489,7 @@ class GuideErrorBoundary extends React.Component {
2891
3489
  console.warn("[erp-shell guide] hidden after an error", error);
2892
3490
  try {
2893
3491
  publishGuideEntry(null, null);
3492
+ publishPageGuides(null, null, null);
2894
3493
  }
2895
3494
  catch {
2896
3495
  /* ignore */
@@ -2967,6 +3566,41 @@ function writeMirror(orgId, suite, p) {
2967
3566
  /* private mode: the server copy is enough */
2968
3567
  }
2969
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
+ }
2970
3604
  /* ------------------------------------------------------------------ */
2971
3605
  /* One tab at a time */
2972
3606
  /* ------------------------------------------------------------------ */
@@ -3054,6 +3688,12 @@ const SAVE_DEBOUNCE_MS = 600;
3054
3688
  const IDLE_POLL_MS = 100;
3055
3689
  /** A celebration earned outside the tour waits this long of nothing loading. */
3056
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;
3057
3697
  function GuideRuntimeInner({ config, organizationId, code, routing, progress: progressAdapter, isBusy, menu, hidden, bottomOffset }) {
3058
3698
  const suite = config.suite;
3059
3699
  const pathname = routing.pathname || "/";
@@ -3068,9 +3708,13 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3068
3708
  // No menu: never a menu step (the machine sees it as always open).
3069
3709
  const sidebarOpen = menu ? !!menu.open : true;
3070
3710
  const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
3071
- // 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).
3072
3713
  const [answer, setAnswer] = useState(null);
3073
- const enabled = primary && config.ready !== false;
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));
3074
3718
  const loaded = useRef(false);
3075
3719
  useEffect(() => {
3076
3720
  if (!enabled || loaded.current)
@@ -3092,7 +3736,11 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3092
3736
  loaded.current = false;
3093
3737
  };
3094
3738
  }, [enabled, suite]);
3095
- const live = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && config.ready !== false && !hidden;
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;
3096
3744
  /* ---------------- machine ---------------- */
3097
3745
  const [state, setState] = useState(null);
3098
3746
  const stateRef = useRef(null);
@@ -3100,7 +3748,9 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3100
3748
  // is not sent and does not bump its rev.
3101
3749
  const savedKey = useRef(null);
3102
3750
  useEffect(() => {
3103
- if (!answer || answer.audience === "none" || answer.newerOnServer) {
3751
+ const missionsOn = !!answer && answer.audience !== "none" && !answer.newerOnServer;
3752
+ const pagesOn = !!answer && hasPages && answer.pageAudience !== "none";
3753
+ if (!answer || (!missionsOn && !pagesOn)) {
3104
3754
  stateRef.current = null;
3105
3755
  setState(null);
3106
3756
  return;
@@ -3114,10 +3764,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3114
3764
  let progress = answer.progress;
3115
3765
  if (mirror && furtherProgress(mirror, progress) === mirror)
3116
3766
  progress = mergeProgress(mirror, progress);
3117
- const s = initialGuideState(answer.audience, progress);
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
+ });
3118
3773
  stateRef.current = s;
3119
3774
  setState(s);
3120
- }, [answer, organizationId, suite]);
3775
+ }, [answer, organizationId, suite, hasPages]);
3121
3776
  // The latest page facts, read by the machine through env.
3122
3777
  const pageRef = useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
3123
3778
  pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
@@ -3214,6 +3869,17 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3214
3869
  // After this render takes the guard away, put the page back.
3215
3870
  runAbort(e.actions);
3216
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
+ }
3217
3883
  }
3218
3884
  catch {
3219
3885
  /* an effect never breaks the app */
@@ -3262,16 +3928,16 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3262
3928
  const screen = state?.screen ?? { name: "idle" };
3263
3929
  const resumed = useRef(false);
3264
3930
  useEffect(() => {
3265
- if (!live || !state || resumed.current)
3931
+ if (!missionsLive || !state || resumed.current)
3266
3932
  return;
3267
3933
  resumed.current = true;
3268
3934
  dispatch({ type: "resume" });
3269
- }, [live, state, dispatch]);
3935
+ }, [missionsLive, state, dispatch]);
3270
3936
  const welcomeRoutes = suite.welcomeRoutes ?? ["/[code]"];
3271
3937
  const onWelcomeRoute = welcomeRoutes.some(r => !!matchRoute(r, pathname, search, code));
3272
3938
  const factsKey = useFactsKey(config.facts);
3273
3939
  const welcomeCtx = state ? guideContext(env(), state.progress.context) : null;
3274
- const welcomeIsDue = !!state && !!welcomeCtx && live && welcomeDue(state, suite, welcomeCtx);
3940
+ const welcomeIsDue = !!state && !!welcomeCtx && missionsLive && welcomeDue(state, suite, welcomeCtx);
3275
3941
  const due = welcomeIsDue && onWelcomeRoute;
3276
3942
  // After a moment of nothing loading (the page has settled).
3277
3943
  useAfterIdle(due, WELCOME_DELAY_MS, busy, () => dispatchRef.current({ type: "welcome" }), [factsKey]);
@@ -3279,7 +3945,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3279
3945
  const passiveNames = useMemo(() => passiveEventNames(suite), [suite]);
3280
3946
  const hasPassive = useMemo(() => suite.missions.some(m => !!m.completeOn), [suite]);
3281
3947
  useEffect(() => {
3282
- if (!live || !hasPassive)
3948
+ if (!missionsLive || !hasPassive)
3283
3949
  return;
3284
3950
  const onApi = (e) => {
3285
3951
  const d = e.detail;
@@ -3297,15 +3963,19 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3297
3963
  window.removeEventListener(API_EVENT, onApi);
3298
3964
  handlers.forEach(([n, h]) => window.removeEventListener(n, h));
3299
3965
  };
3300
- }, [live, hasPassive, passiveNames]);
3966
+ }, [missionsLive, hasPassive, passiveNames]);
3301
3967
  /* ---------------- sync on route, sidebar, facts, DOM ---------------- */
3302
- const inStep = live && screen.name === "step";
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;
3303
3973
  useEffect(() => {
3304
- if (inStep)
3974
+ if (watching)
3305
3975
  dispatch({ type: "sync" });
3306
- }, [inStep, pathname, search, sidebarOpen, factsKey, dispatch]);
3976
+ }, [watching, pathname, search, sidebarOpen, factsKey, dispatch]);
3307
3977
  useEffect(() => {
3308
- if (!inStep || typeof MutationObserver === "undefined")
3978
+ if (!watching || typeof MutationObserver === "undefined")
3309
3979
  return;
3310
3980
  let raf = 0;
3311
3981
  const obs = new MutationObserver(records => {
@@ -3330,7 +4000,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3330
4000
  if (raf)
3331
4001
  cancelAnimationFrame(raf);
3332
4002
  };
3333
- }, [inStep]);
4003
+ }, [watching]);
3334
4004
  /* ---------------- the current step ---------------- */
3335
4005
  const stepInfo = useMemo(() => {
3336
4006
  if (!state || screen.name !== "step")
@@ -3566,7 +4236,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3566
4236
  };
3567
4237
  }, [inStep, stepInfo]);
3568
4238
  /* ---------------- the guard ---------------- */
3569
- const modalScreen = live && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
4239
+ const modalScreen = missionsLive && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
3570
4240
  const panelOpen = live && !!state?.panelOpen;
3571
4241
  const guarding = inStep || modalScreen;
3572
4242
  const guardingRef = useRef(guarding);
@@ -3843,7 +4513,7 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3843
4513
  const total = shown.length;
3844
4514
  // A celebration earned outside the tour: at the next idle moment — no
3845
4515
  // step, card or panel up, the welcome not waiting, nothing loading.
3846
- const passiveDue = live &&
4516
+ const passiveDue = missionsLive &&
3847
4517
  !!state &&
3848
4518
  (state.pendingCelebrate?.length ?? 0) > 0 &&
3849
4519
  screen.name === "idle" &&
@@ -3851,13 +4521,156 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3851
4521
  !due;
3852
4522
  useAfterIdle(passiveDue, PASSIVE_IDLE_MS, busy, () => dispatchRef.current({ type: "idleCelebrate" }), [state?.pendingCelebrate?.join(",")]);
3853
4523
  useEffect(() => {
3854
- if (!live || !state) {
4524
+ if (!missionsLive || !state) {
3855
4525
  publishGuideEntry(null, null);
3856
4526
  return;
3857
4527
  }
3858
4528
  publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
3859
- }, [live, state, done, total]);
4529
+ }, [missionsLive, state, done, total]);
3860
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]);
3861
4674
  if (!live || !state || !layer || !ctxIdle)
3862
4675
  return null;
3863
4676
  /* ---------------- render ---------------- */
@@ -3866,31 +4679,52 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3866
4679
  const gateHref = gate ? resolveText(gate.action.href, ctxIdle) : "";
3867
4680
  const gateFailing = gate ? gateState(suite, ctxIdle) === false : false;
3868
4681
  let content = null;
3869
- 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") {
3870
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" }) }));
3871
4687
  }
3872
4688
  else if (screen.name === "gate" && gate) {
3873
4689
  content = (jsx(GateModal, { title: gate.title, subtitle: gate.subtitle, actionLabel: gate.action.label, href: gateHref, onLater: () => dispatch({ type: "gateLater" }) }));
3874
4690
  }
3875
- else if (screen.name === "step" && stepInfo && stepCtx) {
4691
+ else if (screen.name === "step" && inStep && stepInfo && stepCtx) {
3876
4692
  const { mission, step } = stepInfo;
3877
4693
  const missionNo = missionNumber(suite, mission.key, ctxIdle);
3878
4694
  const kind = stepKind(step);
3879
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
+ : {};
3880
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: {
3881
4707
  missionNo,
3882
4708
  missionTitle: mission.title,
3883
4709
  replay: screen.replay,
3884
4710
  // Steps this person skips (requires / skipIf) are not counted.
3885
- index: stepPosition(mission, screen.index, env(), state.progress.context).no - 1,
3886
- total: stepPosition(mission, screen.index, env(), state.progress.context).total,
4711
+ index: pos.no - 1,
4712
+ total: pos.total,
3887
4713
  title: bridge ? "กดปุ่มเมนู" : resolveText(step.title, stepCtx),
3888
4714
  why: bridge ? `เมนูทั้งหมดของ ${suite.appName} อยู่ในนี้` : resolveText(step.why, stepCtx),
3889
4715
  error: screen.error,
3890
4716
  status: bridge ? null : screen.status,
3891
4717
  showNext: !bridge && (kind === "input" || kind === "view" || (kind === "select" && screen.replay && screen.replayNext)),
3892
4718
  showEndWithoutSave: !bridge && screen.replay && kind === "commit",
3893
- }, 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" }) }));
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
+ }
3894
4728
  }
3895
4729
  else if (screen.name === "celebrate" && celebrateShown) {
3896
4730
  const mission = findMission(suite, screen.mission);
@@ -3925,8 +4759,15 @@ function GuideRuntimeInner({ config, organizationId, code, routing, progress: pr
3925
4759
  .join(", ")
3926
4760
  : undefined,
3927
4761
  }));
3928
- const showChip = chipVisible(state, suite, ctxIdle);
3929
- 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, state.panelOpen && screen.name === "idle" ? (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 ? jsx(GuideToast, { text: toast }) : null] }), layer);
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);
3930
4771
  }
3931
4772
  /** A stable key for the facts object, so effects re-run when they change. */
3932
4773
  function useFactsKey(facts) {
@@ -3940,5 +4781,5 @@ function useFactsKey(facts) {
3940
4781
  }, [facts]);
3941
4782
  }
3942
4783
 
3943
- 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, guideMenuShown as i, matchRoute as j, reportApiEvent as k, loadGuides as l, matchApiPath as m, openGuidePanel as o, reportApiCall as r, storeGuides as s, useGuideEntry as u };
3944
- //# sourceMappingURL=guide-runtime-C7Z00QwM.esm.js.map
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