@eintrek/erp-shell 0.1.67 → 0.1.68
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 +28 -2
- package/dist/chunks/guide-runtime-B2RjoQeb.cjs +3874 -0
- package/dist/chunks/guide-runtime-B2RjoQeb.cjs.map +1 -0
- package/dist/chunks/guide-runtime-BT7rN-5p.esm.js +3837 -0
- package/dist/chunks/guide-runtime-BT7rN-5p.esm.js.map +1 -0
- package/dist/guide/core.d.ts +19 -0
- package/dist/guide/events.d.ts +25 -0
- package/dist/guide/guide-runtime.d.ts +83 -0
- package/dist/guide/guide-styles.d.ts +7 -1
- package/dist/guide/machine.d.ts +33 -2
- package/dist/guide/patterns.d.ts +6 -2
- package/dist/guide/progress-client.d.ts +6 -5
- package/dist/guide/progress-sync.d.ts +68 -0
- package/dist/guide/types.d.ts +23 -2
- package/dist/guide-core.cjs +29 -0
- package/dist/guide-core.cjs.map +1 -0
- package/dist/guide-core.esm.js +7 -0
- package/dist/guide-core.esm.js.map +1 -0
- package/dist/{index.js → index.cjs} +128 -3540
- package/dist/index.cjs.map +1 -0
- package/dist/index.d.ts +3 -1
- package/dist/index.esm.js +109 -3523
- package/dist/index.esm.js.map +1 -1
- package/dist/version.d.ts +1 -1
- package/package.json +46 -2
- package/dist/index.js.map +0 -1
|
@@ -14,8 +14,9 @@ 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
|
|
17
|
+
var guideRuntime = require('./chunks/guide-runtime-B2RjoQeb.cjs');
|
|
18
18
|
var reactQueryDevtools = require('@tanstack/react-query-devtools');
|
|
19
|
+
require('@floating-ui/dom');
|
|
19
20
|
|
|
20
21
|
function _interopNamespaceDefault(e) {
|
|
21
22
|
var n = Object.create(null);
|
|
@@ -1278,35 +1279,35 @@ let declaredApp = null;
|
|
|
1278
1279
|
// The message the dialog should open on (the ตอบกลับ link's ?help=H1004),
|
|
1279
1280
|
// until it is closed or left.
|
|
1280
1281
|
let pendingRef = null;
|
|
1281
|
-
const listeners
|
|
1282
|
-
function emit
|
|
1283
|
-
listeners
|
|
1282
|
+
const listeners = new Set();
|
|
1283
|
+
function emit() {
|
|
1284
|
+
listeners.forEach(l => l());
|
|
1284
1285
|
}
|
|
1285
|
-
function subscribe
|
|
1286
|
-
listeners
|
|
1286
|
+
function subscribe(l) {
|
|
1287
|
+
listeners.add(l);
|
|
1287
1288
|
return () => {
|
|
1288
|
-
listeners
|
|
1289
|
+
listeners.delete(l);
|
|
1289
1290
|
};
|
|
1290
1291
|
}
|
|
1291
1292
|
/** Opens the dialog — a no-op when no layout has mounted one. */
|
|
1292
1293
|
function openHelp() {
|
|
1293
1294
|
if (!open) {
|
|
1294
1295
|
open = true;
|
|
1295
|
-
emit
|
|
1296
|
+
emit();
|
|
1296
1297
|
}
|
|
1297
1298
|
}
|
|
1298
1299
|
function closeHelp() {
|
|
1299
1300
|
pendingRef = null;
|
|
1300
1301
|
if (open) {
|
|
1301
1302
|
open = false;
|
|
1302
|
-
emit
|
|
1303
|
+
emit();
|
|
1303
1304
|
}
|
|
1304
1305
|
}
|
|
1305
1306
|
/** Opens the dialog on one of the person's messages, by id or "H1004". */
|
|
1306
1307
|
function openHelpOn(ref) {
|
|
1307
1308
|
pendingRef = ref;
|
|
1308
1309
|
open = true;
|
|
1309
|
-
emit
|
|
1310
|
+
emit();
|
|
1310
1311
|
}
|
|
1311
1312
|
/** The message the dialog was asked to open on, if it has not been left. */
|
|
1312
1313
|
function pendingHelpRef() {
|
|
@@ -1325,22 +1326,22 @@ function registerHelpHost() {
|
|
|
1325
1326
|
hosts += 1;
|
|
1326
1327
|
if (pendingRef)
|
|
1327
1328
|
open = true;
|
|
1328
|
-
emit
|
|
1329
|
+
emit();
|
|
1329
1330
|
return () => {
|
|
1330
1331
|
hosts -= 1;
|
|
1331
1332
|
if (hosts <= 0) {
|
|
1332
1333
|
hosts = 0;
|
|
1333
1334
|
open = false;
|
|
1334
1335
|
}
|
|
1335
|
-
emit
|
|
1336
|
+
emit();
|
|
1336
1337
|
};
|
|
1337
1338
|
}
|
|
1338
1339
|
function useHelpOpen() {
|
|
1339
|
-
return React.useSyncExternalStore(subscribe
|
|
1340
|
+
return React.useSyncExternalStore(subscribe, () => open, () => false);
|
|
1340
1341
|
}
|
|
1341
1342
|
/** True while a dialog host is mounted, i.e. the ช่วยเหลือ entry works. */
|
|
1342
1343
|
function useHelpAvailable() {
|
|
1343
|
-
return React.useSyncExternalStore(subscribe
|
|
1344
|
+
return React.useSyncExternalStore(subscribe, () => hosts > 0, () => false);
|
|
1344
1345
|
}
|
|
1345
1346
|
/**
|
|
1346
1347
|
* Every app names itself when it builds its app-switcher links
|
|
@@ -1364,17 +1365,17 @@ function declaredHelpApp() {
|
|
|
1364
1365
|
const OWNER_CHIP = "เจ้าของ";
|
|
1365
1366
|
/** How many role names the card spells out before counting the rest. */
|
|
1366
1367
|
const CARD_ROLE_LIMIT = 2;
|
|
1367
|
-
const isRecord
|
|
1368
|
+
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
1368
1369
|
/**
|
|
1369
1370
|
* The answer, checked. Anything that is not the shape the card needs — an
|
|
1370
1371
|
* older API, a proxy's error page, another organisation's answer — is null,
|
|
1371
1372
|
* and the card shows no role line.
|
|
1372
1373
|
*/
|
|
1373
1374
|
function parseMyRoles(payload, organizationId) {
|
|
1374
|
-
if (!isRecord
|
|
1375
|
+
if (!isRecord(payload))
|
|
1375
1376
|
return null;
|
|
1376
1377
|
// Envelope { success, data } or the bare data.
|
|
1377
|
-
const data = isRecord
|
|
1378
|
+
const data = isRecord(payload.data) ? payload.data : payload;
|
|
1378
1379
|
if (!Array.isArray(data.roles))
|
|
1379
1380
|
return null;
|
|
1380
1381
|
if (typeof data.organization_id === "string" && data.organization_id !== organizationId) {
|
|
@@ -1383,7 +1384,7 @@ function parseMyRoles(payload, organizationId) {
|
|
|
1383
1384
|
const roles = [];
|
|
1384
1385
|
const seen = new Set();
|
|
1385
1386
|
for (const r of data.roles) {
|
|
1386
|
-
if (!isRecord
|
|
1387
|
+
if (!isRecord(r))
|
|
1387
1388
|
continue;
|
|
1388
1389
|
const name = typeof r.name_th === "string" ? r.name_th.trim() : "";
|
|
1389
1390
|
const key = typeof r.key === "string" ? r.key : "";
|
|
@@ -1604,242 +1605,6 @@ async function retryWithStoredOrganizations(config, status, send) {
|
|
|
1604
1605
|
return undefined;
|
|
1605
1606
|
}
|
|
1606
1607
|
|
|
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
|
-
|
|
1843
1608
|
const isBrowser = () => typeof window !== "undefined";
|
|
1844
1609
|
const isAuthEndpoint = (url) => !!url && url.includes("/api/auth/");
|
|
1845
1610
|
const isAuthPage = () => isBrowser() && window.location.pathname.startsWith("/auth/");
|
|
@@ -1915,7 +1680,7 @@ instance.interceptors.response.use(response => {
|
|
|
1915
1680
|
}
|
|
1916
1681
|
// The guided tour advances on saves that succeeded (method, path, status
|
|
1917
1682
|
// and the record's id only; never the body).
|
|
1918
|
-
reportApiEvent(response);
|
|
1683
|
+
guideRuntime.reportApiEvent(response);
|
|
1919
1684
|
return response;
|
|
1920
1685
|
}, async (error) => {
|
|
1921
1686
|
const originalRequest = error.config;
|
|
@@ -1932,7 +1697,7 @@ instance.interceptors.response.use(response => {
|
|
|
1932
1697
|
return instance(originalRequest);
|
|
1933
1698
|
}
|
|
1934
1699
|
await redirectToLogout();
|
|
1935
|
-
reportApiEvent(error);
|
|
1700
|
+
guideRuntime.reportApiEvent(error);
|
|
1936
1701
|
return Promise.reject(toApiError(error));
|
|
1937
1702
|
}
|
|
1938
1703
|
// The server refuses every organisation-scoped call when the account has
|
|
@@ -1948,7 +1713,7 @@ instance.interceptors.response.use(response => {
|
|
|
1948
1713
|
if (listed !== undefined)
|
|
1949
1714
|
return listed;
|
|
1950
1715
|
// A refused save keeps the tour's step and shows the server's message.
|
|
1951
|
-
reportApiEvent(error);
|
|
1716
|
+
guideRuntime.reportApiEvent(error);
|
|
1952
1717
|
return Promise.reject(toApiError(error));
|
|
1953
1718
|
});
|
|
1954
1719
|
|
|
@@ -1975,7 +1740,7 @@ function platformApiBase(app) {
|
|
|
1975
1740
|
* This package's version, attached to help messages so the back office can
|
|
1976
1741
|
* tell which shell a report came from. Keep in step with package.json.
|
|
1977
1742
|
*/
|
|
1978
|
-
const SHELL_VERSION = "0.1.
|
|
1743
|
+
const SHELL_VERSION = "0.1.68";
|
|
1979
1744
|
|
|
1980
1745
|
const HELP_APP_LABEL = {
|
|
1981
1746
|
platform: "แพลตฟอร์ม",
|
|
@@ -2647,11 +2412,11 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2647
2412
|
// A one-line row: a link, lit on its own page only. No open
|
|
2648
2413
|
// state, no title tooltip.
|
|
2649
2414
|
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 " +
|
|
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));
|
|
2415
|
+
(workspace ? "gap-3" : "gap-2"), onClick: handleNavLinkClick, "aria-current": isItemActive ? "page" : undefined, ...guideRuntime.guideAnchor(`nav.${item.id}`), children: [icon, jsxRuntime.jsx("span", { id: item.id, className: "flex-1 truncate", children: item.titleTh }), badge] }) }) })] }, item.id));
|
|
2651
2416
|
}
|
|
2652
2417
|
// A group: its header opens and shuts it (the chevron says which).
|
|
2653
2418
|
// An open group is not the current page — only a row in it is.
|
|
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
|
|
2419
|
+
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, ...guideRuntime.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
|
|
2655
2420
|
? "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]"
|
|
2656
2421
|
: undefined, children: item.subModules.map((subItem, subIndex) => {
|
|
2657
2422
|
const subItemUrl = buildUrlWithCode(code, subItem.url);
|
|
@@ -2664,7 +2429,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2664
2429
|
subItem.subPages &&
|
|
2665
2430
|
subItem.subPages.length > 0) {
|
|
2666
2431
|
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 &&
|
|
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) => {
|
|
2432
|
+
activeSubPage === null, ...guideRuntime.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) => {
|
|
2668
2433
|
const subPageUrl = buildUrlWithCode(code, subPage.url);
|
|
2669
2434
|
const isSubPageActive = isSubItemActive &&
|
|
2670
2435
|
activeSubPage === subPageIndex;
|
|
@@ -2672,7 +2437,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2672
2437
|
return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "font-thai w-full text-sm" +
|
|
2673
2438
|
(isSubPageActive
|
|
2674
2439
|
? " font-medium"
|
|
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
|
|
2440
|
+
: ""), isActive: isSubPageActive, children: jsxRuntime.jsxs(Link, { href: subPageUrl, prefetch: false, className: "w-full flex items-center", ...guideRuntime.guideAnchor(`nav.${subPage.id}`), onClick: handleNavLinkClick, "aria-current": isSubPageActive
|
|
2676
2441
|
? "page"
|
|
2677
2442
|
: 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));
|
|
2678
2443
|
}) }) })] }) }, subItem.id));
|
|
@@ -2680,7 +2445,7 @@ function NavMain({ items, orgCode, closeOnNavigation = false, approvalCounts, ch
|
|
|
2680
2445
|
return (jsxRuntime.jsx(SidebarMenuSubItem, { children: jsxRuntime.jsx(SidebarMenuSubButton, { asChild: true, className: "w-full" +
|
|
2681
2446
|
(workspace && isSubItemActive
|
|
2682
2447
|
? " font-semibold"
|
|
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
|
|
2448
|
+
: ""), isActive: isSubItemActive, children: jsxRuntime.jsxs(Link, { href: subItemUrl, prefetch: false, className: "w-full flex items-center", ...guideRuntime.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
|
|
2684
2449
|
? "99+"
|
|
2685
2450
|
: approvalCount }))] }) }) }, subItem.id));
|
|
2686
2451
|
}) }) }))] }) })] }, item.id));
|
|
@@ -2802,37 +2567,6 @@ const WORKSPACE_LIGHT_HEADER_VARS = {
|
|
|
2802
2567
|
"--header-title-py": "0px",
|
|
2803
2568
|
};
|
|
2804
2569
|
|
|
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
2570
|
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)]";
|
|
2837
2571
|
/**
|
|
2838
2572
|
* The ช่วยเหลือ button in the top bar. Drawn in the header's ink (the
|
|
@@ -2843,7 +2577,7 @@ const BUTTON_CLASS = "grid size-[34px] shrink-0 place-items-center rounded-lg te
|
|
|
2843
2577
|
* dialog). Otherwise one press opens the help dialog, as before.
|
|
2844
2578
|
*/
|
|
2845
2579
|
function HelpButton({ className }) {
|
|
2846
|
-
const guide = useGuideEntry();
|
|
2580
|
+
const guide = guideRuntime.useGuideEntry();
|
|
2847
2581
|
const toGuide = React.useRef(false);
|
|
2848
2582
|
const cls = BUTTON_CLASS + (className ? ` ${className}` : "");
|
|
2849
2583
|
if (!guide) {
|
|
@@ -2857,7 +2591,7 @@ function HelpButton({ className }) {
|
|
|
2857
2591
|
}
|
|
2858
2592
|
}, children: [jsxRuntime.jsxs(erpTheme.DropdownMenuItem, { onSelect: () => {
|
|
2859
2593
|
toGuide.current = true;
|
|
2860
|
-
setTimeout(openGuidePanel, 0);
|
|
2594
|
+
setTimeout(guideRuntime.openGuidePanel, 0);
|
|
2861
2595
|
}, 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" })] })] })] }));
|
|
2862
2596
|
}
|
|
2863
2597
|
|
|
@@ -3803,2446 +3537,84 @@ function resolveStaleOrganization({ code, organizations, organizationsStatus, or
|
|
|
3803
3537
|
return { action: "forget" };
|
|
3804
3538
|
}
|
|
3805
3539
|
|
|
3806
|
-
|
|
3807
|
-
|
|
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
|
-
replayPage: null,
|
|
3836
|
-
};
|
|
3837
|
-
}
|
|
3838
|
-
function findMission(suite, key) {
|
|
3839
|
-
return key ? suite.missions.find(m => m.key === key) : undefined;
|
|
3840
|
-
}
|
|
3841
|
-
function guideContext(env, data, params = {}) {
|
|
3842
|
-
return {
|
|
3843
|
-
code: env.code,
|
|
3844
|
-
pathname: env.pathname,
|
|
3845
|
-
search: env.search,
|
|
3846
|
-
params: { code: env.code, ...params },
|
|
3847
|
-
data,
|
|
3848
|
-
facts: env.facts,
|
|
3849
|
-
can: env.can,
|
|
3850
|
-
visible: (anchor) => env.isVisible(anchor),
|
|
3851
|
-
};
|
|
3852
|
-
}
|
|
3853
|
-
function resolveText(t, ctx) {
|
|
3854
|
-
if (t === undefined)
|
|
3855
|
-
return "";
|
|
3856
|
-
try {
|
|
3857
|
-
return typeof t === "function" ? t(ctx) : t;
|
|
3858
|
-
}
|
|
3859
|
-
catch {
|
|
3860
|
-
return "";
|
|
3861
|
-
}
|
|
3862
|
-
}
|
|
3863
|
-
/** Runs an app predicate; a throw counts as `fallback`. */
|
|
3864
|
-
function safe(fn, fallback) {
|
|
3865
|
-
try {
|
|
3866
|
-
return fn();
|
|
3867
|
-
}
|
|
3868
|
-
catch {
|
|
3869
|
-
return fallback;
|
|
3870
|
-
}
|
|
3871
|
-
}
|
|
3872
|
-
function triggersOf(step) {
|
|
3873
|
-
if (!step.advanceOn)
|
|
3874
|
-
return [];
|
|
3875
|
-
return Array.isArray(step.advanceOn) ? step.advanceOn : [step.advanceOn];
|
|
3876
|
-
}
|
|
3877
|
-
function stepKind(step) {
|
|
3878
|
-
return step.kind ?? "click";
|
|
3879
|
-
}
|
|
3880
|
-
/** The last checkpoint at or before `index` (a mission's first step always is one). */
|
|
3881
|
-
function checkpointAtOrBefore(steps, index) {
|
|
3882
|
-
for (let i = Math.min(index, steps.length - 1); i > 0; i--) {
|
|
3883
|
-
if (steps[i].checkpoint)
|
|
3884
|
-
return i;
|
|
3885
|
-
}
|
|
3886
|
-
return 0;
|
|
3887
|
-
}
|
|
3888
|
-
function targetIdOf(step, ctx) {
|
|
3889
|
-
if (typeof step.targetId === "function")
|
|
3890
|
-
return safe(() => step.targetId(ctx), undefined);
|
|
3891
|
-
return step.targetId;
|
|
3892
|
-
}
|
|
3893
|
-
/**
|
|
3894
|
-
* A commit step with nothing to save: its button is not on the page, and what
|
|
3895
|
-
* is saved is (GuideStep.nothingToSave).
|
|
3896
|
-
*/
|
|
3897
|
-
function nothingToSaveHolds(step, env, ctx) {
|
|
3898
|
-
const n = step.nothingToSave;
|
|
3899
|
-
if (!n || !step.target)
|
|
3900
|
-
return false;
|
|
3901
|
-
return !env.isVisible(step.target, targetIdOf(step, ctx)) && env.isVisible(n.target);
|
|
3902
|
-
}
|
|
3540
|
+
const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
|
|
3541
|
+
const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
|
|
3542
|
+
const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
|
|
3903
3543
|
/**
|
|
3904
|
-
*
|
|
3905
|
-
*
|
|
3544
|
+
* Shown when the API refuses every request because the signed-in account has
|
|
3545
|
+
* no employee record in this organisation.
|
|
3546
|
+
*
|
|
3547
|
+
* A gate rather than a banner. Leave, attendance, approvals and payslips all
|
|
3548
|
+
* identify a person by that record, so once the server started enforcing it
|
|
3549
|
+
* every call fails — and without something that says so, the app becomes a
|
|
3550
|
+
* screen of error toasts with no explanation and no way forward.
|
|
3906
3551
|
*/
|
|
3907
|
-
function
|
|
3908
|
-
const
|
|
3909
|
-
|
|
3910
|
-
|
|
3911
|
-
|
|
3912
|
-
|
|
3913
|
-
|
|
3914
|
-
|
|
3915
|
-
|
|
3916
|
-
|
|
3917
|
-
|
|
3918
|
-
|
|
3919
|
-
|
|
3920
|
-
|
|
3921
|
-
|
|
3922
|
-
abort: step.abort,
|
|
3923
|
-
};
|
|
3924
|
-
}
|
|
3925
|
-
/** Whether the step's target (or the bridge's) is on screen. */
|
|
3926
|
-
function stepTargetVisible(step, env, ctx, bridge) {
|
|
3927
|
-
if (bridge)
|
|
3928
|
-
return env.isVisible(BRIDGE_TARGET);
|
|
3929
|
-
if (!step.target)
|
|
3930
|
-
return true;
|
|
3931
|
-
return env.isVisible(step.target, targetIdOf(step, ctx));
|
|
3932
|
-
}
|
|
3933
|
-
/** The anchor the spotlight is on for the current step. */
|
|
3934
|
-
function spotlightTarget(step, ctx, bridge) {
|
|
3935
|
-
if (bridge)
|
|
3936
|
-
return { anchor: BRIDGE_TARGET };
|
|
3937
|
-
if (!step.target)
|
|
3552
|
+
function EmployeeRequiredGate({ platformBaseUrl, }) {
|
|
3553
|
+
const [blocked, setBlocked] = React.useState(false);
|
|
3554
|
+
const params = navigation.useParams();
|
|
3555
|
+
const code = params?.code ?? "";
|
|
3556
|
+
React.useEffect(() => {
|
|
3557
|
+
const onBlocked = () => setBlocked(true);
|
|
3558
|
+
const onOk = () => setBlocked(false);
|
|
3559
|
+
window.addEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
3560
|
+
window.addEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
3561
|
+
return () => {
|
|
3562
|
+
window.removeEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
3563
|
+
window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
3564
|
+
};
|
|
3565
|
+
}, []);
|
|
3566
|
+
if (!blocked)
|
|
3938
3567
|
return null;
|
|
3939
|
-
|
|
3940
|
-
}
|
|
3941
|
-
|
|
3942
|
-
return (!!step.target &&
|
|
3943
|
-
step.target.startsWith("nav.") &&
|
|
3944
|
-
!env.sidebarOpen &&
|
|
3945
|
-
!env.isVisible(step.target, targetIdOf(step, ctx)));
|
|
3946
|
-
}
|
|
3947
|
-
/**
|
|
3948
|
-
* The page a step happens on: its own `route`, or else where the steps before
|
|
3949
|
-
* it left the user (an earlier step's route trigger or route). Undefined when
|
|
3950
|
-
* nothing says (a mission's first steps, which work from any page).
|
|
3951
|
-
*/
|
|
3952
|
-
function stepHomeRoute(steps, index) {
|
|
3953
|
-
for (let j = Math.min(index, steps.length - 1); j >= 0; j--) {
|
|
3954
|
-
const s = steps[j];
|
|
3955
|
-
if (j < index) {
|
|
3956
|
-
// What this earlier step moved the user to comes after where it happened.
|
|
3957
|
-
const t = triggersOf(s).find((x) => "route" in x);
|
|
3958
|
-
if (t)
|
|
3959
|
-
return t.route;
|
|
3960
|
-
}
|
|
3961
|
-
if (s.route)
|
|
3962
|
-
return s.route;
|
|
3963
|
-
}
|
|
3964
|
-
return undefined;
|
|
3965
|
-
}
|
|
3966
|
-
/**
|
|
3967
|
-
* The page a step is shown on, or the "ทำต่อ" card when the user is elsewhere:
|
|
3968
|
-
* its route; for a checkpoint without one, the page the steps before it lead
|
|
3969
|
-
* to (so going back to it from another page cannot loop).
|
|
3970
|
-
*/
|
|
3971
|
-
function placeRoute(steps, index) {
|
|
3972
|
-
const step = steps[index];
|
|
3973
|
-
if (step.route)
|
|
3974
|
-
return step.route;
|
|
3975
|
-
return step.checkpoint ? stepHomeRoute(steps, index) : undefined;
|
|
3976
|
-
}
|
|
3977
|
-
/** The user is not on the page the step happens on. */
|
|
3978
|
-
function offRoute(steps, index, env) {
|
|
3979
|
-
const home = stepHomeRoute(steps, index);
|
|
3980
|
-
return !!home && !matchRoute(home, env.pathname, env.search, env.code);
|
|
3981
|
-
}
|
|
3982
|
-
/** What puts the page back when a replay ends at this step (see GuideStep.abort). */
|
|
3983
|
-
function abortActionsFor(mission, index) {
|
|
3984
|
-
const step = mission.steps[index];
|
|
3985
|
-
const own = step?.abort ?? mission.abort;
|
|
3986
|
-
if (own)
|
|
3987
|
-
return Array.isArray(own) ? own : [own];
|
|
3988
|
-
// Default: Escape to the dialog the tour opened; nothing on a stable page.
|
|
3989
|
-
if (!step || index === 0 || step.checkpoint)
|
|
3990
|
-
return [];
|
|
3991
|
-
return [{ escape: true }];
|
|
3992
|
-
}
|
|
3993
|
-
/** Every anchor the mission's abort actions press. */
|
|
3994
|
-
function abortAnchorsOf(mission) {
|
|
3995
|
-
const out = new Set();
|
|
3996
|
-
const add = (a) => {
|
|
3997
|
-
for (const x of a ? (Array.isArray(a) ? a : [a]) : [])
|
|
3998
|
-
if ("click" in x)
|
|
3999
|
-
out.add(x.click);
|
|
4000
|
-
};
|
|
4001
|
-
add(mission.abort);
|
|
4002
|
-
for (const st of mission.steps)
|
|
4003
|
-
add(st.abort);
|
|
4004
|
-
return Array.from(out);
|
|
3568
|
+
const base = platformBaseUrl?.replace(/\/$/, "") ?? "";
|
|
3569
|
+
const createHref = code ? `${base}/${code}/employee/create` : `${base}/`;
|
|
3570
|
+
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" })] }) }));
|
|
4005
3571
|
}
|
|
3572
|
+
|
|
4006
3573
|
/**
|
|
4007
|
-
*
|
|
4008
|
-
*
|
|
4009
|
-
*
|
|
3574
|
+
* Talking to platform-api about tour progress (API.md): the GET that says
|
|
3575
|
+
* who sees what, and the PUT that saves, with its rev check. This is the
|
|
3576
|
+
* shell's progress adapter (GuideHost); the rules live in progress-sync.ts.
|
|
4010
3577
|
*/
|
|
4011
|
-
function
|
|
4012
|
-
|
|
4013
|
-
|
|
4014
|
-
const busy = before.anchors.length > 0 || before.dialog;
|
|
4015
|
-
return actions.filter(a => "click" in a ? !before.anchors.includes(a.click) : "escape" in a ? !before.dialog : !busy);
|
|
4016
|
-
}
|
|
4017
|
-
function routeParamsFor(step, env) {
|
|
4018
|
-
if (!step.route)
|
|
4019
|
-
return {};
|
|
4020
|
-
return matchRoute(step.route, env.pathname, env.search, env.code) ?? {};
|
|
4021
|
-
}
|
|
4022
|
-
/** A state trigger (route, visible) that holds now; the capture event for it. */
|
|
4023
|
-
function stateTriggerHolds(step, env) {
|
|
4024
|
-
for (const t of triggersOf(step)) {
|
|
4025
|
-
if ("route" in t) {
|
|
4026
|
-
const params = matchRoute(t.route, env.pathname, env.search, env.code);
|
|
4027
|
-
if (params)
|
|
4028
|
-
return { route: { pathname: env.pathname, params } };
|
|
4029
|
-
}
|
|
4030
|
-
else if ("visible" in t) {
|
|
4031
|
-
if (env.isVisible(t.visible))
|
|
4032
|
-
return {};
|
|
4033
|
-
}
|
|
4034
|
-
}
|
|
4035
|
-
return null;
|
|
4036
|
-
}
|
|
4037
|
-
/** Any trigger that holds on a sync (route, visible, and — once armed — hidden). */
|
|
4038
|
-
function syncTriggerHolds(step, env, armed) {
|
|
4039
|
-
const held = stateTriggerHolds(step, env);
|
|
4040
|
-
if (held)
|
|
4041
|
-
return held;
|
|
4042
|
-
if (!armed)
|
|
4043
|
-
return null;
|
|
4044
|
-
for (const t of triggersOf(step)) {
|
|
4045
|
-
if ("hidden" in t && !env.isVisible(t.hidden))
|
|
4046
|
-
return {};
|
|
4047
|
-
}
|
|
4048
|
-
return null;
|
|
4049
|
-
}
|
|
4050
|
-
/** Whether any of the step's `hidden` anchors is on screen now. */
|
|
4051
|
-
function hiddenAnchorShown(step, env) {
|
|
4052
|
-
return triggersOf(step).some(t => "hidden" in t && env.isVisible(t.hidden));
|
|
4053
|
-
}
|
|
4054
|
-
function missionCount(suite) {
|
|
4055
|
-
return suite.missions.length;
|
|
4056
|
-
}
|
|
4057
|
-
function missionSettled(m, entry, ctx) {
|
|
4058
|
-
if (entry?.status === "done")
|
|
4059
|
-
return true;
|
|
4060
|
-
if (m.optional && entry?.status === "skipped")
|
|
4061
|
-
return true;
|
|
4062
|
-
return safe(() => m.alreadyDone?.(ctx) === true, false);
|
|
4063
|
-
}
|
|
4064
|
-
function settledCount(suite, progress, ctx) {
|
|
4065
|
-
return suite.missions.filter(m => missionSettled(m, progress.missions[m.key], ctx)).length;
|
|
4066
|
-
}
|
|
4067
|
-
function gateState(suite, ctx) {
|
|
4068
|
-
if (!suite.gate)
|
|
4069
|
-
return true;
|
|
4070
|
-
return safe(() => suite.gate.ready(ctx), undefined);
|
|
3578
|
+
function base(app) {
|
|
3579
|
+
// LIFF's progress is on peoplx-api, never here; the help channel's default.
|
|
3580
|
+
return platformApiBase(app === "liff" ? "other" : app);
|
|
4071
3581
|
}
|
|
4072
|
-
|
|
4073
|
-
|
|
4074
|
-
const
|
|
4075
|
-
|
|
4076
|
-
|
|
3582
|
+
/** platform-api's `…/organizations/:id/guides/me` as a progress adapter. */
|
|
3583
|
+
function platformGuideProgress(organizationId) {
|
|
3584
|
+
const org = encodeURIComponent(organizationId);
|
|
3585
|
+
return {
|
|
3586
|
+
async fetchProgress({ app, signal }) {
|
|
3587
|
+
const res = await instance.get(`${base(app)}/organizations/${org}/guides/me`, {
|
|
3588
|
+
params: { app },
|
|
3589
|
+
signal,
|
|
3590
|
+
erpOrganizationId: organizationId,
|
|
3591
|
+
});
|
|
3592
|
+
return res.data;
|
|
3593
|
+
},
|
|
3594
|
+
async saveProgress({ app, guideKey, body }) {
|
|
3595
|
+
const res = await instance.put(`${base(app)}/organizations/${org}/guides/me/${encodeURIComponent(guideKey)}`, body, {
|
|
3596
|
+
validateStatus: s => (s >= 200 && s < 300) || s === 409,
|
|
3597
|
+
erpOrganizationId: organizationId,
|
|
3598
|
+
});
|
|
3599
|
+
return { status: res.status, data: res.data };
|
|
3600
|
+
},
|
|
4077
3601
|
};
|
|
4078
|
-
let nextMarked = false;
|
|
4079
|
-
return suite.missions.map(mission => {
|
|
4080
|
-
const entry = progress.missions[mission.key];
|
|
4081
|
-
let view;
|
|
4082
|
-
if (entry?.status === "done")
|
|
4083
|
-
view = "done";
|
|
4084
|
-
else if (safe(() => mission.alreadyDone?.(ctx) === true, false))
|
|
4085
|
-
view = "already";
|
|
4086
|
-
else if (entry?.status === "skipped")
|
|
4087
|
-
view = "skipped";
|
|
4088
|
-
else if (entry?.status === "active")
|
|
4089
|
-
view = "active";
|
|
4090
|
-
else if (gate === false)
|
|
4091
|
-
view = "waiting";
|
|
4092
|
-
else if ((mission.after ?? []).some(k => !settled(k)))
|
|
4093
|
-
view = "locked";
|
|
4094
|
-
else
|
|
4095
|
-
view = "todo";
|
|
4096
|
-
if (!nextMarked && (view === "todo" || view === "active" || view === "skipped")) {
|
|
4097
|
-
nextMarked = true;
|
|
4098
|
-
if (view === "todo")
|
|
4099
|
-
view = "next";
|
|
4100
|
-
}
|
|
4101
|
-
return { mission, view, entry };
|
|
4102
|
-
});
|
|
4103
|
-
}
|
|
4104
|
-
/** The mission the celebration offers next: the first one not settled and not locked. */
|
|
4105
|
-
function nextMissionKey(suite, progress, ctx) {
|
|
4106
|
-
for (const v of missionViews(suite, progress, ctx)) {
|
|
4107
|
-
if (v.view === "next" || v.view === "todo" || v.view === "active" || v.view === "skipped") {
|
|
4108
|
-
return v.mission.key;
|
|
4109
|
-
}
|
|
4110
|
-
}
|
|
4111
|
-
return null;
|
|
4112
|
-
}
|
|
4113
|
-
/** Whether the welcome should open by itself. */
|
|
4114
|
-
function welcomeDue(state, suite) {
|
|
4115
|
-
return (state.audience === "forced" &&
|
|
4116
|
-
state.progress.welcome === "not_seen" &&
|
|
4117
|
-
state.progress.status === "in_progress" &&
|
|
4118
|
-
!state.progress.mission_key &&
|
|
4119
|
-
state.screen.name === "idle" &&
|
|
4120
|
-
suite.missions.length > 0);
|
|
4121
|
-
}
|
|
4122
|
-
/** Whether the floating "ภารกิจ n/N" chip shows. */
|
|
4123
|
-
function chipVisible(state, suite, ctx) {
|
|
4124
|
-
return (state.audience !== "none" &&
|
|
4125
|
-
state.progress.welcome !== "not_seen" &&
|
|
4126
|
-
state.progress.status !== "completed" &&
|
|
4127
|
-
!state.progress.chip_hidden &&
|
|
4128
|
-
state.screen.name === "idle" &&
|
|
4129
|
-
!state.panelOpen &&
|
|
4130
|
-
settledCount(suite, state.progress, ctx) < missionCount(suite));
|
|
4131
|
-
}
|
|
4132
|
-
function withProgress(d, patch) {
|
|
4133
|
-
d.state = { ...d.state, progress: { ...d.state.progress, ...patch } };
|
|
4134
|
-
}
|
|
4135
|
-
function setMission(d, key, entry) {
|
|
4136
|
-
withProgress(d, { missions: { ...d.state.progress.missions, [key]: entry } });
|
|
4137
|
-
}
|
|
4138
|
-
function setScreen(d, screen) {
|
|
4139
|
-
d.state = { ...d.state, screen };
|
|
4140
|
-
}
|
|
4141
|
-
function save(d, immediate = false) {
|
|
4142
|
-
const existing = d.effects.find(e => e.type === "save");
|
|
4143
|
-
if (existing)
|
|
4144
|
-
existing.immediate = existing.immediate || immediate;
|
|
4145
|
-
else
|
|
4146
|
-
d.effects.push({ type: "save", immediate });
|
|
4147
|
-
}
|
|
4148
|
-
function announceStep(d, suite, env) {
|
|
4149
|
-
const s = d.state.screen;
|
|
4150
|
-
if (s.name !== "step")
|
|
4151
|
-
return;
|
|
4152
|
-
const mission = findMission(suite, s.mission);
|
|
4153
|
-
if (!mission)
|
|
4154
|
-
return;
|
|
4155
|
-
const step = shownStep(mission.steps[s.index], s.nothingToSave);
|
|
4156
|
-
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4157
|
-
const title = s.bridge ? COPY.bridgeTitle : plainTitle(resolveText(step.title, ctx));
|
|
4158
|
-
const why = s.bridge ? COPY.bridgeWhy(suite.appName) : plainTitle(resolveText(step.why, ctx));
|
|
4159
|
-
d.effects.push({
|
|
4160
|
-
type: "announce",
|
|
4161
|
-
text: `ขั้นที่ ${s.index + 1} จาก ${mission.steps.length}. ${title}${why ? `. ${why}` : ""}`,
|
|
4162
|
-
});
|
|
4163
3602
|
}
|
|
4164
|
-
|
|
4165
|
-
function
|
|
4166
|
-
|
|
3603
|
+
|
|
3604
|
+
function GuideHost(props) {
|
|
3605
|
+
// The boundary covers the adapters too (useSidebar throws without its provider).
|
|
3606
|
+
return (jsxRuntime.jsx(guideRuntime.GuideErrorBoundary, { children: jsxRuntime.jsx(React.Suspense, { fallback: null, children: jsxRuntime.jsx(GuideHostInner, { ...props }) }) }));
|
|
4167
3607
|
}
|
|
4168
|
-
|
|
4169
|
-
|
|
4170
|
-
|
|
4171
|
-
|
|
4172
|
-
|
|
4173
|
-
|
|
4174
|
-
|
|
4175
|
-
let firstPass = true;
|
|
4176
|
-
while (i < steps.length) {
|
|
4177
|
-
const step = steps[i];
|
|
4178
|
-
const params = routeParamsFor(step, env);
|
|
4179
|
-
const ctx = guideContext(env, d.state.progress.context, params);
|
|
4180
|
-
if (step.requires && !safe(() => env.can(step.requires), true)) {
|
|
4181
|
-
// A replay writes nothing, not even what went wrong.
|
|
4182
|
-
if (!opts.replay)
|
|
4183
|
-
withProgress(d, { last_error: `forbidden:${step.requires}`.slice(0, 128) });
|
|
4184
|
-
i++;
|
|
4185
|
-
continue;
|
|
4186
|
-
}
|
|
4187
|
-
if (step.skipIf && safe(() => step.skipIf(ctx), false)) {
|
|
4188
|
-
i++;
|
|
4189
|
-
continue;
|
|
4190
|
-
}
|
|
4191
|
-
const autoPass = step.autoPass !== false && !(firstPass && opts.noAutoPass);
|
|
4192
|
-
firstPass = false;
|
|
4193
|
-
if (autoPass && stepKind(step) !== "input" && stepKind(step) !== "view") {
|
|
4194
|
-
const held = stateTriggerHolds(step, env);
|
|
4195
|
-
if (held) {
|
|
4196
|
-
captureInto(d, step, held, ctx);
|
|
4197
|
-
i++;
|
|
4198
|
-
continue;
|
|
4199
|
-
}
|
|
4200
|
-
}
|
|
4201
|
-
break;
|
|
4202
|
-
}
|
|
4203
|
-
if (i >= steps.length) {
|
|
4204
|
-
finishMission(d, suite, env, mission, opts.replay);
|
|
4205
|
-
return;
|
|
4206
|
-
}
|
|
4207
|
-
const step = steps[i];
|
|
4208
|
-
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4209
|
-
const where = placeRoute(steps, i);
|
|
4210
|
-
const away = !!where && !matchRoute(where, env.pathname, env.search, env.code);
|
|
4211
|
-
const bridge = !away && needsBridge(step, env, ctx);
|
|
4212
|
-
setScreen(d, {
|
|
4213
|
-
name: "step",
|
|
4214
|
-
mission: mission.key,
|
|
4215
|
-
index: i,
|
|
4216
|
-
replay: opts.replay,
|
|
4217
|
-
bridge,
|
|
4218
|
-
phase: away ? "away" : "waiting",
|
|
4219
|
-
error: opts.error ?? null,
|
|
4220
|
-
confirmExit: false,
|
|
4221
|
-
misses: 0,
|
|
4222
|
-
armed: hiddenAnchorShown(step, env),
|
|
4223
|
-
nothingToSave: !away && !bridge && nothingToSaveHolds(step, env, ctx),
|
|
4224
|
-
});
|
|
4225
|
-
if (!opts.replay) {
|
|
4226
|
-
const cp = checkpointAtOrBefore(steps, i);
|
|
4227
|
-
setMission(d, mission.key, { status: "active", step_key: step.key });
|
|
4228
|
-
withProgress(d, {
|
|
4229
|
-
mission_key: mission.key,
|
|
4230
|
-
step_key: step.key,
|
|
4231
|
-
step_index: i,
|
|
4232
|
-
checkpoint_key: steps[cp].key,
|
|
4233
|
-
});
|
|
4234
|
-
save(d);
|
|
4235
|
-
}
|
|
4236
|
-
if (!away) {
|
|
4237
|
-
// The target may already be there.
|
|
4238
|
-
markReadyIfVisible(d, suite, env);
|
|
4239
|
-
}
|
|
3608
|
+
let fallbackClient = null;
|
|
3609
|
+
function useShellQueryClient() {
|
|
3610
|
+
const provided = React.useContext(reactQuery.QueryClientContext);
|
|
3611
|
+
if (provided)
|
|
3612
|
+
return provided;
|
|
3613
|
+
fallbackClient ?? (fallbackClient = new reactQuery.QueryClient());
|
|
3614
|
+
return fallbackClient;
|
|
4240
3615
|
}
|
|
4241
|
-
/**
|
|
4242
|
-
|
|
4243
|
-
* commit step's other form. A ready step whose new target is not there is
|
|
4244
|
-
* left to the target-lost path (back to the checkpoint).
|
|
4245
|
-
*/
|
|
4246
|
-
function setNothingToSave(d, suite, env, on) {
|
|
4247
|
-
const s = d.state.screen;
|
|
4248
|
-
if (s.name !== "step" || s.nothingToSave === on)
|
|
4249
|
-
return;
|
|
4250
|
-
setScreen(d, { ...s, nothingToSave: on, error: null });
|
|
4251
|
-
if (s.phase !== "ready") {
|
|
4252
|
-
markReadyIfVisible(d, suite, env);
|
|
4253
|
-
return;
|
|
4254
|
-
}
|
|
4255
|
-
const mission = findMission(suite, s.mission);
|
|
4256
|
-
if (!mission)
|
|
4257
|
-
return;
|
|
4258
|
-
const step = shownStep(mission.steps[s.index], on);
|
|
4259
|
-
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4260
|
-
if (stepTargetVisible(step, env, ctx, s.bridge))
|
|
4261
|
-
announceStep(d, suite, env);
|
|
4262
|
-
}
|
|
4263
|
-
function markReadyIfVisible(d, suite, env) {
|
|
4264
|
-
const s = d.state.screen;
|
|
4265
|
-
if (s.name !== "step" || s.phase !== "waiting")
|
|
4266
|
-
return;
|
|
4267
|
-
const mission = findMission(suite, s.mission);
|
|
4268
|
-
if (!mission)
|
|
4269
|
-
return;
|
|
4270
|
-
const step = shownStep(mission.steps[s.index], s.nothingToSave);
|
|
4271
|
-
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4272
|
-
if (stepTargetVisible(step, env, ctx, s.bridge)) {
|
|
4273
|
-
setScreen(d, { ...s, phase: "ready" });
|
|
4274
|
-
announceStep(d, suite, env);
|
|
4275
|
-
if (s.error)
|
|
4276
|
-
d.effects.push({ type: "announce", text: s.error.title });
|
|
4277
|
-
}
|
|
4278
|
-
}
|
|
4279
|
-
/** Where to send the user to resume at a step: its resumeUrl, or its page filled in. */
|
|
4280
|
-
function resumeHref(mission, index, d, env) {
|
|
4281
|
-
const step = mission.steps[index];
|
|
4282
|
-
const ctx = guideContext(env, d.state.progress.context);
|
|
4283
|
-
const where = placeRoute(mission.steps, index);
|
|
4284
|
-
return (safe(() => step.resumeUrl?.(ctx), undefined) ??
|
|
4285
|
-
(where ? fillRoute(where, { ...d.state.progress.context, code: env.code }) : null));
|
|
4286
|
-
}
|
|
4287
|
-
/**
|
|
4288
|
-
* The user left the page the step happens on (a link, the browser's back):
|
|
4289
|
-
* back to the last checkpoint. On another page that is the "ทำต่อ" card;
|
|
4290
|
-
* `navigate` (ลองอีกครั้ง) goes there at once.
|
|
4291
|
-
*/
|
|
4292
|
-
function recoverOffRoute(d, suite, env, navigate) {
|
|
4293
|
-
const s = d.state.screen;
|
|
4294
|
-
if (s.name !== "step")
|
|
4295
|
-
return;
|
|
4296
|
-
const mission = findMission(suite, s.mission);
|
|
4297
|
-
if (!mission)
|
|
4298
|
-
return;
|
|
4299
|
-
const cp = checkpointAtOrBefore(mission.steps, s.index);
|
|
4300
|
-
enterStep(d, suite, env, mission, cp, { replay: s.replay });
|
|
4301
|
-
const now = d.state.screen;
|
|
4302
|
-
if (navigate && now.name === "step" && now.phase === "away") {
|
|
4303
|
-
const href = resumeHref(mission, now.index, d, env);
|
|
4304
|
-
if (href)
|
|
4305
|
-
d.effects.push({ type: "navigate", href });
|
|
4306
|
-
}
|
|
4307
|
-
}
|
|
4308
|
-
function captureInto(d, step, e, ctx) {
|
|
4309
|
-
if (!step.capture)
|
|
4310
|
-
return;
|
|
4311
|
-
const got = safe(() => step.capture(e, ctx), undefined);
|
|
4312
|
-
if (!got)
|
|
4313
|
-
return;
|
|
4314
|
-
const clean = {};
|
|
4315
|
-
for (const [k, v] of Object.entries(got)) {
|
|
4316
|
-
if (typeof v === "string" && v)
|
|
4317
|
-
clean[k] = v;
|
|
4318
|
-
}
|
|
4319
|
-
if (Object.keys(clean).length) {
|
|
4320
|
-
withProgress(d, { context: { ...d.state.progress.context, ...clean } });
|
|
4321
|
-
}
|
|
4322
|
-
}
|
|
4323
|
-
function advance(d, suite, env, capture) {
|
|
4324
|
-
const s = d.state.screen;
|
|
4325
|
-
if (s.name !== "step")
|
|
4326
|
-
return;
|
|
4327
|
-
const mission = findMission(suite, s.mission);
|
|
4328
|
-
if (!mission)
|
|
4329
|
-
return;
|
|
4330
|
-
const step = mission.steps[s.index];
|
|
4331
|
-
if (capture) {
|
|
4332
|
-
captureInto(d, step, capture, guideContext(env, d.state.progress.context, routeParamsFor(step, env)));
|
|
4333
|
-
}
|
|
4334
|
-
enterStep(d, suite, env, mission, s.index + 1, { replay: s.replay });
|
|
4335
|
-
}
|
|
4336
|
-
function finishMission(d, suite, env, mission, replay) {
|
|
4337
|
-
if (replay) {
|
|
4338
|
-
setScreen(d, { name: "idle" });
|
|
4339
|
-
d.effects.push({ type: "toast", text: COPY.replayDone });
|
|
4340
|
-
return;
|
|
4341
|
-
}
|
|
4342
|
-
setMission(d, mission.key, { status: "done", step_key: null, skipped_step: null });
|
|
4343
|
-
withProgress(d, {
|
|
4344
|
-
mission_key: null,
|
|
4345
|
-
step_key: null,
|
|
4346
|
-
step_index: 0,
|
|
4347
|
-
checkpoint_key: null,
|
|
4348
|
-
context: {},
|
|
4349
|
-
last_error: null,
|
|
4350
|
-
});
|
|
4351
|
-
const ctx = guideContext(env, {});
|
|
4352
|
-
const all = settledCount(suite, d.state.progress, ctx) >= missionCount(suite);
|
|
4353
|
-
if (all)
|
|
4354
|
-
withProgress(d, { status: "completed" });
|
|
4355
|
-
setScreen(d, all ? { name: "complete" } : { name: "celebrate", mission: mission.key });
|
|
4356
|
-
d.state = { ...d.state, justDone: mission.key };
|
|
4357
|
-
save(d, true);
|
|
4358
|
-
const n = settledCount(suite, d.state.progress, ctx);
|
|
4359
|
-
const N = missionCount(suite);
|
|
4360
|
-
const title = resolveText(mission.done?.title, ctx) || `${mission.title}แล้ว`;
|
|
4361
|
-
d.effects.push({
|
|
4362
|
-
type: "announce",
|
|
4363
|
-
text: all
|
|
4364
|
-
? `ครบ ${N} ภารกิจ ${resolveText(suite.complete.title, ctx) || "บริษัทพร้อมใช้งานแล้ว"}`
|
|
4365
|
-
: `ภารกิจสำเร็จ ${title} เสร็จ ${n} จาก ${N} ภารกิจ`,
|
|
4366
|
-
});
|
|
4367
|
-
}
|
|
4368
|
-
function startMission(d, suite, env, key, replay) {
|
|
4369
|
-
const mission = findMission(suite, key);
|
|
4370
|
-
if (!mission || mission.steps.length === 0)
|
|
4371
|
-
return;
|
|
4372
|
-
const entry = d.state.progress.missions[key];
|
|
4373
|
-
// A mission already done is only ever replayed; its done status stays.
|
|
4374
|
-
const isReplay = replay || entry?.status === "done";
|
|
4375
|
-
const before = d.state.progress;
|
|
4376
|
-
d.state = { ...d.state, panelOpen: false, justDone: null };
|
|
4377
|
-
if (d.state.progress.welcome === "not_seen")
|
|
4378
|
-
withProgress(d, { welcome: "started" });
|
|
4379
|
-
if (!isReplay && d.state.progress.mission_key !== key)
|
|
4380
|
-
withProgress(d, { context: {} });
|
|
4381
|
-
if (!isReplay && d.state.progress.status !== "in_progress") {
|
|
4382
|
-
// Picking up an optional mission after the tour completed.
|
|
4383
|
-
withProgress(d, { status: "in_progress" });
|
|
4384
|
-
}
|
|
4385
|
-
// Resume where it was left: back to the checkpoint at or before it.
|
|
4386
|
-
let index = 0;
|
|
4387
|
-
if (!isReplay && entry && (entry.status === "active" || entry.status === "skipped")) {
|
|
4388
|
-
const at = mission.steps.findIndex(s => s.key === (entry.step_key ?? entry.skipped_step));
|
|
4389
|
-
if (at > 0)
|
|
4390
|
-
index = checkpointAtOrBefore(mission.steps, at);
|
|
4391
|
-
}
|
|
4392
|
-
if (isReplay) {
|
|
4393
|
-
// What is open now is the user's: ending the replay leaves it alone.
|
|
4394
|
-
d.state = {
|
|
4395
|
-
...d.state,
|
|
4396
|
-
replayPage: {
|
|
4397
|
-
mission: key,
|
|
4398
|
-
anchors: abortAnchorsOf(mission).filter(a => safe(() => env.isVisible(a), false)),
|
|
4399
|
-
dialog: safe(() => env.dialogOpen?.() === true, false),
|
|
4400
|
-
},
|
|
4401
|
-
};
|
|
4402
|
-
}
|
|
4403
|
-
enterStep(d, suite, env, mission, index, { replay: isReplay });
|
|
4404
|
-
// A replay never writes progress; only what starting it changed (the
|
|
4405
|
-
// welcome, first seen through a replay) is saved.
|
|
4406
|
-
if (isReplay && d.state.progress !== before)
|
|
4407
|
-
save(d);
|
|
4408
|
-
}
|
|
4409
|
-
function exitMission(d) {
|
|
4410
|
-
const s = d.state.screen;
|
|
4411
|
-
if (s.name !== "step")
|
|
4412
|
-
return;
|
|
4413
|
-
if (!s.replay) {
|
|
4414
|
-
const prev = d.state.progress.missions[s.mission];
|
|
4415
|
-
if (prev?.status !== "done") {
|
|
4416
|
-
setMission(d, s.mission, {
|
|
4417
|
-
status: "skipped",
|
|
4418
|
-
step_key: d.state.progress.step_key,
|
|
4419
|
-
skipped_step: d.state.progress.step_key,
|
|
4420
|
-
});
|
|
4421
|
-
}
|
|
4422
|
-
withProgress(d, { mission_key: null });
|
|
4423
|
-
save(d, true);
|
|
4424
|
-
}
|
|
4425
|
-
d.state = { ...d.state, chipPulse: d.state.chipPulse + 1 };
|
|
4426
|
-
setScreen(d, { name: "idle" });
|
|
4427
|
-
}
|
|
4428
|
-
function triggerMatchesSignal(t, sig, env, ctx) {
|
|
4429
|
-
if ("api" in t && sig.kind === "api") {
|
|
4430
|
-
if (t.api.method && t.api.method.toUpperCase() !== sig.method.toUpperCase())
|
|
4431
|
-
return null;
|
|
4432
|
-
if (!matchApiPath(t.api.path, sig.path))
|
|
4433
|
-
return null;
|
|
4434
|
-
if (!matchStatus(t.api.status, sig.status))
|
|
4435
|
-
return null;
|
|
4436
|
-
return { api: { method: sig.method.toUpperCase(), path: sig.path, status: sig.status, id: sig.id } };
|
|
4437
|
-
}
|
|
4438
|
-
if ("event" in t && sig.kind === "event") {
|
|
4439
|
-
return t.event === sig.name ? { event: { name: sig.name, detail: sig.detail } } : null;
|
|
4440
|
-
}
|
|
4441
|
-
if ("activate" in t && sig.kind === "activate")
|
|
4442
|
-
return {};
|
|
4443
|
-
if ("value" in t && sig.kind === "value") {
|
|
4444
|
-
return safe(() => t.value(sig.value, ctx), false) ? {} : null;
|
|
4445
|
-
}
|
|
4446
|
-
return null;
|
|
4447
|
-
}
|
|
4448
|
-
/** A refused save that a commit step listens for. */
|
|
4449
|
-
function apiFailureFor(step, sig) {
|
|
4450
|
-
if (sig.kind !== "api" || sig.status < 400)
|
|
4451
|
-
return false;
|
|
4452
|
-
return triggersOf(step).some(t => "api" in t &&
|
|
4453
|
-
(!t.api.method || t.api.method.toUpperCase() === sig.method.toUpperCase()) &&
|
|
4454
|
-
matchApiPath(t.api.path, sig.path));
|
|
4455
|
-
}
|
|
4456
|
-
/** A replay ends early: what puts the page back (nothing on another page). */
|
|
4457
|
-
function pushAbort(d, suite, s) {
|
|
4458
|
-
if (s.phase === "away")
|
|
4459
|
-
return;
|
|
4460
|
-
const mission = findMission(suite, s.mission);
|
|
4461
|
-
if (!mission)
|
|
4462
|
-
return;
|
|
4463
|
-
const before = d.state.replayPage?.mission === s.mission ? d.state.replayPage : null;
|
|
4464
|
-
const actions = abortActionsLeft(abortActionsFor(mission, s.index), before);
|
|
4465
|
-
if (actions.length)
|
|
4466
|
-
d.effects.push({ type: "abort", actions });
|
|
4467
|
-
}
|
|
4468
|
-
function reduceGuide(state, action, suite, env) {
|
|
4469
|
-
const d = { state, effects: [] };
|
|
4470
|
-
const s = state.screen;
|
|
4471
|
-
const ctxNow = () => guideContext(env, d.state.progress.context);
|
|
4472
|
-
switch (action.type) {
|
|
4473
|
-
case "welcome": {
|
|
4474
|
-
if (!welcomeDue(state, suite))
|
|
4475
|
-
break;
|
|
4476
|
-
const gate = gateState(suite, ctxNow());
|
|
4477
|
-
if (gate === undefined)
|
|
4478
|
-
break;
|
|
4479
|
-
if (gate === false) {
|
|
4480
|
-
if (!state.gateDismissed)
|
|
4481
|
-
setScreen(d, { name: "gate" });
|
|
4482
|
-
break;
|
|
4483
|
-
}
|
|
4484
|
-
setScreen(d, { name: "welcome", confirm: false });
|
|
4485
|
-
break;
|
|
4486
|
-
}
|
|
4487
|
-
case "welcomeSkip":
|
|
4488
|
-
if (s.name === "welcome")
|
|
4489
|
-
setScreen(d, { name: "welcome", confirm: true });
|
|
4490
|
-
break;
|
|
4491
|
-
case "welcomeStart":
|
|
4492
|
-
case "welcomeSkipCancel": {
|
|
4493
|
-
if (s.name !== "welcome")
|
|
4494
|
-
break;
|
|
4495
|
-
const first = nextMissionKey(suite, d.state.progress, ctxNow());
|
|
4496
|
-
if (first)
|
|
4497
|
-
startMission(d, suite, env, first, false);
|
|
4498
|
-
else
|
|
4499
|
-
setScreen(d, { name: "idle" });
|
|
4500
|
-
break;
|
|
4501
|
-
}
|
|
4502
|
-
case "welcomeSkipConfirm":
|
|
4503
|
-
if (s.name !== "welcome")
|
|
4504
|
-
break;
|
|
4505
|
-
withProgress(d, { welcome: "skipped" });
|
|
4506
|
-
d.state = { ...d.state, chipPulse: d.state.chipPulse + 1 };
|
|
4507
|
-
setScreen(d, { name: "idle" });
|
|
4508
|
-
save(d, true);
|
|
4509
|
-
break;
|
|
4510
|
-
case "gateLater":
|
|
4511
|
-
if (s.name === "gate") {
|
|
4512
|
-
d.state = { ...d.state, gateDismissed: true };
|
|
4513
|
-
setScreen(d, { name: "idle" });
|
|
4514
|
-
}
|
|
4515
|
-
break;
|
|
4516
|
-
case "start":
|
|
4517
|
-
if (state.audience === "none")
|
|
4518
|
-
break;
|
|
4519
|
-
if (s.name === "step" && !s.replay && s.mission !== action.mission) {
|
|
4520
|
-
// Leaving one mission for another: the first is set aside.
|
|
4521
|
-
exitMission(d);
|
|
4522
|
-
}
|
|
4523
|
-
startMission(d, suite, env, action.mission, !!action.replay);
|
|
4524
|
-
break;
|
|
4525
|
-
case "resume": {
|
|
4526
|
-
if (state.audience === "none" || s.name !== "idle")
|
|
4527
|
-
break;
|
|
4528
|
-
const mission = findMission(suite, d.state.progress.mission_key);
|
|
4529
|
-
if (!mission || d.state.progress.missions[mission.key]?.status !== "active")
|
|
4530
|
-
break;
|
|
4531
|
-
// Back to the last checkpoint at or before the step reached; the saved
|
|
4532
|
-
// checkpoint when the step is no longer in the tour.
|
|
4533
|
-
const stepAt = mission.steps.findIndex(st => st.key === d.state.progress.step_key);
|
|
4534
|
-
const cpAt = mission.steps.findIndex(st => st.key === d.state.progress.checkpoint_key);
|
|
4535
|
-
const index = stepAt >= 0 ? checkpointAtOrBefore(mission.steps, stepAt) : Math.max(cpAt, 0);
|
|
4536
|
-
enterStep(d, suite, env, mission, index, { replay: false });
|
|
4537
|
-
break;
|
|
4538
|
-
}
|
|
4539
|
-
case "sync": {
|
|
4540
|
-
if (s.name !== "step")
|
|
4541
|
-
break;
|
|
4542
|
-
const mission = findMission(suite, s.mission);
|
|
4543
|
-
if (!mission)
|
|
4544
|
-
break;
|
|
4545
|
-
const step = mission.steps[s.index];
|
|
4546
|
-
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4547
|
-
if (s.phase === "away") {
|
|
4548
|
-
const where = placeRoute(mission.steps, s.index);
|
|
4549
|
-
if (!where || matchRoute(where, env.pathname, env.search, env.code)) {
|
|
4550
|
-
// Arrived: run the step again from here (it may already be passed).
|
|
4551
|
-
enterStep(d, suite, env, mission, s.index, { replay: s.replay, error: s.error });
|
|
4552
|
-
}
|
|
4553
|
-
break;
|
|
4554
|
-
}
|
|
4555
|
-
if (s.confirmExit)
|
|
4556
|
-
break;
|
|
4557
|
-
const nts = !s.bridge && nothingToSaveHolds(step, env, ctx);
|
|
4558
|
-
if (nts !== s.nothingToSave) {
|
|
4559
|
-
setNothingToSave(d, suite, env, nts);
|
|
4560
|
-
break;
|
|
4561
|
-
}
|
|
4562
|
-
const kind = stepKind(shownStep(step, s.nothingToSave));
|
|
4563
|
-
const armed = s.armed || hiddenAnchorShown(step, env);
|
|
4564
|
-
if (kind !== "input" && kind !== "view") {
|
|
4565
|
-
const held = syncTriggerHolds(step, env, armed);
|
|
4566
|
-
if (held) {
|
|
4567
|
-
advance(d, suite, env, held);
|
|
4568
|
-
break;
|
|
4569
|
-
}
|
|
4570
|
-
}
|
|
4571
|
-
if (!s.bridge && !stepTargetVisible(shownStep(step, s.nothingToSave), env, ctx, false) && offRoute(mission.steps, s.index, env)) {
|
|
4572
|
-
// The target is missing because the page is another one.
|
|
4573
|
-
recoverOffRoute(d, suite, env, false);
|
|
4574
|
-
break;
|
|
4575
|
-
}
|
|
4576
|
-
if (armed !== s.armed)
|
|
4577
|
-
setScreen(d, { ...s, armed });
|
|
4578
|
-
const cur = d.state.screen;
|
|
4579
|
-
// The menu step: done once the sidebar is open; needed again if shut.
|
|
4580
|
-
if (cur.bridge && (env.sidebarOpen || stepTargetVisible(step, env, ctx, false))) {
|
|
4581
|
-
setScreen(d, { ...cur, bridge: false, phase: "waiting" });
|
|
4582
|
-
}
|
|
4583
|
-
else if (!cur.bridge && needsBridge(step, env, ctx)) {
|
|
4584
|
-
setScreen(d, { ...cur, bridge: true, phase: "waiting" });
|
|
4585
|
-
}
|
|
4586
|
-
markReadyIfVisible(d, suite, env);
|
|
4587
|
-
break;
|
|
4588
|
-
}
|
|
4589
|
-
case "signal": {
|
|
4590
|
-
if (s.name !== "step" || s.phase === "away" || s.confirmExit)
|
|
4591
|
-
break;
|
|
4592
|
-
const mission = findMission(suite, s.mission);
|
|
4593
|
-
if (!mission)
|
|
4594
|
-
break;
|
|
4595
|
-
const step = mission.steps[s.index];
|
|
4596
|
-
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4597
|
-
const sig = action.signal;
|
|
4598
|
-
if (sig.kind === "typing") {
|
|
4599
|
-
if (s.error)
|
|
4600
|
-
setScreen(d, { ...s, error: null });
|
|
4601
|
-
break;
|
|
4602
|
-
}
|
|
4603
|
-
if (s.bridge)
|
|
4604
|
-
break;
|
|
4605
|
-
for (const t of triggersOf(step)) {
|
|
4606
|
-
const hit = triggerMatchesSignal(t, sig, env, ctx);
|
|
4607
|
-
if (hit) {
|
|
4608
|
-
advance(d, suite, env, hit);
|
|
4609
|
-
return { state: d.state, effects: d.effects };
|
|
4610
|
-
}
|
|
4611
|
-
}
|
|
4612
|
-
if (sig.kind === "api" && stepKind(step) === "commit" && apiFailureFor(step, sig)) {
|
|
4613
|
-
const mapped = safe(() => step.onApiError?.({ status: sig.status, message: sig.message }, ctx), undefined);
|
|
4614
|
-
if (mapped && mapped.step) {
|
|
4615
|
-
const to = mission.steps.findIndex(st => st.key === mapped.step);
|
|
4616
|
-
if (to >= 0) {
|
|
4617
|
-
enterStep(d, suite, env, mission, to, {
|
|
4618
|
-
replay: s.replay,
|
|
4619
|
-
noAutoPass: true,
|
|
4620
|
-
error: { title: mapped.message ?? sig.message ?? COPY.saveFailed, detail: COPY.fieldFix },
|
|
4621
|
-
});
|
|
4622
|
-
break;
|
|
4623
|
-
}
|
|
4624
|
-
}
|
|
4625
|
-
const error = mapped?.message
|
|
4626
|
-
? { title: mapped.message, detail: undefined }
|
|
4627
|
-
: { title: COPY.saveFailed, detail: sig.message };
|
|
4628
|
-
setScreen(d, { ...s, error });
|
|
4629
|
-
d.effects.push({ type: "announce", text: [error.title, error.detail].filter(Boolean).join(". ") });
|
|
4630
|
-
if (!s.replay)
|
|
4631
|
-
withProgress(d, { last_error: `api_${sig.status}:${step.key}`.slice(0, 128) });
|
|
4632
|
-
}
|
|
4633
|
-
break;
|
|
4634
|
-
}
|
|
4635
|
-
case "next": {
|
|
4636
|
-
if (s.name !== "step" || s.phase !== "ready" || s.confirmExit || s.bridge)
|
|
4637
|
-
break;
|
|
4638
|
-
const mission = findMission(suite, s.mission);
|
|
4639
|
-
if (!mission)
|
|
4640
|
-
break;
|
|
4641
|
-
const step = mission.steps[s.index];
|
|
4642
|
-
const kind = stepKind(shownStep(step, s.nothingToSave));
|
|
4643
|
-
if (kind === "view") {
|
|
4644
|
-
// A read step, or a commit step with nothing to save.
|
|
4645
|
-
advance(d, suite, env);
|
|
4646
|
-
break;
|
|
4647
|
-
}
|
|
4648
|
-
if (kind !== "input")
|
|
4649
|
-
break;
|
|
4650
|
-
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(step, env));
|
|
4651
|
-
const value = safe(() => env.targetValue(), "");
|
|
4652
|
-
const problem = step.validate
|
|
4653
|
-
? safe(() => step.validate(value, ctx), null)
|
|
4654
|
-
: value.trim()
|
|
4655
|
-
? null
|
|
4656
|
-
: COPY.blank;
|
|
4657
|
-
if (problem) {
|
|
4658
|
-
setScreen(d, { ...s, error: { title: problem, detail: COPY.fieldFix } });
|
|
4659
|
-
d.state = { ...d.state, nudge: d.state.nudge + 1 };
|
|
4660
|
-
d.effects.push({ type: "announce", text: problem });
|
|
4661
|
-
}
|
|
4662
|
-
else {
|
|
4663
|
-
advance(d, suite, env);
|
|
4664
|
-
}
|
|
4665
|
-
break;
|
|
4666
|
-
}
|
|
4667
|
-
case "targetLost": {
|
|
4668
|
-
if (s.name !== "step" || s.phase !== "ready" || s.confirmExit)
|
|
4669
|
-
break;
|
|
4670
|
-
const mission = findMission(suite, s.mission);
|
|
4671
|
-
if (!mission)
|
|
4672
|
-
break;
|
|
4673
|
-
const raw = mission.steps[s.index];
|
|
4674
|
-
const ctx = guideContext(env, d.state.progress.context, routeParamsFor(raw, env));
|
|
4675
|
-
// The save went with nothing left to save, or came back: the other form.
|
|
4676
|
-
setNothingToSave(d, suite, env, !s.bridge && nothingToSaveHolds(raw, env, ctx));
|
|
4677
|
-
const cur = d.state.screen;
|
|
4678
|
-
if (stepTargetVisible(shownStep(raw, cur.nothingToSave), env, ctx, cur.bridge))
|
|
4679
|
-
break;
|
|
4680
|
-
if (!cur.bridge && offRoute(mission.steps, cur.index, env)) {
|
|
4681
|
-
recoverOffRoute(d, suite, env, false);
|
|
4682
|
-
break;
|
|
4683
|
-
}
|
|
4684
|
-
const cp = checkpointAtOrBefore(mission.steps, cur.index);
|
|
4685
|
-
if (cp < cur.index && !cur.bridge) {
|
|
4686
|
-
// A dialog or a menu closed under the step: back to what opens it.
|
|
4687
|
-
enterStep(d, suite, env, mission, cp, { replay: cur.replay, noAutoPass: false });
|
|
4688
|
-
}
|
|
4689
|
-
else {
|
|
4690
|
-
setScreen(d, { ...cur, phase: "waiting" });
|
|
4691
|
-
}
|
|
4692
|
-
break;
|
|
4693
|
-
}
|
|
4694
|
-
case "timeout": {
|
|
4695
|
-
if (s.name !== "step" || s.phase !== "waiting")
|
|
4696
|
-
break;
|
|
4697
|
-
const mission = findMission(suite, s.mission);
|
|
4698
|
-
if (!mission)
|
|
4699
|
-
break;
|
|
4700
|
-
const step = mission.steps[s.index];
|
|
4701
|
-
if (!s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
4702
|
-
recoverOffRoute(d, suite, env, false);
|
|
4703
|
-
break;
|
|
4704
|
-
}
|
|
4705
|
-
const anchor = s.bridge ? BRIDGE_TARGET : (step.target ?? step.key);
|
|
4706
|
-
// Diagnostic only: kept with the progress, sent with its next save (a
|
|
4707
|
-
// replay keeps nothing). The same error again changes nothing, so it
|
|
4708
|
-
// never sends a PUT on its own.
|
|
4709
|
-
if (!s.replay)
|
|
4710
|
-
withProgress(d, { last_error: `target_missing:${anchor}`.slice(0, 128) });
|
|
4711
|
-
const misses = s.misses + 1;
|
|
4712
|
-
const policy = step.onMissing ?? "retry";
|
|
4713
|
-
const cp = checkpointAtOrBefore(mission.steps, s.index);
|
|
4714
|
-
if (policy === "skip") {
|
|
4715
|
-
advance(d, suite, env);
|
|
4716
|
-
}
|
|
4717
|
-
else if (policy === "abort") {
|
|
4718
|
-
exitMission(d);
|
|
4719
|
-
d.effects.push({ type: "toast", text: COPY.aborted });
|
|
4720
|
-
}
|
|
4721
|
-
else if ((policy === "rewind" || (policy === "retry" && misses >= 2)) && cp < s.index) {
|
|
4722
|
-
enterStep(d, suite, env, mission, cp, { replay: s.replay });
|
|
4723
|
-
const now = d.state.screen;
|
|
4724
|
-
if (now.name === "step" && now.index === s.index) {
|
|
4725
|
-
// The checkpoint passed straight back to this step (its dialog is
|
|
4726
|
-
// still open): going back cannot help, so say so.
|
|
4727
|
-
setScreen(d, { ...now, phase: "missing", misses });
|
|
4728
|
-
d.effects.push({ type: "announce", text: "ไม่พบส่วนนี้บนหน้า" });
|
|
4729
|
-
}
|
|
4730
|
-
}
|
|
4731
|
-
else {
|
|
4732
|
-
setScreen(d, { ...s, phase: "missing", misses });
|
|
4733
|
-
d.effects.push({ type: "announce", text: "ไม่พบส่วนนี้บนหน้า" });
|
|
4734
|
-
}
|
|
4735
|
-
break;
|
|
4736
|
-
}
|
|
4737
|
-
case "retry": {
|
|
4738
|
-
if (s.name !== "step" || s.phase !== "missing")
|
|
4739
|
-
break;
|
|
4740
|
-
const mission = findMission(suite, s.mission);
|
|
4741
|
-
if (mission && !s.bridge && offRoute(mission.steps, s.index, env)) {
|
|
4742
|
-
// Polling again cannot help on another page: go back to the step's.
|
|
4743
|
-
recoverOffRoute(d, suite, env, true);
|
|
4744
|
-
break;
|
|
4745
|
-
}
|
|
4746
|
-
setScreen(d, { ...s, phase: "waiting" });
|
|
4747
|
-
markReadyIfVisible(d, suite, env);
|
|
4748
|
-
break;
|
|
4749
|
-
}
|
|
4750
|
-
case "blocked":
|
|
4751
|
-
if (s.name === "step")
|
|
4752
|
-
d.state = { ...d.state, nudge: d.state.nudge + 1 };
|
|
4753
|
-
break;
|
|
4754
|
-
case "exit":
|
|
4755
|
-
if (s.name !== "step")
|
|
4756
|
-
break;
|
|
4757
|
-
if (s.replay) {
|
|
4758
|
-
pushAbort(d, suite, s);
|
|
4759
|
-
setScreen(d, { name: "idle" });
|
|
4760
|
-
d.effects.push({ type: "toast", text: COPY.replayEnded });
|
|
4761
|
-
}
|
|
4762
|
-
else {
|
|
4763
|
-
setScreen(d, { ...s, confirmExit: true });
|
|
4764
|
-
}
|
|
4765
|
-
break;
|
|
4766
|
-
case "exitCancel":
|
|
4767
|
-
if (s.name === "step" && s.confirmExit)
|
|
4768
|
-
setScreen(d, { ...s, confirmExit: false });
|
|
4769
|
-
break;
|
|
4770
|
-
case "exitConfirm":
|
|
4771
|
-
if (s.name !== "step")
|
|
4772
|
-
break;
|
|
4773
|
-
exitMission(d);
|
|
4774
|
-
d.effects.push({ type: "toast", text: COPY.exitToast });
|
|
4775
|
-
break;
|
|
4776
|
-
case "endWithoutSave":
|
|
4777
|
-
if (s.name !== "step" || !s.replay)
|
|
4778
|
-
break;
|
|
4779
|
-
pushAbort(d, suite, s);
|
|
4780
|
-
setScreen(d, { name: "idle" });
|
|
4781
|
-
d.effects.push({ type: "toast", text: COPY.replayEnded });
|
|
4782
|
-
break;
|
|
4783
|
-
case "goResume": {
|
|
4784
|
-
if (s.name !== "step" || s.phase !== "away")
|
|
4785
|
-
break;
|
|
4786
|
-
const mission = findMission(suite, s.mission);
|
|
4787
|
-
if (!mission)
|
|
4788
|
-
break;
|
|
4789
|
-
const href = resumeHref(mission, s.index, d, env);
|
|
4790
|
-
if (href)
|
|
4791
|
-
d.effects.push({ type: "navigate", href });
|
|
4792
|
-
break;
|
|
4793
|
-
}
|
|
4794
|
-
case "celebrateNext": {
|
|
4795
|
-
if (s.name !== "celebrate")
|
|
4796
|
-
break;
|
|
4797
|
-
const next = nextMissionKey(suite, d.state.progress, ctxNow());
|
|
4798
|
-
if (next)
|
|
4799
|
-
startMission(d, suite, env, next, false);
|
|
4800
|
-
else
|
|
4801
|
-
setScreen(d, { name: "idle" });
|
|
4802
|
-
break;
|
|
4803
|
-
}
|
|
4804
|
-
case "rest":
|
|
4805
|
-
if (s.name !== "celebrate")
|
|
4806
|
-
break;
|
|
4807
|
-
setScreen(d, { name: "idle" });
|
|
4808
|
-
d.state = { ...d.state, panelOpen: true, justDone: s.mission };
|
|
4809
|
-
break;
|
|
4810
|
-
case "finish": {
|
|
4811
|
-
if (s.name !== "complete")
|
|
4812
|
-
break;
|
|
4813
|
-
setScreen(d, { name: "idle" });
|
|
4814
|
-
const href = resolveText(suite.complete.action.href, ctxNow());
|
|
4815
|
-
const filled = href ? (fillRoute(href, { code: env.code }) ?? href) : "";
|
|
4816
|
-
if (filled)
|
|
4817
|
-
d.effects.push({ type: "navigate", href: filled });
|
|
4818
|
-
break;
|
|
4819
|
-
}
|
|
4820
|
-
case "openPanel":
|
|
4821
|
-
if (state.audience !== "none" && s.name === "idle") {
|
|
4822
|
-
d.state = { ...d.state, panelOpen: true };
|
|
4823
|
-
}
|
|
4824
|
-
break;
|
|
4825
|
-
case "closePanel":
|
|
4826
|
-
d.state = { ...d.state, panelOpen: false, justDone: null };
|
|
4827
|
-
break;
|
|
4828
|
-
case "hideChip":
|
|
4829
|
-
withProgress(d, { chip_hidden: true });
|
|
4830
|
-
d.state = { ...d.state, panelOpen: false };
|
|
4831
|
-
d.effects.push({ type: "toast", text: COPY.hiddenToast });
|
|
4832
|
-
save(d, true);
|
|
4833
|
-
break;
|
|
4834
|
-
case "dismissMission": {
|
|
4835
|
-
const m = findMission(suite, action.mission);
|
|
4836
|
-
if (!m?.optional)
|
|
4837
|
-
break;
|
|
4838
|
-
setMission(d, m.key, { status: "skipped", step_key: null, skipped_step: null });
|
|
4839
|
-
const all = settledCount(suite, d.state.progress, ctxNow()) >= missionCount(suite);
|
|
4840
|
-
if (all) {
|
|
4841
|
-
withProgress(d, { status: "completed" });
|
|
4842
|
-
d.state = { ...d.state, panelOpen: false };
|
|
4843
|
-
setScreen(d, { name: "complete" });
|
|
4844
|
-
}
|
|
4845
|
-
save(d, true);
|
|
4846
|
-
break;
|
|
4847
|
-
}
|
|
4848
|
-
case "serverRow": {
|
|
4849
|
-
// A saved row came back: its rev, the server's status, and the dates
|
|
4850
|
-
// the server stamps on missions ("เสร็จ 26 ต.ค. 2569 13:04").
|
|
4851
|
-
const missions = { ...d.state.progress.missions };
|
|
4852
|
-
for (const [k, m] of Object.entries(missions)) {
|
|
4853
|
-
const r = action.progress.missions[k];
|
|
4854
|
-
if (r && r.status === m.status) {
|
|
4855
|
-
missions[k] = { ...m, completed_at: r.completed_at ?? m.completed_at, skipped_at: r.skipped_at ?? m.skipped_at };
|
|
4856
|
-
}
|
|
4857
|
-
}
|
|
4858
|
-
withProgress(d, { rev: action.progress.rev, server_status: action.progress.server_status, missions });
|
|
4859
|
-
break;
|
|
4860
|
-
}
|
|
4861
|
-
}
|
|
4862
|
-
return { state: d.state, effects: d.effects };
|
|
4863
|
-
}
|
|
4864
|
-
|
|
4865
|
-
/**
|
|
4866
|
-
* A tour's progress as platform-api stores it (one row per user, org and
|
|
4867
|
-
* suite: GET/PUT /organizations/:id/guides/me), and the rules for reading,
|
|
4868
|
-
* writing and merging it. See _claude-work/peoplx-guide/API.md.
|
|
4869
|
-
*
|
|
4870
|
-
* No runtime imports: the tests run this file in Node as it is.
|
|
4871
|
-
*/
|
|
4872
|
-
const KEY_RE = /^[a-z0-9][a-z0-9._-]{0,63}$/;
|
|
4873
|
-
const isRecord = (v) => typeof v === "object" && v !== null && !Array.isArray(v);
|
|
4874
|
-
const str = (v) => (typeof v === "string" && v !== "" ? v : null);
|
|
4875
|
-
function freshProgress(version, rev = 0) {
|
|
4876
|
-
return {
|
|
4877
|
-
guide_version: version,
|
|
4878
|
-
rev,
|
|
4879
|
-
status: "in_progress",
|
|
4880
|
-
mission_key: null,
|
|
4881
|
-
step_key: null,
|
|
4882
|
-
step_index: 0,
|
|
4883
|
-
checkpoint_key: null,
|
|
4884
|
-
missions: {},
|
|
4885
|
-
context: {},
|
|
4886
|
-
welcome: "not_seen",
|
|
4887
|
-
chip_hidden: false,
|
|
4888
|
-
last_error: null,
|
|
4889
|
-
skipped_step: null,
|
|
4890
|
-
server_status: null,
|
|
4891
|
-
};
|
|
4892
|
-
}
|
|
4893
|
-
const ROW_STATUSES = ["in_progress", "completed", "skipped", "aborted"];
|
|
4894
|
-
const MISSION_STATUSES = ["todo", "active", "done", "skipped"];
|
|
4895
|
-
const WELCOMES = ["not_seen", "started", "skipped"];
|
|
4896
|
-
/** One row (a GET `guides[]` entry, a PUT answer, a 409's `current`). Null when off-shape. */
|
|
4897
|
-
function parseRow(raw) {
|
|
4898
|
-
if (!isRecord(raw))
|
|
4899
|
-
return null;
|
|
4900
|
-
const version = Number(raw.guide_version);
|
|
4901
|
-
const rev = Number(raw.rev);
|
|
4902
|
-
if (!Number.isInteger(version) || version < 1 || !Number.isInteger(rev) || rev < 0)
|
|
4903
|
-
return null;
|
|
4904
|
-
const status = ROW_STATUSES.includes(raw.status)
|
|
4905
|
-
? raw.status
|
|
4906
|
-
: "in_progress";
|
|
4907
|
-
const missions = {};
|
|
4908
|
-
if (isRecord(raw.missions)) {
|
|
4909
|
-
for (const [k, v] of Object.entries(raw.missions)) {
|
|
4910
|
-
if (!isRecord(v) || !MISSION_STATUSES.includes(v.status))
|
|
4911
|
-
continue;
|
|
4912
|
-
missions[k] = {
|
|
4913
|
-
status: v.status,
|
|
4914
|
-
step_key: str(v.step_key),
|
|
4915
|
-
skipped_step: str(v.skipped_step),
|
|
4916
|
-
completed_at: str(v.completed_at),
|
|
4917
|
-
skipped_at: str(v.skipped_at),
|
|
4918
|
-
};
|
|
4919
|
-
}
|
|
4920
|
-
}
|
|
4921
|
-
const context = {};
|
|
4922
|
-
if (isRecord(raw.context)) {
|
|
4923
|
-
for (const [k, v] of Object.entries(raw.context)) {
|
|
4924
|
-
if (typeof v === "string")
|
|
4925
|
-
context[k] = v;
|
|
4926
|
-
else if (typeof v === "number")
|
|
4927
|
-
context[k] = String(v);
|
|
4928
|
-
}
|
|
4929
|
-
}
|
|
4930
|
-
return {
|
|
4931
|
-
guide_version: version,
|
|
4932
|
-
rev,
|
|
4933
|
-
status,
|
|
4934
|
-
mission_key: str(raw.mission_key),
|
|
4935
|
-
step_key: str(raw.step_key),
|
|
4936
|
-
step_index: Number.isInteger(raw.step_index) ? raw.step_index : 0,
|
|
4937
|
-
checkpoint_key: str(raw.checkpoint_key),
|
|
4938
|
-
missions,
|
|
4939
|
-
context,
|
|
4940
|
-
welcome: WELCOMES.includes(raw.welcome) ? raw.welcome : "not_seen",
|
|
4941
|
-
chip_hidden: raw.chip_hidden === true,
|
|
4942
|
-
last_error: str(raw.last_error),
|
|
4943
|
-
skipped_step: str(raw.skipped_step),
|
|
4944
|
-
server_status: status,
|
|
4945
|
-
};
|
|
4946
|
-
}
|
|
4947
|
-
/**
|
|
4948
|
-
* The GET answer for one suite. Null — show nothing — for anything that is
|
|
4949
|
-
* not the expected shape (an older API, a proxy's page, another app's answer).
|
|
4950
|
-
*/
|
|
4951
|
-
function parseGuidesAnswer(payload, suite) {
|
|
4952
|
-
if (!isRecord(payload))
|
|
4953
|
-
return null;
|
|
4954
|
-
const data = isRecord(payload.data) ? payload.data : payload;
|
|
4955
|
-
const mode = data.mode;
|
|
4956
|
-
const audience = data.audience;
|
|
4957
|
-
if (mode !== "off" && mode !== "invite" && mode !== "forced")
|
|
4958
|
-
return null;
|
|
4959
|
-
if (audience !== "forced" && audience !== "optional" && audience !== "none")
|
|
4960
|
-
return null;
|
|
4961
|
-
if (typeof data.app === "string" && data.app !== suite.app)
|
|
4962
|
-
return null;
|
|
4963
|
-
const rows = Array.isArray(data.guides) ? data.guides : [];
|
|
4964
|
-
let row = null;
|
|
4965
|
-
for (const r of rows) {
|
|
4966
|
-
if (isRecord(r) && r.guide_key === suite.key) {
|
|
4967
|
-
row = parseRow(r);
|
|
4968
|
-
break;
|
|
4969
|
-
}
|
|
4970
|
-
}
|
|
4971
|
-
let progress;
|
|
4972
|
-
let newerOnServer = false;
|
|
4973
|
-
if (!row)
|
|
4974
|
-
progress = freshProgress(suite.version);
|
|
4975
|
-
else if (row.guide_version < suite.version) {
|
|
4976
|
-
// A new version of the tour: start over (the server resets the row on the
|
|
4977
|
-
// PUT); keep the rev the server will check.
|
|
4978
|
-
progress = { ...freshProgress(suite.version, row.rev), server_status: row.server_status };
|
|
4979
|
-
}
|
|
4980
|
-
else {
|
|
4981
|
-
progress = row;
|
|
4982
|
-
newerOnServer = row.guide_version > suite.version;
|
|
4983
|
-
}
|
|
4984
|
-
return { mode, audience: mode === "off" ? "none" : audience, progress, newerOnServer };
|
|
4985
|
-
}
|
|
4986
|
-
const TERMINAL = ["completed", "skipped", "aborted"];
|
|
4987
|
-
/** The PUT body for the client's state. Drops keys the server would refuse. */
|
|
4988
|
-
function putBody(p, app) {
|
|
4989
|
-
const key = (k) => (k && KEY_RE.test(k) ? k : null);
|
|
4990
|
-
const missions = {};
|
|
4991
|
-
for (const [k, m] of Object.entries(p.missions)) {
|
|
4992
|
-
if (!KEY_RE.test(k))
|
|
4993
|
-
continue;
|
|
4994
|
-
missions[k] = {
|
|
4995
|
-
status: m.status,
|
|
4996
|
-
step_key: key(m.step_key ?? null),
|
|
4997
|
-
skipped_step: key(m.skipped_step ?? null),
|
|
4998
|
-
};
|
|
4999
|
-
}
|
|
5000
|
-
const context = {};
|
|
5001
|
-
for (const [k, v] of Object.entries(p.context)) {
|
|
5002
|
-
if (typeof v === "string" && v.length <= 200)
|
|
5003
|
-
context[k] = v;
|
|
5004
|
-
}
|
|
5005
|
-
return {
|
|
5006
|
-
app,
|
|
5007
|
-
guide_version: p.guide_version,
|
|
5008
|
-
rev: p.rev,
|
|
5009
|
-
status: p.status,
|
|
5010
|
-
mission_key: key(p.mission_key),
|
|
5011
|
-
step_key: key(p.step_key),
|
|
5012
|
-
step_index: Math.max(0, Math.min(1000, Math.floor(p.step_index || 0))),
|
|
5013
|
-
checkpoint_key: key(p.checkpoint_key),
|
|
5014
|
-
missions,
|
|
5015
|
-
context,
|
|
5016
|
-
welcome: p.welcome,
|
|
5017
|
-
chip_hidden: p.chip_hidden,
|
|
5018
|
-
last_error: p.last_error ? p.last_error.slice(0, 128) : null,
|
|
5019
|
-
skipped_step: key(p.skipped_step),
|
|
5020
|
-
restart: p.status === "in_progress" &&
|
|
5021
|
-
p.server_status !== null &&
|
|
5022
|
-
TERMINAL.includes(p.server_status),
|
|
5023
|
-
};
|
|
5024
|
-
}
|
|
5025
|
-
function stable(v) {
|
|
5026
|
-
if (Array.isArray(v))
|
|
5027
|
-
return `[${v.map(stable).join(",")}]`;
|
|
5028
|
-
if (isRecord(v)) {
|
|
5029
|
-
return `{${Object.keys(v)
|
|
5030
|
-
.sort()
|
|
5031
|
-
.map(k => `${JSON.stringify(k)}:${stable(v[k])}`)
|
|
5032
|
-
.join(",")}}`;
|
|
5033
|
-
}
|
|
5034
|
-
return JSON.stringify(v) ?? "null";
|
|
5035
|
-
}
|
|
5036
|
-
/**
|
|
5037
|
-
* What a PUT would store, without the rev it is checked against: two equal
|
|
5038
|
-
* keys mean the save would change nothing, and it is not sent (a replay
|
|
5039
|
-
* starting, a step entered again, never bump the row's rev). Key order does
|
|
5040
|
-
* not matter.
|
|
5041
|
-
*/
|
|
5042
|
-
function payloadKey(p, app) {
|
|
5043
|
-
const { rev: _rev, restart: _restart, ...rest } = putBody(p, app);
|
|
5044
|
-
return stable(rest);
|
|
5045
|
-
}
|
|
5046
|
-
const MISSION_RANK = { todo: 0, skipped: 1, active: 1, done: 3 };
|
|
5047
|
-
const WELCOME_RANK = { not_seen: 0, skipped: 1, started: 1 };
|
|
5048
|
-
/**
|
|
5049
|
-
* After a 409: this tab's state laid over the server's, keeping whatever is
|
|
5050
|
-
* further along — a mission done anywhere stays done, the welcome once seen
|
|
5051
|
-
* stays seen, a hidden chip stays hidden. Otherwise this tab's own choice
|
|
5052
|
-
* wins. The rev is the server's.
|
|
5053
|
-
*/
|
|
5054
|
-
function mergeProgress(local, remote) {
|
|
5055
|
-
const missions = { ...remote.missions };
|
|
5056
|
-
for (const [k, m] of Object.entries(local.missions)) {
|
|
5057
|
-
const r = remote.missions[k];
|
|
5058
|
-
missions[k] = !r || MISSION_RANK[m.status] >= MISSION_RANK[r.status] ? { ...r, ...m } : r;
|
|
5059
|
-
}
|
|
5060
|
-
const welcome = WELCOME_RANK[local.welcome] >= WELCOME_RANK[remote.welcome] ? local.welcome : remote.welcome;
|
|
5061
|
-
const completed = local.status === "completed" || remote.status === "completed";
|
|
5062
|
-
const mission_key = local.mission_key && missions[local.mission_key]?.status === "active"
|
|
5063
|
-
? local.mission_key
|
|
5064
|
-
: remote.mission_key && missions[remote.mission_key]?.status === "active"
|
|
5065
|
-
? remote.mission_key
|
|
5066
|
-
: null;
|
|
5067
|
-
const fromLocal = mission_key !== null && mission_key === local.mission_key;
|
|
5068
|
-
return {
|
|
5069
|
-
...local,
|
|
5070
|
-
rev: remote.rev,
|
|
5071
|
-
server_status: remote.server_status,
|
|
5072
|
-
status: completed ? "completed" : local.status,
|
|
5073
|
-
missions,
|
|
5074
|
-
welcome,
|
|
5075
|
-
chip_hidden: local.chip_hidden || remote.chip_hidden,
|
|
5076
|
-
mission_key,
|
|
5077
|
-
step_key: fromLocal ? local.step_key : mission_key ? remote.step_key : null,
|
|
5078
|
-
step_index: fromLocal ? local.step_index : mission_key ? remote.step_index : 0,
|
|
5079
|
-
checkpoint_key: fromLocal ? local.checkpoint_key : mission_key ? remote.checkpoint_key : null,
|
|
5080
|
-
context: fromLocal ? local.context : mission_key ? remote.context : {},
|
|
5081
|
-
};
|
|
5082
|
-
}
|
|
5083
|
-
/**
|
|
5084
|
-
* Which of two copies (sessionStorage after F5, the server) to resume from:
|
|
5085
|
-
* the one further along for the same version; the server's on a tie.
|
|
5086
|
-
*/
|
|
5087
|
-
function furtherProgress(a, b) {
|
|
5088
|
-
const score = (p) => Object.values(p.missions).reduce((s, m) => s + MISSION_RANK[m.status] * 1000, 0) +
|
|
5089
|
-
(p.mission_key ? 100 + p.step_index : 0) +
|
|
5090
|
-
WELCOME_RANK[p.welcome];
|
|
5091
|
-
return score(a) > score(b) ? a : b;
|
|
5092
|
-
}
|
|
5093
|
-
|
|
5094
|
-
/**
|
|
5095
|
-
* Talking to platform-api about tour progress (API.md): the GET that says
|
|
5096
|
-
* who sees what, and the PUT that saves, with its rev check.
|
|
5097
|
-
*/
|
|
5098
|
-
function base(app) {
|
|
5099
|
-
return platformApiBase(app);
|
|
5100
|
-
}
|
|
5101
|
-
/** GET …/guides/me?app=…; null (show nothing) on any failure or odd answer. */
|
|
5102
|
-
async function fetchGuides(organizationId, suite, signal) {
|
|
5103
|
-
try {
|
|
5104
|
-
const res = await instance.get(`${base(suite.app)}/organizations/${encodeURIComponent(organizationId)}/guides/me`, { params: { app: suite.app }, signal, erpOrganizationId: organizationId });
|
|
5105
|
-
return parseGuidesAnswer(res.data, suite);
|
|
5106
|
-
}
|
|
5107
|
-
catch {
|
|
5108
|
-
return null;
|
|
5109
|
-
}
|
|
5110
|
-
}
|
|
5111
|
-
/**
|
|
5112
|
-
* PUT the whole state. On 409 the server's row comes back: merge (keeping
|
|
5113
|
-
* whatever is further along) and try once more with its rev. Returns the
|
|
5114
|
-
* stored row, or null when it could not be saved (the tour goes on; the
|
|
5115
|
-
* session copy keeps the place).
|
|
5116
|
-
*/
|
|
5117
|
-
async function saveGuides(organizationId, suite, progress) {
|
|
5118
|
-
const url = `${base(suite.app)}/organizations/${encodeURIComponent(organizationId)}/guides/me/${encodeURIComponent(suite.key)}`;
|
|
5119
|
-
let body = progress;
|
|
5120
|
-
for (let attempt = 0; attempt < 2; attempt++) {
|
|
5121
|
-
let res;
|
|
5122
|
-
try {
|
|
5123
|
-
res = await instance.put(url, putBody(body, suite.app), {
|
|
5124
|
-
validateStatus: s => (s >= 200 && s < 300) || s === 409,
|
|
5125
|
-
erpOrganizationId: organizationId,
|
|
5126
|
-
});
|
|
5127
|
-
}
|
|
5128
|
-
catch {
|
|
5129
|
-
return null;
|
|
5130
|
-
}
|
|
5131
|
-
const payload = res.data;
|
|
5132
|
-
if (res.status !== 409) {
|
|
5133
|
-
return parseRow(payload?.data ?? payload);
|
|
5134
|
-
}
|
|
5135
|
-
const current = parseRow(payload?.current);
|
|
5136
|
-
if (current && current.guide_version > body.guide_version) {
|
|
5137
|
-
// Another app build is ahead of this one: stop writing.
|
|
5138
|
-
return null;
|
|
5139
|
-
}
|
|
5140
|
-
if (!current)
|
|
5141
|
-
body = { ...body, rev: 0, server_status: null };
|
|
5142
|
-
else if (current.guide_version < body.guide_version) {
|
|
5143
|
-
// The server's row is the previous version: this one replaces it.
|
|
5144
|
-
body = { ...body, rev: current.rev, server_status: current.server_status };
|
|
5145
|
-
}
|
|
5146
|
-
else
|
|
5147
|
-
body = mergeProgress(body, current);
|
|
5148
|
-
}
|
|
5149
|
-
return null;
|
|
5150
|
-
}
|
|
5151
|
-
|
|
5152
|
-
/**
|
|
5153
|
-
* The tour's DOM side: finding anchors, deciding whether one is really on
|
|
5154
|
-
* screen, and the rectangles the spotlight cuts out. Browser only; every
|
|
5155
|
-
* function tolerates a missing document.
|
|
5156
|
-
*/
|
|
5157
|
-
const hasDocument = () => typeof document !== "undefined";
|
|
5158
|
-
function cssString(value) {
|
|
5159
|
-
return `"${value.replace(/["\\]/g, "\\$&")}"`;
|
|
5160
|
-
}
|
|
5161
|
-
function anchorSelector(anchor, id) {
|
|
5162
|
-
return (`[${GUIDE_ATTRIBUTE}=${cssString(anchor)}]` +
|
|
5163
|
-
(id ? `[${GUIDE_ID_ATTRIBUTE}=${cssString(id)}]` : ""));
|
|
5164
|
-
}
|
|
5165
|
-
/**
|
|
5166
|
-
* On screen: connected, with a size, not hidden by style, not inside
|
|
5167
|
-
* aria-hidden or inert (other than the guide's own), not a closed Radix part.
|
|
5168
|
-
*
|
|
5169
|
-
* aria-hidden that Radix put there (data-aria-hidden: everything outside an
|
|
5170
|
-
* open dialog, select or menu is hidden from screen readers) does not count:
|
|
5171
|
-
* a dialog's field is still on screen while its select's list is open.
|
|
5172
|
-
*/
|
|
5173
|
-
function isPresent(el) {
|
|
5174
|
-
if (!el || !el.isConnected)
|
|
5175
|
-
return false;
|
|
5176
|
-
const r = el.getBoundingClientRect();
|
|
5177
|
-
if (r.width <= 0 || r.height <= 0)
|
|
5178
|
-
return false;
|
|
5179
|
-
// Slid off the side (a closed drawer); off the top or bottom is only
|
|
5180
|
-
// scrolled away, and the tour scrolls it back. So is a tab scrolled out of
|
|
5181
|
-
// a row of tabs that scrolls sideways, as long as the row is on screen.
|
|
5182
|
-
if ((r.right <= 0 || r.left >= window.innerWidth) && !inSidewaysScroller(el))
|
|
5183
|
-
return false;
|
|
5184
|
-
if (el.getAttribute("data-state") === "closed")
|
|
5185
|
-
return false;
|
|
5186
|
-
const style = getComputedStyle(el);
|
|
5187
|
-
if (style.visibility === "hidden" || style.display === "none")
|
|
5188
|
-
return false;
|
|
5189
|
-
for (let n = el; n; n = n.parentElement) {
|
|
5190
|
-
if (n.hasAttribute(GUIDE_LAYER_ATTRIBUTE))
|
|
5191
|
-
break;
|
|
5192
|
-
if (n.hasAttribute("inert"))
|
|
5193
|
-
return false;
|
|
5194
|
-
if (n.getAttribute("aria-hidden") === "true" && !n.hasAttribute("data-aria-hidden"))
|
|
5195
|
-
return false;
|
|
5196
|
-
}
|
|
5197
|
-
return true;
|
|
5198
|
-
}
|
|
5199
|
-
/** The first matching anchor that is on screen. */
|
|
5200
|
-
function findAnchor(anchor, id) {
|
|
5201
|
-
if (!hasDocument() || !anchor)
|
|
5202
|
-
return null;
|
|
5203
|
-
let list;
|
|
5204
|
-
try {
|
|
5205
|
-
list = document.querySelectorAll(anchorSelector(anchor, id));
|
|
5206
|
-
}
|
|
5207
|
-
catch {
|
|
5208
|
-
return null;
|
|
5209
|
-
}
|
|
5210
|
-
for (const el of Array.from(list)) {
|
|
5211
|
-
if (isPresent(el))
|
|
5212
|
-
return el;
|
|
5213
|
-
}
|
|
5214
|
-
return null;
|
|
5215
|
-
}
|
|
5216
|
-
function anchorVisible(anchor, id) {
|
|
5217
|
-
return findAnchor(anchor, id) !== null;
|
|
5218
|
-
}
|
|
5219
|
-
const FIELD = "input:not([type=hidden]),textarea,select,[contenteditable=''],[contenteditable=true]";
|
|
5220
|
-
/** The field to type into: the anchor itself, or the first field inside it. */
|
|
5221
|
-
function fieldOf(el) {
|
|
5222
|
-
if (!el)
|
|
5223
|
-
return null;
|
|
5224
|
-
if (el.matches(FIELD))
|
|
5225
|
-
return el;
|
|
5226
|
-
return el.querySelector(FIELD);
|
|
5227
|
-
}
|
|
5228
|
-
function fieldValue(el) {
|
|
5229
|
-
const f = fieldOf(el);
|
|
5230
|
-
if (!f)
|
|
5231
|
-
return "";
|
|
5232
|
-
if ("value" in f && typeof f.value === "string")
|
|
5233
|
-
return f.value;
|
|
5234
|
-
return f.textContent ?? "";
|
|
5235
|
-
}
|
|
5236
|
-
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]";
|
|
5237
|
-
function focusablesIn(root) {
|
|
5238
|
-
if (!root)
|
|
5239
|
-
return [];
|
|
5240
|
-
const own = root.matches(FOCUSABLE) ? [root] : [];
|
|
5241
|
-
return own.concat(Array.from(root.querySelectorAll(FOCUSABLE)).filter(isPresent));
|
|
5242
|
-
}
|
|
5243
|
-
function rectOf(el) {
|
|
5244
|
-
const r = el.getBoundingClientRect();
|
|
5245
|
-
return { x: r.left, y: r.top, width: r.width, height: r.height };
|
|
5246
|
-
}
|
|
5247
|
-
function padRect(r, pad) {
|
|
5248
|
-
return { x: r.x - pad, y: r.y - pad, width: r.width + pad * 2, height: r.height + pad * 2 };
|
|
5249
|
-
}
|
|
5250
|
-
function sameRect(a, b) {
|
|
5251
|
-
if (!a || !b)
|
|
5252
|
-
return a === b;
|
|
5253
|
-
return (Math.abs(a.x - b.x) < 0.5 &&
|
|
5254
|
-
Math.abs(a.y - b.y) < 0.5 &&
|
|
5255
|
-
Math.abs(a.width - b.width) < 0.5 &&
|
|
5256
|
-
Math.abs(a.height - b.height) < 0.5);
|
|
5257
|
-
}
|
|
5258
|
-
/** Open Radix poppers (a select's list, a menu): usable while a step runs. */
|
|
5259
|
-
const POPPER_SELECTOR = "[data-radix-popper-content-wrapper]";
|
|
5260
|
-
/** Toasts stay usable above the tour. */
|
|
5261
|
-
const TOASTER_SELECTOR = "[data-sonner-toaster]";
|
|
5262
|
-
/** Open dialogs (Radix dialog, alert dialog, sheet, popover content). */
|
|
5263
|
-
const OPEN_DIALOG_SELECTOR = '[role="dialog"][data-state="open"],[role="alertdialog"][data-state="open"]';
|
|
5264
|
-
/**
|
|
5265
|
-
* Whether an open dialog of the app's covers the target: the target is
|
|
5266
|
-
* neither in it nor around it (a confirm opened by the step's own button).
|
|
5267
|
-
* The ring and the hand would be drawn over the dialog, pointing at a button
|
|
5268
|
-
* behind it.
|
|
5269
|
-
*/
|
|
5270
|
-
function coveredByDialog(el) {
|
|
5271
|
-
if (!hasDocument() || !el)
|
|
5272
|
-
return false;
|
|
5273
|
-
for (const d of Array.from(document.querySelectorAll(OPEN_DIALOG_SELECTOR))) {
|
|
5274
|
-
if (inGuideLayer(d) || !isPresent(d))
|
|
5275
|
-
continue;
|
|
5276
|
-
if (d.contains(el) || el.contains(d))
|
|
5277
|
-
continue;
|
|
5278
|
-
return true;
|
|
5279
|
-
}
|
|
5280
|
-
return false;
|
|
5281
|
-
}
|
|
5282
|
-
/** An app dialog (not the tour's) is open on the page. */
|
|
5283
|
-
function appDialogOpen() {
|
|
5284
|
-
if (!hasDocument())
|
|
5285
|
-
return false;
|
|
5286
|
-
return Array.from(document.querySelectorAll(OPEN_DIALOG_SELECTOR)).some(d => !inGuideLayer(d) && isPresent(d));
|
|
5287
|
-
}
|
|
5288
|
-
function openPoppers() {
|
|
5289
|
-
if (!hasDocument())
|
|
5290
|
-
return [];
|
|
5291
|
-
return Array.from(document.querySelectorAll(POPPER_SELECTOR)).filter(isPresent);
|
|
5292
|
-
}
|
|
5293
|
-
function inGuideLayer(node) {
|
|
5294
|
-
if (!node || typeof Node === "undefined" || !(node instanceof Node))
|
|
5295
|
-
return false;
|
|
5296
|
-
const el = node instanceof Element ? node : node.parentElement;
|
|
5297
|
-
return !!el?.closest(`[${GUIDE_LAYER_ATTRIBUTE}]`);
|
|
5298
|
-
}
|
|
5299
|
-
function prefersReducedMotion() {
|
|
5300
|
-
return typeof window !== "undefined" && !!window.matchMedia?.("(prefers-reduced-motion: reduce)").matches;
|
|
5301
|
-
}
|
|
5302
|
-
function isPhoneWidth() {
|
|
5303
|
-
return typeof window !== "undefined" && window.innerWidth < 768;
|
|
5304
|
-
}
|
|
5305
|
-
/* ---------------- what is really on screen ---------------- */
|
|
5306
|
-
const INFINITE = 1e7;
|
|
5307
|
-
/** The overlap of two rectangles, or null when they do not meet. */
|
|
5308
|
-
function intersectRect(a, b) {
|
|
5309
|
-
const x = Math.max(a.x, b.x);
|
|
5310
|
-
const y = Math.max(a.y, b.y);
|
|
5311
|
-
const right = Math.min(a.x + a.width, b.x + b.width);
|
|
5312
|
-
const bottom = Math.min(a.y + a.height, b.y + b.height);
|
|
5313
|
-
if (right - x <= 0 || bottom - y <= 0)
|
|
5314
|
-
return null;
|
|
5315
|
-
return { x, y, width: right - x, height: bottom - y };
|
|
5316
|
-
}
|
|
5317
|
-
/** The part of `rect` inside every clip (scroll boxes, the viewport); null when none is. */
|
|
5318
|
-
function visibleRect(rect, clips) {
|
|
5319
|
-
let r = rect;
|
|
5320
|
-
for (const c of clips) {
|
|
5321
|
-
if (!r)
|
|
5322
|
-
return null;
|
|
5323
|
-
r = intersectRect(r, c);
|
|
5324
|
-
}
|
|
5325
|
-
return r;
|
|
5326
|
-
}
|
|
5327
|
-
const clipsX = (v) => v !== "visible";
|
|
5328
|
-
const scrollsX = (v) => v === "auto" || v === "scroll";
|
|
5329
|
-
/**
|
|
5330
|
-
* The ancestors that cut the element off: anything with overflow other than
|
|
5331
|
-
* visible on an axis. The body and the root scroll the page and are covered by
|
|
5332
|
-
* the viewport. Read once per target; their rectangles are read every frame.
|
|
5333
|
-
*/
|
|
5334
|
-
function clipAncestors(el) {
|
|
5335
|
-
const out = [];
|
|
5336
|
-
if (typeof getComputedStyle !== "function")
|
|
5337
|
-
return out;
|
|
5338
|
-
for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
|
|
5339
|
-
if (n.hasAttribute(GUIDE_LAYER_ATTRIBUTE))
|
|
5340
|
-
break;
|
|
5341
|
-
const s = getComputedStyle(n);
|
|
5342
|
-
const x = clipsX(s.overflowX);
|
|
5343
|
-
const y = clipsX(s.overflowY);
|
|
5344
|
-
if (x || y)
|
|
5345
|
-
out.push({ el: n, x, y });
|
|
5346
|
-
if (s.position === "fixed")
|
|
5347
|
-
break;
|
|
5348
|
-
}
|
|
5349
|
-
return out;
|
|
5350
|
-
}
|
|
5351
|
-
/** An ancestor's clip as a rectangle: unbounded on an axis it does not clip. */
|
|
5352
|
-
function clipRectOf(c) {
|
|
5353
|
-
const r = c.el.getBoundingClientRect();
|
|
5354
|
-
return {
|
|
5355
|
-
x: c.x ? r.left : -INFINITE,
|
|
5356
|
-
y: c.y ? r.top : -INFINITE,
|
|
5357
|
-
width: c.x ? r.width : INFINITE * 2,
|
|
5358
|
-
height: c.y ? r.height : INFINITE * 2,
|
|
5359
|
-
};
|
|
5360
|
-
}
|
|
5361
|
-
function viewportRect() {
|
|
5362
|
-
const vv = typeof window !== "undefined" ? window.visualViewport : undefined;
|
|
5363
|
-
return {
|
|
5364
|
-
x: 0,
|
|
5365
|
-
y: vv ? vv.offsetTop : 0,
|
|
5366
|
-
width: window.innerWidth,
|
|
5367
|
-
height: vv ? vv.height : window.innerHeight,
|
|
5368
|
-
};
|
|
5369
|
-
}
|
|
5370
|
-
/**
|
|
5371
|
-
* The part of the element a person can see: its box cut by every scroll box
|
|
5372
|
-
* around it and by the viewport. The spotlight, its hole, the ring and the
|
|
5373
|
-
* hand use this, so a tab half out of a sideways-scrolling row of tabs is not
|
|
5374
|
-
* spotlit where it cannot be pressed. Null when none of it is on screen.
|
|
5375
|
-
*/
|
|
5376
|
-
function visibleRectOf(el, ancestors = clipAncestors(el)) {
|
|
5377
|
-
return visibleRect(rectOf(el), [...ancestors.map(clipRectOf), viewportRect()]);
|
|
5378
|
-
}
|
|
5379
|
-
/** Inside a box that scrolls sideways (with somewhere to scroll) that is itself on screen. */
|
|
5380
|
-
function inSidewaysScroller(el) {
|
|
5381
|
-
if (typeof getComputedStyle !== "function")
|
|
5382
|
-
return false;
|
|
5383
|
-
for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
|
|
5384
|
-
const s = getComputedStyle(n);
|
|
5385
|
-
if (scrollsX(s.overflowX) && n.scrollWidth > n.clientWidth + 1) {
|
|
5386
|
-
const r = n.getBoundingClientRect();
|
|
5387
|
-
return r.width > 0 && r.right > 0 && r.left < window.innerWidth;
|
|
5388
|
-
}
|
|
5389
|
-
}
|
|
5390
|
-
return false;
|
|
5391
|
-
}
|
|
5392
|
-
/**
|
|
5393
|
-
* How to bring a target into view, or null when it already is: to the middle
|
|
5394
|
-
* when it is above or below the comfortable band (under the header, at the
|
|
5395
|
-
* bottom edge), the nearest edge when only a side is cut off (a tab out of a
|
|
5396
|
-
* sideways-scrolling row, on a phone).
|
|
5397
|
-
*/
|
|
5398
|
-
function revealPlan(rect, visible, viewportHeight) {
|
|
5399
|
-
const offVertically = rect.y < 70 || rect.y + rect.height > viewportHeight - 20;
|
|
5400
|
-
const cutSideways = !visible || visible.width < rect.width - 1;
|
|
5401
|
-
if (!offVertically && !cutSideways)
|
|
5402
|
-
return null;
|
|
5403
|
-
return { block: offVertically ? "center" : "nearest", inline: "nearest" };
|
|
5404
|
-
}
|
|
5405
|
-
/** Room left beside a target scrolled into a sideways scroll box. */
|
|
5406
|
-
const SIDEWAYS_MARGIN = 12;
|
|
5407
|
-
/**
|
|
5408
|
-
* How far to scroll a sideways scroll box so the target sits inside its
|
|
5409
|
-
* visible part (`box`: the box cut by the viewport) with `margin` to spare on
|
|
5410
|
-
* both sides; 0 when it already does. Clamped to what the box can scroll.
|
|
5411
|
-
* A target wider than the room is aligned to its start.
|
|
5412
|
-
*/
|
|
5413
|
-
function sidewaysDelta(target, box, scrollLeft, maxScrollLeft, margin = SIDEWAYS_MARGIN) {
|
|
5414
|
-
const left = box.x + margin;
|
|
5415
|
-
const right = box.x + box.width - margin;
|
|
5416
|
-
let delta = 0;
|
|
5417
|
-
if (target.x < left || target.width > right - left)
|
|
5418
|
-
delta = target.x - left;
|
|
5419
|
-
else if (target.x + target.width > right)
|
|
5420
|
-
delta = target.x + target.width - right;
|
|
5421
|
-
const next = Math.min(Math.max(scrollLeft + delta, 0), Math.max(maxScrollLeft, 0));
|
|
5422
|
-
return next - scrollLeft;
|
|
5423
|
-
}
|
|
5424
|
-
/** The nearest ancestor that scrolls sideways and has somewhere to scroll. */
|
|
5425
|
-
function sidewaysScroller(el) {
|
|
5426
|
-
if (typeof getComputedStyle !== "function")
|
|
5427
|
-
return null;
|
|
5428
|
-
for (let n = el.parentElement; n && n !== document.body && n !== document.documentElement; n = n.parentElement) {
|
|
5429
|
-
if (scrollsX(getComputedStyle(n).overflowX) && n.scrollWidth > n.clientWidth + 1)
|
|
5430
|
-
return n;
|
|
5431
|
-
}
|
|
5432
|
-
return null;
|
|
5433
|
-
}
|
|
5434
|
-
/**
|
|
5435
|
-
* Scrolls the target into view (instant under reduced motion). In a box that
|
|
5436
|
-
* scrolls sideways (a row of tabs on a phone) the box is scrolled first,
|
|
5437
|
-
* leaving SIDEWAYS_MARGIN beside the target so its whole label shows; then
|
|
5438
|
-
* the page, up or down only.
|
|
5439
|
-
*/
|
|
5440
|
-
function revealTarget(el) {
|
|
5441
|
-
const behavior = prefersReducedMotion() ? "auto" : "smooth";
|
|
5442
|
-
const scroller = sidewaysScroller(el);
|
|
5443
|
-
if (scroller) {
|
|
5444
|
-
const box = intersectRect(rectOf(scroller), viewportRect()) ?? rectOf(scroller);
|
|
5445
|
-
const dx = sidewaysDelta(rectOf(el), box, scroller.scrollLeft, scroller.scrollWidth - scroller.clientWidth);
|
|
5446
|
-
// Instant: the page's own scroll below must not cancel it.
|
|
5447
|
-
if (Math.abs(dx) >= 1)
|
|
5448
|
-
scroller.scrollLeft += dx;
|
|
5449
|
-
}
|
|
5450
|
-
const vh = window.visualViewport?.height ?? window.innerHeight;
|
|
5451
|
-
const plan = revealPlan(rectOf(el), visibleRectOf(el), vh);
|
|
5452
|
-
if (!plan)
|
|
5453
|
-
return;
|
|
5454
|
-
try {
|
|
5455
|
-
el.scrollIntoView({ ...plan, behavior });
|
|
5456
|
-
}
|
|
5457
|
-
catch {
|
|
5458
|
-
el.scrollIntoView();
|
|
5459
|
-
}
|
|
5460
|
-
}
|
|
5461
|
-
/* ---------------- the scrim's holes, as one union ---------------- */
|
|
5462
|
-
/**
|
|
5463
|
-
* The scrim's blocking area: the screen (with a 10 px margin) minus the union
|
|
5464
|
-
* of the holes, as rectangles that do not overlap. Cutting each hole out with
|
|
5465
|
-
* an even-odd fill made the place where two holes overlap (a field and the
|
|
5466
|
-
* select list open over it) solid again, and the option there could not be
|
|
5467
|
-
* pressed.
|
|
5468
|
-
*
|
|
5469
|
-
* Every hole edge splits the screen into a grid; a cell is blocked when no
|
|
5470
|
-
* hole covers it. Cells in a row are joined, and identical rows stacked.
|
|
5471
|
-
*/
|
|
5472
|
-
function blockedRects(holes, width, height) {
|
|
5473
|
-
const x0 = -10;
|
|
5474
|
-
const y0 = -10;
|
|
5475
|
-
const x1 = width + 10;
|
|
5476
|
-
const y1 = height + 10;
|
|
5477
|
-
const clamp = (v, lo, hi) => Math.min(hi, Math.max(lo, v));
|
|
5478
|
-
const hs = holes
|
|
5479
|
-
.filter(h => h.width > 0 && h.height > 0)
|
|
5480
|
-
.map(h => ({
|
|
5481
|
-
l: clamp(h.x, x0, x1),
|
|
5482
|
-
t: clamp(h.y, y0, y1),
|
|
5483
|
-
r: clamp(h.x + h.width, x0, x1),
|
|
5484
|
-
b: clamp(h.y + h.height, y0, y1),
|
|
5485
|
-
}))
|
|
5486
|
-
.filter(h => h.r > h.l && h.b > h.t);
|
|
5487
|
-
if (!hs.length)
|
|
5488
|
-
return [{ x: x0, y: y0, width: x1 - x0, height: y1 - y0 }];
|
|
5489
|
-
const uniq = (v) => Array.from(new Set(v)).sort((a, b) => a - b);
|
|
5490
|
-
const xs = uniq([x0, x1, ...hs.flatMap(h => [h.l, h.r])]);
|
|
5491
|
-
const ys = uniq([y0, y1, ...hs.flatMap(h => [h.t, h.b])]);
|
|
5492
|
-
const out = [];
|
|
5493
|
-
let prev = null;
|
|
5494
|
-
for (let j = 0; j < ys.length - 1; j++) {
|
|
5495
|
-
const top = ys[j];
|
|
5496
|
-
const bottom = ys[j + 1];
|
|
5497
|
-
const cy = (top + bottom) / 2;
|
|
5498
|
-
const row = [];
|
|
5499
|
-
for (let i = 0; i < xs.length - 1; i++) {
|
|
5500
|
-
const left = xs[i];
|
|
5501
|
-
const right = xs[i + 1];
|
|
5502
|
-
const cx = (left + right) / 2;
|
|
5503
|
-
const covered = hs.some(h => cx > h.l && cx < h.r && cy > h.t && cy < h.b);
|
|
5504
|
-
if (covered)
|
|
5505
|
-
continue;
|
|
5506
|
-
const last = row[row.length - 1];
|
|
5507
|
-
if (last && last.x + last.width === left)
|
|
5508
|
-
last.width = right - last.x;
|
|
5509
|
-
else
|
|
5510
|
-
row.push({ x: left, y: top, width: right - left, height: bottom - top });
|
|
5511
|
-
}
|
|
5512
|
-
const key = row.map(r => `${r.x},${r.width}`).join("|");
|
|
5513
|
-
if (prev && prev.key === key) {
|
|
5514
|
-
for (const r of prev.rects)
|
|
5515
|
-
r.height += bottom - top;
|
|
5516
|
-
}
|
|
5517
|
-
else {
|
|
5518
|
-
out.push(...row);
|
|
5519
|
-
prev = { key, rects: row };
|
|
5520
|
-
}
|
|
5521
|
-
}
|
|
5522
|
-
return out;
|
|
5523
|
-
}
|
|
5524
|
-
|
|
5525
|
-
/**
|
|
5526
|
-
* The tour's stylesheet. The shell has no CSS pipeline and the apps only scan
|
|
5527
|
-
* its dist for Tailwind classes, which cannot carry keyframes; so the layer
|
|
5528
|
-
* brings its own sheet, namespaced `eg-`, drawn from the theme's tokens
|
|
5529
|
-
* (--primary carries each app's brand accent; light and dark follow).
|
|
5530
|
-
*/
|
|
5531
|
-
const GUIDE_CSS = `
|
|
5532
|
-
[data-erp-guide-layer]{position:fixed;inset:0;z-index:1000;pointer-events:none;font-family:inherit;color:var(--popover-foreground,#1c2634);
|
|
5533
|
-
--eg-scrim:rgba(12,20,34,.64);--eg-card:var(--popover,#fff);--eg-fg:var(--popover-foreground,#1c2634);
|
|
5534
|
-
--eg-muted:var(--muted-foreground,#6d7886);--eg-soft:var(--muted,#eef3f9);--eg-line:var(--border,#e8ecf1);
|
|
5535
|
-
--eg-primary:var(--primary,#3280dd);--eg-on-primary:var(--primary-foreground,#fff);
|
|
5536
|
-
--eg-tint:color-mix(in oklab,var(--eg-primary) 13%,var(--eg-card));--eg-strong:color-mix(in oklab,var(--eg-primary) 82%,#000);
|
|
5537
|
-
--eg-danger:var(--destructive,#d23b4b);--eg-ok:#1d9a5f;--eg-gold:#e6a417;
|
|
5538
|
-
--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}
|
|
5539
|
-
[data-erp-guide-layer] *{box-sizing:border-box}
|
|
5540
|
-
[data-erp-guide-layer] button{font:inherit;color:inherit;cursor:pointer}
|
|
5541
|
-
.eg-i{pointer-events:auto}
|
|
5542
|
-
.eg-sr{position:absolute;width:1px;height:1px;overflow:hidden;clip:rect(0 0 0 0);white-space:nowrap}
|
|
5543
|
-
.eg-scrim-svg{position:fixed;inset:0;width:100%;height:100%;pointer-events:none;overflow:visible}
|
|
5544
|
-
.eg-scrim-svg .eg-scrim-fill{fill:var(--eg-scrim);pointer-events:none}
|
|
5545
|
-
.eg-scrim-svg .eg-scrim-hit{fill:transparent;pointer-events:all}
|
|
5546
|
-
.eg-scrim{position:fixed;inset:0;background:var(--eg-scrim);pointer-events:auto}
|
|
5547
|
-
.eg-scrim.eg-light{background:rgba(12,20,34,.45)}
|
|
5548
|
-
.eg-scrim.eg-faint{background:rgba(12,20,34,.35)}
|
|
5549
|
-
.eg-ring{position:fixed;border-radius:12px;pointer-events:none;box-shadow:0 0 0 3px #fff,0 0 0 6px var(--eg-primary)}
|
|
5550
|
-
.eg-ring.eg-move{transition:left .26s ease,top .26s ease,width .26s ease,height .26s ease}
|
|
5551
|
-
.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}
|
|
5552
|
-
.eg-ring.eg-flash::after{animation:eg-flash .5s ease 1}
|
|
5553
|
-
@keyframes eg-pulse{0%{opacity:.9;transform:scale(1)}100%{opacity:0;transform:scale(1.12)}}
|
|
5554
|
-
@keyframes eg-flash{0%,100%{opacity:1;transform:none}50%{opacity:.2}}
|
|
5555
|
-
.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}
|
|
5556
|
-
.eg-hand svg{width:46px;height:46px;fill:#fff;stroke:#1c2634;stroke-width:1.4;stroke-linejoin:round;stroke-linecap:round}
|
|
5557
|
-
.eg-hand.eg-flip svg{transform:scaleY(-1)}
|
|
5558
|
-
@keyframes eg-tap{0%,100%{transform:translate(6px,8px)}50%{transform:translate(0,0)}}
|
|
5559
|
-
.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}
|
|
5560
|
-
.eg-bubble.eg-err{border-top-color:var(--eg-danger)}
|
|
5561
|
-
.eg-bubble.eg-nudge{animation:eg-nudge .38s ease}
|
|
5562
|
-
@keyframes eg-nudge{0%,100%{translate:0}20%{translate:-7px}40%{translate:6px}60%{translate:-4px}80%{translate:2px}}
|
|
5563
|
-
.eg-b-top{display:flex;justify-content:space-between;align-items:center;gap:8px;font-size:12px;color:var(--eg-muted)}
|
|
5564
|
-
.eg-b-top .eg-mis{font-weight:600;color:var(--eg-strong);white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
|
5565
|
-
.eg-b-top .eg-cnt{white-space:nowrap;font-weight:600}
|
|
5566
|
-
.eg-pips{display:flex;gap:4px;margin:8px 0 10px}
|
|
5567
|
-
.eg-pips i{flex:1;height:4px;border-radius:2px;background:var(--eg-soft)}
|
|
5568
|
-
.eg-pips i.eg-d{background:var(--eg-primary)}
|
|
5569
|
-
.eg-pips i.eg-c{background:color-mix(in oklab,var(--eg-primary) 45%,var(--eg-card))}
|
|
5570
|
-
.eg-b-t{margin:0;font-size:16px;font-weight:600;line-height:1.45}
|
|
5571
|
-
.eg-b-w{margin:2px 0 0;font-size:13.5px;color:var(--eg-muted)}
|
|
5572
|
-
.eg-bubble.eg-err .eg-b-t{color:var(--eg-danger)}
|
|
5573
|
-
.eg-k{display:inline-block;background:var(--eg-tint);color:var(--eg-strong);border-radius:7px;padding:0 7px;font-weight:600}
|
|
5574
|
-
.eg-b-f{display:flex;align-items:center;justify-content:space-between;margin-top:12px;gap:8px}
|
|
5575
|
-
.eg-b-f .eg-btn{height:34px;padding:0 14px;font-size:13.5px}
|
|
5576
|
-
.eg-exit{border:0;background:none;color:var(--eg-muted);font-size:12.5px;padding:4px 0;text-decoration:underline;text-underline-offset:3px}
|
|
5577
|
-
.eg-exit:hover{color:var(--eg-fg)}
|
|
5578
|
-
.eg-arrow{position:absolute;width:14px;height:14px;background:var(--eg-card);transform:rotate(45deg);border-radius:2px}
|
|
5579
|
-
.eg-arrow.eg-top{background:var(--eg-primary)}
|
|
5580
|
-
.eg-bubble.eg-err .eg-arrow.eg-top{background:var(--eg-danger)}
|
|
5581
|
-
.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}
|
|
5582
|
-
.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}
|
|
5583
|
-
.eg-primary{background:var(--eg-primary);color:var(--eg-on-primary)!important}
|
|
5584
|
-
.eg-primary:hover{background:var(--eg-strong)}
|
|
5585
|
-
.eg-primary[disabled]{opacity:.55;cursor:default}
|
|
5586
|
-
.eg-ghost{background:var(--eg-card);border-color:var(--eg-line)}
|
|
5587
|
-
.eg-ghost:hover{background:var(--eg-soft)}
|
|
5588
|
-
.eg-ghost[disabled]{opacity:.55;cursor:default}
|
|
5589
|
-
.eg-link{background:none;border:0;color:var(--eg-muted);font-weight:500;padding:0 6px;height:32px}
|
|
5590
|
-
.eg-link:hover{color:var(--eg-fg);text-decoration:underline}
|
|
5591
|
-
.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}
|
|
5592
|
-
.eg-modal.eg-pop{animation:eg-pop .32s cubic-bezier(.2,.9,.3,1.3)}
|
|
5593
|
-
@keyframes eg-pop{from{opacity:0;transform:translate(-50%,-46%) scale(.94)}}
|
|
5594
|
-
.eg-modal h2{margin:6px 0 4px;font-size:22px;font-weight:700;letter-spacing:-.2px;line-height:1.3}
|
|
5595
|
-
.eg-modal .eg-sub{margin:0;color:var(--eg-muted);font-size:14.5px}
|
|
5596
|
-
.eg-eyebrow{margin:0;font-size:12.5px;font-weight:600;letter-spacing:.3px;color:var(--eg-strong)}
|
|
5597
|
-
.eg-actions{display:grid;gap:6px;margin-top:20px}
|
|
5598
|
-
.eg-actions .eg-btn{height:44px}
|
|
5599
|
-
.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)}
|
|
5600
|
-
.eg-badge svg.eg-ic{width:30px;height:30px}
|
|
5601
|
-
.eg-quests{list-style:none;padding:0;margin:18px 0 0;text-align:left;display:grid;gap:6px}
|
|
5602
|
-
.eg-quests li{display:flex;align-items:center;gap:12px;padding:9px 12px;border-radius:11px;background:var(--eg-soft);font-size:14px}
|
|
5603
|
-
.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}
|
|
5604
|
-
.eg-quests li.eg-first{background:var(--eg-tint)}
|
|
5605
|
-
.eg-quests li.eg-first .eg-n{border-color:var(--eg-primary);color:var(--eg-strong)}
|
|
5606
|
-
.eg-quests li.eg-ok .eg-n{background:var(--eg-ok);border-color:var(--eg-ok);color:#fff}
|
|
5607
|
-
.eg-quests li .eg-n svg.eg-ic{width:15px;height:15px;stroke-width:3}
|
|
5608
|
-
.eg-quests li .eg-t{flex:1}
|
|
5609
|
-
.eg-quests li small{color:var(--eg-muted);font-size:12px;white-space:nowrap}
|
|
5610
|
-
.eg-modal.eg-confirm{text-align:left;width:min(380px,calc(100vw - 32px));padding:22px}
|
|
5611
|
-
.eg-modal.eg-confirm h2{font-size:18px;margin-top:0}
|
|
5612
|
-
.eg-modal.eg-confirm .eg-actions{grid-template-columns:1fr 1fr}
|
|
5613
|
-
.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}
|
|
5614
|
-
.eg-medal svg.eg-ic{width:42px;height:42px;stroke-width:2.6}
|
|
5615
|
-
@keyframes eg-stamp{from{transform:scale(.3) rotate(-25deg);opacity:0}}
|
|
5616
|
-
.eg-prog{display:flex;align-items:center;gap:10px;margin-top:16px;font-size:13px;color:var(--eg-muted);font-weight:600}
|
|
5617
|
-
.eg-prog .eg-bar{flex:1;height:8px;border-radius:4px;background:var(--eg-soft);overflow:hidden}
|
|
5618
|
-
.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}
|
|
5619
|
-
.eg-burst{position:absolute;left:50%;top:70px;width:0;height:0;pointer-events:none}
|
|
5620
|
-
.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}
|
|
5621
|
-
@keyframes eg-conf{0%{opacity:1;transform:translate(0,0) rotate(0)}100%{opacity:0;transform:translate(var(--x),var(--y)) rotate(var(--r))}}
|
|
5622
|
-
.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}
|
|
5623
|
-
.eg-card .eg-row{display:flex;align-items:center;justify-content:space-between;gap:10px;margin-top:12px}
|
|
5624
|
-
.eg-loading{display:flex;align-items:center;gap:10px;font-weight:600}
|
|
5625
|
-
.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}
|
|
5626
|
-
@keyframes eg-spin{to{transform:rotate(360deg)}}
|
|
5627
|
-
.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}
|
|
5628
|
-
.eg-chip svg{width:34px;height:34px}
|
|
5629
|
-
.eg-chip.eg-hi{animation:eg-hi 1.2s ease 2}
|
|
5630
|
-
@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)}}
|
|
5631
|
-
.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}
|
|
5632
|
-
.eg-panel-bd{display:none}
|
|
5633
|
-
.eg-q-h{display:flex;align-items:center;gap:10px;padding:16px 12px 4px 18px}
|
|
5634
|
-
.eg-q-h h2{margin:0;font-size:16px;font-weight:700;flex:1}
|
|
5635
|
-
.eg-q-h .eg-n{font-size:13px;color:var(--eg-muted);font-weight:600}
|
|
5636
|
-
.eg-x{width:34px;height:34px;border-radius:10px;border:0;background:none;display:grid;place-items:center;color:var(--eg-muted)}
|
|
5637
|
-
.eg-x:hover{background:var(--eg-soft)}
|
|
5638
|
-
.eg-x svg.eg-ic{width:18px;height:18px}
|
|
5639
|
-
.eg-panel .eg-prog{margin:6px 18px 8px}
|
|
5640
|
-
.eg-q-list{list-style:none;margin:0;padding:4px 10px 6px;display:grid;gap:2px}
|
|
5641
|
-
.eg-q-item{display:flex;align-items:center;gap:12px;padding:10px 8px;border-radius:11px}
|
|
5642
|
-
.eg-q-item.eg-next{background:var(--eg-tint)}
|
|
5643
|
-
.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)}
|
|
5644
|
-
.eg-q-item.eg-next .eg-n{border-color:var(--eg-primary);color:var(--eg-strong)}
|
|
5645
|
-
.eg-q-item.eg-done .eg-n{background:var(--eg-ok);border-color:var(--eg-ok);color:#fff}
|
|
5646
|
-
.eg-q-item.eg-done .eg-n svg.eg-ic{width:16px;height:16px;stroke-width:3}
|
|
5647
|
-
.eg-q-item.eg-dim .eg-n{opacity:.55}
|
|
5648
|
-
.eg-q-item.eg-just .eg-n{animation:eg-stamp .55s cubic-bezier(.2,.9,.3,1.4) .2s both}
|
|
5649
|
-
.eg-q-item .eg-t{flex:1;min-width:0}
|
|
5650
|
-
.eg-q-item .eg-t b{display:block;font-size:14px;font-weight:600}
|
|
5651
|
-
.eg-q-item.eg-done .eg-t b{color:var(--eg-muted)}
|
|
5652
|
-
.eg-q-item .eg-t small{display:block;font-size:12px;color:var(--eg-muted)}
|
|
5653
|
-
.eg-q-item .eg-t small.eg-sk{color:#b06a00}
|
|
5654
|
-
.eg-q-item .eg-t small.eg-lock{color:var(--eg-fg);font-weight:500}
|
|
5655
|
-
.eg-q-item .eg-btn{height:32px;padding:0 12px;font-size:13px}
|
|
5656
|
-
.eg-q-acts{display:flex;flex-direction:column;align-items:flex-end;gap:2px}
|
|
5657
|
-
.eg-q-acts .eg-link{height:22px;font-size:12px;padding:0 2px}
|
|
5658
|
-
.eg-q-f{border-top:1px solid var(--eg-line);padding:8px 12px;display:flex;justify-content:flex-end}
|
|
5659
|
-
.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}
|
|
5660
|
-
svg.eg-ic{width:18px;height:18px;fill:none;stroke:currentColor;stroke-width:1.8;stroke-linecap:round;stroke-linejoin:round;flex:none}
|
|
5661
|
-
@media (max-width:767px){
|
|
5662
|
-
.eg-bubble{width:auto}
|
|
5663
|
-
.eg-arrow{display:none}
|
|
5664
|
-
.eg-modal{top:auto;bottom:12px;transform:translateX(-50%);width:calc(100vw - 24px);padding:24px 20px 18px}
|
|
5665
|
-
.eg-modal.eg-pop{animation:eg-up .3s cubic-bezier(.2,.9,.3,1.1)}
|
|
5666
|
-
.eg-modal .eg-actions .eg-btn{height:46px}
|
|
5667
|
-
.eg-chip{right:16px;bottom:calc(16px + env(safe-area-inset-bottom))}
|
|
5668
|
-
.eg-panel-bd{display:block;position:fixed;inset:0;background:rgba(15,23,42,.38);pointer-events:auto}
|
|
5669
|
-
.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)}
|
|
5670
|
-
.eg-q-item{padding:12px 8px}
|
|
5671
|
-
.eg-card{bottom:calc(12px + env(safe-area-inset-bottom))}
|
|
5672
|
-
.eg-toast{bottom:calc(76px + env(safe-area-inset-bottom))}
|
|
5673
|
-
}
|
|
5674
|
-
@keyframes eg-up{from{opacity:0;transform:translate(-50%,30px)}}
|
|
5675
|
-
@media (prefers-reduced-motion:reduce){
|
|
5676
|
-
[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}
|
|
5677
|
-
.eg-ring::after{animation:none!important;opacity:1;transform:none}
|
|
5678
|
-
.eg-ring.eg-flash::after{animation:eg-flash .5s ease 1!important;animation-duration:.5s!important}
|
|
5679
|
-
.eg-hand{animation:none!important;transform:translate(4px,6px)}
|
|
5680
|
-
.eg-burst{display:none}
|
|
5681
|
-
.eg-bubble.eg-nudge{animation:none!important;border-top-color:var(--eg-danger)}
|
|
5682
|
-
}
|
|
5683
|
-
`;
|
|
5684
|
-
|
|
5685
|
-
/* ------------------------------------------------------------------ */
|
|
5686
|
-
/* Bits */
|
|
5687
|
-
/* ------------------------------------------------------------------ */
|
|
5688
|
-
const TH_MONTHS = ["ม.ค.", "ก.พ.", "มี.ค.", "เม.ย.", "พ.ค.", "มิ.ย.", "ก.ค.", "ส.ค.", "ก.ย.", "ต.ค.", "พ.ย.", "ธ.ค."];
|
|
5689
|
-
const pad2 = (n) => String(n).padStart(2, "0");
|
|
5690
|
-
/** "26 ต.ค. 2569 13:04" — day, month, Buddhist year, 24 h. */
|
|
5691
|
-
function formatThaiDateTime(iso) {
|
|
5692
|
-
if (!iso)
|
|
5693
|
-
return "";
|
|
5694
|
-
const d = new Date(iso);
|
|
5695
|
-
if (Number.isNaN(d.getTime()))
|
|
5696
|
-
return "";
|
|
5697
|
-
return `${d.getDate()} ${TH_MONTHS[d.getMonth()]} ${d.getFullYear() + 543} ${pad2(d.getHours())}:${pad2(d.getMinutes())}`;
|
|
5698
|
-
}
|
|
5699
|
-
/** "กด [บันทึก]": the bracketed word is a chip in the brand colour. */
|
|
5700
|
-
function GuideTitle({ text }) {
|
|
5701
|
-
const parts = text.split(/(\[[^\]]+\])/g).filter(Boolean);
|
|
5702
|
-
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))) }));
|
|
5703
|
-
}
|
|
5704
|
-
const Svg = ({ children, className = "eg-ic" }) => (jsxRuntime.jsx("svg", { className: className, viewBox: "0 0 24 24", "aria-hidden": "true", children: children }));
|
|
5705
|
-
const IconCheck = () => (jsxRuntime.jsx(Svg, { children: jsxRuntime.jsx("path", { d: "M20 6 9 17l-5-5" }) }));
|
|
5706
|
-
const IconX = () => (jsxRuntime.jsx(Svg, { children: jsxRuntime.jsx("path", { d: "M18 6 6 18M6 6l12 12" }) }));
|
|
5707
|
-
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" })] }));
|
|
5708
|
-
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" }) }));
|
|
5709
|
-
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";
|
|
5710
|
-
/** Rounded-rectangle subpath for the scrim's holes. */
|
|
5711
|
-
function roundRect(r, radius) {
|
|
5712
|
-
const rad = Math.max(0, Math.min(radius, r.width / 2, r.height / 2));
|
|
5713
|
-
const { x, y, width: w, height: h } = r;
|
|
5714
|
-
return (`M${x + rad} ${y}H${x + w - rad}A${rad} ${rad} 0 0 1 ${x + w} ${y + rad}` +
|
|
5715
|
-
`V${y + h - rad}A${rad} ${rad} 0 0 1 ${x + w - rad} ${y + h}` +
|
|
5716
|
-
`H${x + rad}A${rad} ${rad} 0 0 1 ${x} ${y + h - rad}` +
|
|
5717
|
-
`V${y + rad}A${rad} ${rad} 0 0 1 ${x + rad} ${y}Z`);
|
|
5718
|
-
}
|
|
5719
|
-
const rectPath = (r) => `M${r.x} ${r.y}H${r.x + r.width}V${r.y + r.height}H${r.x}Z`;
|
|
5720
|
-
/**
|
|
5721
|
-
* The dim layer that eats every press outside the holes. The holes are a
|
|
5722
|
-
* union: where two overlap (a field and the select list open over it) the
|
|
5723
|
-
* page stays open.
|
|
5724
|
-
*
|
|
5725
|
-
* - The look: one rectangle masked by the rounded holes (a mask adds holes
|
|
5726
|
-
* up, so an overlap is not filled back in).
|
|
5727
|
-
* - The presses: a transparent path made of the screen minus the holes, as
|
|
5728
|
-
* rectangles that do not overlap (blockedRects). A press inside a hole
|
|
5729
|
-
* reaches the page underneath; any other lands on the path (marked
|
|
5730
|
-
* data-eg-block) and is cancelled.
|
|
5731
|
-
* - `open`: the holes a press may go through, when not all of them (a "view"
|
|
5732
|
-
* step's target is shown but not pressable). Default: every hole.
|
|
5733
|
-
*/
|
|
5734
|
-
function Scrim({ holes, open, width, height }) {
|
|
5735
|
-
const maskId = "eg-scrim-mask-" + React__namespace.useId().replace(/[^a-zA-Z0-9_-]/g, "");
|
|
5736
|
-
const block = blockedRects(open ?? holes, width, height).map(rectPath).join("");
|
|
5737
|
-
return (jsxRuntime.jsxs("svg", { className: "eg-scrim-svg", width: width, height: height, "aria-hidden": "true", children: [holes.length ? (jsxRuntime.jsx("defs", { children: jsxRuntime.jsxs("mask", { id: maskId, maskUnits: "userSpaceOnUse", x: -10, y: -10, width: width + 20, height: height + 20, children: [jsxRuntime.jsx("rect", { x: -10, y: -10, width: width + 20, height: height + 20, fill: "#fff" }), holes.map((h, i) => (jsxRuntime.jsx("path", { d: roundRect(h, 12), fill: "#000" }, i)))] }) })) : null, jsxRuntime.jsx("rect", { className: "eg-scrim-fill", x: -10, y: -10, width: width + 20, height: height + 20, mask: holes.length ? `url(#${maskId})` : undefined }), jsxRuntime.jsx("path", { className: "eg-scrim-hit", d: block, "data-eg-block": "" })] }));
|
|
5738
|
-
}
|
|
5739
|
-
/** Whether two rectangles overlap. */
|
|
5740
|
-
const meets = (a, b) => !!intersectRect(a, b);
|
|
5741
|
-
/** The smallest rectangle around all of them. */
|
|
5742
|
-
function boundingRect(rs) {
|
|
5743
|
-
const x = Math.min(...rs.map(r => r.x));
|
|
5744
|
-
const y = Math.min(...rs.map(r => r.y));
|
|
5745
|
-
const right = Math.max(...rs.map(r => r.x + r.width));
|
|
5746
|
-
const bottom = Math.max(...rs.map(r => r.y + r.height));
|
|
5747
|
-
return { x, y, width: right - x, height: bottom - y };
|
|
5748
|
-
}
|
|
5749
|
-
/** Where the bubble may go, in order of preference. */
|
|
5750
|
-
const BUBBLE_PLACEMENTS = ["right", "bottom", "top", "left"];
|
|
5751
|
-
/**
|
|
5752
|
-
* The first placement (in BUBBLE_PLACEMENTS order) whose bubble is on screen,
|
|
5753
|
-
* off the target (with its open list), and off everything to keep clear;
|
|
5754
|
-
* failing that, the first that is only on screen and off the target; -1 when
|
|
5755
|
-
* none is.
|
|
5756
|
-
*/
|
|
5757
|
-
function pickPlacement(results, bw, bh, anchor, clear, bounds) {
|
|
5758
|
-
const box = (r) => ({ x: r.x, y: r.y, width: bw, height: bh });
|
|
5759
|
-
const fits = (b) => b.x >= bounds.x - 1 && b.y >= bounds.y - 1 && b.x + b.width <= bounds.x + bounds.width + 1 && b.y + b.height <= bounds.y + bounds.height + 1;
|
|
5760
|
-
const ok = results.map(r => {
|
|
5761
|
-
const b = box(r);
|
|
5762
|
-
return fits(b) && !meets(b, anchor);
|
|
5763
|
-
});
|
|
5764
|
-
const best = results.findIndex((r, i) => ok[i] && !clear.some(c => meets(box(r), c)));
|
|
5765
|
-
return best >= 0 ? best : ok.indexOf(true);
|
|
5766
|
-
}
|
|
5767
|
-
const BUBBLE_TITLE_ID = "eg-bubble-title";
|
|
5768
|
-
const BUBBLE_WHY_ID = "eg-bubble-why";
|
|
5769
|
-
function useNudge(nudge, ref) {
|
|
5770
|
-
const first = React.useRef(nudge);
|
|
5771
|
-
React.useEffect(() => {
|
|
5772
|
-
if (nudge === first.current)
|
|
5773
|
-
return;
|
|
5774
|
-
const el = ref.current;
|
|
5775
|
-
if (!el)
|
|
5776
|
-
return;
|
|
5777
|
-
el.classList.remove("eg-nudge");
|
|
5778
|
-
void el.offsetWidth;
|
|
5779
|
-
el.classList.add("eg-nudge");
|
|
5780
|
-
}, [nudge, ref]);
|
|
5781
|
-
}
|
|
5782
|
-
function StepLayer(p) {
|
|
5783
|
-
const { spot, phone, viewport } = p;
|
|
5784
|
-
const bubbleRef = React.useRef(null);
|
|
5785
|
-
const arrowRef = React.useRef(null);
|
|
5786
|
-
const ringRef = React.useRef(null);
|
|
5787
|
-
const [pos, setPos] = React.useState(null);
|
|
5788
|
-
useNudge(p.nudge, bubbleRef);
|
|
5789
|
-
// Flash the ring with each nudge too (a press outside the hole).
|
|
5790
|
-
const firstNudge = React.useRef(p.nudge);
|
|
5791
|
-
React.useEffect(() => {
|
|
5792
|
-
if (p.nudge === firstNudge.current)
|
|
5793
|
-
return;
|
|
5794
|
-
const el = ringRef.current;
|
|
5795
|
-
if (!el)
|
|
5796
|
-
return;
|
|
5797
|
-
el.classList.remove("eg-flash");
|
|
5798
|
-
void el.offsetWidth;
|
|
5799
|
-
el.classList.add("eg-flash");
|
|
5800
|
-
}, [p.nudge]);
|
|
5801
|
-
const showBubble = p.phase === "ready" && (!!spot || !p.hasTarget);
|
|
5802
|
-
const gap = p.showHand ? 34 : 14;
|
|
5803
|
-
const spotKey = spot ? `${spot.x}|${spot.y}|${spot.width}|${spot.height}` : "";
|
|
5804
|
-
const contentKey = `${p.bubble.title}|${p.bubble.why}|${p.bubble.error?.title ?? ""}|${p.bubble.showNext}`;
|
|
5805
|
-
// What the bubble sits beside: the target, with an open list over it (the
|
|
5806
|
-
// list is what the person is choosing from now). What it must not cover:
|
|
5807
|
-
// open lists, and the other anchors the step allows (a map step's
|
|
5808
|
-
// coordinate fields).
|
|
5809
|
-
const popoverList = p.popovers ?? [];
|
|
5810
|
-
const anchor = spot ? boundingRect([spot, ...popoverList.filter(o => meets(o, spot))]) : null;
|
|
5811
|
-
const clear = [...popoverList, ...p.holes];
|
|
5812
|
-
const anchorKey = anchor ? `${anchor.x}|${anchor.y}|${anchor.width}|${anchor.height}` : "";
|
|
5813
|
-
const clearKey = clear.map(r => `${r.x},${r.y},${r.width},${r.height}`).join(";");
|
|
5814
|
-
React.useLayoutEffect(() => {
|
|
5815
|
-
const el = bubbleRef.current;
|
|
5816
|
-
if (!el || !showBubble)
|
|
5817
|
-
return;
|
|
5818
|
-
const m = 16;
|
|
5819
|
-
if (!spot || !anchor) {
|
|
5820
|
-
// Nothing to point at: the middle of the screen (the bottom on a phone).
|
|
5821
|
-
const bw = el.offsetWidth;
|
|
5822
|
-
const bh = el.offsetHeight;
|
|
5823
|
-
setPos(phone
|
|
5824
|
-
? { left: m, top: viewport.offsetTop + viewport.height - bh - m, side: "dock" }
|
|
5825
|
-
: { left: (window.innerWidth - bw) / 2, top: viewport.offsetTop + (viewport.height - bh) / 2, side: "dock" });
|
|
5826
|
-
return;
|
|
5827
|
-
}
|
|
5828
|
-
if (phone) {
|
|
5829
|
-
// Docked to the top or the bottom of the screen, whichever covers less
|
|
5830
|
-
// of the target and its open list (target in the lower half: top).
|
|
5831
|
-
const vh = viewport.height;
|
|
5832
|
-
const oy = viewport.offsetTop;
|
|
5833
|
-
const bh = el.offsetHeight;
|
|
5834
|
-
const topT = oy + m + 4;
|
|
5835
|
-
const botT = oy + vh - bh - m;
|
|
5836
|
-
const { y, height: h } = anchor;
|
|
5837
|
-
const overlapTop = Math.max(0, topT + bh + 8 - y);
|
|
5838
|
-
const overlapBot = Math.max(0, y + h + gap - botT);
|
|
5839
|
-
const top = y + h / 2 > vh / 2
|
|
5840
|
-
? overlapTop <= overlapBot
|
|
5841
|
-
? topT
|
|
5842
|
-
: botT
|
|
5843
|
-
: overlapBot <= overlapTop
|
|
5844
|
-
? botT
|
|
5845
|
-
: topT;
|
|
5846
|
-
setPos({ left: m, top, side: "dock" });
|
|
5847
|
-
return;
|
|
5848
|
-
}
|
|
5849
|
-
let cancelled = false;
|
|
5850
|
-
const a = anchor;
|
|
5851
|
-
const ref = {
|
|
5852
|
-
getBoundingClientRect: () => ({
|
|
5853
|
-
x: a.x,
|
|
5854
|
-
y: a.y,
|
|
5855
|
-
left: a.x,
|
|
5856
|
-
top: a.y,
|
|
5857
|
-
width: a.width,
|
|
5858
|
-
height: a.height,
|
|
5859
|
-
right: a.x + a.width,
|
|
5860
|
-
bottom: a.y + a.height,
|
|
5861
|
-
}),
|
|
5862
|
-
};
|
|
5863
|
-
const at = (placement) => dom.computePosition(ref, el, {
|
|
5864
|
-
strategy: "fixed",
|
|
5865
|
-
placement,
|
|
5866
|
-
middleware: [
|
|
5867
|
-
dom.offset(placement.startsWith("bottom") ? gap : 14),
|
|
5868
|
-
dom.shift({ padding: m }),
|
|
5869
|
-
...(arrowRef.current ? [dom.arrow({ element: arrowRef.current, padding: 14 })] : []),
|
|
5870
|
-
],
|
|
5871
|
-
});
|
|
5872
|
-
Promise.all(BUBBLE_PLACEMENTS.map(at))
|
|
5873
|
-
.then(results => {
|
|
5874
|
-
if (cancelled)
|
|
5875
|
-
return;
|
|
5876
|
-
const bw = el.offsetWidth;
|
|
5877
|
-
const bh = el.offsetHeight;
|
|
5878
|
-
const bounds = { x: m, y: viewport.offsetTop + m, width: window.innerWidth - 2 * m, height: viewport.height - 2 * m };
|
|
5879
|
-
const i = pickPlacement(results, bw, bh, a, clear, bounds);
|
|
5880
|
-
if (i < 0) {
|
|
5881
|
-
// Nowhere beside it: the bottom-right corner.
|
|
5882
|
-
setPos({ left: window.innerWidth - bw - m, top: viewport.height + viewport.offsetTop - bh - m, side: "dock" });
|
|
5883
|
-
return;
|
|
5884
|
-
}
|
|
5885
|
-
const { x, y, placement, middlewareData } = results[i];
|
|
5886
|
-
const side = placement.split("-")[0];
|
|
5887
|
-
setPos({ left: x, top: y, side, ax: middlewareData.arrow?.x, ay: middlewareData.arrow?.y });
|
|
5888
|
-
})
|
|
5889
|
-
.catch(() => {
|
|
5890
|
-
if (!cancelled)
|
|
5891
|
-
setPos({ left: m, top: m, side: "dock" });
|
|
5892
|
-
});
|
|
5893
|
-
return () => {
|
|
5894
|
-
cancelled = true;
|
|
5895
|
-
};
|
|
5896
|
-
// The keys stand for spot, the lists, the holes and the bubble's size.
|
|
5897
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
5898
|
-
}, [spotKey, anchorKey, clearKey, contentKey, phone, showBubble, gap, viewport.height, viewport.offsetTop]);
|
|
5899
|
-
const popovers = p.popovers ?? [];
|
|
5900
|
-
const covered = !!p.covered;
|
|
5901
|
-
const holes = [...(spot && !covered ? [spot] : []), ...popovers, ...p.holes];
|
|
5902
|
-
const openHoles = p.targetPressable === false ? [...popovers, ...p.holes] : holes;
|
|
5903
|
-
const ringShown = !!spot && p.phase === "ready" && !covered && !popovers.some(o => meets(o, spot));
|
|
5904
|
-
const hand = spot && p.showHand && p.phase === "ready" && !covered && !popovers.length ? handPosition(spot, viewport.height) : null;
|
|
5905
|
-
let arrowStyle;
|
|
5906
|
-
if (pos && pos.side !== "dock") {
|
|
5907
|
-
if (pos.side === "right")
|
|
5908
|
-
arrowStyle = { left: -7, top: pos.ay };
|
|
5909
|
-
else if (pos.side === "left")
|
|
5910
|
-
arrowStyle = { right: -7, top: pos.ay };
|
|
5911
|
-
else if (pos.side === "bottom")
|
|
5912
|
-
arrowStyle = { top: -9, left: pos.ax };
|
|
5913
|
-
else
|
|
5914
|
-
arrowStyle = { bottom: -7, left: pos.ax };
|
|
5915
|
-
}
|
|
5916
|
-
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsx(Scrim, { holes: p.phase === "ready" ? holes : [], open: p.phase === "ready" ? openHoles : [], width: viewport.width, height: Math.max(viewport.height + viewport.offsetTop, 0) }), spot && ringShown ? (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: {
|
|
5917
|
-
left: pos?.left ?? 0,
|
|
5918
|
-
top: pos?.top ?? 0,
|
|
5919
|
-
width: phone ? viewport.width - 32 : undefined,
|
|
5920
|
-
visibility: pos ? "visible" : "hidden",
|
|
5921
|
-
}, 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] }));
|
|
5922
|
-
}
|
|
5923
|
-
function BubbleBody(p) {
|
|
5924
|
-
const b = p.bubble;
|
|
5925
|
-
const title = b.error ? b.error.title : b.title;
|
|
5926
|
-
const why = b.error ? (b.error.detail ?? "") : b.why;
|
|
5927
|
-
return (jsxRuntime.jsxs(jsxRuntime.Fragment, { children: [jsxRuntime.jsxs("div", { className: "eg-b-top", children: [jsxRuntime.jsxs("span", { className: "eg-mis", children: ["\u0E20\u0E32\u0E23\u0E01\u0E34\u0E08 ", b.missionNo, " \u00B7 ", b.missionTitle, b.replay ? " (ทบทวน)" : ""] }), jsxRuntime.jsxs("span", { className: "eg-cnt", children: ["\u0E02\u0E31\u0E49\u0E19\u0E17\u0E35\u0E48 ", b.index + 1, "/", b.total] })] }), jsxRuntime.jsx("div", { className: "eg-pips", "aria-hidden": "true", children: Array.from({ length: b.total }, (_, i) => (jsxRuntime.jsx("i", { className: i < b.index ? "eg-d" : i === b.index ? "eg-c" : undefined }, i))) }), jsxRuntime.jsx("p", { className: "eg-b-t", id: BUBBLE_TITLE_ID, children: jsxRuntime.jsx(GuideTitle, { text: title }) }), jsxRuntime.jsx("p", { className: "eg-b-w", id: BUBBLE_WHY_ID, hidden: !why, children: jsxRuntime.jsx(GuideTitle, { text: why }) }), 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] })] })] }));
|
|
5928
|
-
}
|
|
5929
|
-
/** The fingertip at ≈62% across and down the target; upside down near the bottom. */
|
|
5930
|
-
function handPosition(spot, viewportHeight) {
|
|
5931
|
-
const { x, y, width: w, height: h } = spot;
|
|
5932
|
-
const px = x + Math.min(w * 0.62, w - 14);
|
|
5933
|
-
const py = y + h * 0.62;
|
|
5934
|
-
const flip = py + 50 > viewportHeight;
|
|
5935
|
-
return { left: px - 11.5, top: flip ? py - h * 0.3 - 42 : py - 4, flip };
|
|
5936
|
-
}
|
|
5937
|
-
/* ------------------------------------------------------------------ */
|
|
5938
|
-
/* Modals */
|
|
5939
|
-
/* ------------------------------------------------------------------ */
|
|
5940
|
-
function useAutoFocus() {
|
|
5941
|
-
const ref = React.useRef(null);
|
|
5942
|
-
React.useEffect(() => {
|
|
5943
|
-
ref.current?.focus({ preventScroll: true });
|
|
5944
|
-
}, []);
|
|
5945
|
-
return ref;
|
|
5946
|
-
}
|
|
5947
|
-
function ConfirmModal(p) {
|
|
5948
|
-
const stay = useAutoFocus();
|
|
5949
|
-
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 })] })] })] }));
|
|
5950
|
-
}
|
|
5951
|
-
function WelcomeModal(p) {
|
|
5952
|
-
const start = useAutoFocus();
|
|
5953
|
-
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] }));
|
|
5954
|
-
}
|
|
5955
|
-
function GateModal(p) {
|
|
5956
|
-
const go = useAutoFocus();
|
|
5957
|
-
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" })] })] })] }));
|
|
5958
|
-
}
|
|
5959
|
-
const CONFETTI_COLOURS = ["#3b82f6", "#7db7ff", "#e6a417", "#ff7a6b", "#1d9a5f", "#b0d2ff"];
|
|
5960
|
-
function Confetti({ reduced }) {
|
|
5961
|
-
// Fixed per mount, so a re-render does not throw new pieces.
|
|
5962
|
-
const [pieces] = React.useState(() => Array.from({ length: 26 }, (_, i) => {
|
|
5963
|
-
const a = (Math.PI * 2 * i) / 26 + Math.random() * 0.3;
|
|
5964
|
-
const dist = 90 + Math.random() * 90;
|
|
5965
|
-
return {
|
|
5966
|
-
background: CONFETTI_COLOURS[i % CONFETTI_COLOURS.length],
|
|
5967
|
-
"--x": `${Math.cos(a) * dist}px`,
|
|
5968
|
-
"--y": `${Math.sin(a) * dist + 40}px`,
|
|
5969
|
-
"--r": `${Math.random() * 540 - 270}deg`,
|
|
5970
|
-
animationDelay: `${Math.random() * 0.12}s`,
|
|
5971
|
-
};
|
|
5972
|
-
}));
|
|
5973
|
-
if (reduced)
|
|
5974
|
-
return null;
|
|
5975
|
-
return (jsxRuntime.jsx("div", { className: "eg-burst", "aria-hidden": "true", children: pieces.map((s, i) => (jsxRuntime.jsx("span", { style: s }, i))) }));
|
|
5976
|
-
}
|
|
5977
|
-
function ProgressBar({ from, to, total, label }) {
|
|
5978
|
-
const [width, setWidth] = React.useState(total ? (from / total) * 100 : 0);
|
|
5979
|
-
React.useEffect(() => {
|
|
5980
|
-
// Two frames so the bar is drawn at `from` before it runs to `to`.
|
|
5981
|
-
let raf2 = 0;
|
|
5982
|
-
const raf1 = requestAnimationFrame(() => {
|
|
5983
|
-
raf2 = requestAnimationFrame(() => setWidth(total ? (to / total) * 100 : 0));
|
|
5984
|
-
});
|
|
5985
|
-
return () => {
|
|
5986
|
-
cancelAnimationFrame(raf1);
|
|
5987
|
-
cancelAnimationFrame(raf2);
|
|
5988
|
-
};
|
|
5989
|
-
}, [to, total]);
|
|
5990
|
-
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 })] }));
|
|
5991
|
-
}
|
|
5992
|
-
function CelebrateModal(p) {
|
|
5993
|
-
const primary = useAutoFocus();
|
|
5994
|
-
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" })] })] })] }));
|
|
5995
|
-
}
|
|
5996
|
-
function CompleteModal(p) {
|
|
5997
|
-
const primary = useAutoFocus();
|
|
5998
|
-
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] })] })] }));
|
|
5999
|
-
}
|
|
6000
|
-
/* ------------------------------------------------------------------ */
|
|
6001
|
-
/* Quest chip and panel */
|
|
6002
|
-
/* ------------------------------------------------------------------ */
|
|
6003
|
-
function QuestChip({ done, total, pulse, onOpen }) {
|
|
6004
|
-
const ref = React.useRef(null);
|
|
6005
|
-
const first = React.useRef(pulse);
|
|
6006
|
-
React.useEffect(() => {
|
|
6007
|
-
if (pulse === first.current)
|
|
6008
|
-
return;
|
|
6009
|
-
const el = ref.current;
|
|
6010
|
-
if (!el)
|
|
6011
|
-
return;
|
|
6012
|
-
el.classList.remove("eg-hi");
|
|
6013
|
-
void el.offsetWidth;
|
|
6014
|
-
el.classList.add("eg-hi");
|
|
6015
|
-
}, [pulse]);
|
|
6016
|
-
const C = 2 * Math.PI * 14;
|
|
6017
|
-
const pct = total ? done / total : 0;
|
|
6018
|
-
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] }));
|
|
6019
|
-
}
|
|
6020
|
-
function QuestPanel(p) {
|
|
6021
|
-
const ref = useAutoFocus();
|
|
6022
|
-
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] })] }));
|
|
6023
|
-
}
|
|
6024
|
-
function QuestRow({ item, n, just, gate, onStart, onDismiss, }) {
|
|
6025
|
-
const v = item.view;
|
|
6026
|
-
const ticked = v === "done" || v === "already";
|
|
6027
|
-
const cls = "eg-q-item" +
|
|
6028
|
-
(ticked ? " eg-done" : v === "next" ? " eg-next" : "") +
|
|
6029
|
-
(v === "locked" || v === "waiting" ? " eg-dim" : "") +
|
|
6030
|
-
(just ? " eg-just" : "");
|
|
6031
|
-
let meta = `ประมาณ ${item.minutes} นาที`;
|
|
6032
|
-
if (v === "done")
|
|
6033
|
-
meta = item.completedAt ? `เสร็จ ${formatThaiDateTime(item.completedAt)}` : "เสร็จแล้ว";
|
|
6034
|
-
else if (v === "already")
|
|
6035
|
-
meta = "ทำไว้แล้ว";
|
|
6036
|
-
else if (v === "skipped")
|
|
6037
|
-
meta = jsxRuntime.jsx("span", { children: "\u0E02\u0E49\u0E32\u0E21\u0E44\u0E27\u0E49" });
|
|
6038
|
-
else if (v === "waiting" && gate)
|
|
6039
|
-
meta = gate.pending;
|
|
6040
|
-
// Locked: what to do first gets its own line under the title, so the title
|
|
6041
|
-
// keeps the row's width and the button stays short.
|
|
6042
|
-
const lockHint = v === "locked" && item.lockedBy ? `ทำ ${item.lockedBy} ก่อน` : null;
|
|
6043
|
-
let button;
|
|
6044
|
-
if (v === "done")
|
|
6045
|
-
button = jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", onClick: () => onStart(item.key, true), children: "\u0E17\u0E33\u0E0B\u0E49\u0E33" });
|
|
6046
|
-
else if (v === "already")
|
|
6047
|
-
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" });
|
|
6048
|
-
else if (v === "waiting" && gate)
|
|
6049
|
-
button = (jsxRuntime.jsx("a", { className: "eg-btn eg-ghost", href: gate.href, style: { textDecoration: "none" }, children: gate.label }));
|
|
6050
|
-
else if (v === "locked")
|
|
6051
|
-
button = (jsxRuntime.jsx("button", { type: "button", className: "eg-btn eg-ghost", disabled: true, "aria-label": lockHint ? `เริ่ม (${lockHint})` : undefined, children: "\u0E40\u0E23\u0E34\u0E48\u0E21" }));
|
|
6052
|
-
else
|
|
6053
|
-
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" ? "ทำต่อ" : "เริ่ม" }));
|
|
6054
|
-
const dismissable = item.optional && (v === "todo" || v === "next");
|
|
6055
|
-
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 }), lockHint ? jsxRuntime.jsx("small", { className: "eg-lock", children: lockHint }) : null, 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)] }));
|
|
6056
|
-
}
|
|
6057
|
-
function GuideToast({ text }) {
|
|
6058
|
-
return (jsxRuntime.jsx("div", { className: "eg-toast", role: "status", children: text }));
|
|
6059
|
-
}
|
|
6060
|
-
|
|
6061
|
-
const EMPLOYEE_RECORD_REQUIRED = "EMPLOYEE_RECORD_REQUIRED";
|
|
6062
|
-
const EMPLOYEE_RECORD_REQUIRED_EVENT = "erp:employee-record-required";
|
|
6063
|
-
const EMPLOYEE_RECORD_OK_EVENT = "erp:employee-record-ok";
|
|
6064
|
-
/**
|
|
6065
|
-
* Shown when the API refuses every request because the signed-in account has
|
|
6066
|
-
* no employee record in this organisation.
|
|
6067
|
-
*
|
|
6068
|
-
* A gate rather than a banner. Leave, attendance, approvals and payslips all
|
|
6069
|
-
* identify a person by that record, so once the server started enforcing it
|
|
6070
|
-
* every call fails — and without something that says so, the app becomes a
|
|
6071
|
-
* screen of error toasts with no explanation and no way forward.
|
|
6072
|
-
*/
|
|
6073
|
-
function EmployeeRequiredGate({ platformBaseUrl, }) {
|
|
6074
|
-
const [blocked, setBlocked] = React.useState(false);
|
|
6075
|
-
const params = navigation.useParams();
|
|
6076
|
-
const code = params?.code ?? "";
|
|
6077
|
-
React.useEffect(() => {
|
|
6078
|
-
const onBlocked = () => setBlocked(true);
|
|
6079
|
-
const onOk = () => setBlocked(false);
|
|
6080
|
-
window.addEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
6081
|
-
window.addEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
6082
|
-
return () => {
|
|
6083
|
-
window.removeEventListener(EMPLOYEE_RECORD_REQUIRED_EVENT, onBlocked);
|
|
6084
|
-
window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, onOk);
|
|
6085
|
-
};
|
|
6086
|
-
}, []);
|
|
6087
|
-
if (!blocked)
|
|
6088
|
-
return null;
|
|
6089
|
-
const base = platformBaseUrl?.replace(/\/$/, "") ?? "";
|
|
6090
|
-
const createHref = code ? `${base}/${code}/employee/create` : `${base}/`;
|
|
6091
|
-
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" })] }) }));
|
|
6092
|
-
}
|
|
6093
|
-
|
|
6094
|
-
/* ------------------------------------------------------------------ */
|
|
6095
|
-
/* Fail-open wrappers */
|
|
6096
|
-
/* ------------------------------------------------------------------ */
|
|
6097
|
-
class GuideErrorBoundary extends React__namespace.Component {
|
|
6098
|
-
constructor() {
|
|
6099
|
-
super(...arguments);
|
|
6100
|
-
Object.defineProperty(this, "state", {
|
|
6101
|
-
enumerable: true,
|
|
6102
|
-
configurable: true,
|
|
6103
|
-
writable: true,
|
|
6104
|
-
value: { failed: false }
|
|
6105
|
-
});
|
|
6106
|
-
}
|
|
6107
|
-
static getDerivedStateFromError() {
|
|
6108
|
-
return { failed: true };
|
|
6109
|
-
}
|
|
6110
|
-
componentDidCatch(error) {
|
|
6111
|
-
if (typeof console !== "undefined")
|
|
6112
|
-
console.warn("[erp-shell guide] hidden after an error", error);
|
|
6113
|
-
try {
|
|
6114
|
-
publishGuideEntry(null, null);
|
|
6115
|
-
}
|
|
6116
|
-
catch {
|
|
6117
|
-
/* ignore */
|
|
6118
|
-
}
|
|
6119
|
-
}
|
|
6120
|
-
render() {
|
|
6121
|
-
return this.state.failed ? null : this.props.children;
|
|
6122
|
-
}
|
|
6123
|
-
}
|
|
6124
|
-
function GuideHost(props) {
|
|
6125
|
-
return (jsxRuntime.jsx(GuideErrorBoundary, { children: jsxRuntime.jsx(React.Suspense, { fallback: null, children: jsxRuntime.jsx(GuideHostInner, { ...props }) }) }));
|
|
6126
|
-
}
|
|
6127
|
-
let fallbackClient = null;
|
|
6128
|
-
function useShellQueryClient() {
|
|
6129
|
-
const provided = React.useContext(reactQuery.QueryClientContext);
|
|
6130
|
-
if (provided)
|
|
6131
|
-
return provided;
|
|
6132
|
-
fallbackClient ?? (fallbackClient = new reactQuery.QueryClient());
|
|
6133
|
-
return fallbackClient;
|
|
6134
|
-
}
|
|
6135
|
-
/* ------------------------------------------------------------------ */
|
|
6136
|
-
/* Session mirror (instant resume after F5) */
|
|
6137
|
-
/* ------------------------------------------------------------------ */
|
|
6138
|
-
const mirrorKey = (orgId, suite) => `erp-guide:${orgId}:${suite.key}`;
|
|
6139
|
-
function readMirror(orgId, suite) {
|
|
6140
|
-
try {
|
|
6141
|
-
const raw = sessionStorage.getItem(mirrorKey(orgId, suite));
|
|
6142
|
-
if (!raw)
|
|
6143
|
-
return null;
|
|
6144
|
-
const parsed = JSON.parse(raw);
|
|
6145
|
-
const row = parseRow(parsed);
|
|
6146
|
-
if (!row || row.guide_version !== suite.version)
|
|
6147
|
-
return null;
|
|
6148
|
-
return { ...row, server_status: parsed.server_status ?? null };
|
|
6149
|
-
}
|
|
6150
|
-
catch {
|
|
6151
|
-
return null;
|
|
6152
|
-
}
|
|
6153
|
-
}
|
|
6154
|
-
function writeMirror(orgId, suite, p) {
|
|
6155
|
-
try {
|
|
6156
|
-
sessionStorage.setItem(mirrorKey(orgId, suite), JSON.stringify(p));
|
|
6157
|
-
}
|
|
6158
|
-
catch {
|
|
6159
|
-
/* private mode: the server copy is enough */
|
|
6160
|
-
}
|
|
6161
|
-
}
|
|
6162
|
-
/* ------------------------------------------------------------------ */
|
|
6163
|
-
/* One tab at a time */
|
|
6164
|
-
/* ------------------------------------------------------------------ */
|
|
6165
|
-
/**
|
|
6166
|
-
* Whether this tab runs the tour. With two tabs open, the first holds a Web
|
|
6167
|
-
* Lock and the second shows nothing until the first goes away.
|
|
6168
|
-
*/
|
|
6169
|
-
function usePrimaryTab(name) {
|
|
6170
|
-
const [primary, setPrimary] = React.useState(false);
|
|
6171
|
-
React.useEffect(() => {
|
|
6172
|
-
const locks = (typeof navigator !== "undefined" ? navigator.locks : undefined);
|
|
6173
|
-
if (!locks?.request) {
|
|
6174
|
-
setPrimary(true);
|
|
6175
|
-
return;
|
|
6176
|
-
}
|
|
6177
|
-
const abort = new AbortController();
|
|
6178
|
-
let release = null;
|
|
6179
|
-
locks
|
|
6180
|
-
.request(name, { signal: abort.signal }, () => {
|
|
6181
|
-
setPrimary(true);
|
|
6182
|
-
return new Promise(resolve => {
|
|
6183
|
-
release = resolve;
|
|
6184
|
-
});
|
|
6185
|
-
})
|
|
6186
|
-
.catch(() => {
|
|
6187
|
-
/* aborted on unmount */
|
|
6188
|
-
});
|
|
6189
|
-
return () => {
|
|
6190
|
-
abort.abort();
|
|
6191
|
-
release?.();
|
|
6192
|
-
setPrimary(false);
|
|
6193
|
-
};
|
|
6194
|
-
}, [name]);
|
|
6195
|
-
return primary;
|
|
6196
|
-
}
|
|
6197
|
-
/* ------------------------------------------------------------------ */
|
|
6198
|
-
/* Putting the page back after a replay */
|
|
6199
|
-
/* ------------------------------------------------------------------ */
|
|
6200
|
-
const ABORT_DELAY_MS = 60;
|
|
6201
|
-
const ABORT_GAP_MS = 150;
|
|
6202
|
-
/**
|
|
6203
|
-
* Runs a replay's abort actions in order, once the tour has let go of the
|
|
6204
|
-
* page: press an anchor (passed over when it is not there), emit an app
|
|
6205
|
-
* event, or Escape to the open dialog (Radix listens on the document).
|
|
6206
|
-
*/
|
|
6207
|
-
function runAbort(actions) {
|
|
6208
|
-
actions.forEach((a, i) => {
|
|
6209
|
-
setTimeout(() => {
|
|
6210
|
-
try {
|
|
6211
|
-
if ("click" in a) {
|
|
6212
|
-
findAnchor(a.click)?.click();
|
|
6213
|
-
}
|
|
6214
|
-
else if ("event" in a) {
|
|
6215
|
-
emitGuideEvent(a.event);
|
|
6216
|
-
}
|
|
6217
|
-
else if ("escape" in a) {
|
|
6218
|
-
const t = document.activeElement ?? document.body;
|
|
6219
|
-
t.dispatchEvent(new KeyboardEvent("keydown", { key: "Escape", code: "Escape", bubbles: true, cancelable: true }));
|
|
6220
|
-
}
|
|
6221
|
-
}
|
|
6222
|
-
catch {
|
|
6223
|
-
/* never breaks the app */
|
|
6224
|
-
}
|
|
6225
|
-
}, ABORT_DELAY_MS + i * ABORT_GAP_MS);
|
|
6226
|
-
});
|
|
6227
|
-
}
|
|
6228
|
-
/** A step's own target takes presses: not on a "view" step, unless the step allows it. */
|
|
6229
|
-
function targetPressable(step, bridge) {
|
|
6230
|
-
if (!step || bridge)
|
|
6231
|
-
return true;
|
|
6232
|
-
if (stepKind(step) !== "view")
|
|
6233
|
-
return true;
|
|
6234
|
-
return !!step.target && (step.allow ?? []).includes(step.target);
|
|
6235
|
-
}
|
|
6236
|
-
/* ------------------------------------------------------------------ */
|
|
6237
|
-
/* Host */
|
|
6238
|
-
/* ------------------------------------------------------------------ */
|
|
6239
|
-
const TARGET_PAD = 6;
|
|
6240
|
-
const LOST_GRACE_MS = 350;
|
|
6241
|
-
const WAIT_MS = 8000;
|
|
6242
|
-
const WAIT_CAP_MS = 30000;
|
|
6243
|
-
const WELCOME_DELAY_MS = 500;
|
|
6244
|
-
const CELEBRATE_DELAY_MS = 450;
|
|
6245
|
-
const SAVE_DEBOUNCE_MS = 600;
|
|
3616
|
+
/** The shell's own queries (the tour's progress, the roles card) are not "the page loading". */
|
|
3617
|
+
const pageQuery = (q) => q.queryKey[0] !== "erp-shell";
|
|
6246
3618
|
function GuideHostInner({ config, organizationId, code }) {
|
|
6247
3619
|
const suite = config.suite;
|
|
6248
3620
|
const client = useShellQueryClient();
|
|
@@ -6251,8 +3623,6 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6251
3623
|
const search = searchParams?.toString() ?? "";
|
|
6252
3624
|
const router = navigation.useRouter();
|
|
6253
3625
|
const { open: sidebarOpen } = useSidebar();
|
|
6254
|
-
const fetching = reactQuery.useIsFetching({ predicate: q => q.queryKey[0] !== "erp-shell" }, client);
|
|
6255
|
-
const primary = usePrimaryTab(`erp-guide:${organizationId}:${suite.key}`);
|
|
6256
3626
|
// The shell's EmployeeRequiredGate is up: no tour over it.
|
|
6257
3627
|
const [employeeBlocked, setEmployeeBlocked] = React.useState(false);
|
|
6258
3628
|
React.useEffect(() => {
|
|
@@ -6265,811 +3635,26 @@ function GuideHostInner({ config, organizationId, code }) {
|
|
|
6265
3635
|
window.removeEventListener(EMPLOYEE_RECORD_OK_EVENT, off);
|
|
6266
3636
|
};
|
|
6267
3637
|
}, []);
|
|
6268
|
-
const
|
|
6269
|
-
|
|
6270
|
-
|
|
6271
|
-
|
|
6272
|
-
|
|
6273
|
-
|
|
6274
|
-
retry: false,
|
|
6275
|
-
refetchOnWindowFocus: false,
|
|
6276
|
-
}, client);
|
|
6277
|
-
const answer = query.data ?? null;
|
|
6278
|
-
const live = !!answer && answer.audience !== "none" && !answer.newerOnServer && primary && config.ready !== false && !employeeBlocked;
|
|
6279
|
-
/* ---------------- machine ---------------- */
|
|
6280
|
-
const [state, setState] = React.useState(null);
|
|
6281
|
-
const stateRef = React.useRef(null);
|
|
6282
|
-
// What the server holds (payloadKey), so a save that would change nothing
|
|
6283
|
-
// is not sent and does not bump its rev.
|
|
6284
|
-
const savedKey = React.useRef(null);
|
|
6285
|
-
React.useEffect(() => {
|
|
6286
|
-
if (!answer || answer.audience === "none" || answer.newerOnServer) {
|
|
6287
|
-
stateRef.current = null;
|
|
6288
|
-
setState(null);
|
|
6289
|
-
return;
|
|
6290
|
-
}
|
|
6291
|
-
if (stateRef.current)
|
|
6292
|
-
return;
|
|
6293
|
-
savedKey.current = answer.progress && answer.progress.rev > 0 ? payloadKey(answer.progress, suite.app) : null;
|
|
6294
|
-
// Resume from whichever copy is further along: the server's, or this
|
|
6295
|
-
// tab's session copy from before a refresh (its rev is the server's).
|
|
6296
|
-
const mirror = readMirror(organizationId, suite);
|
|
6297
|
-
let progress = answer.progress;
|
|
6298
|
-
if (mirror && furtherProgress(mirror, progress) === mirror)
|
|
6299
|
-
progress = mergeProgress(mirror, progress);
|
|
6300
|
-
const s = initialGuideState(answer.audience, progress);
|
|
6301
|
-
stateRef.current = s;
|
|
6302
|
-
setState(s);
|
|
6303
|
-
}, [answer, organizationId, suite]);
|
|
6304
|
-
// The latest page facts, read by the machine through env.
|
|
6305
|
-
const pageRef = React.useRef({ pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can });
|
|
6306
|
-
pageRef.current = { pathname, search, sidebarOpen, facts: config.facts ?? {}, can: config.can };
|
|
6307
|
-
const targetElRef = React.useRef(null);
|
|
6308
|
-
const env = React.useCallback(() => {
|
|
6309
|
-
const pg = pageRef.current;
|
|
3638
|
+
const isBusy = React.useCallback(() => client.isFetching({ predicate: pageQuery }) > 0, [client]);
|
|
3639
|
+
// platform-api, with the GET answer kept in React Query as before 0.1.68
|
|
3640
|
+
// (fresh for 5 minutes, so a remount does not ask again; a failure is
|
|
3641
|
+
// kept too, and shows nothing).
|
|
3642
|
+
const progress = React.useMemo(() => {
|
|
3643
|
+
const platform = platformGuideProgress(organizationId);
|
|
6310
3644
|
return {
|
|
6311
|
-
|
|
6312
|
-
|
|
6313
|
-
|
|
6314
|
-
|
|
6315
|
-
|
|
6316
|
-
|
|
6317
|
-
|
|
6318
|
-
|
|
6319
|
-
catch {
|
|
6320
|
-
return true;
|
|
6321
|
-
}
|
|
6322
|
-
},
|
|
6323
|
-
isVisible: (anchor, id) => anchorVisible(anchor, id),
|
|
6324
|
-
sidebarOpen: pg.sidebarOpen,
|
|
6325
|
-
targetValue: () => fieldValue(targetElRef.current),
|
|
6326
|
-
dialogOpen: () => appDialogOpen(),
|
|
6327
|
-
};
|
|
6328
|
-
}, [code]);
|
|
6329
|
-
/* ---------------- effects: save, announce, toast, navigate ---------------- */
|
|
6330
|
-
const [liveText, setLiveText] = React.useState("");
|
|
6331
|
-
const [toast, setToast] = React.useState(null);
|
|
6332
|
-
const toastTimer = React.useRef(null);
|
|
6333
|
-
const saveTimer = React.useRef(null);
|
|
6334
|
-
const saving = React.useRef(null);
|
|
6335
|
-
const savePending = React.useRef(false);
|
|
6336
|
-
const flushSave = React.useCallback(async () => {
|
|
6337
|
-
if (saveTimer.current) {
|
|
6338
|
-
clearTimeout(saveTimer.current);
|
|
6339
|
-
saveTimer.current = null;
|
|
6340
|
-
}
|
|
6341
|
-
if (saving.current) {
|
|
6342
|
-
savePending.current = true;
|
|
6343
|
-
return;
|
|
6344
|
-
}
|
|
6345
|
-
const run = async () => {
|
|
6346
|
-
do {
|
|
6347
|
-
savePending.current = false;
|
|
6348
|
-
const cur = stateRef.current;
|
|
6349
|
-
if (!cur)
|
|
6350
|
-
return;
|
|
6351
|
-
if (payloadKey(cur.progress, suite.app) === savedKey.current)
|
|
6352
|
-
continue;
|
|
6353
|
-
const row = await saveGuides(organizationId, suite, cur.progress);
|
|
6354
|
-
if (row)
|
|
6355
|
-
savedKey.current = payloadKey(row, suite.app);
|
|
6356
|
-
if (row && stateRef.current) {
|
|
6357
|
-
// eslint-disable-next-line @typescript-eslint/no-use-before-define
|
|
6358
|
-
dispatchRef.current({ type: "serverRow", progress: row });
|
|
6359
|
-
}
|
|
6360
|
-
} while (savePending.current);
|
|
6361
|
-
};
|
|
6362
|
-
saving.current = run().finally(() => {
|
|
6363
|
-
saving.current = null;
|
|
6364
|
-
});
|
|
6365
|
-
}, [organizationId, suite]);
|
|
6366
|
-
const runEffects = React.useCallback((effects) => {
|
|
6367
|
-
for (const e of effects) {
|
|
6368
|
-
try {
|
|
6369
|
-
if (e.type === "save") {
|
|
6370
|
-
if (stateRef.current)
|
|
6371
|
-
writeMirror(organizationId, suite, stateRef.current.progress);
|
|
6372
|
-
if (e.immediate)
|
|
6373
|
-
void flushSave();
|
|
6374
|
-
else {
|
|
6375
|
-
if (saveTimer.current)
|
|
6376
|
-
clearTimeout(saveTimer.current);
|
|
6377
|
-
saveTimer.current = setTimeout(() => void flushSave(), SAVE_DEBOUNCE_MS);
|
|
6378
|
-
}
|
|
6379
|
-
}
|
|
6380
|
-
else if (e.type === "announce") {
|
|
6381
|
-
setLiveText("");
|
|
6382
|
-
setTimeout(() => setLiveText(e.text), 60);
|
|
6383
|
-
}
|
|
6384
|
-
else if (e.type === "toast") {
|
|
6385
|
-
setToast(e.text);
|
|
6386
|
-
if (toastTimer.current)
|
|
6387
|
-
clearTimeout(toastTimer.current);
|
|
6388
|
-
toastTimer.current = setTimeout(() => setToast(null), 3200);
|
|
6389
|
-
}
|
|
6390
|
-
else if (e.type === "navigate") {
|
|
6391
|
-
if (/^https?:\/\//i.test(e.href))
|
|
6392
|
-
window.location.assign(e.href);
|
|
6393
|
-
else
|
|
6394
|
-
router.push(e.href);
|
|
6395
|
-
}
|
|
6396
|
-
else if (e.type === "abort") {
|
|
6397
|
-
// After this render takes the guard away, put the page back.
|
|
6398
|
-
runAbort(e.actions);
|
|
6399
|
-
}
|
|
6400
|
-
}
|
|
6401
|
-
catch {
|
|
6402
|
-
/* an effect never breaks the app */
|
|
6403
|
-
}
|
|
6404
|
-
}
|
|
6405
|
-
}, [flushSave, organizationId, router, suite]);
|
|
6406
|
-
const dispatch = React.useCallback((action) => {
|
|
6407
|
-
const cur = stateRef.current;
|
|
6408
|
-
if (!cur)
|
|
6409
|
-
return;
|
|
6410
|
-
let r;
|
|
6411
|
-
try {
|
|
6412
|
-
r = reduceGuide(cur, action, suite, env());
|
|
6413
|
-
}
|
|
6414
|
-
catch (err) {
|
|
6415
|
-
if (typeof console !== "undefined")
|
|
6416
|
-
console.warn("[erp-shell guide] stopped", err);
|
|
6417
|
-
stateRef.current = null;
|
|
6418
|
-
setState(null);
|
|
6419
|
-
return;
|
|
6420
|
-
}
|
|
6421
|
-
if (r.state !== cur) {
|
|
6422
|
-
stateRef.current = r.state;
|
|
6423
|
-
setState(r.state);
|
|
6424
|
-
}
|
|
6425
|
-
if (r.effects.length)
|
|
6426
|
-
runEffects(r.effects);
|
|
6427
|
-
}, [env, runEffects, suite]);
|
|
6428
|
-
const dispatchRef = React.useRef(dispatch);
|
|
6429
|
-
dispatchRef.current = dispatch;
|
|
6430
|
-
// Save what is pending when the page goes away.
|
|
6431
|
-
React.useEffect(() => {
|
|
6432
|
-
const flush = () => {
|
|
6433
|
-
if (saveTimer.current)
|
|
6434
|
-
void flushSave();
|
|
6435
|
-
};
|
|
6436
|
-
window.addEventListener("pagehide", flush);
|
|
6437
|
-
return () => {
|
|
6438
|
-
window.removeEventListener("pagehide", flush);
|
|
6439
|
-
flush();
|
|
6440
|
-
if (toastTimer.current)
|
|
6441
|
-
clearTimeout(toastTimer.current);
|
|
6442
|
-
};
|
|
6443
|
-
}, [flushSave]);
|
|
6444
|
-
/* ---------------- boot: resume, welcome ---------------- */
|
|
6445
|
-
const screen = state?.screen ?? { name: "idle" };
|
|
6446
|
-
const resumed = React.useRef(false);
|
|
6447
|
-
React.useEffect(() => {
|
|
6448
|
-
if (!live || !state || resumed.current)
|
|
6449
|
-
return;
|
|
6450
|
-
resumed.current = true;
|
|
6451
|
-
dispatch({ type: "resume" });
|
|
6452
|
-
}, [live, state, dispatch]);
|
|
6453
|
-
const welcomeRoutes = suite.welcomeRoutes ?? ["/[code]"];
|
|
6454
|
-
const onWelcomeRoute = welcomeRoutes.some(r => !!matchRoute(r, pathname, search, code));
|
|
6455
|
-
const due = !!state && live && welcomeDue(state, suite) && onWelcomeRoute && fetching === 0;
|
|
6456
|
-
const factsKey = useFactsKey(config.facts);
|
|
6457
|
-
React.useEffect(() => {
|
|
6458
|
-
if (!due)
|
|
6459
|
-
return;
|
|
6460
|
-
const t = setTimeout(() => dispatchRef.current({ type: "welcome" }), WELCOME_DELAY_MS);
|
|
6461
|
-
return () => clearTimeout(t);
|
|
6462
|
-
}, [due, factsKey]);
|
|
6463
|
-
/* ---------------- sync on route, sidebar, facts, DOM ---------------- */
|
|
6464
|
-
const inStep = live && screen.name === "step";
|
|
6465
|
-
React.useEffect(() => {
|
|
6466
|
-
if (inStep)
|
|
6467
|
-
dispatch({ type: "sync" });
|
|
6468
|
-
}, [inStep, pathname, search, sidebarOpen, factsKey, dispatch]);
|
|
6469
|
-
React.useEffect(() => {
|
|
6470
|
-
if (!inStep || typeof MutationObserver === "undefined")
|
|
6471
|
-
return;
|
|
6472
|
-
let raf = 0;
|
|
6473
|
-
const obs = new MutationObserver(records => {
|
|
6474
|
-
// Our own layer's changes are not the page's.
|
|
6475
|
-
if (records.every(r => inGuideLayer(r.target)))
|
|
6476
|
-
return;
|
|
6477
|
-
if (raf)
|
|
6478
|
-
return;
|
|
6479
|
-
raf = requestAnimationFrame(() => {
|
|
6480
|
-
raf = 0;
|
|
6481
|
-
dispatchRef.current({ type: "sync" });
|
|
6482
|
-
});
|
|
6483
|
-
});
|
|
6484
|
-
obs.observe(document.body, {
|
|
6485
|
-
childList: true,
|
|
6486
|
-
subtree: true,
|
|
6487
|
-
attributes: true,
|
|
6488
|
-
attributeFilter: ["data-state", "hidden", "aria-hidden", "class", "style", "open"],
|
|
6489
|
-
});
|
|
6490
|
-
return () => {
|
|
6491
|
-
obs.disconnect();
|
|
6492
|
-
if (raf)
|
|
6493
|
-
cancelAnimationFrame(raf);
|
|
6494
|
-
};
|
|
6495
|
-
}, [inStep]);
|
|
6496
|
-
/* ---------------- the current step ---------------- */
|
|
6497
|
-
const stepInfo = React.useMemo(() => {
|
|
6498
|
-
if (!state || screen.name !== "step")
|
|
6499
|
-
return null;
|
|
6500
|
-
const mission = findMission(suite, screen.mission);
|
|
6501
|
-
if (!mission)
|
|
6502
|
-
return null;
|
|
6503
|
-
const raw = mission.steps[screen.index];
|
|
6504
|
-
if (!raw)
|
|
6505
|
-
return null;
|
|
6506
|
-
// A commit step with nothing to save is shown as its read form.
|
|
6507
|
-
const step = shownStep(raw, screen.nothingToSave);
|
|
6508
|
-
const missionNo = suite.missions.indexOf(mission) + 1;
|
|
6509
|
-
return { mission, step, raw, missionNo };
|
|
6510
|
-
}, [state, screen, suite]);
|
|
6511
|
-
const stepCtx = React.useMemo(() => {
|
|
6512
|
-
if (!state)
|
|
6513
|
-
return null;
|
|
6514
|
-
const e = env();
|
|
6515
|
-
const params = stepInfo?.step.route ? (matchRoute(stepInfo.step.route, e.pathname, e.search, code) ?? {}) : {};
|
|
6516
|
-
return guideContext(e, state.progress.context, params);
|
|
6517
|
-
// pathname/search/factsKey make it current.
|
|
6518
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
6519
|
-
}, [state, stepInfo, env, code, pathname, search, factsKey]);
|
|
6520
|
-
const target = stepInfo && stepCtx && screen.name === "step"
|
|
6521
|
-
? spotlightTarget(stepInfo.step, stepCtx, screen.bridge)
|
|
6522
|
-
: null;
|
|
6523
|
-
const targetKey = target ? `${target.anchor}|${target.id ?? ""}` : "";
|
|
6524
|
-
const phase = screen.name === "step" ? screen.phase : null;
|
|
6525
|
-
const stepId = screen.name === "step" ? `${screen.mission}:${screen.index}:${screen.bridge ? "b" : ""}${screen.nothingToSave ? "n" : ""}` : "";
|
|
6526
|
-
/* Measure every frame while a step is shown: the spotlight follows
|
|
6527
|
-
scrolling, dialogs animating in, the drawer sliding, the keyboard. */
|
|
6528
|
-
const [layout, setLayout] = React.useState({
|
|
6529
|
-
spot: null,
|
|
6530
|
-
covered: false,
|
|
6531
|
-
holes: [],
|
|
6532
|
-
popovers: [],
|
|
6533
|
-
vw: 0,
|
|
6534
|
-
vh: 0,
|
|
6535
|
-
oy: 0,
|
|
6536
|
-
});
|
|
6537
|
-
React.useEffect(() => {
|
|
6538
|
-
if (!inStep) {
|
|
6539
|
-
targetElRef.current = null;
|
|
6540
|
-
return;
|
|
6541
|
-
}
|
|
6542
|
-
let raf = 0;
|
|
6543
|
-
let lostSince = null;
|
|
6544
|
-
let last = { spot: null, covered: false, holes: [], popovers: [], vw: 0, vh: 0, oy: 0 };
|
|
6545
|
-
targetElRef.current = target ? findAnchor(target.anchor, target.id) : null;
|
|
6546
|
-
const allow = stepInfo?.step.allow ?? [];
|
|
6547
|
-
// The spotlight is the part of the target a person can see: cut by the
|
|
6548
|
-
// scroll boxes around it (a row of tabs that scrolls sideways) and the
|
|
6549
|
-
// viewport. Wholly out of view (scrolled away up or down), its own box,
|
|
6550
|
-
// and the tour scrolls it back.
|
|
6551
|
-
let clipFor = null;
|
|
6552
|
-
let clips = [];
|
|
6553
|
-
const spotOf = (el) => {
|
|
6554
|
-
if (el !== clipFor) {
|
|
6555
|
-
clipFor = el;
|
|
6556
|
-
clips = clipAncestors(el);
|
|
6557
|
-
}
|
|
6558
|
-
return padRect(visibleRectOf(el, clips) ?? rectOf(el), TARGET_PAD);
|
|
6559
|
-
};
|
|
6560
|
-
const tick = () => {
|
|
6561
|
-
raf = requestAnimationFrame(tick);
|
|
6562
|
-
const vv = window.visualViewport;
|
|
6563
|
-
const vw = window.innerWidth;
|
|
6564
|
-
const vh = vv ? vv.height : window.innerHeight;
|
|
6565
|
-
const oy = vv ? vv.offsetTop : 0;
|
|
6566
|
-
let el = targetElRef.current;
|
|
6567
|
-
if (target && (!el || !el.isConnected)) {
|
|
6568
|
-
el = findAnchor(target.anchor, target.id);
|
|
6569
|
-
targetElRef.current = el;
|
|
6570
|
-
}
|
|
6571
|
-
let spot = null;
|
|
6572
|
-
if (target && el && isPresent(el)) {
|
|
6573
|
-
lostSince = null;
|
|
6574
|
-
spot = spotOf(el);
|
|
6575
|
-
}
|
|
6576
|
-
else if (target) {
|
|
6577
|
-
// Maybe re-rendered under another node.
|
|
6578
|
-
const again = findAnchor(target.anchor, target.id);
|
|
6579
|
-
if (again) {
|
|
6580
|
-
targetElRef.current = again;
|
|
6581
|
-
spot = spotOf(again);
|
|
6582
|
-
lostSince = null;
|
|
6583
|
-
}
|
|
6584
|
-
else if (phase === "ready") {
|
|
6585
|
-
lostSince ?? (lostSince = performance.now());
|
|
6586
|
-
if (performance.now() - lostSince > LOST_GRACE_MS) {
|
|
6587
|
-
lostSince = null;
|
|
6588
|
-
dispatchRef.current({ type: "targetLost" });
|
|
6589
|
-
}
|
|
6590
|
-
}
|
|
6591
|
-
}
|
|
6592
|
-
if (phase === "waiting" && spot)
|
|
6593
|
-
dispatchRef.current({ type: "sync" });
|
|
6594
|
-
const popovers = [];
|
|
6595
|
-
for (const p of openPoppers())
|
|
6596
|
-
popovers.push(padRect(rectOf(p), 2));
|
|
6597
|
-
const holes = [];
|
|
6598
|
-
for (const a of allow) {
|
|
6599
|
-
const ael = findAnchor(a);
|
|
6600
|
-
if (ael)
|
|
6601
|
-
holes.push(padRect(rectOf(ael), TARGET_PAD));
|
|
6602
|
-
}
|
|
6603
|
-
const covered = !!spot && coveredByDialog(targetElRef.current);
|
|
6604
|
-
const next = { spot, covered, holes, popovers, vw, vh, oy };
|
|
6605
|
-
const sameList = (a, b) => a.length === b.length && a.every((h, i) => sameRect(h, b[i]));
|
|
6606
|
-
if (!sameRect(next.spot, last.spot) ||
|
|
6607
|
-
next.covered !== last.covered ||
|
|
6608
|
-
next.vw !== last.vw ||
|
|
6609
|
-
next.vh !== last.vh ||
|
|
6610
|
-
next.oy !== last.oy ||
|
|
6611
|
-
!sameList(next.holes, last.holes) ||
|
|
6612
|
-
!sameList(next.popovers, last.popovers)) {
|
|
6613
|
-
last = next;
|
|
6614
|
-
setLayout(next);
|
|
6615
|
-
}
|
|
6616
|
-
};
|
|
6617
|
-
raf = requestAnimationFrame(tick);
|
|
6618
|
-
return () => cancelAnimationFrame(raf);
|
|
6619
|
-
// stepId/targetKey/phase restart the loop for a new step.
|
|
6620
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
6621
|
-
}, [inStep, stepId, targetKey, phase]);
|
|
6622
|
-
/* Waiting for the target: 8 s of not-loading time (30 s at most). */
|
|
6623
|
-
const fetchingRef = React.useRef(fetching);
|
|
6624
|
-
fetchingRef.current = fetching;
|
|
6625
|
-
React.useEffect(() => {
|
|
6626
|
-
if (!inStep || phase !== "waiting")
|
|
6627
|
-
return;
|
|
6628
|
-
let idle = 0;
|
|
6629
|
-
const started = Date.now();
|
|
6630
|
-
const timeout = stepInfo?.step.timeoutMs ?? WAIT_MS;
|
|
6631
|
-
const iv = setInterval(() => {
|
|
6632
|
-
if (fetchingRef.current === 0 && document.visibilityState !== "hidden")
|
|
6633
|
-
idle += 250;
|
|
6634
|
-
if (idle >= timeout || Date.now() - started >= WAIT_CAP_MS) {
|
|
6635
|
-
clearInterval(iv);
|
|
6636
|
-
dispatchRef.current({ type: "timeout" });
|
|
6637
|
-
}
|
|
6638
|
-
}, 250);
|
|
6639
|
-
return () => clearInterval(iv);
|
|
6640
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
6641
|
-
}, [inStep, stepId, phase, screen.name === "step" ? screen.misses : 0]);
|
|
6642
|
-
/* On a step becoming ready: bring the target into view, focus, describe. */
|
|
6643
|
-
const readyKey = inStep && phase === "ready" ? stepId : "";
|
|
6644
|
-
React.useEffect(() => {
|
|
6645
|
-
if (!readyKey || !stepInfo || screen.name !== "step")
|
|
6646
|
-
return;
|
|
6647
|
-
const el = targetElRef.current ?? (target ? findAnchor(target.anchor, target.id) : null);
|
|
6648
|
-
if (el)
|
|
6649
|
-
revealTarget(el);
|
|
6650
|
-
const isInput = !screen.bridge && stepKind(stepInfo.step) === "input";
|
|
6651
|
-
const t = setTimeout(() => {
|
|
6652
|
-
if (isInput && el) {
|
|
6653
|
-
const f = fieldOf(el);
|
|
6654
|
-
f?.focus({ preventScroll: true });
|
|
6655
|
-
}
|
|
6656
|
-
else {
|
|
6657
|
-
const b = document.querySelector(`[${GUIDE_LAYER_ATTRIBUTE}] [data-eg-bubble]`);
|
|
6658
|
-
b?.focus({ preventScroll: true });
|
|
6659
|
-
}
|
|
6660
|
-
}, 30);
|
|
6661
|
-
// The target is described by the bubble while the step lasts.
|
|
6662
|
-
const described = el ? (isInput ? fieldOf(el) : el) : null;
|
|
6663
|
-
const prev = described?.getAttribute("aria-describedby") ?? null;
|
|
6664
|
-
if (described)
|
|
6665
|
-
described.setAttribute("aria-describedby", [BUBBLE_TITLE_ID, BUBBLE_WHY_ID, prev].filter(Boolean).join(" "));
|
|
6666
|
-
return () => {
|
|
6667
|
-
clearTimeout(t);
|
|
6668
|
-
if (!described)
|
|
6669
|
-
return;
|
|
6670
|
-
if (prev)
|
|
6671
|
-
described.setAttribute("aria-describedby", prev);
|
|
6672
|
-
else
|
|
6673
|
-
described.removeAttribute("aria-describedby");
|
|
6674
|
-
};
|
|
6675
|
-
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
6676
|
-
}, [readyKey]);
|
|
6677
|
-
/* Signals from the page: API answers, app events, presses, typing. */
|
|
6678
|
-
React.useEffect(() => {
|
|
6679
|
-
if (!inStep || !stepInfo)
|
|
6680
|
-
return;
|
|
6681
|
-
const onApi = (e) => {
|
|
6682
|
-
const d = e.detail;
|
|
6683
|
-
if (!d || typeof d.status !== "number")
|
|
6684
|
-
return;
|
|
6685
|
-
dispatchRef.current({ type: "signal", signal: { kind: "api", method: d.method, path: d.path, status: d.status, id: d.id, message: d.message } });
|
|
6686
|
-
};
|
|
6687
|
-
window.addEventListener(API_EVENT, onApi);
|
|
6688
|
-
const names = triggersOf(stepInfo.raw).flatMap(t => ("event" in t ? [t.event] : []));
|
|
6689
|
-
const onApp = (name) => (e) => dispatchRef.current({ type: "signal", signal: { kind: "event", name, detail: e.detail ?? undefined } });
|
|
6690
|
-
const appHandlers = names.map(n => [GUIDE_EVENT_PREFIX + n, onApp(n)]);
|
|
6691
|
-
appHandlers.forEach(([n, h]) => window.addEventListener(n, h));
|
|
6692
|
-
const inTarget = (node) => {
|
|
6693
|
-
const el = targetElRef.current;
|
|
6694
|
-
return !!el && node instanceof Node && el.contains(node);
|
|
6695
|
-
};
|
|
6696
|
-
// After the app has handled it, so what it does comes first.
|
|
6697
|
-
const later = (a) => setTimeout(() => dispatchRef.current(a), 0);
|
|
6698
|
-
const onPress = (e) => {
|
|
6699
|
-
if (inTarget(e.target))
|
|
6700
|
-
later({ type: "signal", signal: { kind: "activate" } });
|
|
6701
|
-
};
|
|
6702
|
-
const onKey = (e) => {
|
|
6703
|
-
if ((e.key === "Enter" || e.key === " ") && inTarget(e.target) && stepKind(stepInfo.step) !== "input") {
|
|
6704
|
-
later({ type: "signal", signal: { kind: "activate" } });
|
|
6705
|
-
}
|
|
6706
|
-
};
|
|
6707
|
-
const onInput = (e) => {
|
|
6708
|
-
if (!inTarget(e.target))
|
|
6709
|
-
return;
|
|
6710
|
-
dispatchRef.current({ type: "signal", signal: { kind: "typing" } });
|
|
6711
|
-
later({ type: "signal", signal: { kind: "value", value: fieldValue(targetElRef.current) } });
|
|
6712
|
-
};
|
|
6713
|
-
document.addEventListener("pointerup", onPress, true);
|
|
6714
|
-
document.addEventListener("keydown", onKey, true);
|
|
6715
|
-
document.addEventListener("input", onInput, true);
|
|
6716
|
-
document.addEventListener("change", onInput, true);
|
|
6717
|
-
return () => {
|
|
6718
|
-
window.removeEventListener(API_EVENT, onApi);
|
|
6719
|
-
appHandlers.forEach(([n, h]) => window.removeEventListener(n, h));
|
|
6720
|
-
document.removeEventListener("pointerup", onPress, true);
|
|
6721
|
-
document.removeEventListener("keydown", onKey, true);
|
|
6722
|
-
document.removeEventListener("input", onInput, true);
|
|
6723
|
-
document.removeEventListener("change", onInput, true);
|
|
6724
|
-
};
|
|
6725
|
-
}, [inStep, stepInfo]);
|
|
6726
|
-
/* ---------------- the guard ---------------- */
|
|
6727
|
-
const modalScreen = live && (screen.name === "welcome" || screen.name === "gate" || screen.name === "celebrate" || screen.name === "complete");
|
|
6728
|
-
const panelOpen = live && !!state?.panelOpen;
|
|
6729
|
-
const guarding = inStep || modalScreen;
|
|
6730
|
-
const guardingRef = React.useRef(guarding);
|
|
6731
|
-
guardingRef.current = guarding;
|
|
6732
|
-
const modalRef = React.useRef(false);
|
|
6733
|
-
modalRef.current = modalScreen || (screen.name === "step" && (screen.confirmExit || screen.phase !== "ready"));
|
|
6734
|
-
const stepInfoRef = React.useRef(stepInfo);
|
|
6735
|
-
stepInfoRef.current = stepInfo;
|
|
6736
|
-
React.useEffect(() => {
|
|
6737
|
-
if (!live)
|
|
6738
|
-
return;
|
|
6739
|
-
const allowedTarget = (node) => {
|
|
6740
|
-
if (!(node instanceof Node))
|
|
6741
|
-
return false;
|
|
6742
|
-
const el = targetElRef.current;
|
|
6743
|
-
// A "view" step's target is there to be read: ถัดไป is the way on.
|
|
6744
|
-
const sc = stateRef.current?.screen;
|
|
6745
|
-
const pressable = targetPressable(stepInfoRef.current?.step, sc?.name === "step" && sc.bridge);
|
|
6746
|
-
if (el && pressable && el.contains(node))
|
|
6747
|
-
return true;
|
|
6748
|
-
const elNode = node instanceof Element ? node : node.parentElement;
|
|
6749
|
-
if (!elNode)
|
|
6750
|
-
return false;
|
|
6751
|
-
if (elNode.closest(POPPER_SELECTOR) || elNode.closest(TOASTER_SELECTOR))
|
|
6752
|
-
return true;
|
|
6753
|
-
for (const a of stepInfoRef.current?.step.allow ?? []) {
|
|
6754
|
-
const ael = findAnchor(a);
|
|
6755
|
-
if (ael && ael.contains(node))
|
|
6756
|
-
return true;
|
|
6757
|
-
}
|
|
6758
|
-
return false;
|
|
6759
|
-
};
|
|
6760
|
-
const onPointer = (e) => {
|
|
6761
|
-
const t = e.target;
|
|
6762
|
-
if (inGuideLayer(t)) {
|
|
6763
|
-
// Our dim layer eats the press (and keeps focus where it is).
|
|
6764
|
-
if (t instanceof Element && t.closest("[data-eg-block]")) {
|
|
6765
|
-
e.preventDefault();
|
|
6766
|
-
if (e.type === "pointerdown")
|
|
6767
|
-
dispatchRef.current({ type: "blocked" });
|
|
6768
|
-
}
|
|
6769
|
-
// The app's open dialog or menu never hears the press (the click
|
|
6770
|
-
// itself goes on to our buttons).
|
|
6771
|
-
if (e.type !== "click")
|
|
6772
|
-
e.stopPropagation();
|
|
6773
|
-
return;
|
|
6774
|
-
}
|
|
6775
|
-
if (!guardingRef.current)
|
|
6776
|
-
return;
|
|
6777
|
-
if (modalRef.current || !allowedTarget(t)) {
|
|
6778
|
-
e.preventDefault();
|
|
6779
|
-
e.stopPropagation();
|
|
6780
|
-
if (e.type === "pointerdown")
|
|
6781
|
-
dispatchRef.current({ type: "blocked" });
|
|
6782
|
-
}
|
|
6783
|
-
};
|
|
6784
|
-
// Radix FocusScope pulls focus back into an open dialog; DismissableLayer
|
|
6785
|
-
// closes on focus outside. Neither may see focus moving to the tour.
|
|
6786
|
-
const onFocusIn = (e) => {
|
|
6787
|
-
if (inGuideLayer(e.target))
|
|
6788
|
-
e.stopPropagation();
|
|
6789
|
-
};
|
|
6790
|
-
const onFocusOut = (e) => {
|
|
6791
|
-
if (inGuideLayer(e.relatedTarget))
|
|
6792
|
-
e.stopPropagation();
|
|
6793
|
-
};
|
|
6794
|
-
const onKey = (e) => {
|
|
6795
|
-
const s = stateRef.current;
|
|
6796
|
-
if (!s)
|
|
6797
|
-
return;
|
|
6798
|
-
const sc = s.screen;
|
|
6799
|
-
const stop = () => {
|
|
6800
|
-
e.preventDefault();
|
|
6801
|
-
e.stopImmediatePropagation();
|
|
6802
|
-
};
|
|
6803
|
-
if (e.key === "Escape") {
|
|
6804
|
-
if (sc.name === "step") {
|
|
6805
|
-
stop();
|
|
6806
|
-
if (sc.confirmExit)
|
|
6807
|
-
dispatchRef.current({ type: "exitCancel" });
|
|
6808
|
-
else
|
|
6809
|
-
dispatchRef.current({ type: "exit" });
|
|
6810
|
-
}
|
|
6811
|
-
else if (sc.name === "welcome") {
|
|
6812
|
-
stop();
|
|
6813
|
-
dispatchRef.current({ type: sc.confirm ? "welcomeSkipCancel" : "welcomeSkip" });
|
|
6814
|
-
}
|
|
6815
|
-
else if (sc.name === "gate") {
|
|
6816
|
-
stop();
|
|
6817
|
-
dispatchRef.current({ type: "gateLater" });
|
|
6818
|
-
}
|
|
6819
|
-
else if (sc.name === "celebrate") {
|
|
6820
|
-
stop();
|
|
6821
|
-
dispatchRef.current({ type: "rest" });
|
|
6822
|
-
}
|
|
6823
|
-
else if (sc.name === "complete") {
|
|
6824
|
-
stop();
|
|
6825
|
-
dispatchRef.current({ type: "finish" });
|
|
6826
|
-
}
|
|
6827
|
-
else if (s.panelOpen) {
|
|
6828
|
-
stop();
|
|
6829
|
-
dispatchRef.current({ type: "closePanel" });
|
|
6830
|
-
}
|
|
6831
|
-
return;
|
|
6832
|
-
}
|
|
6833
|
-
if (!guardingRef.current)
|
|
6834
|
-
return;
|
|
6835
|
-
const layer = layerRef.current;
|
|
6836
|
-
if (e.key === "Tab") {
|
|
6837
|
-
// Tab stays on what the step is about: the target and the bubble, or
|
|
6838
|
-
// the open card.
|
|
6839
|
-
const modals = layer ? Array.from(layer.querySelectorAll("[data-eg-modal],[data-eg-card]")) : [];
|
|
6840
|
-
let list;
|
|
6841
|
-
if (sc.name === "step" && !sc.confirmExit && sc.phase === "ready") {
|
|
6842
|
-
const el = targetElRef.current;
|
|
6843
|
-
const step = stepInfoRef.current?.step;
|
|
6844
|
-
const isInput = !!step && !sc.bridge && stepKind(step) === "input";
|
|
6845
|
-
const inTargetList = el && targetPressable(step, sc.bridge) ? focusablesIn(isInput ? (fieldOf(el) ?? el) : el) : [];
|
|
6846
|
-
const bubble = layer?.querySelector("[data-eg-bubble]") ?? null;
|
|
6847
|
-
list = [...inTargetList, ...(bubble ? Array.from(bubble.querySelectorAll(FOCUSABLE)) : [])];
|
|
6848
|
-
for (const p of openPoppers())
|
|
6849
|
-
list.push(...focusablesIn(p));
|
|
6850
|
-
}
|
|
6851
|
-
else {
|
|
6852
|
-
const last = modals[modals.length - 1] ?? null;
|
|
6853
|
-
list = last ? Array.from(last.querySelectorAll(FOCUSABLE)) : [];
|
|
6854
|
-
}
|
|
6855
|
-
if (!list.length)
|
|
6856
|
-
return;
|
|
6857
|
-
stop();
|
|
6858
|
-
const i = list.indexOf(document.activeElement);
|
|
6859
|
-
const n = e.shiftKey ? (i <= 0 ? list.length - 1 : i - 1) : i === -1 || i === list.length - 1 ? 0 : i + 1;
|
|
6860
|
-
list[n].focus();
|
|
6861
|
-
return;
|
|
6862
|
-
}
|
|
6863
|
-
if (e.key === "Enter" || e.key === " ") {
|
|
6864
|
-
const active = document.activeElement;
|
|
6865
|
-
if (inGuideLayer(active))
|
|
6866
|
-
return;
|
|
6867
|
-
if (sc.name === "step" && sc.phase === "ready" && !sc.confirmExit && e.key === "Enter") {
|
|
6868
|
-
const step = stepInfoRef.current?.step;
|
|
6869
|
-
const el = targetElRef.current;
|
|
6870
|
-
if (step && stepKind(step) === "input" && el && active instanceof Node && el.contains(active) && !(active instanceof HTMLTextAreaElement)) {
|
|
6871
|
-
// During the tour Enter is ถัดไป, not "submit the form".
|
|
6872
|
-
stop();
|
|
6873
|
-
dispatchRef.current({ type: "next" });
|
|
6874
|
-
return;
|
|
6875
|
-
}
|
|
6876
|
-
}
|
|
6877
|
-
if (modalRef.current || !allowedTarget(active))
|
|
6878
|
-
stop();
|
|
6879
|
-
}
|
|
6880
|
-
};
|
|
6881
|
-
window.addEventListener("pointerdown", onPointer, true);
|
|
6882
|
-
window.addEventListener("mousedown", onPointer, true);
|
|
6883
|
-
window.addEventListener("touchstart", onPointer, { capture: true, passive: false });
|
|
6884
|
-
window.addEventListener("click", onPointer, true);
|
|
6885
|
-
window.addEventListener("focusin", onFocusIn, true);
|
|
6886
|
-
window.addEventListener("focusout", onFocusOut, true);
|
|
6887
|
-
window.addEventListener("keydown", onKey, true);
|
|
6888
|
-
return () => {
|
|
6889
|
-
window.removeEventListener("pointerdown", onPointer, true);
|
|
6890
|
-
window.removeEventListener("mousedown", onPointer, true);
|
|
6891
|
-
window.removeEventListener("touchstart", onPointer, true);
|
|
6892
|
-
window.removeEventListener("click", onPointer, true);
|
|
6893
|
-
window.removeEventListener("focusin", onFocusIn, true);
|
|
6894
|
-
window.removeEventListener("focusout", onFocusOut, true);
|
|
6895
|
-
window.removeEventListener("keydown", onKey, true);
|
|
6896
|
-
};
|
|
6897
|
-
}, [live]);
|
|
6898
|
-
/* Focus comes back to where it was when the tour lets go. */
|
|
6899
|
-
const lastFocus = React.useRef(null);
|
|
6900
|
-
const engaged = guarding || panelOpen;
|
|
6901
|
-
React.useLayoutEffect(() => {
|
|
6902
|
-
if (engaged) {
|
|
6903
|
-
if (!lastFocus.current) {
|
|
6904
|
-
const a = document.activeElement;
|
|
6905
|
-
lastFocus.current = a instanceof HTMLElement && !inGuideLayer(a) ? a : null;
|
|
6906
|
-
}
|
|
6907
|
-
return;
|
|
6908
|
-
}
|
|
6909
|
-
const back = lastFocus.current;
|
|
6910
|
-
lastFocus.current = null;
|
|
6911
|
-
if (back && back.isConnected)
|
|
6912
|
-
back.focus({ preventScroll: true });
|
|
6913
|
-
}, [engaged]);
|
|
6914
|
-
/* ---------------- the layer ---------------- */
|
|
6915
|
-
const [layer, setLayer] = React.useState(null);
|
|
6916
|
-
const layerRef = React.useRef(null);
|
|
6917
|
-
React.useEffect(() => {
|
|
6918
|
-
const el = document.createElement("div");
|
|
6919
|
-
el.setAttribute(GUIDE_LAYER_ATTRIBUTE, "");
|
|
6920
|
-
document.body.appendChild(el);
|
|
6921
|
-
layerRef.current = el;
|
|
6922
|
-
setLayer(el);
|
|
6923
|
-
// A Radix modal (dialog, select, menu) hides everything outside it from
|
|
6924
|
-
// screen readers with aria-hidden's hideOthers. It keeps [aria-live]
|
|
6925
|
-
// elements but walks into their ancestors and hides the other children:
|
|
6926
|
-
// with the status line live, the bubble and its buttons were hidden.
|
|
6927
|
-
// Take off what it adds, at once. hideOthers marks what it hides with
|
|
6928
|
-
// data-aria-hidden (the layer never sets that, or inert); aria-hidden goes
|
|
6929
|
-
// only where it was just added, so the layer's own decorative aria-hidden
|
|
6930
|
-
// stays. (aria-live="off" on the layer would make hideOthers skip it, but
|
|
6931
|
-
// it changed what test tools match by text.)
|
|
6932
|
-
const obs = new MutationObserver(records => {
|
|
6933
|
-
for (const r of records) {
|
|
6934
|
-
const n = r.target;
|
|
6935
|
-
if (!(n instanceof Element))
|
|
6936
|
-
continue;
|
|
6937
|
-
if (r.attributeName === "inert" && n.hasAttribute("inert"))
|
|
6938
|
-
n.removeAttribute("inert");
|
|
6939
|
-
else if (r.attributeName === "data-aria-hidden" && n.hasAttribute("data-aria-hidden"))
|
|
6940
|
-
n.removeAttribute("data-aria-hidden");
|
|
6941
|
-
else if (r.attributeName === "aria-hidden" &&
|
|
6942
|
-
n.getAttribute("aria-hidden") === "true" &&
|
|
6943
|
-
(n === el || r.oldValue === null)) {
|
|
6944
|
-
n.removeAttribute("aria-hidden");
|
|
6945
|
-
}
|
|
6946
|
-
}
|
|
6947
|
-
});
|
|
6948
|
-
obs.observe(el, { attributes: true, attributeOldValue: true, subtree: true, attributeFilter: ["aria-hidden", "data-aria-hidden", "inert"] });
|
|
6949
|
-
return () => {
|
|
6950
|
-
obs.disconnect();
|
|
6951
|
-
el.remove();
|
|
6952
|
-
layerRef.current = null;
|
|
6953
|
-
};
|
|
6954
|
-
}, []);
|
|
6955
|
-
const [phone, setPhone] = React.useState(false);
|
|
6956
|
-
const [reduced, setReduced] = React.useState(false);
|
|
6957
|
-
React.useEffect(() => {
|
|
6958
|
-
const sync = () => {
|
|
6959
|
-
setPhone(isPhoneWidth());
|
|
6960
|
-
setReduced(prefersReducedMotion());
|
|
3645
|
+
fetchProgress: req => client.fetchQuery({
|
|
3646
|
+
queryKey: ["erp-shell", "guides", organizationId, suite.app, suite.key, suite.version],
|
|
3647
|
+
queryFn: ({ signal }) => platform.fetchProgress({ ...req, signal }).catch(() => null),
|
|
3648
|
+
staleTime: 5 * 60000,
|
|
3649
|
+
gcTime: 30 * 60000,
|
|
3650
|
+
retry: false,
|
|
3651
|
+
}),
|
|
3652
|
+
saveProgress: req => platform.saveProgress(req),
|
|
6961
3653
|
};
|
|
6962
|
-
|
|
6963
|
-
|
|
6964
|
-
|
|
6965
|
-
|
|
6966
|
-
// The celebration waits a beat, so the new row can be seen first.
|
|
6967
|
-
const [celebrateShown, setCelebrateShown] = React.useState(false);
|
|
6968
|
-
const celebrateKey = screen.name === "celebrate" || screen.name === "complete" ? screen.name + (screen.name === "celebrate" ? screen.mission : "") : "";
|
|
6969
|
-
React.useEffect(() => {
|
|
6970
|
-
setCelebrateShown(false);
|
|
6971
|
-
if (!celebrateKey)
|
|
6972
|
-
return;
|
|
6973
|
-
const t = setTimeout(() => setCelebrateShown(true), CELEBRATE_DELAY_MS);
|
|
6974
|
-
return () => clearTimeout(t);
|
|
6975
|
-
}, [celebrateKey]);
|
|
6976
|
-
// Ring moves smoothly only right after a step change.
|
|
6977
|
-
const [moving, setMoving] = React.useState(false);
|
|
6978
|
-
React.useEffect(() => {
|
|
6979
|
-
if (!stepId)
|
|
6980
|
-
return;
|
|
6981
|
-
setMoving(true);
|
|
6982
|
-
const t = setTimeout(() => setMoving(false), 320);
|
|
6983
|
-
return () => clearTimeout(t);
|
|
6984
|
-
}, [stepId]);
|
|
6985
|
-
/* ---------------- the "?" menu entry ---------------- */
|
|
6986
|
-
const ctxIdle = React.useMemo(() => (state ? guideContext(env(), state.progress.context) : null), [state, env, factsKey, pathname]); // eslint-disable-line react-hooks/exhaustive-deps
|
|
6987
|
-
const done = state && ctxIdle ? settledCount(suite, state.progress, ctxIdle) : 0;
|
|
6988
|
-
const total = suite.missions.length;
|
|
6989
|
-
React.useEffect(() => {
|
|
6990
|
-
if (!live || !state) {
|
|
6991
|
-
publishGuideEntry(null, null);
|
|
6992
|
-
return;
|
|
6993
|
-
}
|
|
6994
|
-
publishGuideEntry({ done, total }, () => dispatchRef.current({ type: "openPanel" }));
|
|
6995
|
-
}, [live, state, done, total]);
|
|
6996
|
-
React.useEffect(() => () => publishGuideEntry(null, null), []);
|
|
6997
|
-
if (!live || !state || !layer || !ctxIdle)
|
|
6998
|
-
return null;
|
|
6999
|
-
/* ---------------- render ---------------- */
|
|
7000
|
-
const views = missionViews(suite, state.progress, ctxIdle);
|
|
7001
|
-
const gate = suite.gate;
|
|
7002
|
-
const gateHref = gate ? resolveText(gate.action.href, ctxIdle) : "";
|
|
7003
|
-
const gateFailing = gate ? gateState(suite, ctxIdle) === false : false;
|
|
7004
|
-
let content = null;
|
|
7005
|
-
if (screen.name === "welcome") {
|
|
7006
|
-
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" }) }));
|
|
7007
|
-
}
|
|
7008
|
-
else if (screen.name === "gate" && gate) {
|
|
7009
|
-
content = (jsxRuntime.jsx(GateModal, { title: gate.title, subtitle: gate.subtitle, actionLabel: gate.action.label, href: gateHref, onLater: () => dispatch({ type: "gateLater" }) }));
|
|
7010
|
-
}
|
|
7011
|
-
else if (screen.name === "step" && stepInfo && stepCtx) {
|
|
7012
|
-
const { mission, step, missionNo } = stepInfo;
|
|
7013
|
-
const kind = stepKind(step);
|
|
7014
|
-
const bridge = screen.bridge;
|
|
7015
|
-
content = (jsxRuntime.jsx(StepLayer, { spot: layout.spot, holes: layout.holes, popovers: layout.popovers, covered: layout.covered, viewport: { width: layout.vw || window.innerWidth, height: layout.vh || window.innerHeight, offsetTop: layout.oy }, phone: phone, showHand: bridge || (kind !== "input" && kind !== "view"), bubble: {
|
|
7016
|
-
missionNo,
|
|
7017
|
-
missionTitle: mission.title,
|
|
7018
|
-
replay: screen.replay,
|
|
7019
|
-
index: screen.index,
|
|
7020
|
-
total: mission.steps.length,
|
|
7021
|
-
title: bridge ? "กดปุ่มเมนู" : resolveText(step.title, stepCtx),
|
|
7022
|
-
why: bridge ? `เมนูทั้งหมดของ ${suite.appName} อยู่ในนี้` : resolveText(step.why, stepCtx),
|
|
7023
|
-
error: screen.error,
|
|
7024
|
-
showNext: !bridge && (kind === "input" || kind === "view"),
|
|
7025
|
-
showEndWithoutSave: !bridge && screen.replay && kind === "commit",
|
|
7026
|
-
}, hasTarget: bridge || !!step.target, targetPressable: targetPressable(step, bridge), 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" }) }));
|
|
7027
|
-
}
|
|
7028
|
-
else if (screen.name === "celebrate" && celebrateShown) {
|
|
7029
|
-
const mission = findMission(suite, screen.mission);
|
|
7030
|
-
const next = findMission(suite, nextMissionKey(suite, state.progress, ctxIdle));
|
|
7031
|
-
if (mission) {
|
|
7032
|
-
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" }) }));
|
|
7033
|
-
}
|
|
7034
|
-
}
|
|
7035
|
-
else if (screen.name === "complete" && celebrateShown) {
|
|
7036
|
-
const secondary = suite.complete.secondary;
|
|
7037
|
-
let secondaryHref;
|
|
7038
|
-
try {
|
|
7039
|
-
secondaryHref = secondary ? (typeof secondary.href === "function" ? secondary.href(ctxIdle) : secondary.href) : undefined;
|
|
7040
|
-
}
|
|
7041
|
-
catch {
|
|
7042
|
-
secondaryHref = undefined;
|
|
7043
|
-
}
|
|
7044
|
-
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" }) }));
|
|
7045
|
-
}
|
|
7046
|
-
const items = views.map(v => ({
|
|
7047
|
-
key: v.mission.key,
|
|
7048
|
-
title: v.mission.title,
|
|
7049
|
-
minutes: v.mission.minutes,
|
|
7050
|
-
view: v.view,
|
|
7051
|
-
optional: !!v.mission.optional,
|
|
7052
|
-
completedAt: v.entry?.completed_at ?? null,
|
|
7053
|
-
lockedBy: v.view === "locked"
|
|
7054
|
-
? (v.mission.after ?? [])
|
|
7055
|
-
.map(k => findMission(suite, k)?.title)
|
|
7056
|
-
.filter(Boolean)
|
|
7057
|
-
.join(", ")
|
|
7058
|
-
: undefined,
|
|
7059
|
-
}));
|
|
7060
|
-
const showChip = chipVisible(state, suite, ctxIdle);
|
|
7061
|
-
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);
|
|
7062
|
-
}
|
|
7063
|
-
/** A stable key for the facts object, so effects re-run when they change. */
|
|
7064
|
-
function useFactsKey(facts) {
|
|
7065
|
-
return React.useMemo(() => {
|
|
7066
|
-
try {
|
|
7067
|
-
return JSON.stringify(facts ?? {});
|
|
7068
|
-
}
|
|
7069
|
-
catch {
|
|
7070
|
-
return String(Math.random());
|
|
7071
|
-
}
|
|
7072
|
-
}, [facts]);
|
|
3654
|
+
}, [client, organizationId, suite.app, suite.key, suite.version]);
|
|
3655
|
+
const routing = React.useMemo(() => ({ pathname, search, navigate: (href) => router.push(href) }), [pathname, search, router]);
|
|
3656
|
+
const menu = React.useMemo(() => ({ open: sidebarOpen }), [sidebarOpen]);
|
|
3657
|
+
return (jsxRuntime.jsx(guideRuntime.GuideRuntime, { config: config, organizationId: organizationId, code: code, routing: routing, progress: progress, isBusy: isBusy, menu: menu, hidden: employeeBlocked }));
|
|
7073
3658
|
}
|
|
7074
3659
|
|
|
7075
3660
|
/**
|
|
@@ -7278,7 +3863,7 @@ function AppLayoutShell({ children, sidebar, loadingComponent, breadcrumb, permi
|
|
|
7278
3863
|
// As in the design: the mark, then a menu button, and the
|
|
7279
3864
|
// organisation on the right — no trail, no title, so the
|
|
7280
3865
|
// organisation's name has the room it needs on a phone.
|
|
7281
|
-
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
|
|
3866
|
+
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 ? "ปิดเมนู" : "เปิดเมนู", ...guideRuntime.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
|
|
7282
3867
|
? "relative z-10 px-[18px] pt-[26px] pb-4 sm:px-6 lg:px-10 lg:pt-[38px]"
|
|
7283
3868
|
: cx("relative z-10 pr-6", needsLeftGutter && "pl-6"), children: refusePage
|
|
7284
3869
|
? (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" })))
|
|
@@ -7851,6 +4436,17 @@ Object.defineProperty(exports, "isAxiosError", {
|
|
|
7851
4436
|
enumerable: true,
|
|
7852
4437
|
get: function () { return axios.isAxiosError; }
|
|
7853
4438
|
});
|
|
4439
|
+
exports.GUIDE_API_EVENT = guideRuntime.API_EVENT;
|
|
4440
|
+
exports.GUIDE_EVENT_PREFIX = guideRuntime.GUIDE_EVENT_PREFIX;
|
|
4441
|
+
exports.GUIDE_LAYER_ATTRIBUTE = guideRuntime.GUIDE_LAYER_ATTRIBUTE;
|
|
4442
|
+
exports.GuideProgressConflict = guideRuntime.GuideProgressConflict;
|
|
4443
|
+
exports.GuideRuntime = guideRuntime.GuideRuntime;
|
|
4444
|
+
exports.emitGuideEvent = guideRuntime.emitGuideEvent;
|
|
4445
|
+
exports.guideAnchor = guideRuntime.guideAnchor;
|
|
4446
|
+
exports.openGuidePanel = guideRuntime.openGuidePanel;
|
|
4447
|
+
exports.reportApiCall = guideRuntime.reportApiCall;
|
|
4448
|
+
exports.reportApiEvent = guideRuntime.reportApiEvent;
|
|
4449
|
+
exports.useGuideEntry = guideRuntime.useGuideEntry;
|
|
7854
4450
|
exports.AccessDenied = AccessDenied;
|
|
7855
4451
|
exports.ApiError = ApiError;
|
|
7856
4452
|
exports.AppBreadcrumb = AppBreadcrumb;
|
|
@@ -7862,9 +4458,6 @@ exports.EMPLOYEE_RECORD_REQUIRED_EVENT = EMPLOYEE_RECORD_REQUIRED_EVENT;
|
|
|
7862
4458
|
exports.EmployeeRequiredGate = EmployeeRequiredGate;
|
|
7863
4459
|
exports.FOR_ORGANIZATION_FIELD = FOR_ORGANIZATION_FIELD;
|
|
7864
4460
|
exports.FeatureFlagsProvider = FeatureFlagsProvider;
|
|
7865
|
-
exports.GUIDE_API_EVENT = API_EVENT;
|
|
7866
|
-
exports.GUIDE_EVENT_PREFIX = GUIDE_EVENT_PREFIX;
|
|
7867
|
-
exports.GUIDE_LAYER_ATTRIBUTE = GUIDE_LAYER_ATTRIBUTE;
|
|
7868
4461
|
exports.GuideHost = GuideHost;
|
|
7869
4462
|
exports.HeaderBackground = HeaderBackground;
|
|
7870
4463
|
exports.HelpButton = HelpButton;
|
|
@@ -7908,7 +4501,6 @@ exports.decodeJWT = decodeJWT;
|
|
|
7908
4501
|
exports.describeAttached = describeAttached;
|
|
7909
4502
|
exports.describeBrowser = describeBrowser;
|
|
7910
4503
|
exports.detectHelpApp = detectHelpApp;
|
|
7911
|
-
exports.emitGuideEvent = emitGuideEvent;
|
|
7912
4504
|
exports.filterNavigationModulesByPermission = filterNavigationModulesByPermission;
|
|
7913
4505
|
exports.filterNavigationToolsByPermission = filterNavigationToolsByPermission;
|
|
7914
4506
|
exports.findOrganizationForCode = findOrganizationForCode;
|
|
@@ -7922,19 +4514,16 @@ exports.getRequestOrganizationId = getRequestOrganizationId;
|
|
|
7922
4514
|
exports.getRequiredChildGroups = getRequiredChildGroups;
|
|
7923
4515
|
exports.getStoredOrganizationIds = getStoredOrganizationIds;
|
|
7924
4516
|
exports.getTabOrganizationId = getTabOrganizationId;
|
|
7925
|
-
exports.guideAnchor = guideAnchor;
|
|
7926
4517
|
exports.hasPlanFeature = hasPlanFeature;
|
|
7927
4518
|
exports.isKnownStaleOrganizationId = isKnownStaleOrganizationId;
|
|
7928
4519
|
exports.isLikelyTransientNetworkError = isLikelyTransientNetworkError;
|
|
7929
4520
|
exports.isPathActive = isPathActive;
|
|
7930
4521
|
exports.myRolesQueryKey = myRolesQueryKey;
|
|
7931
4522
|
exports.normalizePathnameForPermissionRules = normalizePathnameForPermissionRules;
|
|
7932
|
-
exports.openGuidePanel = openGuidePanel;
|
|
7933
4523
|
exports.openHelp = openHelp;
|
|
7934
4524
|
exports.organizationScopedQueryHash = organizationScopedQueryHash;
|
|
7935
4525
|
exports.parseMyRoles = parseMyRoles;
|
|
7936
4526
|
exports.platformBaseUrl = platformBaseUrl;
|
|
7937
|
-
exports.reportApiEvent = reportApiEvent;
|
|
7938
4527
|
exports.resolveActiveNav = resolveActiveNav;
|
|
7939
4528
|
exports.resolveSidebarNav = resolveSidebarNav;
|
|
7940
4529
|
exports.resolveStaleOrganization = resolveStaleOrganization;
|
|
@@ -7954,7 +4543,6 @@ exports.updateOrganizationId = updateOrganizationId;
|
|
|
7954
4543
|
exports.useClearOrgOnUserChange = useClearOrgOnUserChange;
|
|
7955
4544
|
exports.useFeatureFlag = useFeatureFlag;
|
|
7956
4545
|
exports.useFeatureFlags = useFeatureFlags;
|
|
7957
|
-
exports.useGuideEntry = useGuideEntry;
|
|
7958
4546
|
exports.useInvalidateQueriesOnOrgChange = useInvalidateQueriesOnOrgChange;
|
|
7959
4547
|
exports.useKeycloakRoles = useKeycloakRoles;
|
|
7960
4548
|
exports.useMyRoles = useMyRoles;
|
|
@@ -7964,4 +4552,4 @@ exports.useSessionRefresh = useSessionRefresh;
|
|
|
7964
4552
|
exports.useSidebar = useSidebar;
|
|
7965
4553
|
exports.useSidebarToggle = useSidebarToggle;
|
|
7966
4554
|
exports.withoutOrganization = withoutOrganization;
|
|
7967
|
-
//# sourceMappingURL=index.
|
|
4555
|
+
//# sourceMappingURL=index.cjs.map
|