@eintrek/erp-shell 0.1.61 → 0.1.63
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +32 -0
- package/dist/guide/dom.d.ts +37 -0
- package/dist/guide/events.d.ts +24 -0
- package/dist/guide/guide-host.d.ts +7 -0
- package/dist/guide/guide-store.d.ts +16 -0
- package/dist/guide/guide-styles.d.ts +7 -0
- package/dist/guide/guide-ui.d.ts +158 -0
- package/dist/guide/machine.d.ts +237 -0
- package/dist/guide/patterns.d.ts +28 -0
- package/dist/guide/progress-client.d.ts +11 -0
- package/dist/guide/progress.d.ts +74 -0
- package/dist/guide/types.d.ts +288 -0
- package/dist/help/help-button.d.ts +4 -0
- package/dist/index.d.ts +5 -0
- package/dist/index.esm.js +3103 -154
- package/dist/index.esm.js.map +1 -1
- package/dist/index.js +3109 -149
- package/dist/index.js.map +1 -1
- package/dist/navigation/app-layout-shell.d.ts +8 -1
- package/dist/navigation/nav-user.d.ts +4 -1
- package/dist/navigation/platform-url.d.ts +23 -0
- package/dist/version.d.ts +1 -1
- package/package.json +4 -1
package/dist/index.js
CHANGED
|
@@ -14,6 +14,7 @@ var Link = require('next/link');
|
|
|
14
14
|
var reactDom = require('react-dom');
|
|
15
15
|
var nextThemes = require('next-themes');
|
|
16
16
|
var axios = require('axios');
|
|
17
|
+
var dom = require('@floating-ui/dom');
|
|
17
18
|
var reactQueryDevtools = require('@tanstack/react-query-devtools');
|
|
18
19
|
|
|
19
20
|
function _interopNamespaceDefault(e) {
|
|
@@ -752,7 +753,7 @@ function SidebarFooter({ children, className }) {
|
|
|
752
753
|
*/
|
|
753
754
|
function SidebarTrigger({ className }) {
|
|
754
755
|
const { open, toggle } = useSidebar();
|
|
755
|
-
return (jsxRuntime.jsx("button", { onClick: toggle, className: erpTheme.cn("inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-8 w-8 md:h-9 md:w-9 -ml-1", className), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", children: open ? (jsxRuntime.jsx(lucideReact.PanelLeftClose, { className: "h-4 w-4 text-primary-foreground" })) : (jsxRuntime.jsx(lucideReact.Menu, { className: "h-4 w-4 text-primary-foreground" })) }));
|
|
756
|
+
return (jsxRuntime.jsx("button", { onClick: toggle, className: erpTheme.cn("inline-flex items-center justify-center rounded-md text-sm font-medium transition-colors focus-visible:outline-none focus-visible:ring-1 focus-visible:ring-ring disabled:pointer-events-none disabled:opacity-50 hover:bg-accent hover:text-accent-foreground h-8 w-8 md:h-9 md:w-9 -ml-1", className), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", "data-guide": "shell.menu-toggle", children: open ? (jsxRuntime.jsx(lucideReact.PanelLeftClose, { className: "h-4 w-4 text-primary-foreground" })) : (jsxRuntime.jsx(lucideReact.Menu, { className: "h-4 w-4 text-primary-foreground" })) }));
|
|
756
757
|
}
|
|
757
758
|
|
|
758
759
|
/**
|
|
@@ -1277,35 +1278,35 @@ let declaredApp = null;
|
|
|
1277
1278
|
// The message the dialog should open on (the ตอบกลับ link's ?help=H1004),
|
|
1278
1279
|
// until it is closed or left.
|
|
1279
1280
|
let pendingRef = null;
|
|
1280
|
-
const listeners = new Set();
|
|
1281
|
-
function emit() {
|
|
1282
|
-
listeners.forEach(l => l());
|
|
1281
|
+
const listeners$1 = new Set();
|
|
1282
|
+
function emit$1() {
|
|
1283
|
+
listeners$1.forEach(l => l());
|
|
1283
1284
|
}
|
|
1284
|
-
function subscribe(l) {
|
|
1285
|
-
listeners.add(l);
|
|
1285
|
+
function subscribe$1(l) {
|
|
1286
|
+
listeners$1.add(l);
|
|
1286
1287
|
return () => {
|
|
1287
|
-
listeners.delete(l);
|
|
1288
|
+
listeners$1.delete(l);
|
|
1288
1289
|
};
|
|
1289
1290
|
}
|
|
1290
1291
|
/** Opens the dialog — a no-op when no layout has mounted one. */
|
|
1291
1292
|
function openHelp() {
|
|
1292
1293
|
if (!open) {
|
|
1293
1294
|
open = true;
|
|
1294
|
-
emit();
|
|
1295
|
+
emit$1();
|
|
1295
1296
|
}
|
|
1296
1297
|
}
|
|
1297
1298
|
function closeHelp() {
|
|
1298
1299
|
pendingRef = null;
|
|
1299
1300
|
if (open) {
|
|
1300
1301
|
open = false;
|
|
1301
|
-
emit();
|
|
1302
|
+
emit$1();
|
|
1302
1303
|
}
|
|
1303
1304
|
}
|
|
1304
1305
|
/** Opens the dialog on one of the person's messages, by id or "H1004". */
|
|
1305
1306
|
function openHelpOn(ref) {
|
|
1306
1307
|
pendingRef = ref;
|
|
1307
1308
|
open = true;
|
|
1308
|
-
emit();
|
|
1309
|
+
emit$1();
|
|
1309
1310
|
}
|
|
1310
1311
|
/** The message the dialog was asked to open on, if it has not been left. */
|
|
1311
1312
|
function pendingHelpRef() {
|
|
@@ -1324,22 +1325,22 @@ function registerHelpHost() {
|
|
|
1324
1325
|
hosts += 1;
|
|
1325
1326
|
if (pendingRef)
|
|
1326
1327
|
open = true;
|
|
1327
|
-
emit();
|
|
1328
|
+
emit$1();
|
|
1328
1329
|
return () => {
|
|
1329
1330
|
hosts -= 1;
|
|
1330
1331
|
if (hosts <= 0) {
|
|
1331
1332
|
hosts = 0;
|
|
1332
1333
|
open = false;
|
|
1333
1334
|
}
|
|
1334
|
-
emit();
|
|
1335
|
+
emit$1();
|
|
1335
1336
|
};
|
|
1336
1337
|
}
|
|
1337
1338
|
function useHelpOpen() {
|
|
1338
|
-
return React.useSyncExternalStore(subscribe, () => open, () => false);
|
|
1339
|
+
return React.useSyncExternalStore(subscribe$1, () => open, () => false);
|
|
1339
1340
|
}
|
|
1340
1341
|
/** True while a dialog host is mounted, i.e. the ช่วยเหลือ entry works. */
|
|
1341
1342
|
function useHelpAvailable() {
|
|
1342
|
-
return React.useSyncExternalStore(subscribe, () => hosts > 0, () => false);
|
|
1343
|
+
return React.useSyncExternalStore(subscribe$1, () => hosts > 0, () => false);
|
|
1343
1344
|
}
|
|
1344
1345
|
/**
|
|
1345
1346
|
* Every app names itself when it builds its app-switcher links
|
|
@@ -1363,17 +1364,17 @@ function declaredHelpApp() {
|
|
|
1363
1364
|
const OWNER_CHIP = "เจ้าของ";
|
|
1364
1365
|
/** How many role names the card spells out before counting the rest. */
|
|
1365
1366
|
const CARD_ROLE_LIMIT = 2;
|
|
1366
|
-
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1367
|
+
const isRecord$2 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1367
1368
|
/**
|
|
1368
1369
|
* The answer, checked. Anything that is not the shape the card needs — an
|
|
1369
1370
|
* older API, a proxy's error page, another organisation's answer — is null,
|
|
1370
1371
|
* and the card shows no role line.
|
|
1371
1372
|
*/
|
|
1372
1373
|
function parseMyRoles(payload, organizationId) {
|
|
1373
|
-
if (!isRecord(payload))
|
|
1374
|
+
if (!isRecord$2(payload))
|
|
1374
1375
|
return null;
|
|
1375
1376
|
// Envelope { success, data } or the bare data.
|
|
1376
|
-
const data = isRecord(payload.data) ? payload.data : payload;
|
|
1377
|
+
const data = isRecord$2(payload.data) ? payload.data : payload;
|
|
1377
1378
|
if (!Array.isArray(data.roles))
|
|
1378
1379
|
return null;
|
|
1379
1380
|
if (typeof data.organization_id === "string" && data.organization_id !== organizationId) {
|
|
@@ -1382,7 +1383,7 @@ function parseMyRoles(payload, organizationId) {
|
|
|
1382
1383
|
const roles = [];
|
|
1383
1384
|
const seen = new Set();
|
|
1384
1385
|
for (const r of data.roles) {
|
|
1385
|
-
if (!isRecord(r))
|
|
1386
|
+
if (!isRecord$2(r))
|
|
1386
1387
|
continue;
|
|
1387
1388
|
const name = typeof r.name_th === "string" ? r.name_th.trim() : "";
|
|
1388
1389
|
const key = typeof r.key === "string" ? r.key : "";
|
|
@@ -1603,6 +1604,242 @@ async function retryWithStoredOrganizations(config, status, send) {
|
|
|
1603
1604
|
return undefined;
|
|
1604
1605
|
}
|
|
1605
1606
|
|
|
1607
|
+
/**
|
|
1608
|
+
* Route and API-path patterns for tour steps.
|
|
1609
|
+
*
|
|
1610
|
+
* No runtime imports: the tests run this file in Node as it is.
|
|
1611
|
+
*/
|
|
1612
|
+
const trimSlash = (p) => (p.length > 1 ? p.replace(/\/+$/, "") : p);
|
|
1613
|
+
function splitPattern(pattern) {
|
|
1614
|
+
const q = pattern.indexOf("?");
|
|
1615
|
+
if (q < 0)
|
|
1616
|
+
return { path: pattern, query: new URLSearchParams() };
|
|
1617
|
+
return { path: pattern.slice(0, q), query: new URLSearchParams(pattern.slice(q + 1)) };
|
|
1618
|
+
}
|
|
1619
|
+
function paramName(segment) {
|
|
1620
|
+
const bracket = /^\[([A-Za-z0-9_]+)\]$/.exec(segment);
|
|
1621
|
+
if (bracket)
|
|
1622
|
+
return bracket[1];
|
|
1623
|
+
const colon = /^:([A-Za-z0-9_]+)$/.exec(segment);
|
|
1624
|
+
return colon ? colon[1] : null;
|
|
1625
|
+
}
|
|
1626
|
+
function safeDecode(s) {
|
|
1627
|
+
try {
|
|
1628
|
+
return decodeURIComponent(s);
|
|
1629
|
+
}
|
|
1630
|
+
catch {
|
|
1631
|
+
return s;
|
|
1632
|
+
}
|
|
1633
|
+
}
|
|
1634
|
+
/**
|
|
1635
|
+
* Matches a Next-style page pattern ("/[code]/employee/[id]", optionally
|
|
1636
|
+
* "?tab=job") against a path and query. `[code]`, when given, must be the
|
|
1637
|
+
* organisation code. Returns the params, or null.
|
|
1638
|
+
*/
|
|
1639
|
+
function matchRoute(pattern, pathname, search, code) {
|
|
1640
|
+
const { path, query } = splitPattern(pattern);
|
|
1641
|
+
const want = trimSlash(path).split("/");
|
|
1642
|
+
const have = trimSlash(pathname || "/").split("/");
|
|
1643
|
+
if (want.length !== have.length)
|
|
1644
|
+
return null;
|
|
1645
|
+
const params = {};
|
|
1646
|
+
for (let i = 0; i < want.length; i++) {
|
|
1647
|
+
const name = paramName(want[i]);
|
|
1648
|
+
if (name) {
|
|
1649
|
+
if (!have[i])
|
|
1650
|
+
return null;
|
|
1651
|
+
params[name] = safeDecode(have[i]);
|
|
1652
|
+
continue;
|
|
1653
|
+
}
|
|
1654
|
+
if (want[i] !== have[i])
|
|
1655
|
+
return null;
|
|
1656
|
+
}
|
|
1657
|
+
if (code !== undefined && params.code !== undefined && params.code !== code) {
|
|
1658
|
+
return null;
|
|
1659
|
+
}
|
|
1660
|
+
if ([...query.keys()].length > 0) {
|
|
1661
|
+
const s = search instanceof URLSearchParams ? search : new URLSearchParams(search ?? "");
|
|
1662
|
+
for (const [k, v] of query.entries()) {
|
|
1663
|
+
if (s.get(k) !== v)
|
|
1664
|
+
return null;
|
|
1665
|
+
}
|
|
1666
|
+
}
|
|
1667
|
+
return params;
|
|
1668
|
+
}
|
|
1669
|
+
/** Fills a page pattern's params ("/[code]/x/[id]" → "/O1/x/42"); null if one is missing. */
|
|
1670
|
+
function fillRoute(pattern, params) {
|
|
1671
|
+
const { path, query } = splitPattern(pattern);
|
|
1672
|
+
const out = [];
|
|
1673
|
+
for (const seg of path.split("/")) {
|
|
1674
|
+
const name = paramName(seg);
|
|
1675
|
+
if (!name) {
|
|
1676
|
+
out.push(seg);
|
|
1677
|
+
continue;
|
|
1678
|
+
}
|
|
1679
|
+
const v = params[name];
|
|
1680
|
+
if (!v)
|
|
1681
|
+
return null;
|
|
1682
|
+
out.push(encodeURIComponent(v));
|
|
1683
|
+
}
|
|
1684
|
+
const qs = query.toString();
|
|
1685
|
+
return out.join("/") + (qs ? `?${qs}` : "");
|
|
1686
|
+
}
|
|
1687
|
+
/**
|
|
1688
|
+
* The path of a request, without origin, base URL query or hash:
|
|
1689
|
+
* ("/shifts/4", "https://hr-api/api/v1") → "/api/v1/shifts/4".
|
|
1690
|
+
*/
|
|
1691
|
+
function requestPath(url, baseURL) {
|
|
1692
|
+
const raw = url ?? "";
|
|
1693
|
+
let full = raw;
|
|
1694
|
+
if (!/^[a-z][a-z0-9+.-]*:\/\//i.test(raw) && baseURL) {
|
|
1695
|
+
full = baseURL.replace(/\/+$/, "") + "/" + raw.replace(/^\/+/, "");
|
|
1696
|
+
}
|
|
1697
|
+
try {
|
|
1698
|
+
return trimSlash(new URL(full, "http://local.invalid").pathname);
|
|
1699
|
+
}
|
|
1700
|
+
catch {
|
|
1701
|
+
return trimSlash(full.split(/[?#]/)[0] || "/");
|
|
1702
|
+
}
|
|
1703
|
+
}
|
|
1704
|
+
/**
|
|
1705
|
+
* Whether a request path matches a step's API path: the pattern's segments
|
|
1706
|
+
* must be the END of the path ("/shifts/[id]" matches "/api/v1/shifts/42");
|
|
1707
|
+
* `[x]`/`:x` is one segment, `*` any number (including none). A RegExp is
|
|
1708
|
+
* tested against the whole path.
|
|
1709
|
+
*/
|
|
1710
|
+
function matchApiPath(pattern, path) {
|
|
1711
|
+
if (pattern instanceof RegExp)
|
|
1712
|
+
return pattern.test(path);
|
|
1713
|
+
const want = trimSlash(pattern).split("/").filter(Boolean);
|
|
1714
|
+
const have = trimSlash(path).split("/").filter(Boolean);
|
|
1715
|
+
// Try every suffix of the path as the place the pattern starts.
|
|
1716
|
+
for (let start = 0; start <= have.length; start++) {
|
|
1717
|
+
if (matchSegments(want, 0, have, start))
|
|
1718
|
+
return true;
|
|
1719
|
+
}
|
|
1720
|
+
return false;
|
|
1721
|
+
}
|
|
1722
|
+
function matchSegments(want, wi, have, hi) {
|
|
1723
|
+
if (wi === want.length)
|
|
1724
|
+
return hi === have.length;
|
|
1725
|
+
const seg = want[wi];
|
|
1726
|
+
if (seg === "*") {
|
|
1727
|
+
for (let k = hi; k <= have.length; k++) {
|
|
1728
|
+
if (matchSegments(want, wi + 1, have, k))
|
|
1729
|
+
return true;
|
|
1730
|
+
}
|
|
1731
|
+
return false;
|
|
1732
|
+
}
|
|
1733
|
+
if (hi >= have.length)
|
|
1734
|
+
return false;
|
|
1735
|
+
if (paramName(seg) || seg === have[hi])
|
|
1736
|
+
return matchSegments(want, wi + 1, have, hi + 1);
|
|
1737
|
+
return false;
|
|
1738
|
+
}
|
|
1739
|
+
/** "2xx" or an exact status. */
|
|
1740
|
+
function matchStatus(want, status) {
|
|
1741
|
+
if (want === undefined || want === "2xx")
|
|
1742
|
+
return status >= 200 && status < 300;
|
|
1743
|
+
return status === want;
|
|
1744
|
+
}
|
|
1745
|
+
|
|
1746
|
+
/**
|
|
1747
|
+
* The tour's event bus and anchor helper — the parts an app calls.
|
|
1748
|
+
*
|
|
1749
|
+
* - `guideAnchor(key, id?)` → `{ "data-guide": key, "data-guide-id"?: id }`
|
|
1750
|
+
* - `emitGuideEvent(name, detail?)` → window CustomEvent `erp:guide:<name>`
|
|
1751
|
+
* - `reportApiEvent(responseOrError)` → window CustomEvent `erp:api`, which
|
|
1752
|
+
* the shell's own axios instance already sends for every answer. An app
|
|
1753
|
+
* with a second axios instance calls it from that instance's interceptor.
|
|
1754
|
+
*
|
|
1755
|
+
* Only method, path, status, the saved record's id and (on a refusal) the
|
|
1756
|
+
* server's message go on the bus — never a request or response body.
|
|
1757
|
+
*
|
|
1758
|
+
* No runtime imports beyond sibling pure modules.
|
|
1759
|
+
*/
|
|
1760
|
+
const GUIDE_ATTRIBUTE = "data-guide";
|
|
1761
|
+
const GUIDE_ID_ATTRIBUTE = "data-guide-id";
|
|
1762
|
+
const GUIDE_LAYER_ATTRIBUTE = "data-erp-guide-layer";
|
|
1763
|
+
const API_EVENT = "erp:api";
|
|
1764
|
+
const GUIDE_EVENT_PREFIX = "erp:guide:";
|
|
1765
|
+
/** Spread onto the element a tour step points at. */
|
|
1766
|
+
function guideAnchor(key, id) {
|
|
1767
|
+
return id === undefined || id === null || id === ""
|
|
1768
|
+
? { [GUIDE_ATTRIBUTE]: key }
|
|
1769
|
+
: { [GUIDE_ATTRIBUTE]: key, [GUIDE_ID_ATTRIBUTE]: String(id) };
|
|
1770
|
+
}
|
|
1771
|
+
/** Announces an app event a step can wait for (`advanceOn: { event: name }`). */
|
|
1772
|
+
function emitGuideEvent(name, detail) {
|
|
1773
|
+
if (typeof window === "undefined" || !name)
|
|
1774
|
+
return;
|
|
1775
|
+
const clean = {};
|
|
1776
|
+
for (const [k, v] of Object.entries(detail ?? {})) {
|
|
1777
|
+
if (typeof v === "string" || typeof v === "number")
|
|
1778
|
+
clean[k] = String(v);
|
|
1779
|
+
}
|
|
1780
|
+
try {
|
|
1781
|
+
window.dispatchEvent(new CustomEvent(GUIDE_EVENT_PREFIX + name, { detail: clean }));
|
|
1782
|
+
}
|
|
1783
|
+
catch {
|
|
1784
|
+
/* never let the tour break the app */
|
|
1785
|
+
}
|
|
1786
|
+
}
|
|
1787
|
+
const isRecord$1 = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1788
|
+
function idOf(data) {
|
|
1789
|
+
const pick = (v) => typeof v === "string" && v ? v : typeof v === "number" ? String(v) : undefined;
|
|
1790
|
+
if (!isRecord$1(data))
|
|
1791
|
+
return undefined;
|
|
1792
|
+
return pick(data.id) ?? (isRecord$1(data.data) ? pick(data.data.id) : undefined);
|
|
1793
|
+
}
|
|
1794
|
+
function messageOf(data) {
|
|
1795
|
+
if (!isRecord$1(data))
|
|
1796
|
+
return undefined;
|
|
1797
|
+
const m = data.message ?? (isRecord$1(data.error) ? data.error.message : data.error);
|
|
1798
|
+
return typeof m === "string" && m.trim() ? m.trim().slice(0, 200) : undefined;
|
|
1799
|
+
}
|
|
1800
|
+
/** The bus detail for an axios answer or error; null when it is neither. */
|
|
1801
|
+
function apiEventDetail(responseOrError) {
|
|
1802
|
+
if (!isRecord$1(responseOrError))
|
|
1803
|
+
return null;
|
|
1804
|
+
const err = responseOrError;
|
|
1805
|
+
// An AxiosError has .response (or none: network failure) and .config.
|
|
1806
|
+
const isError = "isAxiosError" in responseOrError || ("response" in responseOrError && !("data" in responseOrError));
|
|
1807
|
+
const res = isError ? err.response : responseOrError;
|
|
1808
|
+
const config = (res?.config ?? err.config);
|
|
1809
|
+
if (!config)
|
|
1810
|
+
return null;
|
|
1811
|
+
const status = typeof res?.status === "number" ? res.status : 0;
|
|
1812
|
+
const detail = {
|
|
1813
|
+
method: (config.method ?? "get").toUpperCase(),
|
|
1814
|
+
path: requestPath(config.url, config.baseURL),
|
|
1815
|
+
status,
|
|
1816
|
+
};
|
|
1817
|
+
if (status >= 200 && status < 300) {
|
|
1818
|
+
const id = idOf(res?.data);
|
|
1819
|
+
if (id)
|
|
1820
|
+
detail.id = id;
|
|
1821
|
+
}
|
|
1822
|
+
else {
|
|
1823
|
+
const msg = messageOf(res?.data);
|
|
1824
|
+
if (msg)
|
|
1825
|
+
detail.message = msg;
|
|
1826
|
+
}
|
|
1827
|
+
return detail;
|
|
1828
|
+
}
|
|
1829
|
+
/** Puts an axios answer (or error) on the bus. Safe to call with anything. */
|
|
1830
|
+
function reportApiEvent(responseOrError) {
|
|
1831
|
+
if (typeof window === "undefined")
|
|
1832
|
+
return;
|
|
1833
|
+
try {
|
|
1834
|
+
const detail = apiEventDetail(responseOrError);
|
|
1835
|
+
if (detail)
|
|
1836
|
+
window.dispatchEvent(new CustomEvent(API_EVENT, { detail }));
|
|
1837
|
+
}
|
|
1838
|
+
catch {
|
|
1839
|
+
/* never let the tour break the app */
|
|
1840
|
+
}
|
|
1841
|
+
}
|
|
1842
|
+
|
|
1606
1843
|
const isBrowser = () => typeof window !== "undefined";
|
|
1607
1844
|
const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
|
|
1608
1845
|
const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
|
|
@@ -1676,6 +1913,9 @@ instance.interceptors.response.use(response => {
|
|
|
1676
1913
|
if (typeof window !== "undefined") {
|
|
1677
1914
|
window.dispatchEvent(new CustomEvent("erp:employee-record-ok"));
|
|
1678
1915
|
}
|
|
1916
|
+
// The guided tour advances on saves that succeeded (method, path, status
|
|
1917
|
+
// and the record's id only; never the body).
|
|
1918
|
+
reportApiEvent(response);
|
|
1679
1919
|
return response;
|
|
1680
1920
|
}, async (error) => {
|
|
1681
1921
|
const originalRequest = error.config;
|
|
@@ -1692,6 +1932,7 @@ instance.interceptors.response.use(response => {
|
|
|
1692
1932
|
return instance(originalRequest);
|
|
1693
1933
|
}
|
|
1694
1934
|
await redirectToLogout();
|
|
1935
|
+
reportApiEvent(error);
|
|
1695
1936
|
return Promise.reject(toApiError(error));
|
|
1696
1937
|
}
|
|
1697
1938
|
// The server refuses every organisation-scoped call when the account has
|
|
@@ -1706,6 +1947,8 @@ instance.interceptors.response.use(response => {
|
|
|
1706
1947
|
const listed = await retryWithStoredOrganizations(originalRequest, error.response?.status, config => instance(config));
|
|
1707
1948
|
if (listed !== undefined)
|
|
1708
1949
|
return listed;
|
|
1950
|
+
// A refused save keeps the tour's step and shows the server's message.
|
|
1951
|
+
reportApiEvent(error);
|
|
1709
1952
|
return Promise.reject(toApiError(error));
|
|
1710
1953
|
});
|
|
1711
1954
|
|
|
@@ -1732,7 +1975,7 @@ function platformApiBase(app) {
|
|
|
1732
1975
|
* This package's version, attached to help messages so the back office can
|
|
1733
1976
|
* tell which shell a report came from. Keep in step with package.json.
|
|
1734
1977
|
*/
|
|
1735
|
-
const SHELL_VERSION = "0.1.
|
|
1978
|
+
const SHELL_VERSION = "0.1.63";
|
|
1736
1979
|
|
|
1737
1980
|
const HELP_APP_LABEL = {
|
|
1738
1981
|
platform: "แพลตฟอร์ม",
|
|
@@ -1925,13 +2168,13 @@ function describeAttached(app, ctx, email) {
|
|
|
1925
2168
|
const myRolesQueryKey = (organizationId) => ["erp-shell", "my-roles", organizationId];
|
|
1926
2169
|
// Only for a host that renders NavUser outside a QueryClientProvider: the
|
|
1927
2170
|
// card still works there instead of throwing.
|
|
1928
|
-
let fallbackClient = null;
|
|
1929
|
-
function useShellQueryClient() {
|
|
2171
|
+
let fallbackClient$1 = null;
|
|
2172
|
+
function useShellQueryClient$1() {
|
|
1930
2173
|
const provided = React.useContext(reactQuery.QueryClientContext);
|
|
1931
2174
|
if (provided)
|
|
1932
2175
|
return provided;
|
|
1933
|
-
fallbackClient ?? (fallbackClient = new reactQuery.QueryClient());
|
|
1934
|
-
return fallbackClient;
|
|
2176
|
+
fallbackClient$1 ?? (fallbackClient$1 = new reactQuery.QueryClient());
|
|
2177
|
+
return fallbackClient$1;
|
|
1935
2178
|
}
|
|
1936
2179
|
function subscribeStorage(onChange) {
|
|
1937
2180
|
window.addEventListener("storage", onChange);
|
|
@@ -1957,7 +2200,7 @@ function useMyRoles(organizationId, enabled = true) {
|
|
|
1957
2200
|
String(organizationId).trim() !== ""
|
|
1958
2201
|
? String(organizationId).trim()
|
|
1959
2202
|
: null;
|
|
1960
|
-
const client = useShellQueryClient();
|
|
2203
|
+
const client = useShellQueryClient$1();
|
|
1961
2204
|
return reactQuery.useQuery({
|
|
1962
2205
|
queryKey: myRolesQueryKey(orgId),
|
|
1963
2206
|
queryFn: async ({ signal }) => {
|
|
@@ -2075,8 +2318,15 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
|
|
|
2075
2318
|
};
|
|
2076
2319
|
const handleProfile = () => {
|
|
2077
2320
|
setIsOpen(false);
|
|
2078
|
-
if (profileHref)
|
|
2079
|
-
|
|
2321
|
+
if (!profileHref)
|
|
2322
|
+
return;
|
|
2323
|
+
// Account settings live on Platform, so from any other app this is a
|
|
2324
|
+
// full page load, the same as the apps menu.
|
|
2325
|
+
if (isExternalHref(profileHref)) {
|
|
2326
|
+
window.location.assign(profileHref);
|
|
2327
|
+
return;
|
|
2328
|
+
}
|
|
2329
|
+
router.push(profileHref);
|
|
2080
2330
|
};
|
|
2081
2331
|
const handleSystemLink = (href) => {
|
|
2082
2332
|
setIsOpen(false);
|
|
@@ -2143,7 +2393,7 @@ function NavUser({ organization, avatarSrc, profileHref, selectOrganizationHref
|
|
|
2143
2393
|
{ code: "en", label: "English" },
|
|
2144
2394
|
{ code: "zh", label: "中文" },
|
|
2145
2395
|
{ code: "ja", label: "日本語" },
|
|
2146
|
-
].map(({ code, label }) => (jsxRuntime.jsxs("button", { onClick: () => handleLanguageChange(code), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx("span", { className: "flex-1 text-left", children: label }), organization?.language === code && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, code))) }))] })), profileHref && (jsxRuntime.jsxs("button", { onClick: handleProfile, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.
|
|
2396
|
+
].map(({ code, label }) => (jsxRuntime.jsxs("button", { onClick: () => handleLanguageChange(code), className: "flex w-full items-center gap-3 px-3 py-2 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx("span", { className: "flex-1 text-left", children: label }), organization?.language === code && (jsxRuntime.jsx("div", { className: "h-2 w-2 rounded-full bg-primary" }))] }, code))) }))] })), profileHref && (jsxRuntime.jsxs("button", { onClick: handleProfile, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.UserCog, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E15\u0E31\u0E49\u0E07\u0E04\u0E48\u0E32\u0E1A\u0E31\u0E0D\u0E0A\u0E35" })] })), showSelectOrganization && (jsxRuntime.jsxs("button", { onClick: handleSelectOrganization, className: "flex w-full items-center gap-3 px-3 py-2.5 text-sm text-sidebar-foreground hover:bg-sidebar-accent rounded-md transition-colors", children: [jsxRuntime.jsx(lucideReact.Building2, { className: "h-4 w-4" }), jsxRuntime.jsx("span", { className: "flex-1 text-left", children: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E18\u0E38\u0E23\u0E01\u0E34\u0E08" })] })), visibleSystemLinks.length > 0 && (jsxRuntime.jsxs("div", { className: "relative", children: [jsxRuntime.jsxs("button", { onClick: () => {
|
|
2147
2397
|
setShowAppsMenu(!showAppsMenu);
|
|
2148
2398
|
setShowThemeMenu(false);
|
|
2149
2399
|
setShowLanguageMenu(false);
|
|
@@ -2223,6 +2473,32 @@ function hasPlanFeature(features, feature) {
|
|
|
2223
2473
|
return false;
|
|
2224
2474
|
}
|
|
2225
2475
|
|
|
2476
|
+
/**
|
|
2477
|
+
* Where Platform lives, and the pages every app sends people to on it.
|
|
2478
|
+
*
|
|
2479
|
+
* Personal settings — profile, password, signature, LINE — exist once, on
|
|
2480
|
+
* Platform. Every other app links there instead of keeping its own copy.
|
|
2481
|
+
*
|
|
2482
|
+
* Plain .ts (no JSX) so node --test can import it; app-registry.tsx reads the
|
|
2483
|
+
* base from here too, so the app switcher and the account link never disagree.
|
|
2484
|
+
*/
|
|
2485
|
+
const DEFAULT_PLATFORM_URL = "https://platform.antniti.com";
|
|
2486
|
+
/** Read per call: Next inlines NEXT_PUBLIC_* at the consuming app's build. */
|
|
2487
|
+
function platformBaseUrl() {
|
|
2488
|
+
return (process.env.NEXT_PUBLIC_PLATFORM_URL || DEFAULT_PLATFORM_URL).replace(/\/+$/, "");
|
|
2489
|
+
}
|
|
2490
|
+
/**
|
|
2491
|
+
* ตั้งค่าบัญชี for an organisation: `/{code}/account/settings` on Platform.
|
|
2492
|
+
* Undefined without an organisation code — there is no page to open then.
|
|
2493
|
+
*/
|
|
2494
|
+
function accountSettingsHref(orgCode, { activeApp } = {}) {
|
|
2495
|
+
const code = orgCode?.trim();
|
|
2496
|
+
if (!code)
|
|
2497
|
+
return undefined;
|
|
2498
|
+
const path = `/${encodeURIComponent(code)}/account/settings`;
|
|
2499
|
+
return activeApp === "platform" ? path : `${platformBaseUrl()}${path}`;
|
|
2500
|
+
}
|
|
2501
|
+
|
|
2226
2502
|
function normaliseBase(value, fallback) {
|
|
2227
2503
|
return (value ?? fallback).replace(/\/$/, "");
|
|
2228
2504
|
}
|
|
@@ -2239,7 +2515,7 @@ const APPS = [
|
|
|
2239
2515
|
label: "แพลตฟอร์ม",
|
|
2240
2516
|
icon: jsxRuntime.jsx(lucideReact.Building2, { className: "h-4 w-4 shrink-0" }),
|
|
2241
2517
|
features: null,
|
|
2242
|
-
baseUrl:
|
|
2518
|
+
baseUrl: platformBaseUrl,
|
|
2243
2519
|
},
|
|
2244
2520
|
{
|
|
2245
2521
|
id: "accounx",
|
|
@@ -2371,11 +2647,11 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2371
2647
|
// A one-line row: a link, lit on its own page only. No open
|
|
2372
2648
|
// state, no title tooltip.
|
|
2373
2649
|
return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(SidebarMenuItem, { children: jsxRuntime.jsx(SidebarMenuButton, { asChild: true, isActive: isItemActive, className: workspaceButton, children: jsxRuntime.jsxs(Link, { href: itemUrl, prefetch: false, className: "flex items-center w-full min-w-0 flex-1 " +
|
|
2374
|
-
(workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", children: item.titleTh }), badge] }) }) })] }, item.id));
|
|
2650
|
+
(workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, ...guideAnchor(`nav.${item.id}`), children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", children: item.titleTh }), badge] }) }) })] }, item.id));
|
|
2375
2651
|
}
|
|
2376
2652
|
// A group: its header opens and shuts it (the chevron says which).
|
|
2377
2653
|
// An open group is not the current page — only a row in it is.
|
|
2378
|
-
return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuButton, { isActive: isItemActive, className: workspaceButton, children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate text-left", children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] }) }), item.subModules && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
|
|
2654
|
+
return (jsxRuntime.jsxs(React__namespace.Fragment, { children: [groupHeading, jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, open: openIds.has(item.id), onOpenChange: isOpen => setItemOpen(item.id, isOpen), className: "group/collapsible", children: jsxRuntime.jsxs(SidebarMenuItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuButton, { isActive: isItemActive, className: workspaceButton, ...guideAnchor(`nav.${item.id}`), children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate text-left", children: item.titleTh }), jsxRuntime.jsx(lucideReact.ChevronRight, { className: "ml-auto transition-transform duration-200 group-data-[state=open]/collapsible:rotate-90" })] }) }), item.subModules && (jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: workspace
|
|
2379
2655
|
? "mx-0 mt-1 mb-3 ml-[21px] gap-0.5 border-l border-sidebar-border pl-2.5 [&_a]:h-auto [&_a]:gap-[11px] [&_a]:px-3 [&_a]:py-2 [&_a]:text-xs [&_svg]:!size-[17px]"
|
|
2380
2656
|
: undefined, children: item.subModules.map((subItem, subIndex) => {
|
|
2381
2657
|
const subItemUrl = buildUrlWithCode(code, subItem.url);
|
|
@@ -2388,7 +2664,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2388
2664
|
subItem.subPages &&
|
|
2389
2665
|
subItem.subPages.length > 0) {
|
|
2390
2666
|
return (jsxRuntime.jsx(erpTheme.Collapsible, { asChild: true, defaultOpen: isSubItemActive || openSub === subIndex, className: "group/sub-collapsible", children: jsxRuntime.jsxs(SidebarMenuSubItem, { children: [jsxRuntime.jsx(erpTheme.CollapsibleTrigger, { asChild: true, children: jsxRuntime.jsxs(SidebarMenuSubButton, { className: "w-full", isActive: isSubItemActive &&
|
|
2391
|
-
activeSubPage === null, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
|
|
2667
|
+
activeSubPage === null, ...guideAnchor(`nav.${subItem.id}`), children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), jsxRuntime.jsx(lucideReact.ChevronDown, { className: "ml-auto size-4 transition-transform duration-200 group-data-[state=open]/sub-collapsible:rotate-180" })] }) }), jsxRuntime.jsx(erpTheme.CollapsibleContent, { children: jsxRuntime.jsx(SidebarMenuSub, { className: "ml-4", children: subItem.subPages.map((subPage, subPageIndex) => {
|
|
2392
2668
|
const subPageUrl = buildUrlWithCode(code, subPage.url);
|
|
2393
2669
|
const isSubPageActive = isSubItemActive &&
|
|
2394
2670
|
activeSubPage === subPageIndex;
|
|
@@ -2396,7 +2672,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2396
2672
|
return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "font-thai w-full text-sm" +
|
|
2397
2673
|
(isSubPageActive
|
|
2398
2674
|
? " font-medium"
|
|
2399
|
-
: ""), isActive: isSubPageActive, children: jsxRuntime.jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubPageActive
|
|
2675
|
+
: ""), isActive: isSubPageActive, children: jsxRuntime.jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", ...guideAnchor(`nav.${subPage.id}`), onClick: handleNavLinkClick, "aria-current": isSubPageActive
|
|
2400
2676
|
? "page"
|
|
2401
2677
|
: undefined, children: [SubPageIcon && (jsxRuntime.jsx(SubPageIcon, { className: "sidebar-subpage-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subPage.titleTh })] }) }) }, subPage.id));
|
|
2402
2678
|
}) }) })] }) }, subItem.id));
|
|
@@ -2404,7 +2680,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2404
2680
|
return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
|
|
2405
2681
|
(workspace && isSubItemActive
|
|
2406
2682
|
? " font-semibold"
|
|
2407
|
-
: ""), isActive: isSubItemActive, children: jsxRuntime.jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", onClick: handleNavLinkClick, "aria-current": isSubItemActive ? "page" : undefined, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), approvalCount > 0 && (jsxRuntime.jsx(erpTheme.Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
|
|
2683
|
+
: ""), isActive: isSubItemActive, children: jsxRuntime.jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", ...guideAnchor(`nav.${subItem.id}`), onClick: handleNavLinkClick, "aria-current": isSubItemActive ? "page" : undefined, children: [SubIcon && (jsxRuntime.jsx(SubIcon, { className: "sidebar-submodule-icon flex-shrink-0" })), jsxRuntime.jsx("span", { className: "flex-1 truncate", children: subItem.titleTh }), approvalCount > 0 && (jsxRuntime.jsx(erpTheme.Badge, { variant: "destructive", className: "ml-auto h-5 min-w-5 px-1.5 text-xs font-medium tabular-nums", children: approvalCount > 99
|
|
2408
2684
|
? "99+"
|
|
2409
2685
|
: approvalCount }))] }) }) }, subItem.id));
|
|
2410
2686
|
}) }) }))] }) })] }, item.id));
|
|
@@ -2526,13 +2802,63 @@ const WORKSPACE_LIGHT_HEADER_VARS = {
|
|
|
2526
2802
|
"--header-title-py": "0px",
|
|
2527
2803
|
};
|
|
2528
2804
|
|
|
2805
|
+
let entry = null;
|
|
2806
|
+
let openPanelImpl = null;
|
|
2807
|
+
const listeners = new Set();
|
|
2808
|
+
function emit() {
|
|
2809
|
+
listeners.forEach(l => l());
|
|
2810
|
+
}
|
|
2811
|
+
function subscribe(l) {
|
|
2812
|
+
listeners.add(l);
|
|
2813
|
+
return () => {
|
|
2814
|
+
listeners.delete(l);
|
|
2815
|
+
};
|
|
2816
|
+
}
|
|
2817
|
+
/** Called by the host: the entry to show (null: none), and how to open the panel. */
|
|
2818
|
+
function publishGuideEntry(next, openPanel) {
|
|
2819
|
+
const same = (next === null && entry === null) ||
|
|
2820
|
+
(next !== null && entry !== null && next.done === entry.done && next.total === entry.total);
|
|
2821
|
+
openPanelImpl = openPanel;
|
|
2822
|
+
if (!same) {
|
|
2823
|
+
entry = next;
|
|
2824
|
+
emit();
|
|
2825
|
+
}
|
|
2826
|
+
}
|
|
2827
|
+
/** Opens the tour's quest panel, when this user has a tour here. */
|
|
2828
|
+
function openGuidePanel() {
|
|
2829
|
+
openPanelImpl?.();
|
|
2830
|
+
}
|
|
2831
|
+
/** The "?" menu's entry, or null when there is no tour for this user. */
|
|
2832
|
+
function useGuideEntry() {
|
|
2833
|
+
return React.useSyncExternalStore(subscribe, () => entry, () => null);
|
|
2834
|
+
}
|
|
2835
|
+
|
|
2836
|
+
const BUTTON_CLASS = "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted,#ffffffc7)] transition-colors hover:bg-[var(--header-hover,#ffffff1f)] hover:text-[var(--header-fg,#ffffff)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted,#ffffffc7)]";
|
|
2529
2837
|
/**
|
|
2530
2838
|
* The ช่วยเหลือ button in the top bar. Drawn in the header's ink (the
|
|
2531
2839
|
* --header-* variables of the workspace chrome, white on the brand chrome).
|
|
2840
|
+
*
|
|
2841
|
+
* When this user has a guided tour in this app, it opens a small menu:
|
|
2842
|
+
* "ไกด์เริ่มต้น n/N" (the quest panel) and "ติดต่อทีมช่วยเหลือ" (the help
|
|
2843
|
+
* dialog). Otherwise one press opens the help dialog, as before.
|
|
2532
2844
|
*/
|
|
2533
2845
|
function HelpButton({ className }) {
|
|
2534
|
-
|
|
2535
|
-
|
|
2846
|
+
const guide = useGuideEntry();
|
|
2847
|
+
const toGuide = React.useRef(false);
|
|
2848
|
+
const cls = BUTTON_CLASS + (className ? ` ${className}` : "");
|
|
2849
|
+
if (!guide) {
|
|
2850
|
+
return (jsxRuntime.jsx("button", { type: "button", onClick: openHelp, "aria-label": "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", title: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", className: cls, "data-guide": "shell.help", children: jsxRuntime.jsx(lucideReact.CircleHelp, { className: "size-5", strokeWidth: 1.65 }) }));
|
|
2851
|
+
}
|
|
2852
|
+
return (jsxRuntime.jsxs(erpTheme.DropdownMenu, { children: [jsxRuntime.jsx(erpTheme.DropdownMenuTrigger, { asChild: true, children: jsxRuntime.jsx("button", { type: "button", "aria-label": "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", title: "\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D", className: cls, "data-guide": "shell.help", children: jsxRuntime.jsx(lucideReact.CircleHelp, { className: "size-5", strokeWidth: 1.65 }) }) }), jsxRuntime.jsxs(erpTheme.DropdownMenuContent, { align: "end", sideOffset: 8, className: "w-64", onCloseAutoFocus: e => {
|
|
2853
|
+
// The quest panel takes focus; the button must not take it back.
|
|
2854
|
+
if (toGuide.current) {
|
|
2855
|
+
e.preventDefault();
|
|
2856
|
+
toGuide.current = false;
|
|
2857
|
+
}
|
|
2858
|
+
}, children: [jsxRuntime.jsxs(erpTheme.DropdownMenuItem, { onSelect: () => {
|
|
2859
|
+
toGuide.current = true;
|
|
2860
|
+
setTimeout(openGuidePanel, 0);
|
|
2861
|
+
}, className: "gap-2.5", children: [jsxRuntime.jsx(lucideReact.Flag, { className: "size-4" }), jsxRuntime.jsx("span", { className: "flex-1", children: "\u0E44\u0E01\u0E14\u0E4C\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsxs("span", { className: "text-xs font-semibold text-primary tabular-nums", children: [guide.done, "/", guide.total] })] }), jsxRuntime.jsxs(erpTheme.DropdownMenuItem, { onSelect: () => setTimeout(openHelp, 0), className: "gap-2.5", children: [jsxRuntime.jsx(lucideReact.MessageCircle, { className: "size-4" }), jsxRuntime.jsx("span", { className: "flex-1", children: "\u0E15\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E17\u0E35\u0E21\u0E0A\u0E48\u0E27\u0E22\u0E40\u0E2B\u0E25\u0E37\u0E2D" })] })] })] }));
|
|
2536
2862
|
}
|
|
2537
2863
|
|
|
2538
2864
|
const hasPaginationShape = (payload) => {
|
|
@@ -3477,88 +3803,2744 @@ function resolveStaleOrganization({ code, organizations, organizationsStatus, or
|
|
|
3477
3803
|
return { action: "forget" };
|
|
3478
3804
|
}
|
|
3479
3805
|
|
|
3480
|
-
|
|
3481
|
-
|
|
3482
|
-
|
|
3483
|
-
|
|
3484
|
-
|
|
3485
|
-
|
|
3486
|
-
|
|
3487
|
-
|
|
3488
|
-
|
|
3489
|
-
|
|
3490
|
-
|
|
3491
|
-
|
|
3492
|
-
|
|
3493
|
-
|
|
3494
|
-
|
|
3495
|
-
|
|
3496
|
-
|
|
3497
|
-
|
|
3498
|
-
|
|
3499
|
-
|
|
3500
|
-
|
|
3501
|
-
|
|
3502
|
-
|
|
3503
|
-
|
|
3504
|
-
|
|
3505
|
-
|
|
3506
|
-
|
|
3507
|
-
|
|
3508
|
-
|
|
3509
|
-
|
|
3510
|
-
|
|
3511
|
-
|
|
3512
|
-
|
|
3513
|
-
|
|
3514
|
-
|
|
3515
|
-
|
|
3516
|
-
|
|
3517
|
-
|
|
3518
|
-
|
|
3519
|
-
|
|
3520
|
-
|
|
3521
|
-
|
|
3522
|
-
|
|
3523
|
-
|
|
3524
|
-
}
|
|
3525
|
-
|
|
3526
|
-
|
|
3527
|
-
|
|
3528
|
-
|
|
3529
|
-
|
|
3530
|
-
|
|
3531
|
-
|
|
3532
|
-
|
|
3533
|
-
|
|
3806
|
+
/* ------------------------------------------------------------------ */
|
|
3807
|
+
/* Copy */
|
|
3808
|
+
/* ------------------------------------------------------------------ */
|
|
3809
|
+
const COPY = {
|
|
3810
|
+
fieldFix: "แก้ในช่องที่ไฮไลต์ แล้วกด ถัดไป",
|
|
3811
|
+
saveFailed: "บันทึกไม่สำเร็จ",
|
|
3812
|
+
blank: "ยังไม่ได้พิมพ์",
|
|
3813
|
+
exitToast: "ออกจากไกด์แล้ว",
|
|
3814
|
+
replayDone: "ทบทวนภารกิจเสร็จแล้ว",
|
|
3815
|
+
replayEnded: "จบการทบทวนแล้ว",
|
|
3816
|
+
hiddenToast: "ซ่อนแล้ว",
|
|
3817
|
+
aborted: "ไกด์ไปต่อไม่ได้ ลองอีกครั้งภายหลัง",
|
|
3818
|
+
bridgeTitle: "กดปุ่มเมนู",
|
|
3819
|
+
bridgeWhy: (app) => `เมนูทั้งหมดของ ${app} อยู่ในนี้`,
|
|
3820
|
+
};
|
|
3821
|
+
/* ------------------------------------------------------------------ */
|
|
3822
|
+
/* Helpers */
|
|
3823
|
+
/* ------------------------------------------------------------------ */
|
|
3824
|
+
const BRIDGE_TARGET = "shell.menu-toggle";
|
|
3825
|
+
function initialGuideState(audience, progress) {
|
|
3826
|
+
return {
|
|
3827
|
+
audience,
|
|
3828
|
+
progress,
|
|
3829
|
+
screen: { name: "idle" },
|
|
3830
|
+
panelOpen: false,
|
|
3831
|
+
justDone: null,
|
|
3832
|
+
chipPulse: 0,
|
|
3833
|
+
nudge: 0,
|
|
3834
|
+
gateDismissed: false,
|
|
3835
|
+
};
|
|
3836
|
+
}
|
|
3837
|
+
function findMission(suite, key) {
|
|
3838
|
+
return key ? suite.missions.find(m => m.key === key) : undefined;
|
|
3839
|
+
}
|
|
3840
|
+
function guideContext(env, data, params = {}) {
|
|
3841
|
+
return {
|
|
3842
|
+
code: env.code,
|
|
3843
|
+
pathname: env.pathname,
|
|
3844
|
+
search: env.search,
|
|
3845
|
+
params: { code: env.code, ...params },
|
|
3846
|
+
data,
|
|
3847
|
+
facts: env.facts,
|
|
3848
|
+
can: env.can,
|
|
3849
|
+
visible: (anchor) => env.isVisible(anchor),
|
|
3850
|
+
};
|
|
3851
|
+
}
|
|
3852
|
+
function resolveText(t, ctx) {
|
|
3853
|
+
if (t === undefined)
|
|
3854
|
+
return "";
|
|
3855
|
+
try {
|
|
3856
|
+
return typeof t === "function" ? t(ctx) : t;
|
|
3857
|
+
}
|
|
3858
|
+
catch {
|
|
3859
|
+
return "";
|
|
3860
|
+
}
|
|
3861
|
+
}
|
|
3862
|
+
/** Runs an app predicate; a throw counts as `fallback`. */
|
|
3863
|
+
function safe(fn, fallback) {
|
|
3864
|
+
try {
|
|
3865
|
+
return fn();
|
|
3866
|
+
}
|
|
3867
|
+
catch {
|
|
3868
|
+
return fallback;
|
|
3869
|
+
}
|
|
3870
|
+
}
|
|
3871
|
+
function triggersOf(step) {
|
|
3872
|
+
if (!step.advanceOn)
|
|
3873
|
+
return [];
|
|
3874
|
+
return Array.isArray(step.advanceOn) ? step.advanceOn : [step.advanceOn];
|
|
3875
|
+
}
|
|
3876
|
+
function stepKind(step) {
|
|
3877
|
+
return step.kind ?? "click";
|
|
3878
|
+
}
|
|
3879
|
+
/** The last checkpoint at or before `index` (a mission's first step always is one). */
|
|
3880
|
+
function checkpointAtOrBefore(steps, index) {
|
|
3881
|
+
for (let i = Math.min(index, steps.length - 1); i > 0; i--) {
|
|
3882
|
+
if (steps[i].checkpoint)
|
|
3883
|
+
return i;
|
|
3884
|
+
}
|
|
3885
|
+
return 0;
|
|
3886
|
+
}
|
|
3887
|
+
function targetIdOf(step, ctx) {
|
|
3888
|
+
if (typeof step.targetId === "function")
|
|
3889
|
+
return safe(() => step.targetId(ctx), undefined);
|
|
3890
|
+
return step.targetId;
|
|
3891
|
+
}
|
|
3892
|
+
/** Whether the step's target (or the bridge's) is on screen. */
|
|
3893
|
+
function stepTargetVisible(step, env, ctx, bridge) {
|
|
3894
|
+
if (bridge)
|
|
3895
|
+
return env.isVisible(BRIDGE_TARGET);
|
|
3896
|
+
if (!step.target)
|
|
3897
|
+
return true;
|
|
3898
|
+
return env.isVisible(step.target, targetIdOf(step, ctx));
|
|
3899
|
+
}
|
|
3900
|
+
/** The anchor the spotlight is on for the current step. */
|
|
3901
|
+
function spotlightTarget(step, ctx, bridge) {
|
|
3902
|
+
if (bridge)
|
|
3903
|
+
return { anchor: BRIDGE_TARGET };
|
|
3904
|
+
if (!step.target)
|
|
3905
|
+
return null;
|
|
3906
|
+
return { anchor: step.target, id: targetIdOf(step, ctx) };
|
|
3907
|
+
}
|
|
3908
|
+
function needsBridge(step, env, ctx) {
|
|
3909
|
+
return (!!step.target &&
|
|
3910
|
+
step.target.startsWith("nav.") &&
|
|
3911
|
+
!env.sidebarOpen &&
|
|
3912
|
+
!env.isVisible(step.target, targetIdOf(step, ctx)));
|
|
3913
|
+
}
|
|
3914
|
+
function routeParamsFor(step, env) {
|
|
3915
|
+
if (!step.route)
|
|
3916
|
+
return {};
|
|
3917
|
+
return matchRoute(step.route, env.pathname, env.search, env.code) ?? {};
|
|
3918
|
+
}
|
|
3919
|
+
/** A state trigger (route, visible) that holds now; the capture event for it. */
|
|
3920
|
+
function stateTriggerHolds(step, env) {
|
|
3921
|
+
for (const t of triggersOf(step)) {
|
|
3922
|
+
if ("route" in t) {
|
|
3923
|
+
const params = matchRoute(t.route, env.pathname, env.search, env.code);
|
|
3924
|
+
if (params)
|
|
3925
|
+
return { route: { pathname: env.pathname, params } };
|
|
3534
3926
|
}
|
|
3535
|
-
if (
|
|
3536
|
-
|
|
3537
|
-
|
|
3538
|
-
void setOrganizationId(switchTargetId);
|
|
3539
|
-
}, [switchTargetId, setOrganizationId]);
|
|
3540
|
-
// Once the user's organisations are known, requests prefer a stored
|
|
3541
|
-
// organisation that is one of them (getRequestOrganizationId).
|
|
3542
|
-
const organizationsLoaded = (organizationsStatus ??
|
|
3543
|
-
(organizations.length > 0 ? "success" : undefined)) === "success";
|
|
3544
|
-
React.useEffect(() => {
|
|
3545
|
-
if (organizationsLoaded) {
|
|
3546
|
-
setKnownOrganizationIds(organizations.map(org => org.id));
|
|
3927
|
+
else if ("visible" in t) {
|
|
3928
|
+
if (env.isVisible(t.visible))
|
|
3929
|
+
return {};
|
|
3547
3930
|
}
|
|
3548
|
-
}
|
|
3549
|
-
|
|
3550
|
-
|
|
3551
|
-
|
|
3552
|
-
|
|
3553
|
-
|
|
3554
|
-
|
|
3555
|
-
|
|
3556
|
-
|
|
3557
|
-
|
|
3558
|
-
|
|
3559
|
-
|
|
3931
|
+
}
|
|
3932
|
+
return null;
|
|
3933
|
+
}
|
|
3934
|
+
/** Any trigger that holds on a sync (route, visible, and — once armed — hidden). */
|
|
3935
|
+
function syncTriggerHolds(step, env, armed) {
|
|
3936
|
+
const held = stateTriggerHolds(step, env);
|
|
3937
|
+
if (held)
|
|
3938
|
+
return held;
|
|
3939
|
+
if (!armed)
|
|
3940
|
+
return null;
|
|
3941
|
+
for (const t of triggersOf(step)) {
|
|
3942
|
+
if ("hidden" in t && !env.isVisible(t.hidden))
|
|
3943
|
+
return {};
|
|
3944
|
+
}
|
|
3945
|
+
return null;
|
|
3946
|
+
}
|
|
3947
|
+
/** Whether any of the step's `hidden` anchors is on screen now. */
|
|
3948
|
+
function hiddenAnchorShown(step, env) {
|
|
3949
|
+
return triggersOf(step).some(t => "hidden" in t && env.isVisible(t.hidden));
|
|
3950
|
+
}
|
|
3951
|
+
function missionCount(suite) {
|
|
3952
|
+
return suite.missions.length;
|
|
3953
|
+
}
|
|
3954
|
+
function missionSettled(m, entry, ctx) {
|
|
3955
|
+
if (entry?.status === "done")
|
|
3956
|
+
return true;
|
|
3957
|
+
if (m.optional && entry?.status === "skipped")
|
|
3958
|
+
return true;
|
|
3959
|
+
return safe(() => m.alreadyDone?.(ctx) === true, false);
|
|
3960
|
+
}
|
|
3961
|
+
function settledCount(suite, progress, ctx) {
|
|
3962
|
+
return suite.missions.filter(m => missionSettled(m, progress.missions[m.key], ctx)).length;
|
|
3963
|
+
}
|
|
3964
|
+
function gateState(suite, ctx) {
|
|
3965
|
+
if (!suite.gate)
|
|
3966
|
+
return true;
|
|
3967
|
+
return safe(() => suite.gate.ready(ctx), undefined);
|
|
3968
|
+
}
|
|
3969
|
+
function missionViews(suite, progress, ctx) {
|
|
3970
|
+
const gate = gateState(suite, ctx);
|
|
3971
|
+
const settled = (key) => {
|
|
3972
|
+
const m = findMission(suite, key);
|
|
3973
|
+
return !!m && missionSettled(m, progress.missions[key], ctx);
|
|
3974
|
+
};
|
|
3975
|
+
let nextMarked = false;
|
|
3976
|
+
return suite.missions.map(mission => {
|
|
3977
|
+
const entry = progress.missions[mission.key];
|
|
3978
|
+
let view;
|
|
3979
|
+
if (entry?.status === "done")
|
|
3980
|
+
view = "done";
|
|
3981
|
+
else if (safe(() => mission.alreadyDone?.(ctx) === true, false))
|
|
3982
|
+
view = "already";
|
|
3983
|
+
else if (entry?.status === "skipped")
|
|
3984
|
+
view = "skipped";
|
|
3985
|
+
else if (entry?.status === "active")
|
|
3986
|
+
view = "active";
|
|
3987
|
+
else if (gate === false)
|
|
3988
|
+
view = "waiting";
|
|
3989
|
+
else if ((mission.after ?? []).some(k => !settled(k)))
|
|
3990
|
+
view = "locked";
|
|
3991
|
+
else
|
|
3992
|
+
view = "todo";
|
|
3993
|
+
if (!nextMarked && (view === "todo" || view === "active" || view === "skipped")) {
|
|
3994
|
+
nextMarked = true;
|
|
3995
|
+
if (view === "todo")
|
|
3996
|
+
view = "next";
|
|
3997
|
+
}
|
|
3998
|
+
return { mission, view, entry };
|
|
3560
3999
|
});
|
|
3561
|
-
|
|
4000
|
+
}
|
|
4001
|
+
/** The mission the celebration offers next: the first one not settled and not locked. */
|
|
4002
|
+
function nextMissionKey(suite, progress, ctx) {
|
|
4003
|
+
for (const v of missionViews(suite, progress, ctx)) {
|
|
4004
|
+
if (v.view === "next" || v.view === "todo" || v.view === "active" || v.view === "skipped") {
|
|
4005
|
+
return v.mission.key;
|
|
4006
|
+
}
|
|
4007
|
+
}
|
|
4008
|
+
return null;
|
|
4009
|
+
}
|
|
4010
|
+
/** Whether the welcome should open by itself. */
|
|
4011
|
+
function welcomeDue(state, suite) {
|
|
4012
|
+
return (state.audience === "forced" &&
|
|
4013
|
+
state.progress.welcome === "not_seen" &&
|
|
4014
|
+
state.progress.status === "in_progress" &&
|
|
4015
|
+
!state.progress.mission_key &&
|
|
4016
|
+
state.screen.name === "idle" &&
|
|
4017
|
+
suite.missions.length > 0);
|
|
4018
|
+
}
|
|
4019
|
+
/** Whether the floating "ภารกิจ n/N" chip shows. */
|
|
4020
|
+
function chipVisible(state, suite, ctx) {
|
|
4021
|
+
return (state.audience !== "none" &&
|
|
4022
|
+
state.progress.welcome !== "not_seen" &&
|
|
4023
|
+
state.progress.status !== "completed" &&
|
|
4024
|
+
!state.progress.chip_hidden &&
|
|
4025
|
+
state.screen.name === "idle" &&
|
|
4026
|
+
!state.panelOpen &&
|
|
4027
|
+
settledCount(suite, state.progress, ctx) < missionCount(suite));
|
|
4028
|
+
}
|
|
4029
|
+
function withProgress(d, patch) {
|
|
4030
|
+
d.state = { ...d.state, progress: { ...d.state.progress, ...patch } };
|
|
4031
|
+
}
|
|
4032
|
+
function setMission(d, key, entry) {
|
|
4033
|
+
withProgress(d, { missions: { ...d.state.progress.missions, [key]: entry } });
|
|
4034
|
+
}
|
|
4035
|
+
function setScreen(d, screen) {
|
|
4036
|
+
d.state = { ...d.state, screen };
|
|
4037
|
+
}
|
|
4038
|
+
function save(d, immediate = false) {
|
|
4039
|
+
const existing = d.effects.find(e => e.type === "save");
|
|
4040
|
+
if (existing)
|
|
4041
|
+
existing.immediate = existing.immediate || immediate;
|
|
4042
|
+
else
|
|
4043
|
+
d.effects.push({ type: "save", immediate });
|
|
4044
|
+
}
|
|
4045
|
+
function announceStep(d, suite, env) {
|
|
4046
|
+
const s = d.state.screen;
|
|
4047
|
+
if (s.name !== "step")
|
|
4048
|
+
return;
|
|
4049
|
+
const mission = findMission(suite, s.mission);
|
|
4050
|
+
if (!mission)
|
|
4051
|
+
return;
|
|
4052
|
+
const step = mission.steps[s.index];
|
|
4053
|
+
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4054
|
+
const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
|
|
4055
|
+
const why = s.bridge ? COPY.bridgeWhy(suite.appName) : resolveText(step.why, ctx);
|
|
4056
|
+
d.effects.push({
|
|
4057
|
+
type: "announce",
|
|
4058
|
+
text: `ขั้นที่ ${s.index + 1} จาก ${mission.steps.length}. ${title}${why ? `. ${why}` : ""}`,
|
|
4059
|
+
});
|
|
4060
|
+
}
|
|
4061
|
+
/** "กด [บันทึก]" → "กด บันทึก" (for screen readers). */
|
|
4062
|
+
function plainTitle(t) {
|
|
4063
|
+
return t.replace(/\[([^\]]+)\]/g, "$1");
|
|
4064
|
+
}
|
|
4065
|
+
/**
|
|
4066
|
+
* Enter step `index` of the mission (or the first one after it that is not
|
|
4067
|
+
* skipped, forbidden or already passed). Past the end, the mission is over.
|
|
4068
|
+
*/
|
|
4069
|
+
function enterStep(d, suite, env, mission, index, opts) {
|
|
4070
|
+
const steps = mission.steps;
|
|
4071
|
+
let i = Math.max(0, index);
|
|
4072
|
+
let firstPass = true;
|
|
4073
|
+
while (i < steps.length) {
|
|
4074
|
+
const step = steps[i];
|
|
4075
|
+
const params = routeParamsFor(step, env);
|
|
4076
|
+
const ctx = guideContext(env, d.state.progress.context, params);
|
|
4077
|
+
if (step.requires && !safe(() => env.can(step.requires), true)) {
|
|
4078
|
+
withProgress(d, { last_error: `forbidden:${step.requires}`.slice(0, 128) });
|
|
4079
|
+
i++;
|
|
4080
|
+
continue;
|
|
4081
|
+
}
|
|
4082
|
+
if (step.skipIf && safe(() => step.skipIf(ctx), false)) {
|
|
4083
|
+
i++;
|
|
4084
|
+
continue;
|
|
4085
|
+
}
|
|
4086
|
+
const autoPass = step.autoPass !== false && !(firstPass && opts.noAutoPass);
|
|
4087
|
+
firstPass = false;
|
|
4088
|
+
if (autoPass && stepKind(step) !== "input" && stepKind(step) !== "view") {
|
|
4089
|
+
const held = stateTriggerHolds(step, env);
|
|
4090
|
+
if (held) {
|
|
4091
|
+
captureInto(d, step, held, ctx);
|
|
4092
|
+
i++;
|
|
4093
|
+
continue;
|
|
4094
|
+
}
|
|
4095
|
+
}
|
|
4096
|
+
break;
|
|
4097
|
+
}
|
|
4098
|
+
if (i >= steps.length) {
|
|
4099
|
+
finishMission(d, suite, env, mission, opts.replay);
|
|
4100
|
+
return;
|
|
4101
|
+
}
|
|
4102
|
+
const step = steps[i];
|
|
4103
|
+
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4104
|
+
const away = !!step.route && !matchRoute(step.route, env.pathname, env.search, env.code);
|
|
4105
|
+
const bridge = !away && needsBridge(step, env, ctx);
|
|
4106
|
+
setScreen(d, {
|
|
4107
|
+
name: "step",
|
|
4108
|
+
mission: mission.key,
|
|
4109
|
+
index: i,
|
|
4110
|
+
replay: opts.replay,
|
|
4111
|
+
bridge,
|
|
4112
|
+
phase: away ? "away" : "waiting",
|
|
4113
|
+
error: opts.error ?? null,
|
|
4114
|
+
confirmExit: false,
|
|
4115
|
+
misses: 0,
|
|
4116
|
+
armed: hiddenAnchorShown(step, env),
|
|
4117
|
+
});
|
|
4118
|
+
if (!opts.replay) {
|
|
4119
|
+
const cp = checkpointAtOrBefore(steps, i);
|
|
4120
|
+
setMission(d, mission.key, { status: "active", step_key: step.key });
|
|
4121
|
+
withProgress(d, {
|
|
4122
|
+
mission_key: mission.key,
|
|
4123
|
+
step_key: step.key,
|
|
4124
|
+
step_index: i,
|
|
4125
|
+
checkpoint_key: steps[cp].key,
|
|
4126
|
+
});
|
|
4127
|
+
save(d);
|
|
4128
|
+
}
|
|
4129
|
+
if (!away) {
|
|
4130
|
+
// The target may already be there.
|
|
4131
|
+
markReadyIfVisible(d, suite, env);
|
|
4132
|
+
}
|
|
4133
|
+
}
|
|
4134
|
+
function markReadyIfVisible(d, suite, env) {
|
|
4135
|
+
const s = d.state.screen;
|
|
4136
|
+
if (s.name !== "step" || s.phase !== "waiting")
|
|
4137
|
+
return;
|
|
4138
|
+
const mission = findMission(suite, s.mission);
|
|
4139
|
+
if (!mission)
|
|
4140
|
+
return;
|
|
4141
|
+
const step = mission.steps[s.index];
|
|
4142
|
+
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4143
|
+
if (stepTargetVisible(step, env, ctx, s.bridge)) {
|
|
4144
|
+
setScreen(d, { ...s, phase: "ready" });
|
|
4145
|
+
announceStep(d, suite, env);
|
|
4146
|
+
if (s.error)
|
|
4147
|
+
d.effects.push({ type: "announce", text: s.error.title });
|
|
4148
|
+
}
|
|
4149
|
+
}
|
|
4150
|
+
function captureInto(d, step, e, ctx) {
|
|
4151
|
+
if (!step.capture)
|
|
4152
|
+
return;
|
|
4153
|
+
const got = safe(() => step.capture(e, ctx), undefined);
|
|
4154
|
+
if (!got)
|
|
4155
|
+
return;
|
|
4156
|
+
const clean = {};
|
|
4157
|
+
for (const [k, v] of Object.entries(got)) {
|
|
4158
|
+
if (typeof v === "string" && v)
|
|
4159
|
+
clean[k] = v;
|
|
4160
|
+
}
|
|
4161
|
+
if (Object.keys(clean).length) {
|
|
4162
|
+
withProgress(d, { context: { ...d.state.progress.context, ...clean } });
|
|
4163
|
+
}
|
|
4164
|
+
}
|
|
4165
|
+
function advance(d, suite, env, capture) {
|
|
4166
|
+
const s = d.state.screen;
|
|
4167
|
+
if (s.name !== "step")
|
|
4168
|
+
return;
|
|
4169
|
+
const mission = findMission(suite, s.mission);
|
|
4170
|
+
if (!mission)
|
|
4171
|
+
return;
|
|
4172
|
+
const step = mission.steps[s.index];
|
|
4173
|
+
if (capture) {
|
|
4174
|
+
captureInto(d, step, capture, guideContext(env, d.state.progress.context, routeParamsFor(step, env)));
|
|
4175
|
+
}
|
|
4176
|
+
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
4177
|
+
}
|
|
4178
|
+
function finishMission(d, suite, env, mission, replay) {
|
|
4179
|
+
if (replay) {
|
|
4180
|
+
setScreen(d, { name: "idle" });
|
|
4181
|
+
d.effects.push({ type: "toast", text: COPY.replayDone });
|
|
4182
|
+
return;
|
|
4183
|
+
}
|
|
4184
|
+
setMission(d, mission.key, { status: "done", step_key: null, skipped_step: null });
|
|
4185
|
+
withProgress(d, {
|
|
4186
|
+
mission_key: null,
|
|
4187
|
+
step_key: null,
|
|
4188
|
+
step_index: 0,
|
|
4189
|
+
checkpoint_key: null,
|
|
4190
|
+
context: {},
|
|
4191
|
+
last_error: null,
|
|
4192
|
+
});
|
|
4193
|
+
const ctx = guideContext(env, {});
|
|
4194
|
+
const all = settledCount(suite, d.state.progress, ctx) >= missionCount(suite);
|
|
4195
|
+
if (all)
|
|
4196
|
+
withProgress(d, { status: "completed" });
|
|
4197
|
+
setScreen(d, all ? { name: "complete" } : { name: "celebrate", mission: mission.key });
|
|
4198
|
+
d.state = { ...d.state, justDone: mission.key };
|
|
4199
|
+
save(d, true);
|
|
4200
|
+
const n = settledCount(suite, d.state.progress, ctx);
|
|
4201
|
+
const N = missionCount(suite);
|
|
4202
|
+
const title = resolveText(mission.done?.title, ctx) || `${mission.title}แล้ว`;
|
|
4203
|
+
d.effects.push({
|
|
4204
|
+
type: "announce",
|
|
4205
|
+
text: all
|
|
4206
|
+
? `ครบ ${N} ภารกิจ ${resolveText(suite.complete.title, ctx) || "บริษัทพร้อมใช้งานแล้ว"}`
|
|
4207
|
+
: `ภารกิจสำเร็จ ${title} เสร็จ ${n} จาก ${N} ภารกิจ`,
|
|
4208
|
+
});
|
|
4209
|
+
}
|
|
4210
|
+
function startMission(d, suite, env, key, replay) {
|
|
4211
|
+
const mission = findMission(suite, key);
|
|
4212
|
+
if (!mission || mission.steps.length === 0)
|
|
4213
|
+
return;
|
|
4214
|
+
const entry = d.state.progress.missions[key];
|
|
4215
|
+
// A mission already done is only ever replayed; its done status stays.
|
|
4216
|
+
const isReplay = replay || entry?.status === "done";
|
|
4217
|
+
d.state = { ...d.state, panelOpen: false, justDone: null };
|
|
4218
|
+
if (d.state.progress.welcome === "not_seen")
|
|
4219
|
+
withProgress(d, { welcome: "started" });
|
|
4220
|
+
if (!isReplay && d.state.progress.mission_key !== key)
|
|
4221
|
+
withProgress(d, { context: {} });
|
|
4222
|
+
if (!isReplay && d.state.progress.status !== "in_progress") {
|
|
4223
|
+
// Picking up an optional mission after the tour completed.
|
|
4224
|
+
withProgress(d, { status: "in_progress" });
|
|
4225
|
+
}
|
|
4226
|
+
// Resume where it was left: back to the checkpoint at or before it.
|
|
4227
|
+
let index = 0;
|
|
4228
|
+
if (!isReplay && entry && (entry.status === "active" || entry.status === "skipped")) {
|
|
4229
|
+
const at = mission.steps.findIndex(s => s.key === (entry.step_key ?? entry.skipped_step));
|
|
4230
|
+
if (at > 0)
|
|
4231
|
+
index = checkpointAtOrBefore(mission.steps, at);
|
|
4232
|
+
}
|
|
4233
|
+
enterStep(d, suite, env, mission, index, { replay: isReplay });
|
|
4234
|
+
if (isReplay)
|
|
4235
|
+
save(d);
|
|
4236
|
+
}
|
|
4237
|
+
function exitMission(d) {
|
|
4238
|
+
const s = d.state.screen;
|
|
4239
|
+
if (s.name !== "step")
|
|
4240
|
+
return;
|
|
4241
|
+
if (!s.replay) {
|
|
4242
|
+
const prev = d.state.progress.missions[s.mission];
|
|
4243
|
+
if (prev?.status !== "done") {
|
|
4244
|
+
setMission(d, s.mission, {
|
|
4245
|
+
status: "skipped",
|
|
4246
|
+
step_key: d.state.progress.step_key,
|
|
4247
|
+
skipped_step: d.state.progress.step_key,
|
|
4248
|
+
});
|
|
4249
|
+
}
|
|
4250
|
+
withProgress(d, { mission_key: null });
|
|
4251
|
+
save(d, true);
|
|
4252
|
+
}
|
|
4253
|
+
d.state = { ...d.state, chipPulse: d.state.chipPulse + 1 };
|
|
4254
|
+
setScreen(d, { name: "idle" });
|
|
4255
|
+
}
|
|
4256
|
+
function triggerMatchesSignal(t, sig, env, ctx) {
|
|
4257
|
+
if ("api" in t && sig.kind === "api") {
|
|
4258
|
+
if (t.api.method && t.api.method.toUpperCase() !== sig.method.toUpperCase())
|
|
4259
|
+
return null;
|
|
4260
|
+
if (!matchApiPath(t.api.path, sig.path))
|
|
4261
|
+
return null;
|
|
4262
|
+
if (!matchStatus(t.api.status, sig.status))
|
|
4263
|
+
return null;
|
|
4264
|
+
return { api: { method: sig.method.toUpperCase(), path: sig.path, status: sig.status, id: sig.id } };
|
|
4265
|
+
}
|
|
4266
|
+
if ("event" in t && sig.kind === "event") {
|
|
4267
|
+
return t.event === sig.name ? { event: { name: sig.name, detail: sig.detail } } : null;
|
|
4268
|
+
}
|
|
4269
|
+
if ("activate" in t && sig.kind === "activate")
|
|
4270
|
+
return {};
|
|
4271
|
+
if ("value" in t && sig.kind === "value") {
|
|
4272
|
+
return safe(() => t.value(sig.value, ctx), false) ? {} : null;
|
|
4273
|
+
}
|
|
4274
|
+
return null;
|
|
4275
|
+
}
|
|
4276
|
+
/** A refused save that a commit step listens for. */
|
|
4277
|
+
function apiFailureFor(step, sig) {
|
|
4278
|
+
if (sig.kind !== "api" || sig.status < 400)
|
|
4279
|
+
return false;
|
|
4280
|
+
return triggersOf(step).some(t => "api" in t &&
|
|
4281
|
+
(!t.api.method || t.api.method.toUpperCase() === sig.method.toUpperCase()) &&
|
|
4282
|
+
matchApiPath(t.api.path, sig.path));
|
|
4283
|
+
}
|
|
4284
|
+
function reduceGuide(state, action, suite, env) {
|
|
4285
|
+
const d = { state, effects: [] };
|
|
4286
|
+
const s = state.screen;
|
|
4287
|
+
const ctxNow = () => guideContext(env, d.state.progress.context);
|
|
4288
|
+
switch (action.type) {
|
|
4289
|
+
case "welcome": {
|
|
4290
|
+
if (!welcomeDue(state, suite))
|
|
4291
|
+
break;
|
|
4292
|
+
const gate = gateState(suite, ctxNow());
|
|
4293
|
+
if (gate === undefined)
|
|
4294
|
+
break;
|
|
4295
|
+
if (gate === false) {
|
|
4296
|
+
if (!state.gateDismissed)
|
|
4297
|
+
setScreen(d, { name: "gate" });
|
|
4298
|
+
break;
|
|
4299
|
+
}
|
|
4300
|
+
setScreen(d, { name: "welcome", confirm: false });
|
|
4301
|
+
break;
|
|
4302
|
+
}
|
|
4303
|
+
case "welcomeSkip":
|
|
4304
|
+
if (s.name === "welcome")
|
|
4305
|
+
setScreen(d, { name: "welcome", confirm: true });
|
|
4306
|
+
break;
|
|
4307
|
+
case "welcomeStart":
|
|
4308
|
+
case "welcomeSkipCancel": {
|
|
4309
|
+
if (s.name !== "welcome")
|
|
4310
|
+
break;
|
|
4311
|
+
const first = nextMissionKey(suite, d.state.progress, ctxNow());
|
|
4312
|
+
if (first)
|
|
4313
|
+
startMission(d, suite, env, first, false);
|
|
4314
|
+
else
|
|
4315
|
+
setScreen(d, { name: "idle" });
|
|
4316
|
+
break;
|
|
4317
|
+
}
|
|
4318
|
+
case "welcomeSkipConfirm":
|
|
4319
|
+
if (s.name !== "welcome")
|
|
4320
|
+
break;
|
|
4321
|
+
withProgress(d, { welcome: "skipped" });
|
|
4322
|
+
d.state = { ...d.state, chipPulse: d.state.chipPulse + 1 };
|
|
4323
|
+
setScreen(d, { name: "idle" });
|
|
4324
|
+
save(d, true);
|
|
4325
|
+
break;
|
|
4326
|
+
case "gateLater":
|
|
4327
|
+
if (s.name === "gate") {
|
|
4328
|
+
d.state = { ...d.state, gateDismissed: true };
|
|
4329
|
+
setScreen(d, { name: "idle" });
|
|
4330
|
+
}
|
|
4331
|
+
break;
|
|
4332
|
+
case "start":
|
|
4333
|
+
if (state.audience === "none")
|
|
4334
|
+
break;
|
|
4335
|
+
if (s.name === "step" && !s.replay && s.mission !== action.mission) {
|
|
4336
|
+
// Leaving one mission for another: the first is set aside.
|
|
4337
|
+
exitMission(d);
|
|
4338
|
+
}
|
|
4339
|
+
startMission(d, suite, env, action.mission, !!action.replay);
|
|
4340
|
+
break;
|
|
4341
|
+
case "resume": {
|
|
4342
|
+
if (state.audience === "none" || s.name !== "idle")
|
|
4343
|
+
break;
|
|
4344
|
+
const mission = findMission(suite, d.state.progress.mission_key);
|
|
4345
|
+
if (!mission || d.state.progress.missions[mission.key]?.status !== "active")
|
|
4346
|
+
break;
|
|
4347
|
+
// Back to the last checkpoint at or before the step reached; the saved
|
|
4348
|
+
// checkpoint when the step is no longer in the tour.
|
|
4349
|
+
const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
|
|
4350
|
+
const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
|
|
4351
|
+
const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
|
|
4352
|
+
enterStep(d, suite, env, mission, index, { replay: false });
|
|
4353
|
+
break;
|
|
4354
|
+
}
|
|
4355
|
+
case "sync": {
|
|
4356
|
+
if (s.name !== "step")
|
|
4357
|
+
break;
|
|
4358
|
+
const mission = findMission(suite, s.mission);
|
|
4359
|
+
if (!mission)
|
|
4360
|
+
break;
|
|
4361
|
+
const step = mission.steps[s.index];
|
|
4362
|
+
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4363
|
+
if (s.phase === "away") {
|
|
4364
|
+
if (!step.route || matchRoute(step.route, env.pathname, env.search, env.code)) {
|
|
4365
|
+
// Arrived: run the step again from here (it may already be passed).
|
|
4366
|
+
enterStep(d, suite, env, mission, s.index, { replay: s.replay, error: s.error });
|
|
4367
|
+
}
|
|
4368
|
+
break;
|
|
4369
|
+
}
|
|
4370
|
+
if (s.confirmExit)
|
|
4371
|
+
break;
|
|
4372
|
+
const kind = stepKind(step);
|
|
4373
|
+
const armed = s.armed || hiddenAnchorShown(step, env);
|
|
4374
|
+
if (kind !== "input" && kind !== "view") {
|
|
4375
|
+
const held = syncTriggerHolds(step, env, armed);
|
|
4376
|
+
if (held) {
|
|
4377
|
+
advance(d, suite, env, held);
|
|
4378
|
+
break;
|
|
4379
|
+
}
|
|
4380
|
+
}
|
|
4381
|
+
if (armed !== s.armed)
|
|
4382
|
+
setScreen(d, { ...s, armed });
|
|
4383
|
+
const cur = d.state.screen;
|
|
4384
|
+
// The menu step: done once the sidebar is open; needed again if shut.
|
|
4385
|
+
if (cur.bridge && (env.sidebarOpen || stepTargetVisible(step, env, ctx, false))) {
|
|
4386
|
+
setScreen(d, { ...cur, bridge: false, phase: "waiting" });
|
|
4387
|
+
}
|
|
4388
|
+
else if (!cur.bridge && needsBridge(step, env, ctx)) {
|
|
4389
|
+
setScreen(d, { ...cur, bridge: true, phase: "waiting" });
|
|
4390
|
+
}
|
|
4391
|
+
markReadyIfVisible(d, suite, env);
|
|
4392
|
+
break;
|
|
4393
|
+
}
|
|
4394
|
+
case "signal": {
|
|
4395
|
+
if (s.name !== "step" || s.phase === "away" || s.confirmExit)
|
|
4396
|
+
break;
|
|
4397
|
+
const mission = findMission(suite, s.mission);
|
|
4398
|
+
if (!mission)
|
|
4399
|
+
break;
|
|
4400
|
+
const step = mission.steps[s.index];
|
|
4401
|
+
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4402
|
+
const sig = action.signal;
|
|
4403
|
+
if (sig.kind === "typing") {
|
|
4404
|
+
if (s.error)
|
|
4405
|
+
setScreen(d, { ...s, error: null });
|
|
4406
|
+
break;
|
|
4407
|
+
}
|
|
4408
|
+
if (s.bridge)
|
|
4409
|
+
break;
|
|
4410
|
+
for (const t of triggersOf(step)) {
|
|
4411
|
+
const hit = triggerMatchesSignal(t, sig, env, ctx);
|
|
4412
|
+
if (hit) {
|
|
4413
|
+
advance(d, suite, env, hit);
|
|
4414
|
+
return { state: d.state, effects: d.effects };
|
|
4415
|
+
}
|
|
4416
|
+
}
|
|
4417
|
+
if (sig.kind === "api" && stepKind(step) === "commit" && apiFailureFor(step, sig)) {
|
|
4418
|
+
const mapped = safe(() => step.onApiError?.({ status: sig.status, message: sig.message }, ctx), undefined);
|
|
4419
|
+
if (mapped && mapped.step) {
|
|
4420
|
+
const to = mission.steps.findIndex(st => st.key === mapped.step);
|
|
4421
|
+
if (to >= 0) {
|
|
4422
|
+
enterStep(d, suite, env, mission, to, {
|
|
4423
|
+
replay: s.replay,
|
|
4424
|
+
noAutoPass: true,
|
|
4425
|
+
error: { title: mapped.message ?? sig.message ?? COPY.saveFailed, detail: COPY.fieldFix },
|
|
4426
|
+
});
|
|
4427
|
+
break;
|
|
4428
|
+
}
|
|
4429
|
+
}
|
|
4430
|
+
const error = mapped?.message
|
|
4431
|
+
? { title: mapped.message, detail: undefined }
|
|
4432
|
+
: { title: COPY.saveFailed, detail: sig.message };
|
|
4433
|
+
setScreen(d, { ...s, error });
|
|
4434
|
+
d.effects.push({ type: "announce", text: [error.title, error.detail].filter(Boolean).join(". ") });
|
|
4435
|
+
withProgress(d, { last_error: `api_${sig.status}:${step.key}`.slice(0, 128) });
|
|
4436
|
+
}
|
|
4437
|
+
break;
|
|
4438
|
+
}
|
|
4439
|
+
case "next": {
|
|
4440
|
+
if (s.name !== "step" || s.phase !== "ready" || s.confirmExit || s.bridge)
|
|
4441
|
+
break;
|
|
4442
|
+
const mission = findMission(suite, s.mission);
|
|
4443
|
+
if (!mission)
|
|
4444
|
+
break;
|
|
4445
|
+
const step = mission.steps[s.index];
|
|
4446
|
+
const kind = stepKind(step);
|
|
4447
|
+
if (kind === "view") {
|
|
4448
|
+
advance(d, suite, env);
|
|
4449
|
+
break;
|
|
4450
|
+
}
|
|
4451
|
+
if (kind !== "input")
|
|
4452
|
+
break;
|
|
4453
|
+
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4454
|
+
const value = safe(() => env.targetValue(), "");
|
|
4455
|
+
const problem = step.validate
|
|
4456
|
+
? safe(() => step.validate(value, ctx), null)
|
|
4457
|
+
: value.trim()
|
|
4458
|
+
? null
|
|
4459
|
+
: COPY.blank;
|
|
4460
|
+
if (problem) {
|
|
4461
|
+
setScreen(d, { ...s, error: { title: problem, detail: COPY.fieldFix } });
|
|
4462
|
+
d.state = { ...d.state, nudge: d.state.nudge + 1 };
|
|
4463
|
+
d.effects.push({ type: "announce", text: problem });
|
|
4464
|
+
}
|
|
4465
|
+
else {
|
|
4466
|
+
advance(d, suite, env);
|
|
4467
|
+
}
|
|
4468
|
+
break;
|
|
4469
|
+
}
|
|
4470
|
+
case "targetLost": {
|
|
4471
|
+
if (s.name !== "step" || s.phase !== "ready" || s.confirmExit)
|
|
4472
|
+
break;
|
|
4473
|
+
const mission = findMission(suite, s.mission);
|
|
4474
|
+
if (!mission)
|
|
4475
|
+
break;
|
|
4476
|
+
const step = mission.steps[s.index];
|
|
4477
|
+
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4478
|
+
if (stepTargetVisible(step, env, ctx, s.bridge))
|
|
4479
|
+
break;
|
|
4480
|
+
const cp = checkpointAtOrBefore(mission.steps, s.index);
|
|
4481
|
+
if (cp < s.index && !s.bridge) {
|
|
4482
|
+
// A dialog or a menu closed under the step: back to what opens it.
|
|
4483
|
+
enterStep(d, suite, env, mission, cp, { replay: s.replay, noAutoPass: false });
|
|
4484
|
+
}
|
|
4485
|
+
else {
|
|
4486
|
+
setScreen(d, { ...s, phase: "waiting" });
|
|
4487
|
+
}
|
|
4488
|
+
break;
|
|
4489
|
+
}
|
|
4490
|
+
case "timeout": {
|
|
4491
|
+
if (s.name !== "step" || s.phase !== "waiting")
|
|
4492
|
+
break;
|
|
4493
|
+
const mission = findMission(suite, s.mission);
|
|
4494
|
+
if (!mission)
|
|
4495
|
+
break;
|
|
4496
|
+
const step = mission.steps[s.index];
|
|
4497
|
+
const anchor = s.bridge ? BRIDGE_TARGET : (step.target ?? step.key);
|
|
4498
|
+
withProgress(d, { last_error: `target_missing:${anchor}`.slice(0, 128) });
|
|
4499
|
+
save(d);
|
|
4500
|
+
const misses = s.misses + 1;
|
|
4501
|
+
const policy = step.onMissing ?? "retry";
|
|
4502
|
+
const cp = checkpointAtOrBefore(mission.steps, s.index);
|
|
4503
|
+
if (policy === "skip") {
|
|
4504
|
+
advance(d, suite, env);
|
|
4505
|
+
}
|
|
4506
|
+
else if (policy === "abort") {
|
|
4507
|
+
exitMission(d);
|
|
4508
|
+
d.effects.push({ type: "toast", text: COPY.aborted });
|
|
4509
|
+
}
|
|
4510
|
+
else if ((policy === "rewind" || (policy === "retry" && misses >= 2)) && cp < s.index) {
|
|
4511
|
+
enterStep(d, suite, env, mission, cp, { replay: s.replay });
|
|
4512
|
+
const now = d.state.screen;
|
|
4513
|
+
if (now.name === "step" && now.index === s.index) {
|
|
4514
|
+
// The checkpoint passed straight back to this step (its dialog is
|
|
4515
|
+
// still open): going back cannot help, so say so.
|
|
4516
|
+
setScreen(d, { ...now, phase: "missing", misses });
|
|
4517
|
+
d.effects.push({ type: "announce", text: "ไม่พบส่วนนี้บนหน้า" });
|
|
4518
|
+
}
|
|
4519
|
+
}
|
|
4520
|
+
else {
|
|
4521
|
+
setScreen(d, { ...s, phase: "missing", misses });
|
|
4522
|
+
d.effects.push({ type: "announce", text: "ไม่พบส่วนนี้บนหน้า" });
|
|
4523
|
+
}
|
|
4524
|
+
break;
|
|
4525
|
+
}
|
|
4526
|
+
case "retry":
|
|
4527
|
+
if (s.name === "step" && s.phase === "missing") {
|
|
4528
|
+
setScreen(d, { ...s, phase: "waiting" });
|
|
4529
|
+
markReadyIfVisible(d, suite, env);
|
|
4530
|
+
}
|
|
4531
|
+
break;
|
|
4532
|
+
case "blocked":
|
|
4533
|
+
if (s.name === "step")
|
|
4534
|
+
d.state = { ...d.state, nudge: d.state.nudge + 1 };
|
|
4535
|
+
break;
|
|
4536
|
+
case "exit":
|
|
4537
|
+
if (s.name !== "step")
|
|
4538
|
+
break;
|
|
4539
|
+
if (s.replay) {
|
|
4540
|
+
setScreen(d, { name: "idle" });
|
|
4541
|
+
d.effects.push({ type: "toast", text: COPY.replayEnded });
|
|
4542
|
+
}
|
|
4543
|
+
else {
|
|
4544
|
+
setScreen(d, { ...s, confirmExit: true });
|
|
4545
|
+
}
|
|
4546
|
+
break;
|
|
4547
|
+
case "exitCancel":
|
|
4548
|
+
if (s.name === "step" && s.confirmExit)
|
|
4549
|
+
setScreen(d, { ...s, confirmExit: false });
|
|
4550
|
+
break;
|
|
4551
|
+
case "exitConfirm":
|
|
4552
|
+
if (s.name !== "step")
|
|
4553
|
+
break;
|
|
4554
|
+
exitMission(d);
|
|
4555
|
+
d.effects.push({ type: "toast", text: COPY.exitToast });
|
|
4556
|
+
break;
|
|
4557
|
+
case "endWithoutSave":
|
|
4558
|
+
if (s.name !== "step" || !s.replay)
|
|
4559
|
+
break;
|
|
4560
|
+
setScreen(d, { name: "idle" });
|
|
4561
|
+
d.effects.push({ type: "closeAppDialog" }, { type: "toast", text: COPY.replayEnded });
|
|
4562
|
+
break;
|
|
4563
|
+
case "goResume": {
|
|
4564
|
+
if (s.name !== "step" || s.phase !== "away")
|
|
4565
|
+
break;
|
|
4566
|
+
const mission = findMission(suite, s.mission);
|
|
4567
|
+
if (!mission)
|
|
4568
|
+
break;
|
|
4569
|
+
const step = mission.steps[s.index];
|
|
4570
|
+
const ctx = guideContext(env, d.state.progress.context);
|
|
4571
|
+
const href = safe(() => step.resumeUrl?.(ctx), undefined) ??
|
|
4572
|
+
(step.route ? fillRoute(step.route, { ...d.state.progress.context, code: env.code }) : null);
|
|
4573
|
+
if (href)
|
|
4574
|
+
d.effects.push({ type: "navigate", href });
|
|
4575
|
+
break;
|
|
4576
|
+
}
|
|
4577
|
+
case "celebrateNext": {
|
|
4578
|
+
if (s.name !== "celebrate")
|
|
4579
|
+
break;
|
|
4580
|
+
const next = nextMissionKey(suite, d.state.progress, ctxNow());
|
|
4581
|
+
if (next)
|
|
4582
|
+
startMission(d, suite, env, next, false);
|
|
4583
|
+
else
|
|
4584
|
+
setScreen(d, { name: "idle" });
|
|
4585
|
+
break;
|
|
4586
|
+
}
|
|
4587
|
+
case "rest":
|
|
4588
|
+
if (s.name !== "celebrate")
|
|
4589
|
+
break;
|
|
4590
|
+
setScreen(d, { name: "idle" });
|
|
4591
|
+
d.state = { ...d.state, panelOpen: true, justDone: s.mission };
|
|
4592
|
+
break;
|
|
4593
|
+
case "finish": {
|
|
4594
|
+
if (s.name !== "complete")
|
|
4595
|
+
break;
|
|
4596
|
+
setScreen(d, { name: "idle" });
|
|
4597
|
+
const href = resolveText(suite.complete.action.href, ctxNow());
|
|
4598
|
+
const filled = href ? (fillRoute(href, { code: env.code }) ?? href) : "";
|
|
4599
|
+
if (filled)
|
|
4600
|
+
d.effects.push({ type: "navigate", href: filled });
|
|
4601
|
+
break;
|
|
4602
|
+
}
|
|
4603
|
+
case "openPanel":
|
|
4604
|
+
if (state.audience !== "none" && s.name === "idle") {
|
|
4605
|
+
d.state = { ...d.state, panelOpen: true };
|
|
4606
|
+
}
|
|
4607
|
+
break;
|
|
4608
|
+
case "closePanel":
|
|
4609
|
+
d.state = { ...d.state, panelOpen: false, justDone: null };
|
|
4610
|
+
break;
|
|
4611
|
+
case "hideChip":
|
|
4612
|
+
withProgress(d, { chip_hidden: true });
|
|
4613
|
+
d.state = { ...d.state, panelOpen: false };
|
|
4614
|
+
d.effects.push({ type: "toast", text: COPY.hiddenToast });
|
|
4615
|
+
save(d, true);
|
|
4616
|
+
break;
|
|
4617
|
+
case "dismissMission": {
|
|
4618
|
+
const m = findMission(suite, action.mission);
|
|
4619
|
+
if (!m?.optional)
|
|
4620
|
+
break;
|
|
4621
|
+
setMission(d, m.key, { status: "skipped", step_key: null, skipped_step: null });
|
|
4622
|
+
const all = settledCount(suite, d.state.progress, ctxNow()) >= missionCount(suite);
|
|
4623
|
+
if (all) {
|
|
4624
|
+
withProgress(d, { status: "completed" });
|
|
4625
|
+
d.state = { ...d.state, panelOpen: false };
|
|
4626
|
+
setScreen(d, { name: "complete" });
|
|
4627
|
+
}
|
|
4628
|
+
save(d, true);
|
|
4629
|
+
break;
|
|
4630
|
+
}
|
|
4631
|
+
case "serverRow": {
|
|
4632
|
+
// A saved row came back: its rev, the server's status, and the dates
|
|
4633
|
+
// the server stamps on missions ("เสร็จ 26 ต.ค. 2569 13:04").
|
|
4634
|
+
const missions = { ...d.state.progress.missions };
|
|
4635
|
+
for (const [k, m] of Object.entries(missions)) {
|
|
4636
|
+
const r = action.progress.missions[k];
|
|
4637
|
+
if (r && r.status === m.status) {
|
|
4638
|
+
missions[k] = { ...m, completed_at: r.completed_at ?? m.completed_at, skipped_at: r.skipped_at ?? m.skipped_at };
|
|
4639
|
+
}
|
|
4640
|
+
}
|
|
4641
|
+
withProgress(d, { rev: action.progress.rev, server_status: action.progress.server_status, missions });
|
|
4642
|
+
break;
|
|
4643
|
+
}
|
|
4644
|
+
}
|
|
4645
|
+
return { state: d.state, effects: d.effects };
|
|
4646
|
+
}
|
|
4647
|
+
|
|
4648
|
+
/**
|
|
4649
|
+
* A tour's progress as platform-api stores it (one row per user, org and
|
|
4650
|
+
* suite: GET/PUT /organizations/:id/guides/me), and the rules for reading,
|
|
4651
|
+
* writing and merging it. See _claude-work/peoplx-guide/API.md.
|
|
4652
|
+
*
|
|
4653
|
+
* No runtime imports: the tests run this file in Node as it is.
|
|
4654
|
+
*/
|
|
4655
|
+
const KEY_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
4656
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4657
|
+
const str = (v) => (typeof v === "string" && v !== "" ? v : null);
|
|
4658
|
+
function freshProgress(version, rev = 0) {
|
|
4659
|
+
return {
|
|
4660
|
+
guide_version: version,
|
|
4661
|
+
rev,
|
|
4662
|
+
status: "in_progress",
|
|
4663
|
+
mission_key: null,
|
|
4664
|
+
step_key: null,
|
|
4665
|
+
step_index: 0,
|
|
4666
|
+
checkpoint_key: null,
|
|
4667
|
+
missions: {},
|
|
4668
|
+
context: {},
|
|
4669
|
+
welcome: "not_seen",
|
|
4670
|
+
chip_hidden: false,
|
|
4671
|
+
last_error: null,
|
|
4672
|
+
skipped_step: null,
|
|
4673
|
+
server_status: null,
|
|
4674
|
+
};
|
|
4675
|
+
}
|
|
4676
|
+
const ROW_STATUSES = ["in_progress", "completed", "skipped", "aborted"];
|
|
4677
|
+
const MISSION_STATUSES = ["todo", "active", "done", "skipped"];
|
|
4678
|
+
const WELCOMES = ["not_seen", "started", "skipped"];
|
|
4679
|
+
/** One row (a GET `guides[]` entry, a PUT answer, a 409's `current`). Null when off-shape. */
|
|
4680
|
+
function parseRow(raw) {
|
|
4681
|
+
if (!isRecord(raw))
|
|
4682
|
+
return null;
|
|
4683
|
+
const version = Number(raw.guide_version);
|
|
4684
|
+
const rev = Number(raw.rev);
|
|
4685
|
+
if (!Number.isInteger(version) || version < 1 || !Number.isInteger(rev) || rev < 0)
|
|
4686
|
+
return null;
|
|
4687
|
+
const status = ROW_STATUSES.includes(raw.status)
|
|
4688
|
+
? raw.status
|
|
4689
|
+
: "in_progress";
|
|
4690
|
+
const missions = {};
|
|
4691
|
+
if (isRecord(raw.missions)) {
|
|
4692
|
+
for (const [k, v] of Object.entries(raw.missions)) {
|
|
4693
|
+
if (!isRecord(v) || !MISSION_STATUSES.includes(v.status))
|
|
4694
|
+
continue;
|
|
4695
|
+
missions[k] = {
|
|
4696
|
+
status: v.status,
|
|
4697
|
+
step_key: str(v.step_key),
|
|
4698
|
+
skipped_step: str(v.skipped_step),
|
|
4699
|
+
completed_at: str(v.completed_at),
|
|
4700
|
+
skipped_at: str(v.skipped_at),
|
|
4701
|
+
};
|
|
4702
|
+
}
|
|
4703
|
+
}
|
|
4704
|
+
const context = {};
|
|
4705
|
+
if (isRecord(raw.context)) {
|
|
4706
|
+
for (const [k, v] of Object.entries(raw.context)) {
|
|
4707
|
+
if (typeof v === "string")
|
|
4708
|
+
context[k] = v;
|
|
4709
|
+
else if (typeof v === "number")
|
|
4710
|
+
context[k] = String(v);
|
|
4711
|
+
}
|
|
4712
|
+
}
|
|
4713
|
+
return {
|
|
4714
|
+
guide_version: version,
|
|
4715
|
+
rev,
|
|
4716
|
+
status,
|
|
4717
|
+
mission_key: str(raw.mission_key),
|
|
4718
|
+
step_key: str(raw.step_key),
|
|
4719
|
+
step_index: Number.isInteger(raw.step_index) ? raw.step_index : 0,
|
|
4720
|
+
checkpoint_key: str(raw.checkpoint_key),
|
|
4721
|
+
missions,
|
|
4722
|
+
context,
|
|
4723
|
+
welcome: WELCOMES.includes(raw.welcome) ? raw.welcome : "not_seen",
|
|
4724
|
+
chip_hidden: raw.chip_hidden === true,
|
|
4725
|
+
last_error: str(raw.last_error),
|
|
4726
|
+
skipped_step: str(raw.skipped_step),
|
|
4727
|
+
server_status: status,
|
|
4728
|
+
};
|
|
4729
|
+
}
|
|
4730
|
+
/**
|
|
4731
|
+
* The GET answer for one suite. Null — show nothing — for anything that is
|
|
4732
|
+
* not the expected shape (an older API, a proxy's page, another app's answer).
|
|
4733
|
+
*/
|
|
4734
|
+
function parseGuidesAnswer(payload, suite) {
|
|
4735
|
+
if (!isRecord(payload))
|
|
4736
|
+
return null;
|
|
4737
|
+
const data = isRecord(payload.data) ? payload.data : payload;
|
|
4738
|
+
const mode = data.mode;
|
|
4739
|
+
const audience = data.audience;
|
|
4740
|
+
if (mode !== "off" && mode !== "invite" && mode !== "forced")
|
|
4741
|
+
return null;
|
|
4742
|
+
if (audience !== "forced" && audience !== "optional" && audience !== "none")
|
|
4743
|
+
return null;
|
|
4744
|
+
if (typeof data.app === "string" && data.app !== suite.app)
|
|
4745
|
+
return null;
|
|
4746
|
+
const rows = Array.isArray(data.guides) ? data.guides : [];
|
|
4747
|
+
let row = null;
|
|
4748
|
+
for (const r of rows) {
|
|
4749
|
+
if (isRecord(r) && r.guide_key === suite.key) {
|
|
4750
|
+
row = parseRow(r);
|
|
4751
|
+
break;
|
|
4752
|
+
}
|
|
4753
|
+
}
|
|
4754
|
+
let progress;
|
|
4755
|
+
let newerOnServer = false;
|
|
4756
|
+
if (!row)
|
|
4757
|
+
progress = freshProgress(suite.version);
|
|
4758
|
+
else if (row.guide_version < suite.version) {
|
|
4759
|
+
// A new version of the tour: start over (the server resets the row on the
|
|
4760
|
+
// PUT); keep the rev the server will check.
|
|
4761
|
+
progress = { ...freshProgress(suite.version, row.rev), server_status: row.server_status };
|
|
4762
|
+
}
|
|
4763
|
+
else {
|
|
4764
|
+
progress = row;
|
|
4765
|
+
newerOnServer = row.guide_version > suite.version;
|
|
4766
|
+
}
|
|
4767
|
+
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer };
|
|
4768
|
+
}
|
|
4769
|
+
const TERMINAL = ["completed", "skipped", "aborted"];
|
|
4770
|
+
/** The PUT body for the client's state. Drops keys the server would refuse. */
|
|
4771
|
+
function putBody(p, app) {
|
|
4772
|
+
const key = (k) => (k && KEY_RE.test(k) ? k : null);
|
|
4773
|
+
const missions = {};
|
|
4774
|
+
for (const [k, m] of Object.entries(p.missions)) {
|
|
4775
|
+
if (!KEY_RE.test(k))
|
|
4776
|
+
continue;
|
|
4777
|
+
missions[k] = {
|
|
4778
|
+
status: m.status,
|
|
4779
|
+
step_key: key(m.step_key ?? null),
|
|
4780
|
+
skipped_step: key(m.skipped_step ?? null),
|
|
4781
|
+
};
|
|
4782
|
+
}
|
|
4783
|
+
const context = {};
|
|
4784
|
+
for (const [k, v] of Object.entries(p.context)) {
|
|
4785
|
+
if (typeof v === "string" && v.length <= 200)
|
|
4786
|
+
context[k] = v;
|
|
4787
|
+
}
|
|
4788
|
+
return {
|
|
4789
|
+
app,
|
|
4790
|
+
guide_version: p.guide_version,
|
|
4791
|
+
rev: p.rev,
|
|
4792
|
+
status: p.status,
|
|
4793
|
+
mission_key: key(p.mission_key),
|
|
4794
|
+
step_key: key(p.step_key),
|
|
4795
|
+
step_index: Math.max(0, Math.min(1000, Math.floor(p.step_index || 0))),
|
|
4796
|
+
checkpoint_key: key(p.checkpoint_key),
|
|
4797
|
+
missions,
|
|
4798
|
+
context,
|
|
4799
|
+
welcome: p.welcome,
|
|
4800
|
+
chip_hidden: p.chip_hidden,
|
|
4801
|
+
last_error: p.last_error ? p.last_error.slice(0, 128) : null,
|
|
4802
|
+
skipped_step: key(p.skipped_step),
|
|
4803
|
+
restart: p.status === "in_progress" &&
|
|
4804
|
+
p.server_status !== null &&
|
|
4805
|
+
TERMINAL.includes(p.server_status),
|
|
4806
|
+
};
|
|
4807
|
+
}
|
|
4808
|
+
const MISSION_RANK = { todo: 0, skipped: 1, active: 1, done: 3 };
|
|
4809
|
+
const WELCOME_RANK = { not_seen: 0, skipped: 1, started: 1 };
|
|
4810
|
+
/**
|
|
4811
|
+
* After a 409: this tab's state laid over the server's, keeping whatever is
|
|
4812
|
+
* further along — a mission done anywhere stays done, the welcome once seen
|
|
4813
|
+
* stays seen, a hidden chip stays hidden. Otherwise this tab's own choice
|
|
4814
|
+
* wins. The rev is the server's.
|
|
4815
|
+
*/
|
|
4816
|
+
function mergeProgress(local, remote) {
|
|
4817
|
+
const missions = { ...remote.missions };
|
|
4818
|
+
for (const [k, m] of Object.entries(local.missions)) {
|
|
4819
|
+
const r = remote.missions[k];
|
|
4820
|
+
missions[k] = !r || MISSION_RANK[m.status] >= MISSION_RANK[r.status] ? { ...r, ...m } : r;
|
|
4821
|
+
}
|
|
4822
|
+
const welcome = WELCOME_RANK[local.welcome] >= WELCOME_RANK[remote.welcome] ? local.welcome : remote.welcome;
|
|
4823
|
+
const completed = local.status === "completed" || remote.status === "completed";
|
|
4824
|
+
const mission_key = local.mission_key && missions[local.mission_key]?.status === "active"
|
|
4825
|
+
? local.mission_key
|
|
4826
|
+
: remote.mission_key && missions[remote.mission_key]?.status === "active"
|
|
4827
|
+
? remote.mission_key
|
|
4828
|
+
: null;
|
|
4829
|
+
const fromLocal = mission_key !== null && mission_key === local.mission_key;
|
|
4830
|
+
return {
|
|
4831
|
+
...local,
|
|
4832
|
+
rev: remote.rev,
|
|
4833
|
+
server_status: remote.server_status,
|
|
4834
|
+
status: completed ? "completed" : local.status,
|
|
4835
|
+
missions,
|
|
4836
|
+
welcome,
|
|
4837
|
+
chip_hidden: local.chip_hidden || remote.chip_hidden,
|
|
4838
|
+
mission_key,
|
|
4839
|
+
step_key: fromLocal ? local.step_key : mission_key ? remote.step_key : null,
|
|
4840
|
+
step_index: fromLocal ? local.step_index : mission_key ? remote.step_index : 0,
|
|
4841
|
+
checkpoint_key: fromLocal ? local.checkpoint_key : mission_key ? remote.checkpoint_key : null,
|
|
4842
|
+
context: fromLocal ? local.context : mission_key ? remote.context : {},
|
|
4843
|
+
};
|
|
4844
|
+
}
|
|
4845
|
+
/**
|
|
4846
|
+
* Which of two copies (sessionStorage after F5, the server) to resume from:
|
|
4847
|
+
* the one further along for the same version; the server's on a tie.
|
|
4848
|
+
*/
|
|
4849
|
+
function furtherProgress(a, b) {
|
|
4850
|
+
const score = (p) => Object.values(p.missions).reduce((s, m) => s + MISSION_RANK[m.status] * 1000, 0) +
|
|
4851
|
+
(p.mission_key ? 100 + p.step_index : 0) +
|
|
4852
|
+
WELCOME_RANK[p.welcome];
|
|
4853
|
+
return score(a) > score(b) ? a : b;
|
|
4854
|
+
}
|
|
4855
|
+
|
|
4856
|
+
/**
|
|
4857
|
+
* Talking to platform-api about tour progress (API.md): the GET that says
|
|
4858
|
+
* who sees what, and the PUT that saves, with its rev check.
|
|
4859
|
+
*/
|
|
4860
|
+
function base(app) {
|
|
4861
|
+
return platformApiBase(app);
|
|
4862
|
+
}
|
|
4863
|
+
/** GET …/guides/me?app=…; null (show nothing) on any failure or odd answer. */
|
|
4864
|
+
async function fetchGuides(organizationId, suite, signal) {
|
|
4865
|
+
try {
|
|
4866
|
+
const res = await instance.get(`${base(suite.app)}/organizations/${encodeURIComponent(organizationId)}/guides/me`, { params: { app: suite.app }, signal, erpOrganizationId: organizationId });
|
|
4867
|
+
return parseGuidesAnswer(res.data, suite);
|
|
4868
|
+
}
|
|
4869
|
+
catch {
|
|
4870
|
+
return null;
|
|
4871
|
+
}
|
|
4872
|
+
}
|
|
4873
|
+
/**
|
|
4874
|
+
* PUT the whole state. On 409 the server's row comes back: merge (keeping
|
|
4875
|
+
* whatever is further along) and try once more with its rev. Returns the
|
|
4876
|
+
* stored row, or null when it could not be saved (the tour goes on; the
|
|
4877
|
+
* session copy keeps the place).
|
|
4878
|
+
*/
|
|
4879
|
+
async function saveGuides(organizationId, suite, progress) {
|
|
4880
|
+
const url = `${base(suite.app)}/organizations/${encodeURIComponent(organizationId)}/guides/me/${encodeURIComponent(suite.key)}`;
|
|
4881
|
+
let body = progress;
|
|
4882
|
+
for (let attempt = 0; attempt < 2; attempt++) {
|
|
4883
|
+
let res;
|
|
4884
|
+
try {
|
|
4885
|
+
res = await instance.put(url, putBody(body, suite.app), {
|
|
4886
|
+
validateStatus: s => (s >= 200 && s < 300) || s === 409,
|
|
4887
|
+
erpOrganizationId: organizationId,
|
|
4888
|
+
});
|
|
4889
|
+
}
|
|
4890
|
+
catch {
|
|
4891
|
+
return null;
|
|
4892
|
+
}
|
|
4893
|
+
const payload = res.data;
|
|
4894
|
+
if (res.status !== 409) {
|
|
4895
|
+
return parseRow(payload?.data ?? payload);
|
|
4896
|
+
}
|
|
4897
|
+
const current = parseRow(payload?.current);
|
|
4898
|
+
if (current && current.guide_version > body.guide_version) {
|
|
4899
|
+
// Another app build is ahead of this one: stop writing.
|
|
4900
|
+
return null;
|
|
4901
|
+
}
|
|
4902
|
+
if (!current)
|
|
4903
|
+
body = { ...body, rev: 0, server_status: null };
|
|
4904
|
+
else if (current.guide_version < body.guide_version) {
|
|
4905
|
+
// The server's row is the previous version: this one replaces it.
|
|
4906
|
+
body = { ...body, rev: current.rev, server_status: current.server_status };
|
|
4907
|
+
}
|
|
4908
|
+
else
|
|
4909
|
+
body = mergeProgress(body, current);
|
|
4910
|
+
}
|
|
4911
|
+
return null;
|
|
4912
|
+
}
|
|
4913
|
+
|
|
4914
|
+
/**
|
|
4915
|
+
* The tour's DOM side: finding anchors, deciding whether one is really on
|
|
4916
|
+
* screen, and the rectangles the spotlight cuts out. Browser only; every
|
|
4917
|
+
* function tolerates a missing document.
|
|
4918
|
+
*/
|
|
4919
|
+
const hasDocument = () => typeof document !== "undefined";
|
|
4920
|
+
function cssString(value) {
|
|
4921
|
+
return `"${value.replace(/["\\]/g, "\\$&")}"`;
|
|
4922
|
+
}
|
|
4923
|
+
function anchorSelector(anchor, id) {
|
|
4924
|
+
return (`[${GUIDE_ATTRIBUTE}=${cssString(anchor)}]` +
|
|
4925
|
+
(id ? `[${GUIDE_ID_ATTRIBUTE}=${cssString(id)}]` : ""));
|
|
4926
|
+
}
|
|
4927
|
+
/**
|
|
4928
|
+
* On screen: connected, with a size, not hidden by style, not inside
|
|
4929
|
+
* aria-hidden or inert (other than the guide's own), not a closed Radix part.
|
|
4930
|
+
*
|
|
4931
|
+
* aria-hidden that Radix put there (data-aria-hidden: everything outside an
|
|
4932
|
+
* open dialog, select or menu is hidden from screen readers) does not count:
|
|
4933
|
+
* a dialog's field is still on screen while its select's list is open.
|
|
4934
|
+
*/
|
|
4935
|
+
function isPresent(el) {
|
|
4936
|
+
if (!el || !el.isConnected)
|
|
4937
|
+
return false;
|
|
4938
|
+
const r = el.getBoundingClientRect();
|
|
4939
|
+
if (r.width <= 0 || r.height <= 0)
|
|
4940
|
+
return false;
|
|
4941
|
+
// Slid off the side (a closed drawer); off the top or bottom is only
|
|
4942
|
+
// scrolled away, and the tour scrolls it back.
|
|
4943
|
+
if (r.right <= 0 || r.left >= window.innerWidth)
|
|
4944
|
+
return false;
|
|
4945
|
+
if (el.getAttribute("data-state") === "closed")
|
|
4946
|
+
return false;
|
|
4947
|
+
const style = getComputedStyle(el);
|
|
4948
|
+
if (style.visibility === "hidden" || style.display === "none")
|
|
4949
|
+
return false;
|
|
4950
|
+
for (let n = el; n; n = n.parentElement) {
|
|
4951
|
+
if (n.hasAttribute(GUIDE_LAYER_ATTRIBUTE))
|
|
4952
|
+
break;
|
|
4953
|
+
if (n.hasAttribute("inert"))
|
|
4954
|
+
return false;
|
|
4955
|
+
if (n.getAttribute("aria-hidden") === "true" && !n.hasAttribute("data-aria-hidden"))
|
|
4956
|
+
return false;
|
|
4957
|
+
}
|
|
4958
|
+
return true;
|
|
4959
|
+
}
|
|
4960
|
+
/** The first matching anchor that is on screen. */
|
|
4961
|
+
function findAnchor(anchor, id) {
|
|
4962
|
+
if (!hasDocument() || !anchor)
|
|
4963
|
+
return null;
|
|
4964
|
+
let list;
|
|
4965
|
+
try {
|
|
4966
|
+
list = document.querySelectorAll(anchorSelector(anchor, id));
|
|
4967
|
+
}
|
|
4968
|
+
catch {
|
|
4969
|
+
return null;
|
|
4970
|
+
}
|
|
4971
|
+
for (const el of Array.from(list)) {
|
|
4972
|
+
if (isPresent(el))
|
|
4973
|
+
return el;
|
|
4974
|
+
}
|
|
4975
|
+
return null;
|
|
4976
|
+
}
|
|
4977
|
+
function anchorVisible(anchor, id) {
|
|
4978
|
+
return findAnchor(anchor, id) !== null;
|
|
4979
|
+
}
|
|
4980
|
+
const FIELD = "input:not([type=hidden]),textarea,select,[contenteditable=''],[contenteditable=true]";
|
|
4981
|
+
/** The field to type into: the anchor itself, or the first field inside it. */
|
|
4982
|
+
function fieldOf(el) {
|
|
4983
|
+
if (!el)
|
|
4984
|
+
return null;
|
|
4985
|
+
if (el.matches(FIELD))
|
|
4986
|
+
return el;
|
|
4987
|
+
return el.querySelector(FIELD);
|
|
4988
|
+
}
|
|
4989
|
+
function fieldValue(el) {
|
|
4990
|
+
const f = fieldOf(el);
|
|
4991
|
+
if (!f)
|
|
4992
|
+
return "";
|
|
4993
|
+
if ("value" in f && typeof f.value === "string")
|
|
4994
|
+
return f.value;
|
|
4995
|
+
return f.textContent ?? "";
|
|
4996
|
+
}
|
|
4997
|
+
const FOCUSABLE = "a[href],button:not([disabled]),input:not([disabled]):not([type=hidden]),select:not([disabled]),textarea:not([disabled]),[tabindex]:not([tabindex='-1']),[contenteditable=''],[contenteditable=true]";
|
|
4998
|
+
function focusablesIn(root) {
|
|
4999
|
+
if (!root)
|
|
5000
|
+
return [];
|
|
5001
|
+
const own = root.matches(FOCUSABLE) ? [root] : [];
|
|
5002
|
+
return own.concat(Array.from(root.querySelectorAll(FOCUSABLE)).filter(isPresent));
|
|
5003
|
+
}
|
|
5004
|
+
function rectOf(el) {
|
|
5005
|
+
const r = el.getBoundingClientRect();
|
|
5006
|
+
return { x: r.left, y: r.top, width: r.width, height: r.height };
|
|
5007
|
+
}
|
|
5008
|
+
function padRect(r, pad) {
|
|
5009
|
+
return { x: r.x - pad, y: r.y - pad, width: r.width + pad * 2, height: r.height + pad * 2 };
|
|
5010
|
+
}
|
|
5011
|
+
function sameRect(a, b) {
|
|
5012
|
+
if (!a || !b)
|
|
5013
|
+
return a === b;
|
|
5014
|
+
return (Math.abs(a.x - b.x) < 0.5 &&
|
|
5015
|
+
Math.abs(a.y - b.y) < 0.5 &&
|
|
5016
|
+
Math.abs(a.width - b.width) < 0.5 &&
|
|
5017
|
+
Math.abs(a.height - b.height) < 0.5);
|
|
5018
|
+
}
|
|
5019
|
+
/** Open Radix poppers (a select's list, a menu): usable while a step runs. */
|
|
5020
|
+
const POPPER_SELECTOR = "[data-radix-popper-content-wrapper]";
|
|
5021
|
+
/** Toasts stay usable above the tour. */
|
|
5022
|
+
const TOASTER_SELECTOR = "[data-sonner-toaster]";
|
|
5023
|
+
function openPoppers() {
|
|
5024
|
+
if (!hasDocument())
|
|
5025
|
+
return [];
|
|
5026
|
+
return Array.from(document.querySelectorAll(POPPER_SELECTOR)).filter(isPresent);
|
|
5027
|
+
}
|
|
5028
|
+
function inGuideLayer(node) {
|
|
5029
|
+
if (!node || typeof Node === "undefined" || !(node instanceof Node))
|
|
5030
|
+
return false;
|
|
5031
|
+
const el = node instanceof Element ? node : node.parentElement;
|
|
5032
|
+
return !!el?.closest(`[${GUIDE_LAYER_ATTRIBUTE}]`);
|
|
5033
|
+
}
|
|
5034
|
+
function prefersReducedMotion() {
|
|
5035
|
+
return typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
5036
|
+
}
|
|
5037
|
+
function isPhoneWidth() {
|
|
5038
|
+
return typeof window !== "undefined" && window.innerWidth < 768;
|
|
5039
|
+
}
|
|
5040
|
+
/** Scrolls the target to the middle when it is out of view (instant under reduced motion). */
|
|
5041
|
+
function revealTarget(el) {
|
|
5042
|
+
const r = el.getBoundingClientRect();
|
|
5043
|
+
const vh = window.visualViewport?.height ?? window.innerHeight;
|
|
5044
|
+
if (r.top < 70 || r.bottom > vh - 20) {
|
|
5045
|
+
try {
|
|
5046
|
+
el.scrollIntoView({ block: "center", behavior: prefersReducedMotion() ? "auto" : "smooth" });
|
|
5047
|
+
}
|
|
5048
|
+
catch {
|
|
5049
|
+
el.scrollIntoView();
|
|
5050
|
+
}
|
|
5051
|
+
}
|
|
5052
|
+
}
|
|
5053
|
+
|
|
5054
|
+
/**
|
|
5055
|
+
* The tour's stylesheet. The shell has no CSS pipeline and the apps only scan
|
|
5056
|
+
* its dist for Tailwind classes, which cannot carry keyframes; so the layer
|
|
5057
|
+
* brings its own sheet, namespaced `eg-`, drawn from the theme's tokens
|
|
5058
|
+
* (--primary carries each app's brand accent; light and dark follow).
|
|
5059
|
+
*/
|
|
5060
|
+
const GUIDE_CSS = `
|
|
5061
|
+
[data-erp-guide-layer]{position:fixed;inset:0;z-index:1000;pointer-events:none;font-family:inherit;color:var(--popover-foreground,#1c2634);
|
|
5062
|
+
--eg-scrim:rgba(12,20,34,.64);--eg-card:var(--popover,#fff);--eg-fg:var(--popover-foreground,#1c2634);
|
|
5063
|
+
--eg-muted:var(--muted-foreground,#6d7886);--eg-soft:var(--muted,#eef3f9);--eg-line:var(--border,#e8ecf1);
|
|
5064
|
+
--eg-primary:var(--primary,#3280dd);--eg-on-primary:var(--primary-foreground,#fff);
|
|
5065
|
+
--eg-tint:color-mix(in oklab,var(--eg-primary) 13%,var(--eg-card));--eg-strong:color-mix(in oklab,var(--eg-primary) 82%,#000);
|
|
5066
|
+
--eg-danger:var(--destructive,#d23b4b);--eg-ok:#1d9a5f;--eg-gold:#e6a417;
|
|
5067
|
+
--eg-shadow:0 18px 50px -12px rgba(16,32,64,.35),0 4px 12px rgba(16,32,64,.08);line-height:1.5;font-size:14px}
|
|
5068
|
+
[data-erp-guide-layer] *{box-sizing:border-box}
|
|
5069
|
+
[data-erp-guide-layer] button{font:inherit;color:inherit;cursor:pointer}
|
|
5070
|
+
.eg-i{pointer-events:auto}
|
|
5071
|
+
.eg-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
5072
|
+
.eg-scrim-svg{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
|
|
5073
|
+
.eg-scrim-svg path{fill:var(--eg-scrim);pointer-events:visiblePainted;fill-rule:evenodd}
|
|
5074
|
+
.eg-scrim{position:fixed;inset:0;background:var(--eg-scrim);pointer-events:auto}
|
|
5075
|
+
.eg-scrim.eg-light{background:rgba(12,20,34,.45)}
|
|
5076
|
+
.eg-scrim.eg-faint{background:rgba(12,20,34,.35)}
|
|
5077
|
+
.eg-ring{position:fixed;border-radius:12px;pointer-events:none;box-shadow:0 0 0 3px #fff,0 0 0 6px var(--eg-primary)}
|
|
5078
|
+
.eg-ring.eg-move{transition:left .26s ease,top .26s ease,width .26s ease,height .26s ease}
|
|
5079
|
+
.eg-ring::after{content:"";position:absolute;inset:-6px;border-radius:15px;border:3px solid var(--eg-primary);animation:eg-pulse 1.6s ease-out infinite}
|
|
5080
|
+
.eg-ring.eg-flash::after{animation:eg-flash .5s ease 1}
|
|
5081
|
+
@keyframes eg-pulse{0%{opacity:.9;transform:scale(1)}100%{opacity:0;transform:scale(1.12)}}
|
|
5082
|
+
@keyframes eg-flash{0%,100%{opacity:1;transform:none}50%{opacity:.2}}
|
|
5083
|
+
.eg-hand{position:fixed;width:46px;height:46px;pointer-events:none;filter:drop-shadow(0 4px 6px rgba(0,0,0,.35));animation:eg-tap 1.1s ease-in-out infinite}
|
|
5084
|
+
.eg-hand svg{width:46px;height:46px;fill:#fff;stroke:#1c2634;stroke-width:1.4;stroke-linejoin:round;stroke-linecap:round}
|
|
5085
|
+
.eg-hand.eg-flip svg{transform:scaleY(-1)}
|
|
5086
|
+
@keyframes eg-tap{0%,100%{transform:translate(6px,8px)}50%{transform:translate(0,0)}}
|
|
5087
|
+
.eg-bubble{position:fixed;left:0;top:0;width:320px;background:var(--eg-card);color:var(--eg-fg);border-radius:14px;box-shadow:var(--eg-shadow);padding:14px 16px 12px;border-top:4px solid var(--eg-primary);outline:none;pointer-events:auto}
|
|
5088
|
+
.eg-bubble.eg-err{border-top-color:var(--eg-danger)}
|
|
5089
|
+
.eg-bubble.eg-nudge{animation:eg-nudge .38s ease}
|
|
5090
|
+
@keyframes eg-nudge{0%,100%{translate:0}20%{translate:-7px}40%{translate:6px}60%{translate:-4px}80%{translate:2px}}
|
|
5091
|
+
.eg-b-top{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;color:var(--eg-muted)}
|
|
5092
|
+
.eg-b-top .eg-mis{font-weight:600;color:var(--eg-strong);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5093
|
+
.eg-b-top .eg-cnt{white-space:nowrap;font-weight:600}
|
|
5094
|
+
.eg-pips{display:flex;gap:4px;margin:8px 0 10px}
|
|
5095
|
+
.eg-pips i{flex:1;height:4px;border-radius:2px;background:var(--eg-soft)}
|
|
5096
|
+
.eg-pips i.eg-d{background:var(--eg-primary)}
|
|
5097
|
+
.eg-pips i.eg-c{background:color-mix(in oklab,var(--eg-primary) 45%,var(--eg-card))}
|
|
5098
|
+
.eg-b-t{margin:0;font-size:16px;font-weight:600;line-height:1.45}
|
|
5099
|
+
.eg-b-w{margin:2px 0 0;font-size:13.5px;color:var(--eg-muted)}
|
|
5100
|
+
.eg-bubble.eg-err .eg-b-t{color:var(--eg-danger)}
|
|
5101
|
+
.eg-k{display:inline-block;background:var(--eg-tint);color:var(--eg-strong);border-radius:7px;padding:0 7px;font-weight:600}
|
|
5102
|
+
.eg-b-f{display:flex;align-items:center;justify-content:space-between;margin-top:12px;gap:8px}
|
|
5103
|
+
.eg-b-f .eg-btn{height:34px;padding:0 14px;font-size:13.5px}
|
|
5104
|
+
.eg-exit{border:0;background:none;color:var(--eg-muted);font-size:12.5px;padding:4px 0;text-decoration:underline;text-underline-offset:3px}
|
|
5105
|
+
.eg-exit:hover{color:var(--eg-fg)}
|
|
5106
|
+
.eg-arrow{position:absolute;width:14px;height:14px;background:var(--eg-card);transform:rotate(45deg);border-radius:2px}
|
|
5107
|
+
.eg-arrow.eg-top{background:var(--eg-primary)}
|
|
5108
|
+
.eg-bubble.eg-err .eg-arrow.eg-top{background:var(--eg-danger)}
|
|
5109
|
+
.eg-btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;height:40px;padding:0 16px;border-radius:10px;border:1px solid transparent;font-size:14px;font-weight:600;white-space:nowrap}
|
|
5110
|
+
.eg-btn:focus-visible,.eg-exit:focus-visible,.eg-chip:focus-visible,.eg-x:focus-visible{outline:2px solid var(--eg-primary);outline-offset:2px}
|
|
5111
|
+
.eg-primary{background:var(--eg-primary);color:var(--eg-on-primary)!important}
|
|
5112
|
+
.eg-primary:hover{background:var(--eg-strong)}
|
|
5113
|
+
.eg-primary[disabled]{opacity:.55;cursor:default}
|
|
5114
|
+
.eg-ghost{background:var(--eg-card);border-color:var(--eg-line)}
|
|
5115
|
+
.eg-ghost:hover{background:var(--eg-soft)}
|
|
5116
|
+
.eg-ghost[disabled]{opacity:.55;cursor:default}
|
|
5117
|
+
.eg-link{background:none;border:0;color:var(--eg-muted);font-weight:500;padding:0 6px;height:32px}
|
|
5118
|
+
.eg-link:hover{color:var(--eg-fg);text-decoration:underline}
|
|
5119
|
+
.eg-modal{position:fixed;left:50%;top:50%;transform:translate(-50%,-50%);width:min(440px,calc(100vw - 32px));max-height:calc(100vh - 32px);overflow:auto;background:var(--eg-card);color:var(--eg-fg);border-radius:20px;box-shadow:var(--eg-shadow);padding:28px 28px 22px;text-align:center;outline:none;pointer-events:auto}
|
|
5120
|
+
.eg-modal.eg-pop{animation:eg-pop .32s cubic-bezier(.2,.9,.3,1.3)}
|
|
5121
|
+
@keyframes eg-pop{from{opacity:0;transform:translate(-50%,-46%) scale(.94)}}
|
|
5122
|
+
.eg-modal h2{margin:6px 0 4px;font-size:22px;font-weight:700;letter-spacing:-.2px;line-height:1.3}
|
|
5123
|
+
.eg-modal .eg-sub{margin:0;color:var(--eg-muted);font-size:14.5px}
|
|
5124
|
+
.eg-eyebrow{margin:0;font-size:12.5px;font-weight:600;letter-spacing:.3px;color:var(--eg-strong)}
|
|
5125
|
+
.eg-actions{display:grid;gap:6px;margin-top:20px}
|
|
5126
|
+
.eg-actions .eg-btn{height:44px}
|
|
5127
|
+
.eg-badge{width:64px;height:64px;border-radius:18px;margin:0 auto 12px;display:grid;place-items:center;background:var(--eg-tint);color:var(--eg-strong)}
|
|
5128
|
+
.eg-badge svg.eg-ic{width:30px;height:30px}
|
|
5129
|
+
.eg-quests{list-style:none;padding:0;margin:18px 0 0;text-align:left;display:grid;gap:6px}
|
|
5130
|
+
.eg-quests li{display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:11px;background:var(--eg-soft);font-size:14px}
|
|
5131
|
+
.eg-quests li .eg-n{width:26px;height:26px;border-radius:50%;background:var(--eg-card);border:1.5px solid var(--eg-line);display:grid;place-items:center;font-size:12px;font-weight:700;color:var(--eg-muted);flex:none}
|
|
5132
|
+
.eg-quests li.eg-first{background:var(--eg-tint)}
|
|
5133
|
+
.eg-quests li.eg-first .eg-n{border-color:var(--eg-primary);color:var(--eg-strong)}
|
|
5134
|
+
.eg-quests li.eg-ok .eg-n{background:var(--eg-ok);border-color:var(--eg-ok);color:#fff}
|
|
5135
|
+
.eg-quests li .eg-n svg.eg-ic{width:15px;height:15px;stroke-width:3}
|
|
5136
|
+
.eg-quests li .eg-t{flex:1}
|
|
5137
|
+
.eg-quests li small{color:var(--eg-muted);font-size:12px;white-space:nowrap}
|
|
5138
|
+
.eg-modal.eg-confirm{text-align:left;width:min(380px,calc(100vw - 32px));padding:22px}
|
|
5139
|
+
.eg-modal.eg-confirm h2{font-size:18px;margin-top:0}
|
|
5140
|
+
.eg-modal.eg-confirm .eg-actions{grid-template-columns:1fr 1fr}
|
|
5141
|
+
.eg-medal{width:84px;height:84px;border-radius:50%;margin:4px auto 10px;display:grid;place-items:center;color:#fff;background:radial-gradient(circle at 35% 30%,#ffd666,var(--eg-gold));box-shadow:0 0 0 8px #fff4d6;animation:eg-stamp .5s cubic-bezier(.2,.9,.3,1.4) .1s both}
|
|
5142
|
+
.eg-medal svg.eg-ic{width:42px;height:42px;stroke-width:2.6}
|
|
5143
|
+
@keyframes eg-stamp{from{transform:scale(.3) rotate(-25deg);opacity:0}}
|
|
5144
|
+
.eg-prog{display:flex;align-items:center;gap:10px;margin-top:16px;font-size:13px;color:var(--eg-muted);font-weight:600}
|
|
5145
|
+
.eg-prog .eg-bar{flex:1;height:8px;border-radius:4px;background:var(--eg-soft);overflow:hidden}
|
|
5146
|
+
.eg-prog .eg-bar i{display:block;height:100%;background:var(--eg-primary);border-radius:4px;transition:width .9s cubic-bezier(.2,.8,.2,1) .35s}
|
|
5147
|
+
.eg-burst{position:absolute;left:50%;top:70px;width:0;height:0;pointer-events:none}
|
|
5148
|
+
.eg-burst span{position:absolute;width:8px;height:12px;border-radius:2px;opacity:0;animation:eg-conf 1.1s cubic-bezier(.15,.7,.3,1) forwards}
|
|
5149
|
+
@keyframes eg-conf{0%{opacity:1;transform:translate(0,0) rotate(0)}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
|
|
5150
|
+
.eg-card{position:fixed;left:50%;bottom:28px;transform:translateX(-50%);width:min(360px,calc(100vw - 32px));background:var(--eg-card);color:var(--eg-fg);border-radius:14px;box-shadow:var(--eg-shadow);padding:14px 16px;border-top:4px solid var(--eg-primary);pointer-events:auto;outline:none}
|
|
5151
|
+
.eg-card .eg-row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}
|
|
5152
|
+
.eg-loading{display:flex;align-items:center;gap:10px;font-weight:600}
|
|
5153
|
+
.eg-spin{width:18px;height:18px;border-radius:50%;border:2.5px solid var(--eg-soft);border-top-color:var(--eg-primary);animation:eg-spin .8s linear infinite;flex:none}
|
|
5154
|
+
@keyframes eg-spin{to{transform:rotate(360deg)}}
|
|
5155
|
+
.eg-chip{position:fixed;right:22px;bottom:22px;display:flex;align-items:center;gap:10px;height:48px;padding:0 18px 0 8px;border-radius:24px;border:1px solid var(--eg-line);background:var(--eg-card);color:var(--eg-fg);box-shadow:0 8px 24px -8px rgba(16,32,64,.3);font-weight:600;font-size:14px;pointer-events:auto}
|
|
5156
|
+
.eg-chip svg{width:34px;height:34px}
|
|
5157
|
+
.eg-chip.eg-hi{animation:eg-hi 1.2s ease 2}
|
|
5158
|
+
@keyframes eg-hi{50%{box-shadow:0 0 0 6px color-mix(in oklab,var(--eg-primary) 35%,transparent),0 8px 24px -8px rgba(16,32,64,.3)}}
|
|
5159
|
+
.eg-panel{position:fixed;right:22px;bottom:82px;width:360px;max-height:calc(100vh - 110px);overflow:auto;background:var(--eg-card);color:var(--eg-fg);border:1px solid var(--eg-line);border-radius:16px;box-shadow:var(--eg-shadow);outline:none;pointer-events:auto}
|
|
5160
|
+
.eg-panel-bd{display:none}
|
|
5161
|
+
.eg-q-h{display:flex;align-items:center;gap:10px;padding:16px 12px 4px 18px}
|
|
5162
|
+
.eg-q-h h2{margin:0;font-size:16px;font-weight:700;flex:1}
|
|
5163
|
+
.eg-q-h .eg-n{font-size:13px;color:var(--eg-muted);font-weight:600}
|
|
5164
|
+
.eg-x{width:34px;height:34px;border-radius:10px;border:0;background:none;display:grid;place-items:center;color:var(--eg-muted)}
|
|
5165
|
+
.eg-x:hover{background:var(--eg-soft)}
|
|
5166
|
+
.eg-x svg.eg-ic{width:18px;height:18px}
|
|
5167
|
+
.eg-panel .eg-prog{margin:6px 18px 8px}
|
|
5168
|
+
.eg-q-list{list-style:none;margin:0;padding:4px 10px 6px;display:grid;gap:2px}
|
|
5169
|
+
.eg-q-item{display:flex;align-items:center;gap:12px;padding:10px 8px;border-radius:11px}
|
|
5170
|
+
.eg-q-item.eg-next{background:var(--eg-tint)}
|
|
5171
|
+
.eg-q-item .eg-n{width:30px;height:30px;border-radius:50%;border:1.5px solid var(--eg-line);display:grid;place-items:center;font-size:12.5px;font-weight:700;color:var(--eg-muted);flex:none;background:var(--eg-card)}
|
|
5172
|
+
.eg-q-item.eg-next .eg-n{border-color:var(--eg-primary);color:var(--eg-strong)}
|
|
5173
|
+
.eg-q-item.eg-done .eg-n{background:var(--eg-ok);border-color:var(--eg-ok);color:#fff}
|
|
5174
|
+
.eg-q-item.eg-done .eg-n svg.eg-ic{width:16px;height:16px;stroke-width:3}
|
|
5175
|
+
.eg-q-item.eg-dim .eg-n{opacity:.55}
|
|
5176
|
+
.eg-q-item.eg-just .eg-n{animation:eg-stamp .55s cubic-bezier(.2,.9,.3,1.4) .2s both}
|
|
5177
|
+
.eg-q-item .eg-t{flex:1;min-width:0}
|
|
5178
|
+
.eg-q-item .eg-t b{display:block;font-size:14px;font-weight:600}
|
|
5179
|
+
.eg-q-item.eg-done .eg-t b{color:var(--eg-muted)}
|
|
5180
|
+
.eg-q-item .eg-t small{display:block;font-size:12px;color:var(--eg-muted)}
|
|
5181
|
+
.eg-q-item .eg-t small.eg-sk{color:#b06a00}
|
|
5182
|
+
.eg-q-item .eg-btn{height:32px;padding:0 12px;font-size:13px}
|
|
5183
|
+
.eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
|
5184
|
+
.eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
|
|
5185
|
+
.eg-q-f{border-top:1px solid var(--eg-line);padding:8px 12px;display:flex;justify-content:flex-end}
|
|
5186
|
+
.eg-toast{position:fixed;left:50%;bottom:84px;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}
|
|
5187
|
+
svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
|
5188
|
+
@media (max-width:767px){
|
|
5189
|
+
.eg-bubble{width:auto}
|
|
5190
|
+
.eg-arrow{display:none}
|
|
5191
|
+
.eg-modal{top:auto;bottom:12px;transform:translateX(-50%);width:calc(100vw - 24px);padding:24px 20px 18px}
|
|
5192
|
+
.eg-modal.eg-pop{animation:eg-up .3s cubic-bezier(.2,.9,.3,1.1)}
|
|
5193
|
+
.eg-modal .eg-actions .eg-btn{height:46px}
|
|
5194
|
+
.eg-chip{right:16px;bottom:calc(16px + env(safe-area-inset-bottom))}
|
|
5195
|
+
.eg-panel-bd{display:block;position:fixed;inset:0;background:rgba(15,23,42,.38);pointer-events:auto}
|
|
5196
|
+
.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)}
|
|
5197
|
+
.eg-q-item{padding:12px 8px}
|
|
5198
|
+
.eg-card{bottom:calc(12px + env(safe-area-inset-bottom))}
|
|
5199
|
+
.eg-toast{bottom:calc(76px + env(safe-area-inset-bottom))}
|
|
5200
|
+
}
|
|
5201
|
+
@keyframes eg-up{from{opacity:0;transform:translate(-50%,30px)}}
|
|
5202
|
+
@media (prefers-reduced-motion:reduce){
|
|
5203
|
+
[data-erp-guide-layer] *,[data-erp-guide-layer] *::before,[data-erp-guide-layer] *::after{animation-duration:.001ms!important;animation-iteration-count:1!important;transition-duration:.001ms!important}
|
|
5204
|
+
.eg-ring::after{animation:none!important;opacity:1;transform:none}
|
|
5205
|
+
.eg-ring.eg-flash::after{animation:eg-flash .5s ease 1!important;animation-duration:.5s!important}
|
|
5206
|
+
.eg-hand{animation:none!important;transform:translate(4px,6px)}
|
|
5207
|
+
.eg-burst{display:none}
|
|
5208
|
+
.eg-bubble.eg-nudge{animation:none!important;border-top-color:var(--eg-danger)}
|
|
5209
|
+
}
|
|
5210
|
+
`;
|
|
5211
|
+
|
|
5212
|
+
/* ------------------------------------------------------------------ */
|
|
5213
|
+
/* Bits */
|
|
5214
|
+
/* ------------------------------------------------------------------ */
|
|
5215
|
+
const TH_MONTHS = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."];
|
|
5216
|
+
const pad2 = (n) => String(n).padStart(2, "0");
|
|
5217
|
+
/** "26 ต.ค. 2569 13:04" — day, month, Buddhist year, 24 h. */
|
|
5218
|
+
function formatThaiDateTime(iso) {
|
|
5219
|
+
if (!iso)
|
|
5220
|
+
return "";
|
|
5221
|
+
const d = new Date(iso);
|
|
5222
|
+
if (Number.isNaN(d.getTime()))
|
|
5223
|
+
return "";
|
|
5224
|
+
return `${d.getDate()} ${TH_MONTHS[d.getMonth()]} ${d.getFullYear() + 543} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
5225
|
+
}
|
|
5226
|
+
/** "กด [บันทึก]": the bracketed word is a chip in the brand colour. */
|
|
5227
|
+
function GuideTitle({ text }) {
|
|
5228
|
+
const parts = text.split(/(\[[^\]]+\])/g).filter(Boolean);
|
|
5229
|
+
return (jsxRuntime.jsx(jsxRuntime.Fragment, { children: parts.map((p, i) => p.startsWith("[") && p.endsWith("]") ? (jsxRuntime.jsx("span", { className: "eg-k", children: p.slice(1, -1) }, i)) : (jsxRuntime.jsx(React__namespace.Fragment, { children: p }, i))) }));
|
|
5230
|
+
}
|
|
5231
|
+
const Svg = ({ children, className = "eg-ic" }) => (jsxRuntime.jsx("svg", { className: className, viewBox: "0 0 24 24", "aria-hidden": "true", children: children }));
|
|
5232
|
+
const IconCheck = () => (jsxRuntime.jsx(Svg, { children: jsxRuntime.jsx("path", { d: "M20 6 9 17l-5-5" }) }));
|
|
5233
|
+
const IconX = () => (jsxRuntime.jsx(Svg, { children: jsxRuntime.jsx("path", { d: "M18 6 6 18M6 6l12 12" }) }));
|
|
5234
|
+
const IconFlag = () => (jsxRuntime.jsxs(Svg, { children: [jsxRuntime.jsx("path", { d: "M4 15s1-1 4-1 5 2 8 2 4-1 4-1V3s-1 1-4 1-5-2-8-2-4 1-4 1z" }), jsxRuntime.jsx("path", { d: "M4 22v-7" })] }));
|
|
5235
|
+
const IconTrophy = () => (jsxRuntime.jsx(Svg, { children: jsxRuntime.jsx("path", { d: "M6 9H4.5a2.5 2.5 0 0 1 0-5H6M18 9h1.5a2.5 2.5 0 0 0 0-5H18M4 22h16M10 14.66V17c0 .55-.47.98-.97 1.21C7.85 18.75 7 20.24 7 22M14 14.66V17c0 .55.47.98.97 1.21C16.15 18.75 17 20.24 17 22M18 2H6v7a6 6 0 0 0 12 0V2Z" }) }));
|
|
5236
|
+
const HAND_PATH = "M18 11V9.5a2 2 0 0 0-4 0V9a2 2 0 0 0-4 0v.5V4a2 2 0 0 0-4 0v10l-1.6-1.6a2 2 0 0 0-2.83 2.83l3.6 3.6C6.66 20.3 8.6 22 11.5 22H14a8 8 0 0 0 8-8v-3a2 2 0 0 0-4 0z";
|
|
5237
|
+
/** Rounded-rectangle subpath for the scrim's holes. */
|
|
5238
|
+
function roundRect(r, radius) {
|
|
5239
|
+
const rad = Math.max(0, Math.min(radius, r.width / 2, r.height / 2));
|
|
5240
|
+
const { x, y, width: w, height: h } = r;
|
|
5241
|
+
return (`M${x + rad} ${y}H${x + w - rad}A${rad} ${rad} 0 0 1 ${x + w} ${y + rad}` +
|
|
5242
|
+
`V${y + h - rad}A${rad} ${rad} 0 0 1 ${x + w - rad} ${y + h}` +
|
|
5243
|
+
`H${x + rad}A${rad} ${rad} 0 0 1 ${x} ${y + h - rad}` +
|
|
5244
|
+
`V${y + rad}A${rad} ${rad} 0 0 1 ${x + rad} ${y}Z`);
|
|
5245
|
+
}
|
|
5246
|
+
/**
|
|
5247
|
+
* The dim layer that eats every press outside the holes. One SVG path with
|
|
5248
|
+
* even-odd holes: a press inside a hole reaches the page underneath, any
|
|
5249
|
+
* other lands on the path (marked data-eg-block) and is cancelled.
|
|
5250
|
+
*/
|
|
5251
|
+
function Scrim({ holes, width, height }) {
|
|
5252
|
+
const outer = `M-10 -10H${width + 10}V${height + 10}H-10Z`;
|
|
5253
|
+
const d = outer + holes.map(h => roundRect(h, 12)).join("");
|
|
5254
|
+
return (jsxRuntime.jsx("svg", { className: "eg-scrim-svg", width: width, height: height, "aria-hidden": "true", children: jsxRuntime.jsx("path", { d: d, "data-eg-block": "" }) }));
|
|
5255
|
+
}
|
|
5256
|
+
const BUBBLE_TITLE_ID = "eg-bubble-title";
|
|
5257
|
+
const BUBBLE_WHY_ID = "eg-bubble-why";
|
|
5258
|
+
function useNudge(nudge, ref) {
|
|
5259
|
+
const first = React.useRef(nudge);
|
|
5260
|
+
React.useEffect(() => {
|
|
5261
|
+
if (nudge === first.current)
|
|
5262
|
+
return;
|
|
5263
|
+
const el = ref.current;
|
|
5264
|
+
if (!el)
|
|
5265
|
+
return;
|
|
5266
|
+
el.classList.remove("eg-nudge");
|
|
5267
|
+
void el.offsetWidth;
|
|
5268
|
+
el.classList.add("eg-nudge");
|
|
5269
|
+
}, [nudge, ref]);
|
|
5270
|
+
}
|
|
5271
|
+
function StepLayer(p) {
|
|
5272
|
+
const { spot, phone, viewport } = p;
|
|
5273
|
+
const bubbleRef = React.useRef(null);
|
|
5274
|
+
const arrowRef = React.useRef(null);
|
|
5275
|
+
const ringRef = React.useRef(null);
|
|
5276
|
+
const [pos, setPos] = React.useState(null);
|
|
5277
|
+
useNudge(p.nudge, bubbleRef);
|
|
5278
|
+
// Flash the ring with each nudge too (a press outside the hole).
|
|
5279
|
+
const firstNudge = React.useRef(p.nudge);
|
|
5280
|
+
React.useEffect(() => {
|
|
5281
|
+
if (p.nudge === firstNudge.current)
|
|
5282
|
+
return;
|
|
5283
|
+
const el = ringRef.current;
|
|
5284
|
+
if (!el)
|
|
5285
|
+
return;
|
|
5286
|
+
el.classList.remove("eg-flash");
|
|
5287
|
+
void el.offsetWidth;
|
|
5288
|
+
el.classList.add("eg-flash");
|
|
5289
|
+
}, [p.nudge]);
|
|
5290
|
+
const showBubble = p.phase === "ready" && (!!spot || !p.hasTarget);
|
|
5291
|
+
const gap = p.showHand ? 34 : 14;
|
|
5292
|
+
const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
|
|
5293
|
+
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.showNext}`;
|
|
5294
|
+
React.useLayoutEffect(() => {
|
|
5295
|
+
const el = bubbleRef.current;
|
|
5296
|
+
if (!el || !showBubble)
|
|
5297
|
+
return;
|
|
5298
|
+
const m = 16;
|
|
5299
|
+
if (!spot) {
|
|
5300
|
+
// Nothing to point at: the middle of the screen (the bottom on a phone).
|
|
5301
|
+
const bw = el.offsetWidth;
|
|
5302
|
+
const bh = el.offsetHeight;
|
|
5303
|
+
setPos(phone
|
|
5304
|
+
? { left: m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
|
|
5305
|
+
: { left: (window.innerWidth - bw) / 2, top: viewport.offsetTop + (viewport.height - bh) / 2, side: "dock" });
|
|
5306
|
+
return;
|
|
5307
|
+
}
|
|
5308
|
+
if (phone) {
|
|
5309
|
+
// Docked to the top or the bottom of the screen, whichever covers less
|
|
5310
|
+
// of the target (target in the lower half: top).
|
|
5311
|
+
const vh = viewport.height;
|
|
5312
|
+
const oy = viewport.offsetTop;
|
|
5313
|
+
const bh = el.offsetHeight;
|
|
5314
|
+
const topT = oy + m + 4;
|
|
5315
|
+
const botT = oy + vh - bh - m;
|
|
5316
|
+
const { y, height: h } = spot;
|
|
5317
|
+
const overlapTop = Math.max(0, topT + bh + 8 - y);
|
|
5318
|
+
const overlapBot = Math.max(0, y + h + gap - botT);
|
|
5319
|
+
const top = y + h / 2 > vh / 2
|
|
5320
|
+
? overlapTop <= overlapBot
|
|
5321
|
+
? topT
|
|
5322
|
+
: botT
|
|
5323
|
+
: overlapBot <= overlapTop
|
|
5324
|
+
? botT
|
|
5325
|
+
: topT;
|
|
5326
|
+
setPos({ left: m, top, side: "dock" });
|
|
5327
|
+
return;
|
|
5328
|
+
}
|
|
5329
|
+
let cancelled = false;
|
|
5330
|
+
const ref = {
|
|
5331
|
+
getBoundingClientRect: () => ({
|
|
5332
|
+
x: spot.x,
|
|
5333
|
+
y: spot.y,
|
|
5334
|
+
left: spot.x,
|
|
5335
|
+
top: spot.y,
|
|
5336
|
+
width: spot.width,
|
|
5337
|
+
height: spot.height,
|
|
5338
|
+
right: spot.x + spot.width,
|
|
5339
|
+
bottom: spot.y + spot.height,
|
|
5340
|
+
}),
|
|
5341
|
+
};
|
|
5342
|
+
dom.computePosition(ref, el, {
|
|
5343
|
+
strategy: "fixed",
|
|
5344
|
+
placement: "right",
|
|
5345
|
+
middleware: [
|
|
5346
|
+
dom.offset(({ placement }) => (placement.startsWith("bottom") ? gap : 14)),
|
|
5347
|
+
dom.flip({ fallbackPlacements: ["bottom", "top", "left"], padding: m }),
|
|
5348
|
+
dom.shift({ padding: m }),
|
|
5349
|
+
...(arrowRef.current ? [dom.arrow({ element: arrowRef.current, padding: 14 })] : []),
|
|
5350
|
+
],
|
|
5351
|
+
})
|
|
5352
|
+
.then(({ x, y, placement, middlewareData }) => {
|
|
5353
|
+
if (cancelled)
|
|
5354
|
+
return;
|
|
5355
|
+
const bw = el.offsetWidth;
|
|
5356
|
+
const bh = el.offsetHeight;
|
|
5357
|
+
const fits = x >= m - 1 && y >= m - 1 && x + bw <= window.innerWidth - m + 1 && y + bh <= viewport.height + viewport.offsetTop - m + 1;
|
|
5358
|
+
const overlaps = x < spot.x + spot.width && x + bw > spot.x && y < spot.y + spot.height && y + bh > spot.y;
|
|
5359
|
+
if (!fits || overlaps) {
|
|
5360
|
+
// Nowhere beside it: the bottom-right corner.
|
|
5361
|
+
setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
|
|
5362
|
+
return;
|
|
5363
|
+
}
|
|
5364
|
+
const side = placement.split("-")[0];
|
|
5365
|
+
setPos({ left: x, top: y, side, ax: middlewareData.arrow?.x, ay: middlewareData.arrow?.y });
|
|
5366
|
+
})
|
|
5367
|
+
.catch(() => {
|
|
5368
|
+
if (!cancelled)
|
|
5369
|
+
setPos({ left: m, top: m, side: "dock" });
|
|
5370
|
+
});
|
|
5371
|
+
return () => {
|
|
5372
|
+
cancelled = true;
|
|
5373
|
+
};
|
|
5374
|
+
// spotKey/contentKey stand for spot and the bubble's size.
|
|
5375
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5376
|
+
}, [spotKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
|
|
5377
|
+
const holes = [...(spot ? [spot] : []), ...p.holes];
|
|
5378
|
+
const hand = spot && p.showHand && p.phase === "ready" ? handPosition(spot, viewport.height) : null;
|
|
5379
|
+
let arrowStyle;
|
|
5380
|
+
if (pos && pos.side !== "dock") {
|
|
5381
|
+
if (pos.side === "right")
|
|
5382
|
+
arrowStyle = { left: -7, top: pos.ay };
|
|
5383
|
+
else if (pos.side === "left")
|
|
5384
|
+
arrowStyle = { right: -7, top: pos.ay };
|
|
5385
|
+
else if (pos.side === "bottom")
|
|
5386
|
+
arrowStyle = { top: -9, left: pos.ax };
|
|
5387
|
+
else
|
|
5388
|
+
arrowStyle = { bottom: -7, left: pos.ax };
|
|
5389
|
+
}
|
|
5390
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Scrim, { holes: p.phase === "ready" ? holes : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && p.phase === "ready" ? (jsxRuntime.jsx("div", { ref: ringRef, className: "eg-ring" + (p.moving ? " eg-move" : ""), style: { left: spot.x, top: spot.y, width: spot.width, height: spot.height }, "aria-hidden": "true" })) : null, hand ? (jsxRuntime.jsx("div", { className: "eg-hand" + (hand.flip ? " eg-flip" : ""), style: { left: hand.left, top: hand.top }, "aria-hidden": "true", children: jsxRuntime.jsx("svg", { viewBox: "0 0 24 24", children: jsxRuntime.jsx("path", { d: HAND_PATH }) }) })) : null, showBubble ? (jsxRuntime.jsxs("div", { ref: bubbleRef, className: "eg-bubble" + (p.bubble.error ? " eg-err" : ""), role: "dialog", "aria-modal": "false", "aria-labelledby": BUBBLE_TITLE_ID, "aria-describedby": BUBBLE_WHY_ID, tabIndex: -1, "data-eg-bubble": "", style: {
|
|
5391
|
+
left: pos?.left ?? 0,
|
|
5392
|
+
top: pos?.top ?? 0,
|
|
5393
|
+
width: phone ? viewport.width - 32 : undefined,
|
|
5394
|
+
visibility: pos ? "visible" : "hidden",
|
|
5395
|
+
}, 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: "\u0E40\u0E1B\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48\u0E1B\u0E38\u0E48\u0E21 ? \u0E21\u0E38\u0E21\u0E02\u0E27\u0E32\u0E1A\u0E19", leaveLabel: "\u0E2D\u0E2D\u0E01\u0E08\u0E32\u0E01\u0E44\u0E01\u0E14\u0E4C", stayLabel: "\u0E17\u0E33\u0E15\u0E48\u0E2D", onLeave: p.onExitConfirm, onStay: p.onExitCancel })) : null] }));
|
|
5396
|
+
}
|
|
5397
|
+
function BubbleBody(p) {
|
|
5398
|
+
const b = p.bubble;
|
|
5399
|
+
const title = b.error ? b.error.title : b.title;
|
|
5400
|
+
const why = b.error ? (b.error.detail ?? "") : b.why;
|
|
5401
|
+
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: why }), 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] })] })] }));
|
|
5402
|
+
}
|
|
5403
|
+
/** The fingertip at ≈62% across and down the target; upside down near the bottom. */
|
|
5404
|
+
function handPosition(spot, viewportHeight) {
|
|
5405
|
+
const { x, y, width: w, height: h } = spot;
|
|
5406
|
+
const px = x + Math.min(w * 0.62, w - 14);
|
|
5407
|
+
const py = y + h * 0.62;
|
|
5408
|
+
const flip = py + 50 > viewportHeight;
|
|
5409
|
+
return { left: px - 11.5, top: flip ? py - h * 0.3 - 42 : py - 4, flip };
|
|
5410
|
+
}
|
|
5411
|
+
/* ------------------------------------------------------------------ */
|
|
5412
|
+
/* Modals */
|
|
5413
|
+
/* ------------------------------------------------------------------ */
|
|
5414
|
+
function useAutoFocus() {
|
|
5415
|
+
const ref = React.useRef(null);
|
|
5416
|
+
React.useEffect(() => {
|
|
5417
|
+
ref.current?.focus({ preventScroll: true });
|
|
5418
|
+
}, []);
|
|
5419
|
+
return ref;
|
|
5420
|
+
}
|
|
5421
|
+
function ConfirmModal(p) {
|
|
5422
|
+
const stay = useAutoFocus();
|
|
5423
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-scrim eg-faint", "data-eg-block": "", onClick: p.onStay }), jsxRuntime.jsxs("div", { className: "eg-modal eg-confirm eg-pop", role: "alertdialog", "aria-modal": "true", "aria-labelledby": "eg-c-t", "aria-describedby": "eg-c-d", "data-eg-modal": "", children: [jsxRuntime.jsx("h2", { id: "eg-c-t", children: p.title }), jsxRuntime.jsx("p", { className: "eg-sub", id: "eg-c-d", style: { fontSize: 14 }, children: p.line }), jsxRuntime.jsxs("div", { className: "eg-actions", children: [jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: p.onLeave, children: p.leaveLabel }), jsxRuntime.jsx("button", { type: "button", ref: stay, className: "eg-btn eg-primary", onClick: p.onStay, children: p.stayLabel })] })] })] }));
|
|
5424
|
+
}
|
|
5425
|
+
function WelcomeModal(p) {
|
|
5426
|
+
const start = useAutoFocus();
|
|
5427
|
+
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-w-t", "data-eg-modal": "", tabIndex: -1, children: [jsxRuntime.jsx("div", { className: "eg-badge", children: jsxRuntime.jsx(IconFlag, {}) }), jsxRuntime.jsx("p", { className: "eg-eyebrow", children: "\u0E44\u0E01\u0E14\u0E4C\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsx("h2", { id: "eg-w-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: i === 0 ? "eg-first" : undefined, children: [jsxRuntime.jsx("span", { className: "eg-n", children: i + 1 }), jsxRuntime.jsx("span", { className: "eg-t", children: m.title }), jsxRuntime.jsxs("small", { children: [m.minutes, " \u0E19\u0E32\u0E17\u0E35"] })] }, i))) }), jsxRuntime.jsxs("div", { className: "eg-actions", children: [jsxRuntime.jsx("button", { type: "button", ref: start, className: "eg-btn eg-primary", onClick: p.onStart, children: "\u0E40\u0E23\u0E34\u0E48\u0E21\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E41\u0E23\u0E01" }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onSkip, children: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E1B\u0E01\u0E48\u0E2D\u0E19" })] })] }), p.confirm ? (jsxRuntime.jsx(ConfirmModal, { title: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E01\u0E14\u0E4C\u0E44\u0E1B\u0E01\u0E48\u0E2D\u0E19?", line: "\u0E40\u0E1B\u0E34\u0E14\u0E15\u0E48\u0E2D\u0E44\u0E14\u0E49\u0E17\u0E35\u0E48\u0E1B\u0E38\u0E48\u0E21 ? \u0E21\u0E38\u0E21\u0E02\u0E27\u0E32\u0E1A\u0E19", leaveLabel: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E1B\u0E01\u0E48\u0E2D\u0E19", stayLabel: "\u0E40\u0E23\u0E34\u0E48\u0E21\u0E40\u0E25\u0E22", onLeave: p.onSkipConfirm, onStay: p.onSkipCancel })) : null] }));
|
|
5428
|
+
}
|
|
5429
|
+
function GateModal(p) {
|
|
5430
|
+
const go = useAutoFocus();
|
|
5431
|
+
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-g-t", "data-eg-modal": "", tabIndex: -1, children: [jsxRuntime.jsx("div", { className: "eg-badge", children: jsxRuntime.jsx(IconFlag, {}) }), jsxRuntime.jsx("p", { className: "eg-eyebrow", children: "\u0E44\u0E01\u0E14\u0E4C\u0E40\u0E23\u0E34\u0E48\u0E21\u0E15\u0E49\u0E19" }), jsxRuntime.jsx("h2", { id: "eg-g-t", children: p.title }), p.subtitle ? jsxRuntime.jsx("p", { className: "eg-sub", children: p.subtitle }) : null, jsxRuntime.jsxs("div", { className: "eg-actions", children: [jsxRuntime.jsx("a", { ref: go, className: "eg-btn eg-primary", href: p.href, style: { textDecoration: "none" }, children: p.actionLabel }), jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: p.onLater, children: "\u0E44\u0E27\u0E49\u0E17\u0E35\u0E2B\u0E25\u0E31\u0E07" })] })] })] }));
|
|
5432
|
+
}
|
|
5433
|
+
const CONFETTI_COLOURS = ["#3b82f6", "#7db7ff", "#e6a417", "#ff7a6b", "#1d9a5f", "#b0d2ff"];
|
|
5434
|
+
function Confetti({ reduced }) {
|
|
5435
|
+
// Fixed per mount, so a re-render does not throw new pieces.
|
|
5436
|
+
const [pieces] = React.useState(() => Array.from({ length: 26 }, (_, i) => {
|
|
5437
|
+
const a = (Math.PI * 2 * i) / 26 + Math.random() * 0.3;
|
|
5438
|
+
const dist = 90 + Math.random() * 90;
|
|
5439
|
+
return {
|
|
5440
|
+
background: CONFETTI_COLOURS[i % CONFETTI_COLOURS.length],
|
|
5441
|
+
"--x": `${Math.cos(a) * dist}px`,
|
|
5442
|
+
"--y": `${Math.sin(a) * dist + 40}px`,
|
|
5443
|
+
"--r": `${Math.random() * 540 - 270}deg`,
|
|
5444
|
+
animationDelay: `${Math.random() * 0.12}s`,
|
|
5445
|
+
};
|
|
5446
|
+
}));
|
|
5447
|
+
if (reduced)
|
|
5448
|
+
return null;
|
|
5449
|
+
return (jsxRuntime.jsx("div", { className: "eg-burst", "aria-hidden": "true", children: pieces.map((s, i) => (jsxRuntime.jsx("span", { style: s }, i))) }));
|
|
5450
|
+
}
|
|
5451
|
+
function ProgressBar({ from, to, total, label }) {
|
|
5452
|
+
const [width, setWidth] = React.useState(total ? (from / total) * 100 : 0);
|
|
5453
|
+
React.useEffect(() => {
|
|
5454
|
+
// Two frames so the bar is drawn at `from` before it runs to `to`.
|
|
5455
|
+
let raf2 = 0;
|
|
5456
|
+
const raf1 = requestAnimationFrame(() => {
|
|
5457
|
+
raf2 = requestAnimationFrame(() => setWidth(total ? (to / total) * 100 : 0));
|
|
5458
|
+
});
|
|
5459
|
+
return () => {
|
|
5460
|
+
cancelAnimationFrame(raf1);
|
|
5461
|
+
cancelAnimationFrame(raf2);
|
|
5462
|
+
};
|
|
5463
|
+
}, [to, total]);
|
|
5464
|
+
return (jsxRuntime.jsxs("div", { className: "eg-prog", children: [jsxRuntime.jsx("div", { className: "eg-bar", children: jsxRuntime.jsx("i", { style: { width: `${width}%` } }) }), jsxRuntime.jsx("span", { children: label })] }));
|
|
5465
|
+
}
|
|
5466
|
+
function CelebrateModal(p) {
|
|
5467
|
+
const primary = useAutoFocus();
|
|
5468
|
+
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx("div", { className: "eg-scrim eg-light", "data-eg-block": "" }), jsxRuntime.jsxs("div", { className: "eg-modal eg-pop", role: "dialog", "aria-modal": "true", "aria-labelledby": "eg-cel-t", "data-eg-modal": "", tabIndex: -1, children: [jsxRuntime.jsx(Confetti, { reduced: p.reduced }), jsxRuntime.jsx("div", { className: "eg-medal", children: jsxRuntime.jsx(IconCheck, {}) }), jsxRuntime.jsxs("p", { className: "eg-eyebrow", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", p.missionNo, " \u0E2A\u0E33\u0E40\u0E23\u0E47\u0E08"] }), jsxRuntime.jsx("h2", { id: "eg-cel-t", children: p.title }), p.subtitle ? jsxRuntime.jsx("p", { className: "eg-sub", children: p.subtitle }) : null, jsxRuntime.jsx(ProgressBar, { from: p.reduced ? p.done : Math.max(0, p.done - 1), to: p.done, total: p.total, label: `${p.done}/${p.total} ภารกิจ` }), jsxRuntime.jsxs("div", { className: "eg-actions", children: [p.nextTitle ? (jsxRuntime.jsxs("button", { type: "button", ref: primary, className: "eg-btn eg-primary", onClick: p.onNext, children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E16\u0E31\u0E14\u0E44\u0E1B: ", p.nextTitle] })) : null, jsxRuntime.jsx("button", { type: "button", ref: p.nextTitle ? undefined : primary, className: "eg-btn eg-link", onClick: p.onRest, children: "\u0E1E\u0E31\u0E01\u0E01\u0E48\u0E2D\u0E19" })] })] })] }));
|
|
5469
|
+
}
|
|
5470
|
+
function CompleteModal(p) {
|
|
5471
|
+
const primary = useAutoFocus();
|
|
5472
|
+
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] })] })] }));
|
|
5473
|
+
}
|
|
5474
|
+
/* ------------------------------------------------------------------ */
|
|
5475
|
+
/* Quest chip and panel */
|
|
5476
|
+
/* ------------------------------------------------------------------ */
|
|
5477
|
+
function QuestChip({ done, total, pulse, onOpen }) {
|
|
5478
|
+
const ref = React.useRef(null);
|
|
5479
|
+
const first = React.useRef(pulse);
|
|
5480
|
+
React.useEffect(() => {
|
|
5481
|
+
if (pulse === first.current)
|
|
5482
|
+
return;
|
|
5483
|
+
const el = ref.current;
|
|
5484
|
+
if (!el)
|
|
5485
|
+
return;
|
|
5486
|
+
el.classList.remove("eg-hi");
|
|
5487
|
+
void el.offsetWidth;
|
|
5488
|
+
el.classList.add("eg-hi");
|
|
5489
|
+
}, [pulse]);
|
|
5490
|
+
const C = 2 * Math.PI * 14;
|
|
5491
|
+
const pct = total ? done / total : 0;
|
|
5492
|
+
return (jsxRuntime.jsxs("button", { ref: ref, type: "button", className: "eg-chip", onClick: onOpen, "aria-label": `ภารกิจเริ่มต้น เสร็จ ${done} จาก ${total}`, children: [jsxRuntime.jsxs("svg", { viewBox: "0 0 34 34", "aria-hidden": "true", children: [jsxRuntime.jsx("circle", { cx: "17", cy: "17", r: "14", fill: "none", stroke: "var(--eg-soft)", strokeWidth: "4" }), done ? (jsxRuntime.jsx("circle", { cx: "17", cy: "17", r: "14", fill: "none", stroke: "var(--eg-primary)", strokeWidth: "4", strokeLinecap: "round", strokeDasharray: `${C * pct} ${C}`, transform: "rotate(-90 17 17)" })) : null] }), "\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", done, "/", total] }));
|
|
5493
|
+
}
|
|
5494
|
+
function QuestPanel(p) {
|
|
5495
|
+
const ref = useAutoFocus();
|
|
5496
|
+
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] })] }));
|
|
5497
|
+
}
|
|
5498
|
+
function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
5499
|
+
const v = item.view;
|
|
5500
|
+
const ticked = v === "done" || v === "already";
|
|
5501
|
+
const cls = "eg-q-item" +
|
|
5502
|
+
(ticked ? " eg-done" : v === "next" ? " eg-next" : "") +
|
|
5503
|
+
(v === "locked" || v === "waiting" ? " eg-dim" : "") +
|
|
5504
|
+
(just ? " eg-just" : "");
|
|
5505
|
+
let meta = `ประมาณ ${item.minutes} นาที`;
|
|
5506
|
+
if (v === "done")
|
|
5507
|
+
meta = item.completedAt ? `เสร็จ ${formatThaiDateTime(item.completedAt)}` : "เสร็จแล้ว";
|
|
5508
|
+
else if (v === "already")
|
|
5509
|
+
meta = "ทำไว้แล้ว";
|
|
5510
|
+
else if (v === "skipped")
|
|
5511
|
+
meta = jsxRuntime.jsx("span", { children: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E27\u0E49" });
|
|
5512
|
+
else if (v === "waiting" && gate)
|
|
5513
|
+
meta = gate.pending;
|
|
5514
|
+
let button;
|
|
5515
|
+
if (v === "done")
|
|
5516
|
+
button = jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onStart(item.key, true), children: "\u0E17\u0E33\u0E0B\u0E49\u0E33" });
|
|
5517
|
+
else if (v === "already")
|
|
5518
|
+
button = jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onStart(item.key, true), children: "\u0E14\u0E39\u0E27\u0E34\u0E18\u0E35\u0E17\u0E33" });
|
|
5519
|
+
else if (v === "waiting" && gate)
|
|
5520
|
+
button = (jsxRuntime.jsx("a", { className: "eg-btn eg-ghost", href: gate.href, style: { textDecoration: "none" }, children: gate.label }));
|
|
5521
|
+
else if (v === "locked")
|
|
5522
|
+
button = (jsxRuntime.jsxs("button", { type: "button", className: "eg-btn eg-ghost", disabled: true, children: ["\u0E17\u0E33 ", item.lockedBy, " \u0E01\u0E48\u0E2D\u0E19"] }));
|
|
5523
|
+
else
|
|
5524
|
+
button = (jsxRuntime.jsx("button", { type: "button", className: "eg-btn " + (v === "next" ? "eg-primary" : "eg-ghost"), onClick: () => onStart(item.key, false), children: v === "skipped" || v === "active" ? "ทำต่อ" : "เริ่ม" }));
|
|
5525
|
+
const dismissable = item.optional && (v === "todo" || v === "next");
|
|
5526
|
+
return (jsxRuntime.jsxs("li", { className: cls, children: [jsxRuntime.jsx("span", { className: "eg-n", children: ticked ? jsxRuntime.jsx(IconCheck, {}) : n }), jsxRuntime.jsxs("div", { className: "eg-t", children: [jsxRuntime.jsx("b", { children: item.title }), jsxRuntime.jsx("small", { className: v === "skipped" ? "eg-sk" : undefined, children: meta })] }), dismissable ? (jsxRuntime.jsxs("span", { className: "eg-q-acts", children: [button, jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-link", onClick: () => onDismiss(item.key), children: "\u0E44\u0E21\u0E48\u0E43\u0E0A\u0E49\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08\u0E19\u0E35\u0E49" })] })) : (button)] }));
|
|
5527
|
+
}
|
|
5528
|
+
function GuideToast({ text }) {
|
|
5529
|
+
return (jsxRuntime.jsx("div", { className: "eg-toast", role: "status", children: text }));
|
|
5530
|
+
}
|
|
5531
|
+
|
|
5532
|
+
const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
|
|
5533
|
+
const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
|
|
5534
|
+
const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
|
|
5535
|
+
/**
|
|
5536
|
+
* Shown when the API refuses every request because the signed-in account has
|
|
5537
|
+
* no employee record in this organisation.
|
|
5538
|
+
*
|
|
5539
|
+
* A gate rather than a banner. Leave, attendance, approvals and payslips all
|
|
5540
|
+
* identify a person by that record, so once the server started enforcing it
|
|
5541
|
+
* every call fails — and without something that says so, the app becomes a
|
|
5542
|
+
* screen of error toasts with no explanation and no way forward.
|
|
5543
|
+
*/
|
|
5544
|
+
function EmployeeRequiredGate({ platformBaseUrl, }) {
|
|
5545
|
+
const [blocked, setBlocked] = React.useState(false);
|
|
5546
|
+
const params = navigation.useParams();
|
|
5547
|
+
const code = params?.code ?? "";
|
|
5548
|
+
React.useEffect(() => {
|
|
5549
|
+
const onBlocked = () => setBlocked(true);
|
|
5550
|
+
const onOk = () => setBlocked(false);
|
|
5551
|
+
window.addEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
5552
|
+
window.addEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
5553
|
+
return () => {
|
|
5554
|
+
window.removeEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
5555
|
+
window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
5556
|
+
};
|
|
5557
|
+
}, []);
|
|
5558
|
+
if (!blocked)
|
|
5559
|
+
return null;
|
|
5560
|
+
const base = platformBaseUrl?.replace(/\/$/, "") ?? "";
|
|
5561
|
+
const createHref = code ? `${base}/${code}/employee/create` : `${base}/`;
|
|
5562
|
+
return (jsxRuntime.jsx("div", { className: "fixed inset-0 z-[100] flex items-center justify-center bg-background/95 p-6 backdrop-blur-sm", children: jsxRuntime.jsxs("div", { className: "w-full max-w-md space-y-5 text-center", children: [jsxRuntime.jsx(lucideReact.ContactRound, { className: "mx-auto h-12 w-12 text-muted-foreground" }), jsxRuntime.jsxs("div", { className: "space-y-2", children: [jsxRuntime.jsx("h1", { className: "text-lg font-semibold tracking-tight", children: "\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E19\u0E35\u0E49\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E1C\u0E39\u0E01\u0E01\u0E31\u0E1A\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E25\u0E32 \u0E25\u0E07\u0E40\u0E27\u0E25\u0E32 \u0E40\u0E1A\u0E34\u0E01\u0E40\u0E07\u0E34\u0E19 \u0E41\u0E25\u0E30\u0E01\u0E32\u0E23\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34 \u0E23\u0E30\u0E1A\u0E38\u0E15\u0E31\u0E27\u0E04\u0E19\u0E08\u0E32\u0E01\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19 \u0E08\u0E36\u0E07\u0E22\u0E31\u0E07\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E43\u0E19\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49" })] }), jsxRuntime.jsxs("div", { className: "flex flex-wrap justify-center gap-2", children: [jsxRuntime.jsx(erpTheme.Button, { onClick: () => window.location.reload(), children: "\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48" }), jsxRuntime.jsx(erpTheme.Button, { variant: "outline", asChild: true, children: jsxRuntime.jsxs("a", { href: createHref, children: [jsxRuntime.jsx(lucideReact.UserPlus, { className: "mr-1 h-4 w-4" }), "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19"] }) })] }), jsxRuntime.jsx("p", { className: "text-xs text-muted-foreground", children: "\u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E41\u0E01\u0E49\u0E44\u0E02 \u0E43\u0E2B\u0E49\u0E41\u0E08\u0E49\u0E07\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] }) }));
|
|
5563
|
+
}
|
|
5564
|
+
|
|
5565
|
+
/* ------------------------------------------------------------------ */
|
|
5566
|
+
/* Fail-open wrappers */
|
|
5567
|
+
/* ------------------------------------------------------------------ */
|
|
5568
|
+
class GuideErrorBoundary extends React__namespace.Component {
|
|
5569
|
+
constructor() {
|
|
5570
|
+
super(...arguments);
|
|
5571
|
+
Object.defineProperty(this, "state", {
|
|
5572
|
+
enumerable: true,
|
|
5573
|
+
configurable: true,
|
|
5574
|
+
writable: true,
|
|
5575
|
+
value: { failed: false }
|
|
5576
|
+
});
|
|
5577
|
+
}
|
|
5578
|
+
static getDerivedStateFromError() {
|
|
5579
|
+
return { failed: true };
|
|
5580
|
+
}
|
|
5581
|
+
componentDidCatch(error) {
|
|
5582
|
+
if (typeof console !== "undefined")
|
|
5583
|
+
console.warn("[erp-shell guide] hidden after an error", error);
|
|
5584
|
+
try {
|
|
5585
|
+
publishGuideEntry(null, null);
|
|
5586
|
+
}
|
|
5587
|
+
catch {
|
|
5588
|
+
/* ignore */
|
|
5589
|
+
}
|
|
5590
|
+
}
|
|
5591
|
+
render() {
|
|
5592
|
+
return this.state.failed ? null : this.props.children;
|
|
5593
|
+
}
|
|
5594
|
+
}
|
|
5595
|
+
function GuideHost(props) {
|
|
5596
|
+
return (jsxRuntime.jsx(GuideErrorBoundary, { children: jsxRuntime.jsx(React.Suspense, { fallback: null, children: jsxRuntime.jsx(GuideHostInner, { ...props }) }) }));
|
|
5597
|
+
}
|
|
5598
|
+
let fallbackClient = null;
|
|
5599
|
+
function useShellQueryClient() {
|
|
5600
|
+
const provided = React.useContext(reactQuery.QueryClientContext);
|
|
5601
|
+
if (provided)
|
|
5602
|
+
return provided;
|
|
5603
|
+
fallbackClient ?? (fallbackClient = new reactQuery.QueryClient());
|
|
5604
|
+
return fallbackClient;
|
|
5605
|
+
}
|
|
5606
|
+
/* ------------------------------------------------------------------ */
|
|
5607
|
+
/* Session mirror (instant resume after F5) */
|
|
5608
|
+
/* ------------------------------------------------------------------ */
|
|
5609
|
+
const mirrorKey = (orgId, suite) => `erp-guide:${orgId}:${suite.key}`;
|
|
5610
|
+
function readMirror(orgId, suite) {
|
|
5611
|
+
try {
|
|
5612
|
+
const raw = sessionStorage.getItem(mirrorKey(orgId, suite));
|
|
5613
|
+
if (!raw)
|
|
5614
|
+
return null;
|
|
5615
|
+
const parsed = JSON.parse(raw);
|
|
5616
|
+
const row = parseRow(parsed);
|
|
5617
|
+
if (!row || row.guide_version !== suite.version)
|
|
5618
|
+
return null;
|
|
5619
|
+
return { ...row, server_status: parsed.server_status ?? null };
|
|
5620
|
+
}
|
|
5621
|
+
catch {
|
|
5622
|
+
return null;
|
|
5623
|
+
}
|
|
5624
|
+
}
|
|
5625
|
+
function writeMirror(orgId, suite, p) {
|
|
5626
|
+
try {
|
|
5627
|
+
sessionStorage.setItem(mirrorKey(orgId, suite), JSON.stringify(p));
|
|
5628
|
+
}
|
|
5629
|
+
catch {
|
|
5630
|
+
/* private mode: the server copy is enough */
|
|
5631
|
+
}
|
|
5632
|
+
}
|
|
5633
|
+
/* ------------------------------------------------------------------ */
|
|
5634
|
+
/* One tab at a time */
|
|
5635
|
+
/* ------------------------------------------------------------------ */
|
|
5636
|
+
/**
|
|
5637
|
+
* Whether this tab runs the tour. With two tabs open, the first holds a Web
|
|
5638
|
+
* Lock and the second shows nothing until the first goes away.
|
|
5639
|
+
*/
|
|
5640
|
+
function usePrimaryTab(name) {
|
|
5641
|
+
const [primary, setPrimary] = React.useState(false);
|
|
5642
|
+
React.useEffect(() => {
|
|
5643
|
+
const locks = (typeof navigator !== "undefined" ? navigator.locks : undefined);
|
|
5644
|
+
if (!locks?.request) {
|
|
5645
|
+
setPrimary(true);
|
|
5646
|
+
return;
|
|
5647
|
+
}
|
|
5648
|
+
const abort = new AbortController();
|
|
5649
|
+
let release = null;
|
|
5650
|
+
locks
|
|
5651
|
+
.request(name, { signal: abort.signal }, () => {
|
|
5652
|
+
setPrimary(true);
|
|
5653
|
+
return new Promise(resolve => {
|
|
5654
|
+
release = resolve;
|
|
5655
|
+
});
|
|
5656
|
+
})
|
|
5657
|
+
.catch(() => {
|
|
5658
|
+
/* aborted on unmount */
|
|
5659
|
+
});
|
|
5660
|
+
return () => {
|
|
5661
|
+
abort.abort();
|
|
5662
|
+
release?.();
|
|
5663
|
+
setPrimary(false);
|
|
5664
|
+
};
|
|
5665
|
+
}, [name]);
|
|
5666
|
+
return primary;
|
|
5667
|
+
}
|
|
5668
|
+
/* ------------------------------------------------------------------ */
|
|
5669
|
+
/* Host */
|
|
5670
|
+
/* ------------------------------------------------------------------ */
|
|
5671
|
+
const TARGET_PAD = 6;
|
|
5672
|
+
const LOST_GRACE_MS = 350;
|
|
5673
|
+
const WAIT_MS = 8000;
|
|
5674
|
+
const WAIT_CAP_MS = 30000;
|
|
5675
|
+
const WELCOME_DELAY_MS = 500;
|
|
5676
|
+
const CELEBRATE_DELAY_MS = 450;
|
|
5677
|
+
const SAVE_DEBOUNCE_MS = 600;
|
|
5678
|
+
function GuideHostInner({ config, organizationId, code }) {
|
|
5679
|
+
const suite = config.suite;
|
|
5680
|
+
const client = useShellQueryClient();
|
|
5681
|
+
const pathname = navigation.usePathname() ?? "/";
|
|
5682
|
+
const searchParams = navigation.useSearchParams();
|
|
5683
|
+
const search = searchParams?.toString() ?? "";
|
|
5684
|
+
const router = navigation.useRouter();
|
|
5685
|
+
const { open: sidebarOpen } = useSidebar();
|
|
5686
|
+
const fetching = reactQuery.useIsFetching({ predicate: q => q.queryKey[0] !== "erp-shell" }, client);
|
|
5687
|
+
const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
|
|
5688
|
+
// The shell's EmployeeRequiredGate is up: no tour over it.
|
|
5689
|
+
const [employeeBlocked, setEmployeeBlocked] = React.useState(false);
|
|
5690
|
+
React.useEffect(() => {
|
|
5691
|
+
const on = () => setEmployeeBlocked(true);
|
|
5692
|
+
const off = () => setEmployeeBlocked(false);
|
|
5693
|
+
window.addEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, on);
|
|
5694
|
+
window.addEventListener(EMPLOYEE_RECORD_OK_EVENT, off);
|
|
5695
|
+
return () => {
|
|
5696
|
+
window.removeEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, on);
|
|
5697
|
+
window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, off);
|
|
5698
|
+
};
|
|
5699
|
+
}, []);
|
|
5700
|
+
const query = reactQuery.useQuery({
|
|
5701
|
+
queryKey: ["erp-shell", "guides", organizationId, suite.app, suite.key, suite.version],
|
|
5702
|
+
queryFn: ({ signal }) => fetchGuides(organizationId, suite, signal),
|
|
5703
|
+
enabled: primary && config.ready !== false,
|
|
5704
|
+
staleTime: 5 * 60000,
|
|
5705
|
+
gcTime: 30 * 60000,
|
|
5706
|
+
retry: false,
|
|
5707
|
+
refetchOnWindowFocus: false,
|
|
5708
|
+
}, client);
|
|
5709
|
+
const answer = query.data ?? null;
|
|
5710
|
+
const live = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && config.ready !== false && !employeeBlocked;
|
|
5711
|
+
/* ---------------- machine ---------------- */
|
|
5712
|
+
const [state, setState] = React.useState(null);
|
|
5713
|
+
const stateRef = React.useRef(null);
|
|
5714
|
+
React.useEffect(() => {
|
|
5715
|
+
if (!answer || answer.audience === "none" || answer.newerOnServer) {
|
|
5716
|
+
stateRef.current = null;
|
|
5717
|
+
setState(null);
|
|
5718
|
+
return;
|
|
5719
|
+
}
|
|
5720
|
+
if (stateRef.current)
|
|
5721
|
+
return;
|
|
5722
|
+
// Resume from whichever copy is further along: the server's, or this
|
|
5723
|
+
// tab's session copy from before a refresh (its rev is the server's).
|
|
5724
|
+
const mirror = readMirror(organizationId, suite);
|
|
5725
|
+
let progress = answer.progress;
|
|
5726
|
+
if (mirror && furtherProgress(mirror, progress) === mirror)
|
|
5727
|
+
progress = mergeProgress(mirror, progress);
|
|
5728
|
+
const s = initialGuideState(answer.audience, progress);
|
|
5729
|
+
stateRef.current = s;
|
|
5730
|
+
setState(s);
|
|
5731
|
+
}, [answer, organizationId, suite]);
|
|
5732
|
+
// The latest page facts, read by the machine through env.
|
|
5733
|
+
const pageRef = React.useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
|
|
5734
|
+
pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
|
|
5735
|
+
const targetElRef = React.useRef(null);
|
|
5736
|
+
const env = React.useCallback(() => {
|
|
5737
|
+
const pg = pageRef.current;
|
|
5738
|
+
return {
|
|
5739
|
+
code,
|
|
5740
|
+
pathname: pg.pathname,
|
|
5741
|
+
search: new URLSearchParams(pg.search),
|
|
5742
|
+
facts: pg.facts,
|
|
5743
|
+
can: (key) => {
|
|
5744
|
+
try {
|
|
5745
|
+
return pg.can ? pg.can(key) : true;
|
|
5746
|
+
}
|
|
5747
|
+
catch {
|
|
5748
|
+
return true;
|
|
5749
|
+
}
|
|
5750
|
+
},
|
|
5751
|
+
isVisible: (anchor, id) => anchorVisible(anchor, id),
|
|
5752
|
+
sidebarOpen: pg.sidebarOpen,
|
|
5753
|
+
targetValue: () => fieldValue(targetElRef.current),
|
|
5754
|
+
};
|
|
5755
|
+
}, [code]);
|
|
5756
|
+
/* ---------------- effects: save, announce, toast, navigate ---------------- */
|
|
5757
|
+
const [liveText, setLiveText] = React.useState("");
|
|
5758
|
+
const [toast, setToast] = React.useState(null);
|
|
5759
|
+
const toastTimer = React.useRef(null);
|
|
5760
|
+
const saveTimer = React.useRef(null);
|
|
5761
|
+
const saving = React.useRef(null);
|
|
5762
|
+
const savePending = React.useRef(false);
|
|
5763
|
+
const flushSave = React.useCallback(async () => {
|
|
5764
|
+
if (saveTimer.current) {
|
|
5765
|
+
clearTimeout(saveTimer.current);
|
|
5766
|
+
saveTimer.current = null;
|
|
5767
|
+
}
|
|
5768
|
+
if (saving.current) {
|
|
5769
|
+
savePending.current = true;
|
|
5770
|
+
return;
|
|
5771
|
+
}
|
|
5772
|
+
const run = async () => {
|
|
5773
|
+
do {
|
|
5774
|
+
savePending.current = false;
|
|
5775
|
+
const cur = stateRef.current;
|
|
5776
|
+
if (!cur)
|
|
5777
|
+
return;
|
|
5778
|
+
const row = await saveGuides(organizationId, suite, cur.progress);
|
|
5779
|
+
if (row && stateRef.current) {
|
|
5780
|
+
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
5781
|
+
dispatchRef.current({ type: "serverRow", progress: row });
|
|
5782
|
+
}
|
|
5783
|
+
} while (savePending.current);
|
|
5784
|
+
};
|
|
5785
|
+
saving.current = run().finally(() => {
|
|
5786
|
+
saving.current = null;
|
|
5787
|
+
});
|
|
5788
|
+
}, [organizationId, suite]);
|
|
5789
|
+
const runEffects = React.useCallback((effects) => {
|
|
5790
|
+
for (const e of effects) {
|
|
5791
|
+
try {
|
|
5792
|
+
if (e.type === "save") {
|
|
5793
|
+
if (stateRef.current)
|
|
5794
|
+
writeMirror(organizationId, suite, stateRef.current.progress);
|
|
5795
|
+
if (e.immediate)
|
|
5796
|
+
void flushSave();
|
|
5797
|
+
else {
|
|
5798
|
+
if (saveTimer.current)
|
|
5799
|
+
clearTimeout(saveTimer.current);
|
|
5800
|
+
saveTimer.current = setTimeout(() => void flushSave(), SAVE_DEBOUNCE_MS);
|
|
5801
|
+
}
|
|
5802
|
+
}
|
|
5803
|
+
else if (e.type === "announce") {
|
|
5804
|
+
setLiveText("");
|
|
5805
|
+
setTimeout(() => setLiveText(e.text), 60);
|
|
5806
|
+
}
|
|
5807
|
+
else if (e.type === "toast") {
|
|
5808
|
+
setToast(e.text);
|
|
5809
|
+
if (toastTimer.current)
|
|
5810
|
+
clearTimeout(toastTimer.current);
|
|
5811
|
+
toastTimer.current = setTimeout(() => setToast(null), 3200);
|
|
5812
|
+
}
|
|
5813
|
+
else if (e.type === "navigate") {
|
|
5814
|
+
if (/^https?:\/\//i.test(e.href))
|
|
5815
|
+
window.location.assign(e.href);
|
|
5816
|
+
else
|
|
5817
|
+
router.push(e.href);
|
|
5818
|
+
}
|
|
5819
|
+
else if (e.type === "closeAppDialog") {
|
|
5820
|
+
// After this render takes the guard away, Escape to the app's
|
|
5821
|
+
// dialog (Radix listens on the document).
|
|
5822
|
+
setTimeout(() => {
|
|
5823
|
+
const t = document.activeElement ?? document.body;
|
|
5824
|
+
t.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }));
|
|
5825
|
+
}, 60);
|
|
5826
|
+
}
|
|
5827
|
+
}
|
|
5828
|
+
catch {
|
|
5829
|
+
/* an effect never breaks the app */
|
|
5830
|
+
}
|
|
5831
|
+
}
|
|
5832
|
+
}, [flushSave, organizationId, router, suite]);
|
|
5833
|
+
const dispatch = React.useCallback((action) => {
|
|
5834
|
+
const cur = stateRef.current;
|
|
5835
|
+
if (!cur)
|
|
5836
|
+
return;
|
|
5837
|
+
let r;
|
|
5838
|
+
try {
|
|
5839
|
+
r = reduceGuide(cur, action, suite, env());
|
|
5840
|
+
}
|
|
5841
|
+
catch (err) {
|
|
5842
|
+
if (typeof console !== "undefined")
|
|
5843
|
+
console.warn("[erp-shell guide] stopped", err);
|
|
5844
|
+
stateRef.current = null;
|
|
5845
|
+
setState(null);
|
|
5846
|
+
return;
|
|
5847
|
+
}
|
|
5848
|
+
if (r.state !== cur) {
|
|
5849
|
+
stateRef.current = r.state;
|
|
5850
|
+
setState(r.state);
|
|
5851
|
+
}
|
|
5852
|
+
if (r.effects.length)
|
|
5853
|
+
runEffects(r.effects);
|
|
5854
|
+
}, [env, runEffects, suite]);
|
|
5855
|
+
const dispatchRef = React.useRef(dispatch);
|
|
5856
|
+
dispatchRef.current = dispatch;
|
|
5857
|
+
// Save what is pending when the page goes away.
|
|
5858
|
+
React.useEffect(() => {
|
|
5859
|
+
const flush = () => {
|
|
5860
|
+
if (saveTimer.current)
|
|
5861
|
+
void flushSave();
|
|
5862
|
+
};
|
|
5863
|
+
window.addEventListener("pagehide", flush);
|
|
5864
|
+
return () => {
|
|
5865
|
+
window.removeEventListener("pagehide", flush);
|
|
5866
|
+
flush();
|
|
5867
|
+
if (toastTimer.current)
|
|
5868
|
+
clearTimeout(toastTimer.current);
|
|
5869
|
+
};
|
|
5870
|
+
}, [flushSave]);
|
|
5871
|
+
/* ---------------- boot: resume, welcome ---------------- */
|
|
5872
|
+
const screen = state?.screen ?? { name: "idle" };
|
|
5873
|
+
const resumed = React.useRef(false);
|
|
5874
|
+
React.useEffect(() => {
|
|
5875
|
+
if (!live || !state || resumed.current)
|
|
5876
|
+
return;
|
|
5877
|
+
resumed.current = true;
|
|
5878
|
+
dispatch({ type: "resume" });
|
|
5879
|
+
}, [live, state, dispatch]);
|
|
5880
|
+
const welcomeRoutes = suite.welcomeRoutes ?? ["/[code]"];
|
|
5881
|
+
const onWelcomeRoute = welcomeRoutes.some(r => !!matchRoute(r, pathname, search, code));
|
|
5882
|
+
const due = !!state && live && welcomeDue(state, suite) && onWelcomeRoute && fetching === 0;
|
|
5883
|
+
const factsKey = useFactsKey(config.facts);
|
|
5884
|
+
React.useEffect(() => {
|
|
5885
|
+
if (!due)
|
|
5886
|
+
return;
|
|
5887
|
+
const t = setTimeout(() => dispatchRef.current({ type: "welcome" }), WELCOME_DELAY_MS);
|
|
5888
|
+
return () => clearTimeout(t);
|
|
5889
|
+
}, [due, factsKey]);
|
|
5890
|
+
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
5891
|
+
const inStep = live && screen.name === "step";
|
|
5892
|
+
React.useEffect(() => {
|
|
5893
|
+
if (inStep)
|
|
5894
|
+
dispatch({ type: "sync" });
|
|
5895
|
+
}, [inStep, pathname, search, sidebarOpen, factsKey, dispatch]);
|
|
5896
|
+
React.useEffect(() => {
|
|
5897
|
+
if (!inStep || typeof MutationObserver === "undefined")
|
|
5898
|
+
return;
|
|
5899
|
+
let raf = 0;
|
|
5900
|
+
const obs = new MutationObserver(records => {
|
|
5901
|
+
// Our own layer's changes are not the page's.
|
|
5902
|
+
if (records.every(r => inGuideLayer(r.target)))
|
|
5903
|
+
return;
|
|
5904
|
+
if (raf)
|
|
5905
|
+
return;
|
|
5906
|
+
raf = requestAnimationFrame(() => {
|
|
5907
|
+
raf = 0;
|
|
5908
|
+
dispatchRef.current({ type: "sync" });
|
|
5909
|
+
});
|
|
5910
|
+
});
|
|
5911
|
+
obs.observe(document.body, {
|
|
5912
|
+
childList: true,
|
|
5913
|
+
subtree: true,
|
|
5914
|
+
attributes: true,
|
|
5915
|
+
attributeFilter: ["data-state", "hidden", "aria-hidden", "class", "style", "open"],
|
|
5916
|
+
});
|
|
5917
|
+
return () => {
|
|
5918
|
+
obs.disconnect();
|
|
5919
|
+
if (raf)
|
|
5920
|
+
cancelAnimationFrame(raf);
|
|
5921
|
+
};
|
|
5922
|
+
}, [inStep]);
|
|
5923
|
+
/* ---------------- the current step ---------------- */
|
|
5924
|
+
const stepInfo = React.useMemo(() => {
|
|
5925
|
+
if (!state || screen.name !== "step")
|
|
5926
|
+
return null;
|
|
5927
|
+
const mission = findMission(suite, screen.mission);
|
|
5928
|
+
if (!mission)
|
|
5929
|
+
return null;
|
|
5930
|
+
const step = mission.steps[screen.index];
|
|
5931
|
+
if (!step)
|
|
5932
|
+
return null;
|
|
5933
|
+
const missionNo = suite.missions.indexOf(mission) + 1;
|
|
5934
|
+
return { mission, step, missionNo };
|
|
5935
|
+
}, [state, screen, suite]);
|
|
5936
|
+
const stepCtx = React.useMemo(() => {
|
|
5937
|
+
if (!state)
|
|
5938
|
+
return null;
|
|
5939
|
+
const e = env();
|
|
5940
|
+
const params = stepInfo?.step.route ? (matchRoute(stepInfo.step.route, e.pathname, e.search, code) ?? {}) : {};
|
|
5941
|
+
return guideContext(e, state.progress.context, params);
|
|
5942
|
+
// pathname/search/factsKey make it current.
|
|
5943
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5944
|
+
}, [state, stepInfo, env, code, pathname, search, factsKey]);
|
|
5945
|
+
const target = stepInfo && stepCtx && screen.name === "step"
|
|
5946
|
+
? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
|
|
5947
|
+
: null;
|
|
5948
|
+
const targetKey = target ? `${target.anchor}|${target.id ?? ""}` : "";
|
|
5949
|
+
const phase = screen.name === "step" ? screen.phase : null;
|
|
5950
|
+
const stepId = screen.name === "step" ? `${screen.mission}:${screen.index}:${screen.bridge ? "b" : ""}` : "";
|
|
5951
|
+
/* Measure every frame while a step is shown: the spotlight follows
|
|
5952
|
+
scrolling, dialogs animating in, the drawer sliding, the keyboard. */
|
|
5953
|
+
const [layout, setLayout] = React.useState({
|
|
5954
|
+
spot: null,
|
|
5955
|
+
holes: [],
|
|
5956
|
+
vw: 0,
|
|
5957
|
+
vh: 0,
|
|
5958
|
+
oy: 0,
|
|
5959
|
+
});
|
|
5960
|
+
React.useEffect(() => {
|
|
5961
|
+
if (!inStep) {
|
|
5962
|
+
targetElRef.current = null;
|
|
5963
|
+
return;
|
|
5964
|
+
}
|
|
5965
|
+
let raf = 0;
|
|
5966
|
+
let lostSince = null;
|
|
5967
|
+
let last = { spot: null, holes: [], vw: 0, vh: 0, oy: 0 };
|
|
5968
|
+
targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
|
|
5969
|
+
const allow = stepInfo?.step.allow ?? [];
|
|
5970
|
+
const tick = () => {
|
|
5971
|
+
raf = requestAnimationFrame(tick);
|
|
5972
|
+
const vv = window.visualViewport;
|
|
5973
|
+
const vw = window.innerWidth;
|
|
5974
|
+
const vh = vv ? vv.height : window.innerHeight;
|
|
5975
|
+
const oy = vv ? vv.offsetTop : 0;
|
|
5976
|
+
let el = targetElRef.current;
|
|
5977
|
+
if (target && (!el || !el.isConnected)) {
|
|
5978
|
+
el = findAnchor(target.anchor, target.id);
|
|
5979
|
+
targetElRef.current = el;
|
|
5980
|
+
}
|
|
5981
|
+
let spot = null;
|
|
5982
|
+
if (target && el && isPresent(el)) {
|
|
5983
|
+
lostSince = null;
|
|
5984
|
+
spot = padRect(rectOf(el), TARGET_PAD);
|
|
5985
|
+
}
|
|
5986
|
+
else if (target) {
|
|
5987
|
+
// Maybe re-rendered under another node.
|
|
5988
|
+
const again = findAnchor(target.anchor, target.id);
|
|
5989
|
+
if (again) {
|
|
5990
|
+
targetElRef.current = again;
|
|
5991
|
+
spot = padRect(rectOf(again), TARGET_PAD);
|
|
5992
|
+
lostSince = null;
|
|
5993
|
+
}
|
|
5994
|
+
else if (phase === "ready") {
|
|
5995
|
+
lostSince ?? (lostSince = performance.now());
|
|
5996
|
+
if (performance.now() - lostSince > LOST_GRACE_MS) {
|
|
5997
|
+
lostSince = null;
|
|
5998
|
+
dispatchRef.current({ type: "targetLost" });
|
|
5999
|
+
}
|
|
6000
|
+
}
|
|
6001
|
+
}
|
|
6002
|
+
if (phase === "waiting" && spot)
|
|
6003
|
+
dispatchRef.current({ type: "sync" });
|
|
6004
|
+
const holes = [];
|
|
6005
|
+
for (const p of openPoppers())
|
|
6006
|
+
holes.push(padRect(rectOf(p), 2));
|
|
6007
|
+
for (const a of allow) {
|
|
6008
|
+
const ael = findAnchor(a);
|
|
6009
|
+
if (ael)
|
|
6010
|
+
holes.push(padRect(rectOf(ael), TARGET_PAD));
|
|
6011
|
+
}
|
|
6012
|
+
const next = { spot, holes, vw, vh, oy };
|
|
6013
|
+
if (!sameRect(next.spot, last.spot) ||
|
|
6014
|
+
next.vw !== last.vw ||
|
|
6015
|
+
next.vh !== last.vh ||
|
|
6016
|
+
next.oy !== last.oy ||
|
|
6017
|
+
next.holes.length !== last.holes.length ||
|
|
6018
|
+
next.holes.some((h, i) => !sameRect(h, last.holes[i]))) {
|
|
6019
|
+
last = next;
|
|
6020
|
+
setLayout(next);
|
|
6021
|
+
}
|
|
6022
|
+
};
|
|
6023
|
+
raf = requestAnimationFrame(tick);
|
|
6024
|
+
return () => cancelAnimationFrame(raf);
|
|
6025
|
+
// stepId/targetKey/phase restart the loop for a new step.
|
|
6026
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
6027
|
+
}, [inStep, stepId, targetKey, phase]);
|
|
6028
|
+
/* Waiting for the target: 8 s of not-loading time (30 s at most). */
|
|
6029
|
+
const fetchingRef = React.useRef(fetching);
|
|
6030
|
+
fetchingRef.current = fetching;
|
|
6031
|
+
React.useEffect(() => {
|
|
6032
|
+
if (!inStep || phase !== "waiting")
|
|
6033
|
+
return;
|
|
6034
|
+
let idle = 0;
|
|
6035
|
+
const started = Date.now();
|
|
6036
|
+
const timeout = stepInfo?.step.timeoutMs ?? WAIT_MS;
|
|
6037
|
+
const iv = setInterval(() => {
|
|
6038
|
+
if (fetchingRef.current === 0 && document.visibilityState !== "hidden")
|
|
6039
|
+
idle += 250;
|
|
6040
|
+
if (idle >= timeout || Date.now() - started >= WAIT_CAP_MS) {
|
|
6041
|
+
clearInterval(iv);
|
|
6042
|
+
dispatchRef.current({ type: "timeout" });
|
|
6043
|
+
}
|
|
6044
|
+
}, 250);
|
|
6045
|
+
return () => clearInterval(iv);
|
|
6046
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
6047
|
+
}, [inStep, stepId, phase, screen.name === "step" ? screen.misses : 0]);
|
|
6048
|
+
/* On a step becoming ready: bring the target into view, focus, describe. */
|
|
6049
|
+
const readyKey = inStep && phase === "ready" ? stepId : "";
|
|
6050
|
+
React.useEffect(() => {
|
|
6051
|
+
if (!readyKey || !stepInfo || screen.name !== "step")
|
|
6052
|
+
return;
|
|
6053
|
+
const el = targetElRef.current ?? (target ? findAnchor(target.anchor, target.id) : null);
|
|
6054
|
+
if (el)
|
|
6055
|
+
revealTarget(el);
|
|
6056
|
+
const isInput = !screen.bridge && stepKind(stepInfo.step) === "input";
|
|
6057
|
+
const t = setTimeout(() => {
|
|
6058
|
+
if (isInput && el) {
|
|
6059
|
+
const f = fieldOf(el);
|
|
6060
|
+
f?.focus({ preventScroll: true });
|
|
6061
|
+
}
|
|
6062
|
+
else {
|
|
6063
|
+
const b = document.querySelector(`[${GUIDE_LAYER_ATTRIBUTE}] [data-eg-bubble]`);
|
|
6064
|
+
b?.focus({ preventScroll: true });
|
|
6065
|
+
}
|
|
6066
|
+
}, 30);
|
|
6067
|
+
// The target is described by the bubble while the step lasts.
|
|
6068
|
+
const described = el ? (isInput ? fieldOf(el) : el) : null;
|
|
6069
|
+
const prev = described?.getAttribute("aria-describedby") ?? null;
|
|
6070
|
+
if (described)
|
|
6071
|
+
described.setAttribute("aria-describedby", [BUBBLE_TITLE_ID, BUBBLE_WHY_ID, prev].filter(Boolean).join(" "));
|
|
6072
|
+
return () => {
|
|
6073
|
+
clearTimeout(t);
|
|
6074
|
+
if (!described)
|
|
6075
|
+
return;
|
|
6076
|
+
if (prev)
|
|
6077
|
+
described.setAttribute("aria-describedby", prev);
|
|
6078
|
+
else
|
|
6079
|
+
described.removeAttribute("aria-describedby");
|
|
6080
|
+
};
|
|
6081
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
6082
|
+
}, [readyKey]);
|
|
6083
|
+
/* Signals from the page: API answers, app events, presses, typing. */
|
|
6084
|
+
React.useEffect(() => {
|
|
6085
|
+
if (!inStep || !stepInfo)
|
|
6086
|
+
return;
|
|
6087
|
+
const onApi = (e) => {
|
|
6088
|
+
const d = e.detail;
|
|
6089
|
+
if (!d || typeof d.status !== "number")
|
|
6090
|
+
return;
|
|
6091
|
+
dispatchRef.current({ type: "signal", signal: { kind: "api", method: d.method, path: d.path, status: d.status, id: d.id, message: d.message } });
|
|
6092
|
+
};
|
|
6093
|
+
window.addEventListener(API_EVENT, onApi);
|
|
6094
|
+
const names = triggersOf(stepInfo.step).flatMap(t => ("event" in t ? [t.event] : []));
|
|
6095
|
+
const onApp = (name) => (e) => dispatchRef.current({ type: "signal", signal: { kind: "event", name, detail: e.detail ?? undefined } });
|
|
6096
|
+
const appHandlers = names.map(n => [GUIDE_EVENT_PREFIX + n, onApp(n)]);
|
|
6097
|
+
appHandlers.forEach(([n, h]) => window.addEventListener(n, h));
|
|
6098
|
+
const inTarget = (node) => {
|
|
6099
|
+
const el = targetElRef.current;
|
|
6100
|
+
return !!el && node instanceof Node && el.contains(node);
|
|
6101
|
+
};
|
|
6102
|
+
// After the app has handled it, so what it does comes first.
|
|
6103
|
+
const later = (a) => setTimeout(() => dispatchRef.current(a), 0);
|
|
6104
|
+
const onPress = (e) => {
|
|
6105
|
+
if (inTarget(e.target))
|
|
6106
|
+
later({ type: "signal", signal: { kind: "activate" } });
|
|
6107
|
+
};
|
|
6108
|
+
const onKey = (e) => {
|
|
6109
|
+
if ((e.key === "Enter" || e.key === " ") && inTarget(e.target) && stepKind(stepInfo.step) !== "input") {
|
|
6110
|
+
later({ type: "signal", signal: { kind: "activate" } });
|
|
6111
|
+
}
|
|
6112
|
+
};
|
|
6113
|
+
const onInput = (e) => {
|
|
6114
|
+
if (!inTarget(e.target))
|
|
6115
|
+
return;
|
|
6116
|
+
dispatchRef.current({ type: "signal", signal: { kind: "typing" } });
|
|
6117
|
+
later({ type: "signal", signal: { kind: "value", value: fieldValue(targetElRef.current) } });
|
|
6118
|
+
};
|
|
6119
|
+
document.addEventListener("pointerup", onPress, true);
|
|
6120
|
+
document.addEventListener("keydown", onKey, true);
|
|
6121
|
+
document.addEventListener("input", onInput, true);
|
|
6122
|
+
document.addEventListener("change", onInput, true);
|
|
6123
|
+
return () => {
|
|
6124
|
+
window.removeEventListener(API_EVENT, onApi);
|
|
6125
|
+
appHandlers.forEach(([n, h]) => window.removeEventListener(n, h));
|
|
6126
|
+
document.removeEventListener("pointerup", onPress, true);
|
|
6127
|
+
document.removeEventListener("keydown", onKey, true);
|
|
6128
|
+
document.removeEventListener("input", onInput, true);
|
|
6129
|
+
document.removeEventListener("change", onInput, true);
|
|
6130
|
+
};
|
|
6131
|
+
}, [inStep, stepInfo]);
|
|
6132
|
+
/* ---------------- the guard ---------------- */
|
|
6133
|
+
const modalScreen = live && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
|
|
6134
|
+
const panelOpen = live && !!state?.panelOpen;
|
|
6135
|
+
const guarding = inStep || modalScreen;
|
|
6136
|
+
const guardingRef = React.useRef(guarding);
|
|
6137
|
+
guardingRef.current = guarding;
|
|
6138
|
+
const modalRef = React.useRef(false);
|
|
6139
|
+
modalRef.current = modalScreen || (screen.name === "step" && (screen.confirmExit || screen.phase !== "ready"));
|
|
6140
|
+
const stepInfoRef = React.useRef(stepInfo);
|
|
6141
|
+
stepInfoRef.current = stepInfo;
|
|
6142
|
+
React.useEffect(() => {
|
|
6143
|
+
if (!live)
|
|
6144
|
+
return;
|
|
6145
|
+
const allowedTarget = (node) => {
|
|
6146
|
+
if (!(node instanceof Node))
|
|
6147
|
+
return false;
|
|
6148
|
+
const el = targetElRef.current;
|
|
6149
|
+
if (el && el.contains(node))
|
|
6150
|
+
return true;
|
|
6151
|
+
const elNode = node instanceof Element ? node : node.parentElement;
|
|
6152
|
+
if (!elNode)
|
|
6153
|
+
return false;
|
|
6154
|
+
if (elNode.closest(POPPER_SELECTOR) || elNode.closest(TOASTER_SELECTOR))
|
|
6155
|
+
return true;
|
|
6156
|
+
for (const a of stepInfoRef.current?.step.allow ?? []) {
|
|
6157
|
+
const ael = findAnchor(a);
|
|
6158
|
+
if (ael && ael.contains(node))
|
|
6159
|
+
return true;
|
|
6160
|
+
}
|
|
6161
|
+
return false;
|
|
6162
|
+
};
|
|
6163
|
+
const onPointer = (e) => {
|
|
6164
|
+
const t = e.target;
|
|
6165
|
+
if (inGuideLayer(t)) {
|
|
6166
|
+
// Our dim layer eats the press (and keeps focus where it is).
|
|
6167
|
+
if (t instanceof Element && t.closest("[data-eg-block]")) {
|
|
6168
|
+
e.preventDefault();
|
|
6169
|
+
if (e.type === "pointerdown")
|
|
6170
|
+
dispatchRef.current({ type: "blocked" });
|
|
6171
|
+
}
|
|
6172
|
+
// The app's open dialog or menu never hears the press (the click
|
|
6173
|
+
// itself goes on to our buttons).
|
|
6174
|
+
if (e.type !== "click")
|
|
6175
|
+
e.stopPropagation();
|
|
6176
|
+
return;
|
|
6177
|
+
}
|
|
6178
|
+
if (!guardingRef.current)
|
|
6179
|
+
return;
|
|
6180
|
+
if (modalRef.current || !allowedTarget(t)) {
|
|
6181
|
+
e.preventDefault();
|
|
6182
|
+
e.stopPropagation();
|
|
6183
|
+
if (e.type === "pointerdown")
|
|
6184
|
+
dispatchRef.current({ type: "blocked" });
|
|
6185
|
+
}
|
|
6186
|
+
};
|
|
6187
|
+
// Radix FocusScope pulls focus back into an open dialog; DismissableLayer
|
|
6188
|
+
// closes on focus outside. Neither may see focus moving to the tour.
|
|
6189
|
+
const onFocusIn = (e) => {
|
|
6190
|
+
if (inGuideLayer(e.target))
|
|
6191
|
+
e.stopPropagation();
|
|
6192
|
+
};
|
|
6193
|
+
const onFocusOut = (e) => {
|
|
6194
|
+
if (inGuideLayer(e.relatedTarget))
|
|
6195
|
+
e.stopPropagation();
|
|
6196
|
+
};
|
|
6197
|
+
const onKey = (e) => {
|
|
6198
|
+
const s = stateRef.current;
|
|
6199
|
+
if (!s)
|
|
6200
|
+
return;
|
|
6201
|
+
const sc = s.screen;
|
|
6202
|
+
const stop = () => {
|
|
6203
|
+
e.preventDefault();
|
|
6204
|
+
e.stopImmediatePropagation();
|
|
6205
|
+
};
|
|
6206
|
+
if (e.key === "Escape") {
|
|
6207
|
+
if (sc.name === "step") {
|
|
6208
|
+
stop();
|
|
6209
|
+
if (sc.confirmExit)
|
|
6210
|
+
dispatchRef.current({ type: "exitCancel" });
|
|
6211
|
+
else
|
|
6212
|
+
dispatchRef.current({ type: "exit" });
|
|
6213
|
+
}
|
|
6214
|
+
else if (sc.name === "welcome") {
|
|
6215
|
+
stop();
|
|
6216
|
+
dispatchRef.current({ type: sc.confirm ? "welcomeSkipCancel" : "welcomeSkip" });
|
|
6217
|
+
}
|
|
6218
|
+
else if (sc.name === "gate") {
|
|
6219
|
+
stop();
|
|
6220
|
+
dispatchRef.current({ type: "gateLater" });
|
|
6221
|
+
}
|
|
6222
|
+
else if (sc.name === "celebrate") {
|
|
6223
|
+
stop();
|
|
6224
|
+
dispatchRef.current({ type: "rest" });
|
|
6225
|
+
}
|
|
6226
|
+
else if (sc.name === "complete") {
|
|
6227
|
+
stop();
|
|
6228
|
+
dispatchRef.current({ type: "finish" });
|
|
6229
|
+
}
|
|
6230
|
+
else if (s.panelOpen) {
|
|
6231
|
+
stop();
|
|
6232
|
+
dispatchRef.current({ type: "closePanel" });
|
|
6233
|
+
}
|
|
6234
|
+
return;
|
|
6235
|
+
}
|
|
6236
|
+
if (!guardingRef.current)
|
|
6237
|
+
return;
|
|
6238
|
+
const layer = layerRef.current;
|
|
6239
|
+
if (e.key === "Tab") {
|
|
6240
|
+
// Tab stays on what the step is about: the target and the bubble, or
|
|
6241
|
+
// the open card.
|
|
6242
|
+
const modals = layer ? Array.from(layer.querySelectorAll("[data-eg-modal],[data-eg-card]")) : [];
|
|
6243
|
+
let list;
|
|
6244
|
+
if (sc.name === "step" && !sc.confirmExit && sc.phase === "ready") {
|
|
6245
|
+
const el = targetElRef.current;
|
|
6246
|
+
const step = stepInfoRef.current?.step;
|
|
6247
|
+
const isInput = !!step && !sc.bridge && stepKind(step) === "input";
|
|
6248
|
+
const inTargetList = el ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
|
|
6249
|
+
const bubble = layer?.querySelector("[data-eg-bubble]") ?? null;
|
|
6250
|
+
list = [...inTargetList, ...(bubble ? Array.from(bubble.querySelectorAll(FOCUSABLE)) : [])];
|
|
6251
|
+
for (const p of openPoppers())
|
|
6252
|
+
list.push(...focusablesIn(p));
|
|
6253
|
+
}
|
|
6254
|
+
else {
|
|
6255
|
+
const last = modals[modals.length - 1] ?? null;
|
|
6256
|
+
list = last ? Array.from(last.querySelectorAll(FOCUSABLE)) : [];
|
|
6257
|
+
}
|
|
6258
|
+
if (!list.length)
|
|
6259
|
+
return;
|
|
6260
|
+
stop();
|
|
6261
|
+
const i = list.indexOf(document.activeElement);
|
|
6262
|
+
const n = e.shiftKey ? (i <= 0 ? list.length - 1 : i - 1) : i === -1 || i === list.length - 1 ? 0 : i + 1;
|
|
6263
|
+
list[n].focus();
|
|
6264
|
+
return;
|
|
6265
|
+
}
|
|
6266
|
+
if (e.key === "Enter" || e.key === " ") {
|
|
6267
|
+
const active = document.activeElement;
|
|
6268
|
+
if (inGuideLayer(active))
|
|
6269
|
+
return;
|
|
6270
|
+
if (sc.name === "step" && sc.phase === "ready" && !sc.confirmExit && e.key === "Enter") {
|
|
6271
|
+
const step = stepInfoRef.current?.step;
|
|
6272
|
+
const el = targetElRef.current;
|
|
6273
|
+
if (step && stepKind(step) === "input" && el && active instanceof Node && el.contains(active) && !(active instanceof HTMLTextAreaElement)) {
|
|
6274
|
+
// During the tour Enter is ถัดไป, not "submit the form".
|
|
6275
|
+
stop();
|
|
6276
|
+
dispatchRef.current({ type: "next" });
|
|
6277
|
+
return;
|
|
6278
|
+
}
|
|
6279
|
+
}
|
|
6280
|
+
if (modalRef.current || !allowedTarget(active))
|
|
6281
|
+
stop();
|
|
6282
|
+
}
|
|
6283
|
+
};
|
|
6284
|
+
window.addEventListener("pointerdown", onPointer, true);
|
|
6285
|
+
window.addEventListener("mousedown", onPointer, true);
|
|
6286
|
+
window.addEventListener("touchstart", onPointer, { capture: true, passive: false });
|
|
6287
|
+
window.addEventListener("click", onPointer, true);
|
|
6288
|
+
window.addEventListener("focusin", onFocusIn, true);
|
|
6289
|
+
window.addEventListener("focusout", onFocusOut, true);
|
|
6290
|
+
window.addEventListener("keydown", onKey, true);
|
|
6291
|
+
return () => {
|
|
6292
|
+
window.removeEventListener("pointerdown", onPointer, true);
|
|
6293
|
+
window.removeEventListener("mousedown", onPointer, true);
|
|
6294
|
+
window.removeEventListener("touchstart", onPointer, true);
|
|
6295
|
+
window.removeEventListener("click", onPointer, true);
|
|
6296
|
+
window.removeEventListener("focusin", onFocusIn, true);
|
|
6297
|
+
window.removeEventListener("focusout", onFocusOut, true);
|
|
6298
|
+
window.removeEventListener("keydown", onKey, true);
|
|
6299
|
+
};
|
|
6300
|
+
}, [live]);
|
|
6301
|
+
/* Focus comes back to where it was when the tour lets go. */
|
|
6302
|
+
const lastFocus = React.useRef(null);
|
|
6303
|
+
const engaged = guarding || panelOpen;
|
|
6304
|
+
React.useLayoutEffect(() => {
|
|
6305
|
+
if (engaged) {
|
|
6306
|
+
if (!lastFocus.current) {
|
|
6307
|
+
const a = document.activeElement;
|
|
6308
|
+
lastFocus.current = a instanceof HTMLElement && !inGuideLayer(a) ? a : null;
|
|
6309
|
+
}
|
|
6310
|
+
return;
|
|
6311
|
+
}
|
|
6312
|
+
const back = lastFocus.current;
|
|
6313
|
+
lastFocus.current = null;
|
|
6314
|
+
if (back && back.isConnected)
|
|
6315
|
+
back.focus({ preventScroll: true });
|
|
6316
|
+
}, [engaged]);
|
|
6317
|
+
/* ---------------- the layer ---------------- */
|
|
6318
|
+
const [layer, setLayer] = React.useState(null);
|
|
6319
|
+
const layerRef = React.useRef(null);
|
|
6320
|
+
React.useEffect(() => {
|
|
6321
|
+
const el = document.createElement("div");
|
|
6322
|
+
el.setAttribute(GUIDE_LAYER_ATTRIBUTE, "");
|
|
6323
|
+
document.body.appendChild(el);
|
|
6324
|
+
layerRef.current = el;
|
|
6325
|
+
setLayer(el);
|
|
6326
|
+
// A Radix modal hides everything else from screen readers; not us.
|
|
6327
|
+
const obs = new MutationObserver(() => {
|
|
6328
|
+
if (el.getAttribute("aria-hidden") === "true")
|
|
6329
|
+
el.removeAttribute("aria-hidden");
|
|
6330
|
+
if (el.hasAttribute("data-aria-hidden"))
|
|
6331
|
+
el.removeAttribute("data-aria-hidden");
|
|
6332
|
+
if (el.hasAttribute("inert"))
|
|
6333
|
+
el.removeAttribute("inert");
|
|
6334
|
+
});
|
|
6335
|
+
obs.observe(el, { attributes: true, attributeFilter: ["aria-hidden", "data-aria-hidden", "inert"] });
|
|
6336
|
+
return () => {
|
|
6337
|
+
obs.disconnect();
|
|
6338
|
+
el.remove();
|
|
6339
|
+
layerRef.current = null;
|
|
6340
|
+
};
|
|
6341
|
+
}, []);
|
|
6342
|
+
const [phone, setPhone] = React.useState(false);
|
|
6343
|
+
const [reduced, setReduced] = React.useState(false);
|
|
6344
|
+
React.useEffect(() => {
|
|
6345
|
+
const sync = () => {
|
|
6346
|
+
setPhone(isPhoneWidth());
|
|
6347
|
+
setReduced(prefersReducedMotion());
|
|
6348
|
+
};
|
|
6349
|
+
sync();
|
|
6350
|
+
window.addEventListener("resize", sync);
|
|
6351
|
+
return () => window.removeEventListener("resize", sync);
|
|
6352
|
+
}, []);
|
|
6353
|
+
// The celebration waits a beat, so the new row can be seen first.
|
|
6354
|
+
const [celebrateShown, setCelebrateShown] = React.useState(false);
|
|
6355
|
+
const celebrateKey = screen.name === "celebrate" || screen.name === "complete" ? screen.name + (screen.name === "celebrate" ? screen.mission : "") : "";
|
|
6356
|
+
React.useEffect(() => {
|
|
6357
|
+
setCelebrateShown(false);
|
|
6358
|
+
if (!celebrateKey)
|
|
6359
|
+
return;
|
|
6360
|
+
const t = setTimeout(() => setCelebrateShown(true), CELEBRATE_DELAY_MS);
|
|
6361
|
+
return () => clearTimeout(t);
|
|
6362
|
+
}, [celebrateKey]);
|
|
6363
|
+
// Ring moves smoothly only right after a step change.
|
|
6364
|
+
const [moving, setMoving] = React.useState(false);
|
|
6365
|
+
React.useEffect(() => {
|
|
6366
|
+
if (!stepId)
|
|
6367
|
+
return;
|
|
6368
|
+
setMoving(true);
|
|
6369
|
+
const t = setTimeout(() => setMoving(false), 320);
|
|
6370
|
+
return () => clearTimeout(t);
|
|
6371
|
+
}, [stepId]);
|
|
6372
|
+
/* ---------------- the "?" menu entry ---------------- */
|
|
6373
|
+
const ctxIdle = React.useMemo(() => (state ? guideContext(env(), state.progress.context) : null), [state, env, factsKey, pathname]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
6374
|
+
const done = state && ctxIdle ? settledCount(suite, state.progress, ctxIdle) : 0;
|
|
6375
|
+
const total = suite.missions.length;
|
|
6376
|
+
React.useEffect(() => {
|
|
6377
|
+
if (!live || !state) {
|
|
6378
|
+
publishGuideEntry(null, null);
|
|
6379
|
+
return;
|
|
6380
|
+
}
|
|
6381
|
+
publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
|
|
6382
|
+
}, [live, state, done, total]);
|
|
6383
|
+
React.useEffect(() => () => publishGuideEntry(null, null), []);
|
|
6384
|
+
if (!live || !state || !layer || !ctxIdle)
|
|
6385
|
+
return null;
|
|
6386
|
+
/* ---------------- render ---------------- */
|
|
6387
|
+
const views = missionViews(suite, state.progress, ctxIdle);
|
|
6388
|
+
const gate = suite.gate;
|
|
6389
|
+
const gateHref = gate ? resolveText(gate.action.href, ctxIdle) : "";
|
|
6390
|
+
const gateFailing = gate ? gateState(suite, ctxIdle) === false : false;
|
|
6391
|
+
let content = null;
|
|
6392
|
+
if (screen.name === "welcome") {
|
|
6393
|
+
content = (jsxRuntime.jsx(WelcomeModal, { title: resolveText(suite.welcome.title, ctxIdle), subtitle: resolveText(suite.welcome.subtitle, ctxIdle) || `มาตั้งค่าบริษัทให้พร้อมใน ${total} ภารกิจ`, missions: suite.missions.map(m => ({ title: m.title, minutes: m.minutes })), confirm: screen.confirm, onStart: () => dispatch({ type: "welcomeStart" }), onSkip: () => dispatch({ type: "welcomeSkip" }), onSkipConfirm: () => dispatch({ type: "welcomeSkipConfirm" }), onSkipCancel: () => dispatch({ type: "welcomeSkipCancel" }) }));
|
|
6394
|
+
}
|
|
6395
|
+
else if (screen.name === "gate" && gate) {
|
|
6396
|
+
content = (jsxRuntime.jsx(GateModal, { title: gate.title, subtitle: gate.subtitle, actionLabel: gate.action.label, href: gateHref, onLater: () => dispatch({ type: "gateLater" }) }));
|
|
6397
|
+
}
|
|
6398
|
+
else if (screen.name === "step" && stepInfo && stepCtx) {
|
|
6399
|
+
const { mission, step, missionNo } = stepInfo;
|
|
6400
|
+
const kind = stepKind(step);
|
|
6401
|
+
const bridge = screen.bridge;
|
|
6402
|
+
content = (jsxRuntime.jsx(StepLayer, { spot: layout.spot, holes: layout.holes, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
|
|
6403
|
+
missionNo,
|
|
6404
|
+
missionTitle: mission.title,
|
|
6405
|
+
replay: screen.replay,
|
|
6406
|
+
index: screen.index,
|
|
6407
|
+
total: mission.steps.length,
|
|
6408
|
+
title: bridge ? "กดปุ่มเมนู" : resolveText(step.title, stepCtx),
|
|
6409
|
+
why: bridge ? `เมนูทั้งหมดของ ${suite.appName} อยู่ในนี้` : resolveText(step.why, stepCtx),
|
|
6410
|
+
error: screen.error,
|
|
6411
|
+
showNext: !bridge && (kind === "input" || kind === "view"),
|
|
6412
|
+
showEndWithoutSave: !bridge && screen.replay && kind === "commit",
|
|
6413
|
+
}, hasTarget: bridge || !!step.target, phase: screen.phase, confirmExit: screen.confirmExit, 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" }) }));
|
|
6414
|
+
}
|
|
6415
|
+
else if (screen.name === "celebrate" && celebrateShown) {
|
|
6416
|
+
const mission = findMission(suite, screen.mission);
|
|
6417
|
+
const next = findMission(suite, nextMissionKey(suite, state.progress, ctxIdle));
|
|
6418
|
+
if (mission) {
|
|
6419
|
+
content = (jsxRuntime.jsx(CelebrateModal, { missionNo: suite.missions.indexOf(mission) + 1, title: resolveText(mission.done?.title, ctxIdle) || `${mission.title}แล้ว`, subtitle: resolveText(mission.done?.subtitle, ctxIdle), done: done, total: total, nextTitle: next?.title ?? null, reduced: reduced, onNext: () => dispatch({ type: "celebrateNext" }), onRest: () => dispatch({ type: "rest" }) }));
|
|
6420
|
+
}
|
|
6421
|
+
}
|
|
6422
|
+
else if (screen.name === "complete" && celebrateShown) {
|
|
6423
|
+
const secondary = suite.complete.secondary;
|
|
6424
|
+
let secondaryHref;
|
|
6425
|
+
try {
|
|
6426
|
+
secondaryHref = secondary ? (typeof secondary.href === "function" ? secondary.href(ctxIdle) : secondary.href) : undefined;
|
|
6427
|
+
}
|
|
6428
|
+
catch {
|
|
6429
|
+
secondaryHref = undefined;
|
|
6430
|
+
}
|
|
6431
|
+
content = (jsxRuntime.jsx(CompleteModal, { total: total, title: resolveText(suite.complete.title, ctxIdle) || "บริษัทพร้อมใช้งานแล้ว", subtitle: resolveText(suite.complete.subtitle, ctxIdle), missions: suite.missions.map(m => m.title), actionLabel: suite.complete.action.label, secondary: secondary && secondaryHref ? { label: secondary.label, href: secondaryHref } : null, reduced: reduced, onFinish: () => dispatch({ type: "finish" }) }));
|
|
6432
|
+
}
|
|
6433
|
+
const items = views.map(v => ({
|
|
6434
|
+
key: v.mission.key,
|
|
6435
|
+
title: v.mission.title,
|
|
6436
|
+
minutes: v.mission.minutes,
|
|
6437
|
+
view: v.view,
|
|
6438
|
+
optional: !!v.mission.optional,
|
|
6439
|
+
completedAt: v.entry?.completed_at ?? null,
|
|
6440
|
+
lockedBy: v.view === "locked"
|
|
6441
|
+
? (v.mission.after ?? [])
|
|
6442
|
+
.map(k => findMission(suite, k)?.title)
|
|
6443
|
+
.filter(Boolean)
|
|
6444
|
+
.join(", ")
|
|
6445
|
+
: undefined,
|
|
6446
|
+
}));
|
|
6447
|
+
const showChip = chipVisible(state, suite, ctxIdle);
|
|
6448
|
+
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);
|
|
6449
|
+
}
|
|
6450
|
+
/** A stable key for the facts object, so effects re-run when they change. */
|
|
6451
|
+
function useFactsKey(facts) {
|
|
6452
|
+
return React.useMemo(() => {
|
|
6453
|
+
try {
|
|
6454
|
+
return JSON.stringify(facts ?? {});
|
|
6455
|
+
}
|
|
6456
|
+
catch {
|
|
6457
|
+
return String(Math.random());
|
|
6458
|
+
}
|
|
6459
|
+
}, [facts]);
|
|
6460
|
+
}
|
|
6461
|
+
|
|
6462
|
+
/**
|
|
6463
|
+
* Top-level app chrome: sticky header, sidebar slot, content gutter and the
|
|
6464
|
+
* session/org-context plumbing that both Accounting and HR need. Apps wrap
|
|
6465
|
+
* it inside their `SidebarProvider` (server-rendered with the cookie-backed
|
|
6466
|
+
* `defaultOpen`) and their `ERPProvider`, then pass app-specific bits as
|
|
6467
|
+
* props.
|
|
6468
|
+
*
|
|
6469
|
+
* The padding contract is deliberately owned here so future style tweaks
|
|
6470
|
+
* (e.g. the recent left/right/bottom gutter pass) propagate to both apps via
|
|
6471
|
+
* an erp-shell bump — domain components must NOT add their own root padding
|
|
6472
|
+
* or content gets indented twice.
|
|
6473
|
+
*/
|
|
6474
|
+
function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permissionProvider, organizationId, setOrganizationId, organizations, organizationsStatus, notMemberComponent, signInUrl, selectOrganizationUrl, chrome = "brand", workspaceHeader = "brand", headerEnd, mobileBrand, showHelp = true, helpApp, guides, }) {
|
|
6475
|
+
const { data: session, status } = react.useSession();
|
|
6476
|
+
const { open, setOpen } = useSidebar();
|
|
6477
|
+
const router = navigation.useRouter();
|
|
6478
|
+
const params = navigation.useParams();
|
|
6479
|
+
const code = params?.code;
|
|
6480
|
+
const extendedSession = session;
|
|
6481
|
+
// Track once-authenticated so a transient accessToken drop during the
|
|
6482
|
+
// periodic session refresh (SessionRefreshTrigger every 4 min) doesn't
|
|
6483
|
+
// blank the whole page. The initial-load gate below still handles cold
|
|
6484
|
+
// start; once we've seen a real token we render children optimistically
|
|
6485
|
+
// and let the API layer surface a real 401 if the token actually died.
|
|
6486
|
+
const hasSeenAccessTokenRef = React.useRef(false);
|
|
6487
|
+
React.useEffect(() => {
|
|
6488
|
+
if (extendedSession?.accessToken)
|
|
6489
|
+
hasSeenAccessTokenRef.current = true;
|
|
6490
|
+
}, [extendedSession?.accessToken]);
|
|
6491
|
+
// The URL's `[code]` is authoritative: the page mounts only once the active
|
|
6492
|
+
// organisation is the one it names (see url-org-gate.ts).
|
|
6493
|
+
//
|
|
6494
|
+
// The gate used to start only when the code had resolved AND an active id
|
|
6495
|
+
// existed, which left two holes. While the organisation list loaded, the
|
|
6496
|
+
// page mounted at once and its first requests carried the previous
|
|
6497
|
+
// organisation — /O26000021/procurement/purchase-requisition/<id> fetched
|
|
6498
|
+
// the document from O26000019 before the switch. And a code naming none of
|
|
6499
|
+
// the user's organisations never resolved, so the page rendered the
|
|
6500
|
+
// previous organisation's data under it.
|
|
6501
|
+
const urlOrgGate = resolveUrlOrgGate({
|
|
6502
|
+
code,
|
|
6503
|
+
organizations,
|
|
6504
|
+
organizationsStatus,
|
|
6505
|
+
organizationId,
|
|
6506
|
+
});
|
|
6507
|
+
const switchTargetId = urlOrgGate.state === "switching" ? urlOrgGate.targetId : null;
|
|
6508
|
+
// Switch the active organisation to the URL's. Once per target: the setter
|
|
6509
|
+
// writes the cookie through a server action, and re-firing it on every
|
|
6510
|
+
// render while that round trip is in flight only queues duplicates.
|
|
6511
|
+
const requestedSwitchRef = React.useRef(null);
|
|
6512
|
+
React.useEffect(() => {
|
|
6513
|
+
if (!switchTargetId) {
|
|
6514
|
+
requestedSwitchRef.current = null;
|
|
6515
|
+
return;
|
|
6516
|
+
}
|
|
6517
|
+
if (requestedSwitchRef.current === switchTargetId)
|
|
6518
|
+
return;
|
|
6519
|
+
requestedSwitchRef.current = switchTargetId;
|
|
6520
|
+
void setOrganizationId(switchTargetId);
|
|
6521
|
+
}, [switchTargetId, setOrganizationId]);
|
|
6522
|
+
// Once the user's organisations are known, requests prefer a stored
|
|
6523
|
+
// organisation that is one of them (getRequestOrganizationId).
|
|
6524
|
+
const organizationsLoaded = (organizationsStatus ??
|
|
6525
|
+
(organizations.length > 0 ? "success" : undefined)) === "success";
|
|
6526
|
+
React.useEffect(() => {
|
|
6527
|
+
if (organizationsLoaded) {
|
|
6528
|
+
setKnownOrganizationIds(organizations.map(org => org.id));
|
|
6529
|
+
}
|
|
6530
|
+
}, [organizationsLoaded, organizations]);
|
|
6531
|
+
// An active organisation that is not one of the user's — deleted, or left —
|
|
6532
|
+
// is replaced, so it stops being sent with every request. The URL's
|
|
6533
|
+
// organisation, when the user is a member, is the gate's switch above;
|
|
6534
|
+
// otherwise the user's only organisation, or none (they pick one: the
|
|
6535
|
+
// refusal below offers it, and a path without a code goes to the picker).
|
|
6536
|
+
// Once per stale id and target, so a setter that does not take cannot loop.
|
|
6537
|
+
const staleRecovery = resolveStaleOrganization({
|
|
6538
|
+
code,
|
|
6539
|
+
organizations,
|
|
6540
|
+
organizationsStatus,
|
|
6541
|
+
organizationId,
|
|
6542
|
+
});
|
|
6543
|
+
const staleRecoveryTarget = staleRecovery.action === "forget"
|
|
3562
6544
|
? null
|
|
3563
6545
|
: staleRecovery.action === "use" && !staleRecovery.fromUrl
|
|
3564
6546
|
? staleRecovery.targetId
|
|
@@ -3683,11 +6665,11 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
|
|
|
3683
6665
|
// As in the design: the mark, then a menu button, and the
|
|
3684
6666
|
// organisation on the right — no trail, no title, so the
|
|
3685
6667
|
// organisation's name has the room it needs on a phone.
|
|
3686
|
-
jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [mobileBrand ? (jsxRuntime.jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsxRuntime.jsx("button", { type: "button", onClick: () => setOpen(!open), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted)] transition-colors hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted)]", children: jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsxRuntime.jsx("div", { className: "hidden min-w-0 sm:block", children: trail }), jsxRuntime.jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] })] })) : (jsxRuntime.jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsxRuntime.jsx(HeaderBackground, {}), jsxRuntime.jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxRuntime.jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: trail }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] }), jsxRuntime.jsx("div", { id: "header-title" })] })] })), jsxRuntime.jsx(React.Suspense, { fallback: jsxRuntime.jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center", children: jsxRuntime.jsxs("div", { className: "space-y-4 text-center", children: [jsxRuntime.jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsxRuntime.jsx("div", { className: workspace
|
|
6668
|
+
jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [mobileBrand ? (jsxRuntime.jsx("div", { className: "shrink-0", children: mobileBrand })) : null, jsxRuntime.jsx("button", { type: "button", onClick: () => setOpen(!open), "aria-label": open ? "ปิดเมนู" : "เปิดเมนู", ...guideAnchor("shell.menu-toggle"), className: "grid size-[34px] shrink-0 place-items-center rounded-lg text-[var(--header-fg-muted)] transition-colors hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-[var(--header-fg-muted)]", children: jsxRuntime.jsx(lucideReact.LayoutGrid, { className: "size-5", strokeWidth: 1.65 }) })] })) : (jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-[var(--header-fg-muted)] hover:bg-[var(--header-hover)] hover:text-[var(--header-fg)] [&_svg]:!text-current" })), jsxRuntime.jsxs("div", { className: "flex min-w-0 flex-1 flex-col justify-center gap-0.5", children: [jsxRuntime.jsx("div", { className: "hidden min-w-0 sm:block", children: trail }), jsxRuntime.jsx("div", { id: "header-title", className: "min-w-0 max-sm:hidden" })] }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3 sm:gap-5", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] })] })) : (jsxRuntime.jsxs("div", { className: "sticky top-0 z-30 shrink-0 w-full", children: [jsxRuntime.jsx(HeaderBackground, {}), jsxRuntime.jsxs("header", { className: "relative shrink-0 gap-2 py-4 h-auto min-h-[123.5px] w-full z-10", children: [jsxRuntime.jsxs("div", { className: "flex w-full items-center gap-2 px-3 sm:px-4 lg:px-6", children: [jsxRuntime.jsx(SidebarTrigger, { className: "shrink-0 text-white hover:bg-white/10" }), jsxRuntime.jsx("div", { className: "flex-1 min-w-0", children: trail }), headerEnd || showHelp ? (jsxRuntime.jsxs("div", { className: "flex shrink-0 items-center gap-3", children: [headerEnd, showHelp ? jsxRuntime.jsx(HelpButton, {}) : null] })) : null] }), jsxRuntime.jsx("div", { id: "header-title" })] })] })), jsxRuntime.jsx(React.Suspense, { fallback: jsxRuntime.jsx("div", { className: "relative z-10 min-h-[calc(100vh-8rem)]", children: jsxRuntime.jsx("div", { className: "flex h-full items-center justify-center", children: jsxRuntime.jsxs("div", { className: "space-y-4 text-center", children: [jsxRuntime.jsx("div", { className: "mx-auto h-8 w-8 animate-spin rounded-full border-b-2 border-primary" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E01\u0E33\u0E25\u0E31\u0E07\u0E42\u0E2B\u0E25\u0E14..." })] }) }) }), children: jsxRuntime.jsx("div", { className: workspace
|
|
3687
6669
|
? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
|
|
3688
6670
|
: cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: refusePage
|
|
3689
6671
|
? (notMemberComponent ?? (jsxRuntime.jsx(AccessDenied, { title: "\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E40\u0E02\u0E49\u0E32\u0E16\u0E36\u0E07\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23\u0E19\u0E35\u0E49", message: `บัญชีนี้ไม่ได้เป็นสมาชิกขององค์กร ${code}`, homeHref: selectOrganizationUrl, homeLabel: "\u0E40\u0E25\u0E37\u0E2D\u0E01\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })))
|
|
3690
|
-
: wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsxRuntime.jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null] }));
|
|
6672
|
+
: wrap(children) }, `org-${contentOrgKey}`) })] }) }), showHelp ? jsxRuntime.jsx(HelpDialogHost, { app: helpApp, orgCode: code }) : null, guides && !refusePage && urlOrgGate.state === "ready" && organizationId != null && String(organizationId).trim() !== "" ? (jsxRuntime.jsx(GuideHost, { config: guides, organizationId: String(organizationId), code: code }, `guide-${String(organizationId)}`)) : null] }));
|
|
3691
6673
|
}
|
|
3692
6674
|
/** Local tiny classnames helper — avoids pulling clsx/tailwind-merge into
|
|
3693
6675
|
* the shell just for the layout file. Falsy values are skipped. */
|
|
@@ -3726,39 +6708,6 @@ function SidebarHelpLink({ label, href, icon, className }) {
|
|
|
3726
6708
|
return external ? (jsxRuntime.jsx("a", { href: href, target: "_blank", rel: "noopener noreferrer", className: cls, children: body })) : (jsxRuntime.jsx(Link, { href: href, className: cls, children: body }));
|
|
3727
6709
|
}
|
|
3728
6710
|
|
|
3729
|
-
const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
|
|
3730
|
-
const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
|
|
3731
|
-
const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
|
|
3732
|
-
/**
|
|
3733
|
-
* Shown when the API refuses every request because the signed-in account has
|
|
3734
|
-
* no employee record in this organisation.
|
|
3735
|
-
*
|
|
3736
|
-
* A gate rather than a banner. Leave, attendance, approvals and payslips all
|
|
3737
|
-
* identify a person by that record, so once the server started enforcing it
|
|
3738
|
-
* every call fails — and without something that says so, the app becomes a
|
|
3739
|
-
* screen of error toasts with no explanation and no way forward.
|
|
3740
|
-
*/
|
|
3741
|
-
function EmployeeRequiredGate({ platformBaseUrl, }) {
|
|
3742
|
-
const [blocked, setBlocked] = React.useState(false);
|
|
3743
|
-
const params = navigation.useParams();
|
|
3744
|
-
const code = params?.code ?? "";
|
|
3745
|
-
React.useEffect(() => {
|
|
3746
|
-
const onBlocked = () => setBlocked(true);
|
|
3747
|
-
const onOk = () => setBlocked(false);
|
|
3748
|
-
window.addEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
3749
|
-
window.addEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
3750
|
-
return () => {
|
|
3751
|
-
window.removeEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
3752
|
-
window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
3753
|
-
};
|
|
3754
|
-
}, []);
|
|
3755
|
-
if (!blocked)
|
|
3756
|
-
return null;
|
|
3757
|
-
const base = platformBaseUrl?.replace(/\/$/, "") ?? "";
|
|
3758
|
-
const createHref = code ? `${base}/${code}/employee/create` : `${base}/`;
|
|
3759
|
-
return (jsxRuntime.jsx("div", { className: "fixed inset-0 z-[100] flex items-center justify-center bg-background/95 p-6 backdrop-blur-sm", children: jsxRuntime.jsxs("div", { className: "w-full max-w-md space-y-5 text-center", children: [jsxRuntime.jsx(lucideReact.ContactRound, { className: "mx-auto h-12 w-12 text-muted-foreground" }), jsxRuntime.jsxs("div", { className: "space-y-2", children: [jsxRuntime.jsx("h1", { className: "text-lg font-semibold tracking-tight", children: "\u0E1A\u0E31\u0E0D\u0E0A\u0E35\u0E19\u0E35\u0E49\u0E22\u0E31\u0E07\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49\u0E1C\u0E39\u0E01\u0E01\u0E31\u0E1A\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19" }), jsxRuntime.jsx("p", { className: "text-sm text-muted-foreground", children: "\u0E25\u0E32 \u0E25\u0E07\u0E40\u0E27\u0E25\u0E32 \u0E40\u0E1A\u0E34\u0E01\u0E40\u0E07\u0E34\u0E19 \u0E41\u0E25\u0E30\u0E01\u0E32\u0E23\u0E2D\u0E19\u0E38\u0E21\u0E31\u0E15\u0E34 \u0E23\u0E30\u0E1A\u0E38\u0E15\u0E31\u0E27\u0E04\u0E19\u0E08\u0E32\u0E01\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19 \u0E08\u0E36\u0E07\u0E22\u0E31\u0E07\u0E43\u0E0A\u0E49\u0E07\u0E32\u0E19\u0E43\u0E19\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23\u0E19\u0E35\u0E49\u0E44\u0E21\u0E48\u0E44\u0E14\u0E49" })] }), jsxRuntime.jsxs("div", { className: "flex flex-wrap justify-center gap-2", children: [jsxRuntime.jsx(erpTheme.Button, { onClick: () => window.location.reload(), children: "\u0E25\u0E2D\u0E07\u0E43\u0E2B\u0E21\u0E48" }), jsxRuntime.jsx(erpTheme.Button, { variant: "outline", asChild: true, children: jsxRuntime.jsxs("a", { href: createHref, children: [jsxRuntime.jsx(lucideReact.UserPlus, { className: "mr-1 h-4 w-4" }), "\u0E2A\u0E23\u0E49\u0E32\u0E07\u0E02\u0E49\u0E2D\u0E21\u0E39\u0E25\u0E1E\u0E19\u0E31\u0E01\u0E07\u0E32\u0E19"] }) })] }), jsxRuntime.jsx("p", { className: "text-xs text-muted-foreground", children: "\u0E16\u0E49\u0E32\u0E04\u0E38\u0E13\u0E44\u0E21\u0E48\u0E21\u0E35\u0E2A\u0E34\u0E17\u0E18\u0E34\u0E4C\u0E41\u0E01\u0E49\u0E44\u0E02 \u0E43\u0E2B\u0E49\u0E41\u0E08\u0E49\u0E07\u0E1C\u0E39\u0E49\u0E14\u0E39\u0E41\u0E25\u0E2D\u0E07\u0E04\u0E4C\u0E01\u0E23" })] }) }));
|
|
3760
|
-
}
|
|
3761
|
-
|
|
3762
6711
|
const shellClassName = "flex min-w-0 flex-1 flex-col gap-6 overflow-x-hidden";
|
|
3763
6712
|
/**
|
|
3764
6713
|
* Shared layout for “กำลังตรวจสอบสิทธิ์” (skeleton) and “ไม่มีสิทธิ์” (message).
|
|
@@ -4300,6 +7249,10 @@ exports.EMPLOYEE_RECORD_REQUIRED_EVENT = EMPLOYEE_RECORD_REQUIRED_EVENT;
|
|
|
4300
7249
|
exports.EmployeeRequiredGate = EmployeeRequiredGate;
|
|
4301
7250
|
exports.FOR_ORGANIZATION_FIELD = FOR_ORGANIZATION_FIELD;
|
|
4302
7251
|
exports.FeatureFlagsProvider = FeatureFlagsProvider;
|
|
7252
|
+
exports.GUIDE_API_EVENT = API_EVENT;
|
|
7253
|
+
exports.GUIDE_EVENT_PREFIX = GUIDE_EVENT_PREFIX;
|
|
7254
|
+
exports.GUIDE_LAYER_ATTRIBUTE = GUIDE_LAYER_ATTRIBUTE;
|
|
7255
|
+
exports.GuideHost = GuideHost;
|
|
4303
7256
|
exports.HeaderBackground = HeaderBackground;
|
|
4304
7257
|
exports.HelpButton = HelpButton;
|
|
4305
7258
|
exports.HelpDialogHost = HelpDialogHost;
|
|
@@ -4329,6 +7282,7 @@ exports.WITHOUT_ORGANIZATION_FLAG = WITHOUT_ORGANIZATION_FLAG;
|
|
|
4329
7282
|
exports.WORKSPACE_HEADER_VARS = WORKSPACE_HEADER_VARS;
|
|
4330
7283
|
exports.WORKSPACE_LIGHT_HEADER_VARS = WORKSPACE_LIGHT_HEADER_VARS;
|
|
4331
7284
|
exports.WorkspaceBrand = WorkspaceBrand;
|
|
7285
|
+
exports.accountSettingsHref = accountSettingsHref;
|
|
4332
7286
|
exports.axiosInstance = instance;
|
|
4333
7287
|
exports.buildSystemLinks = buildSystemLinks;
|
|
4334
7288
|
exports.buildUrlWithCode = buildUrlWithCode;
|
|
@@ -4341,6 +7295,7 @@ exports.decodeJWT = decodeJWT;
|
|
|
4341
7295
|
exports.describeAttached = describeAttached;
|
|
4342
7296
|
exports.describeBrowser = describeBrowser;
|
|
4343
7297
|
exports.detectHelpApp = detectHelpApp;
|
|
7298
|
+
exports.emitGuideEvent = emitGuideEvent;
|
|
4344
7299
|
exports.filterNavigationModulesByPermission = filterNavigationModulesByPermission;
|
|
4345
7300
|
exports.filterNavigationToolsByPermission = filterNavigationToolsByPermission;
|
|
4346
7301
|
exports.findOrganizationForCode = findOrganizationForCode;
|
|
@@ -4354,15 +7309,19 @@ exports.getRequestOrganizationId = getRequestOrganizationId;
|
|
|
4354
7309
|
exports.getRequiredChildGroups = getRequiredChildGroups;
|
|
4355
7310
|
exports.getStoredOrganizationIds = getStoredOrganizationIds;
|
|
4356
7311
|
exports.getTabOrganizationId = getTabOrganizationId;
|
|
7312
|
+
exports.guideAnchor = guideAnchor;
|
|
4357
7313
|
exports.hasPlanFeature = hasPlanFeature;
|
|
4358
7314
|
exports.isKnownStaleOrganizationId = isKnownStaleOrganizationId;
|
|
4359
7315
|
exports.isLikelyTransientNetworkError = isLikelyTransientNetworkError;
|
|
4360
7316
|
exports.isPathActive = isPathActive;
|
|
4361
7317
|
exports.myRolesQueryKey = myRolesQueryKey;
|
|
4362
7318
|
exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRules;
|
|
7319
|
+
exports.openGuidePanel = openGuidePanel;
|
|
4363
7320
|
exports.openHelp = openHelp;
|
|
4364
7321
|
exports.organizationScopedQueryHash = organizationScopedQueryHash;
|
|
4365
7322
|
exports.parseMyRoles = parseMyRoles;
|
|
7323
|
+
exports.platformBaseUrl = platformBaseUrl;
|
|
7324
|
+
exports.reportApiEvent = reportApiEvent;
|
|
4366
7325
|
exports.resolveActiveNav = resolveActiveNav;
|
|
4367
7326
|
exports.resolveSidebarNav = resolveSidebarNav;
|
|
4368
7327
|
exports.resolveStaleOrganization = resolveStaleOrganization;
|
|
@@ -4382,6 +7341,7 @@ exports.updateOrganizationId = updateOrganizationId;
|
|
|
4382
7341
|
exports.useClearOrgOnUserChange = useClearOrgOnUserChange;
|
|
4383
7342
|
exports.useFeatureFlag = useFeatureFlag;
|
|
4384
7343
|
exports.useFeatureFlags = useFeatureFlags;
|
|
7344
|
+
exports.useGuideEntry = useGuideEntry;
|
|
4385
7345
|
exports.useInvalidateQueriesOnOrgChange = useInvalidateQueriesOnOrgChange;
|
|
4386
7346
|
exports.useKeycloakRoles = useKeycloakRoles;
|
|
4387
7347
|
exports.useMyRoles = useMyRoles;
|