@eifi1/ui-kit 0.43.0 → 0.44.0
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 +5 -5
- package/dist/billing/provider-page.d.ts +9 -0
- package/dist/billing/provider-page.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/i18n/german.js +2 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/locales/en.js +3 -3
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/fr.js +2 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/it.js +1 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/tour/tour-catalogue-labels.js +2 -2
- package/dist/tour/tour-catalogue-labels.js.map +1 -1
- package/dist/tour/tour-info.d.ts +2 -2
- package/dist/tour/tour-info.js.map +1 -1
- package/dist/tour/tour.d.ts +38 -2
- package/dist/tour/tour.js +40 -3
- package/dist/tour/tour.js.map +1 -1
- package/dist/tour/use-tour-progress.d.ts +66 -11
- package/dist/tour/use-tour-progress.js +44 -14
- package/dist/tour/use-tour-progress.js.map +1 -1
- package/dist/tour.d.ts +1 -1
- package/dist/tour.js +2 -1
- package/dist/tour.js.map +1 -1
- package/package.json +1 -1
- package/src/billing/provider-page.ts +9 -0
- package/src/i18n/german.ts +2 -2
- package/src/i18n/locales/en.ts +1 -1
- package/src/i18n/locales/fr.ts +2 -2
- package/src/i18n/locales/it.ts +1 -1
- package/src/tour/tour-catalogue-labels.ts +2 -2
- package/src/tour/tour-info.ts +2 -2
- package/src/tour/tour.tsx +84 -3
- package/src/tour/use-tour-progress.ts +124 -23
- package/src/tour.ts +2 -1
|
@@ -6,6 +6,14 @@ const EMPTY = Object.freeze({});
|
|
|
6
6
|
const listeners = /* @__PURE__ */ new Map();
|
|
7
7
|
const memory = /* @__PURE__ */ new Map();
|
|
8
8
|
const cache = /* @__PURE__ */ new Map();
|
|
9
|
+
const migrates = /* @__PURE__ */ new Map();
|
|
10
|
+
function rememberMigrate(key, migrate) {
|
|
11
|
+
if (migrate && !migrates.has(key)) {
|
|
12
|
+
migrates.set(key, migrate);
|
|
13
|
+
if (cache.delete(key)) queueMicrotask(() => emit(key));
|
|
14
|
+
}
|
|
15
|
+
return migrates.get(key);
|
|
16
|
+
}
|
|
9
17
|
function emit(key) {
|
|
10
18
|
listeners.get(key)?.forEach((listener) => listener());
|
|
11
19
|
}
|
|
@@ -26,7 +34,7 @@ function fromRecord(value) {
|
|
|
26
34
|
return out;
|
|
27
35
|
}
|
|
28
36
|
function parse(raw, migrate) {
|
|
29
|
-
if (raw === null) return EMPTY;
|
|
37
|
+
if (raw === null || raw.trim() === "") return { value: EMPTY, readable: true };
|
|
30
38
|
let value = raw;
|
|
31
39
|
try {
|
|
32
40
|
value = JSON.parse(raw);
|
|
@@ -34,23 +42,36 @@ function parse(raw, migrate) {
|
|
|
34
42
|
}
|
|
35
43
|
if (migrate && !isKitShape(value)) {
|
|
36
44
|
try {
|
|
37
|
-
const migrated = migrate(value);
|
|
38
|
-
if (migrated !== void 0) return
|
|
45
|
+
const migrated = fromRecord(migrate(value));
|
|
46
|
+
if (migrated !== void 0) return { value: migrated, readable: true };
|
|
39
47
|
} catch {
|
|
40
48
|
}
|
|
41
49
|
}
|
|
42
|
-
return fromRecord(value) ?? EMPTY;
|
|
50
|
+
return { value: fromRecord(value) ?? EMPTY, readable: isKitShape(value) };
|
|
43
51
|
}
|
|
44
52
|
function snapshot(key, migrate) {
|
|
53
|
+
const known = rememberMigrate(key, migrate);
|
|
45
54
|
const raw = rawOf(key);
|
|
46
55
|
const hit = cache.get(key);
|
|
47
56
|
if (hit && hit.raw === raw) return hit.value;
|
|
48
|
-
const
|
|
49
|
-
cache.set(key, { raw,
|
|
57
|
+
const parsed = parse(raw, known);
|
|
58
|
+
cache.set(key, { raw, ...parsed });
|
|
59
|
+
return parsed.value;
|
|
60
|
+
}
|
|
61
|
+
function readForWrite(key, migrate, own = false) {
|
|
62
|
+
const known = rememberMigrate(key, migrate);
|
|
63
|
+
const { value, readable } = own && migrate && known !== migrate ? parse(rawOf(key), migrate) : { value: snapshot(key, migrate), readable: cache.get(key)?.readable };
|
|
64
|
+
if (!readable) {
|
|
65
|
+
throw new Error(
|
|
66
|
+
`[ui-kit] tour progress: the text stored under "${key}" is not the kit's shape and no migrate reads it; refusing to overwrite it. Pass the key's migrate (the shared useTourProgress / TourProvider options, or completeTour's {migrate}).`
|
|
67
|
+
);
|
|
68
|
+
}
|
|
50
69
|
return value;
|
|
51
70
|
}
|
|
52
71
|
function write(key, record) {
|
|
53
|
-
const
|
|
72
|
+
const stored = {};
|
|
73
|
+
for (const [id, at] of Object.entries(record)) stored[id] = at ?? "";
|
|
74
|
+
const raw = JSON.stringify(stored);
|
|
54
75
|
writeStored(key, raw);
|
|
55
76
|
if (readStored(key) === raw) memory.delete(key);
|
|
56
77
|
else memory.set(key, raw);
|
|
@@ -77,6 +98,18 @@ function resetTourProgress(storageKey) {
|
|
|
77
98
|
cache.delete(storageKey);
|
|
78
99
|
emit(storageKey);
|
|
79
100
|
}
|
|
101
|
+
function completeStored(storageKey, migrate, tour) {
|
|
102
|
+
const subject = typeof tour === "string" ? { id: tour } : tour;
|
|
103
|
+
const current = readForWrite(storageKey, migrate);
|
|
104
|
+
if (isTourDone(subject, current[subject.id])) return false;
|
|
105
|
+
write(storageKey, { ...current, [subject.id]: (/* @__PURE__ */ new Date()).toISOString() });
|
|
106
|
+
return true;
|
|
107
|
+
}
|
|
108
|
+
function completeTour(storageKey, tour, at, opts) {
|
|
109
|
+
const id = typeof tour === "string" ? tour : tour.id;
|
|
110
|
+
const when = at === null ? null : at instanceof Date ? at.toISOString() : at ?? (/* @__PURE__ */ new Date()).toISOString();
|
|
111
|
+
write(storageKey, { ...readForWrite(storageKey, opts?.migrate, true), [id]: when });
|
|
112
|
+
}
|
|
80
113
|
function useTourProgress({ storageKey, migrate }) {
|
|
81
114
|
const migrateRef = useRef(migrate);
|
|
82
115
|
useEffect(() => {
|
|
@@ -88,13 +121,7 @@ function useTourProgress({ storageKey, migrate }) {
|
|
|
88
121
|
() => EMPTY
|
|
89
122
|
);
|
|
90
123
|
const complete = useCallback(
|
|
91
|
-
(tour) =>
|
|
92
|
-
const subject = typeof tour === "string" ? { id: tour } : tour;
|
|
93
|
-
const current = snapshot(storageKey, migrateRef.current);
|
|
94
|
-
if (isTourDone(subject, current[subject.id])) return false;
|
|
95
|
-
write(storageKey, { ...current, [subject.id]: (/* @__PURE__ */ new Date()).toISOString() });
|
|
96
|
-
return true;
|
|
97
|
-
},
|
|
124
|
+
(tour) => completeStored(storageKey, migrateRef.current, tour),
|
|
98
125
|
[storageKey]
|
|
99
126
|
);
|
|
100
127
|
const reset = useCallback(
|
|
@@ -110,6 +137,9 @@ function useTourProgress({ storageKey, migrate }) {
|
|
|
110
137
|
return useMemo(() => ({ completed, complete, reset }), [completed, complete, reset]);
|
|
111
138
|
}
|
|
112
139
|
export {
|
|
140
|
+
completeStored,
|
|
141
|
+
completeTour,
|
|
142
|
+
rememberMigrate,
|
|
113
143
|
resetTourProgress,
|
|
114
144
|
useTourProgress
|
|
115
145
|
};
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/tour/use-tour-progress.ts"],"sourcesContent":["import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { readStored, removeStored, writeStored } from \"../lib/safe-storage\";\nimport { isTourDone, type TourCompletions } from \"./tour-info\";\n\n/**\n * Which tours this device has completed (docs/tours-harmonization.md §2.6, §6.2) —\n * keksdose's `tour-progress-store` and kastlan's `completions.ts`, as one hook.\n *\n * - **Device storage, the app's key.** `storageKey` is always the app's: an app on shared\n * devices scopes it per account (KS, bench PCs), and an app that lists its keys\n * (KS `storage-keys.ts`) spells it there. Every access is guarded: a blocked or full\n * storage keeps the marks for this page's life instead of throwing.\n * - **One store, every reader.** A change notifies every hook on the same key in this tab\n * (an in-tab emitter) and in other tabs (the `storage` event), so the tours page and\n * `TourMenu` agree without a reload (ka F3).\n * - **`migrate(raw)`** reads an older shape, so nobody loses their done marks at adoption:\n * it gets the stored JSON (or the raw text, if it is no JSON) and returns the record for\n * a shape it recognises, `undefined` for any other — the kit's own shape included,\n * which it then reads itself. keksdose: zustand's `{state: {completed}, version: 1}`;\n * kastlan: an array of ids, completed without a date (`null`). It runs only on a stored\n * text that is not the kit's shape already, once per text and key, whichever reader\n * reads it first; its identity is ignored. **Define `migrate` once, at module level**,\n * and hand every reader of the key the same one: readers of one key share one snapshot.\n * - **Resetting the device** (settings §11): register the key with `reset: () =>\n * resetTourProgress(storageKey)`, which clears storage and the page's memory and tells\n * every reader in this tab.\n *\n * The kit's own shape is the record itself, `{ [id]: ISO | null }`; an empty string reads\n * as `null` (kastlan's record of 0.42).\n */\nexport interface UseTourProgressOptions {\n storageKey: string;\n migrate?: (raw: unknown) => Record<string, string | null> | undefined;\n}\n\nexport interface TourProgress {\n /** Tour id → the completion's ISO timestamp, or `null` when recorded without a date. */\n completed: TourCompletions;\n /**\n * Record a completion. Keeps the FIRST completion's date, so a replay changes nothing —\n * unless the tour was reset by an update since (§8): pass the tour (`{id, updated}`) and\n * a completion from before `updated.date` is replaced by today's.\n *\n * Returns `true` when this counts as a new completion — the moment to award the tour's\n * points and show the app's toast (§6.1) — and `false` for a replay.\n */\n complete: (tour: string | { id: string; updated?: { date: string } | null }) => boolean;\n /** \"Mark as not done\": forget the completion. */\n reset: (id: string) => void;\n}\n\n/* ── The store: one per key, shared by every hook ───────────────────────────── */\n\nconst EMPTY: TourCompletions = Object.freeze({});\n/** In-tab listeners per key. */\nconst listeners = new Map<string, Set<() => void>>();\n/** What this page wrote where storage refused to keep it (blocked, full): per key. */\nconst memory = new Map<string, string>();\n/**\n * The last raw text read per key and what it parsed to, so a snapshot keeps its identity:\n * the same text gives the same object, whichever reader asks and whatever `migrate` it\n * holds. Several readers of one key with inline `migrate` arrows used to evict each other\n * here and loop (kastlan B1, 0.43).\n */\nconst cache = new Map<string, { raw: string | null; value: TourCompletions }>();\n\nfunction emit(key: string) {\n listeners.get(key)?.forEach((listener) => listener());\n}\n\nfunction rawOf(key: string): string | null {\n return memory.get(key) ?? readStored(key);\n}\n\n/** Is this the kit's own shape already: a record whose every value is a string or null? */\nfunction isKitShape(value: unknown): boolean {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n return Object.values(value as Record<string, unknown>).every((at) => at === null || typeof at === \"string\");\n}\n\n/** The kit's own shape: a record of string or null; anything else is dropped. */\nfunction fromRecord(value: unknown): TourCompletions | undefined {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return undefined;\n const out: Record<string, string | null> = {};\n for (const [id, at] of Object.entries(value as Record<string, unknown>)) {\n if (at === null || at === \"\") out[id] = null;\n else if (typeof at === \"string\") out[id] = at;\n }\n return out;\n}\n\nfunction parse(raw: string | null, migrate: UseTourProgressOptions[\"migrate\"]): TourCompletions {\n if (raw === null) return EMPTY;\n let value: unknown = raw;\n try {\n value = JSON.parse(raw);\n } catch {\n // Not JSON: `migrate` still sees the text.\n }\n if (migrate && !isKitShape(value)) {\n try {\n const migrated = migrate(value);\n if (migrated !== undefined) return fromRecord(migrated) ?? EMPTY;\n } catch {\n // A migrate that throws reads as \"not a shape it knows\".\n }\n }\n return fromRecord(value) ?? EMPTY;\n}\n\nfunction snapshot(key: string, migrate: UseTourProgressOptions[\"migrate\"]): TourCompletions {\n const raw = rawOf(key);\n const hit = cache.get(key);\n if (hit && hit.raw === raw) return hit.value;\n const value = parse(raw, migrate);\n cache.set(key, { raw, value });\n return value;\n}\n\nfunction write(key: string, record: TourCompletions) {\n const raw = JSON.stringify(record);\n writeStored(key, raw);\n // Storage refused it (blocked, full): keep it for this page's life instead.\n if (readStored(key) === raw) memory.delete(key);\n else memory.set(key, raw);\n emit(key);\n}\n\nfunction subscribe(key: string, listener: () => void): () => void {\n let set = listeners.get(key);\n if (!set) listeners.set(key, (set = new Set()));\n set.add(listener);\n // Another tab wrote (or cleared all storage: `key` null).\n const onStorage = (event: StorageEvent) => {\n if (event.key !== null && event.key !== key) return;\n memory.delete(key);\n listener();\n };\n if (typeof window !== \"undefined\") window.addEventListener(\"storage\", onStorage);\n return () => {\n set.delete(listener);\n if (typeof window !== \"undefined\") window.removeEventListener(\"storage\", onStorage);\n };\n}\n\n/**\n * Forget every tour completion under `storageKey` on this device: the stored key, what the\n * page kept in memory where storage refused it, and every reader in this tab moves at\n * once. For \"Reset this device's settings\" (settings §11):\n *\n * registerDeviceSetting({ key: TOUR_KEY, kind: \"view\", reset: () => resetTourProgress(TOUR_KEY) });\n */\nexport function resetTourProgress(storageKey: string): void {\n removeStored(storageKey);\n memory.delete(storageKey);\n cache.delete(storageKey);\n emit(storageKey);\n}\n\n/** The device's tour completions under `storageKey`; see {@link UseTourProgressOptions}. */\nexport function useTourProgress({ storageKey, migrate }: UseTourProgressOptions): TourProgress {\n // Held, not depended on: an inline arrow is a new function every render, and neither the\n // snapshot nor the callbacks may change with it.\n const migrateRef = useRef(migrate);\n useEffect(() => {\n migrateRef.current = migrate;\n });\n const completed = useSyncExternalStore(\n useCallback((listener: () => void) => subscribe(storageKey, listener), [storageKey]),\n () => snapshot(storageKey, migrateRef.current),\n () => EMPTY,\n );\n\n const complete = useCallback(\n (tour: string | { id: string; updated?: { date: string } | null }) => {\n const subject = typeof tour === \"string\" ? { id: tour } : tour;\n // Read now rather than from the render: two completions in one handler both count.\n const current = snapshot(storageKey, migrateRef.current);\n if (isTourDone(subject, current[subject.id])) return false;\n write(storageKey, { ...current, [subject.id]: new Date().toISOString() });\n return true;\n },\n [storageKey],\n );\n\n const reset = useCallback(\n (id: string) => {\n const current = snapshot(storageKey, migrateRef.current);\n if (current[id] === undefined) return;\n const next = { ...current };\n delete next[id];\n write(storageKey, next);\n },\n [storageKey],\n );\n\n return useMemo(() => ({ completed, complete, reset }), [completed, complete, reset]);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/tour/use-tour-progress.ts"],"sourcesContent":["import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from \"react\";\nimport { readStored, removeStored, writeStored } from \"../lib/safe-storage\";\nimport { isTourDone, type TourCompletions } from \"./tour-info\";\n\n/**\n * Which tours this device has completed (docs/tours-harmonization.md §2.6, §6.2) —\n * keksdose's `tour-progress-store` and kastlan's `completions.ts`, as one hook.\n *\n * - **Device storage, the app's key.** `storageKey` is always the app's: an app on shared\n * devices scopes it per account (KS, bench PCs), and an app that lists its keys\n * (KS `storage-keys.ts`) spells it there. Every access is guarded: a blocked or full\n * storage keeps the marks for this page's life instead of throwing.\n * - **One store, every reader.** A change notifies every hook on the same key in this tab\n * (an in-tab emitter) and in other tabs (the `storage` event), so the tours page and\n * `TourMenu` agree without a reload (ka F3).\n * - **`migrate(raw)`** reads an older shape, so nobody loses their done marks at adoption:\n * it gets the stored JSON (or the raw text, if it is no JSON) and returns the record for\n * a shape it recognises, `undefined` for any other — the kit's own shape included,\n * which it then reads itself. keksdose: zustand's `{state: {completed}, version: 1}`;\n * kastlan: an array of ids, completed without a date (`null`). It runs only on a stored\n * text that is not the kit's shape already, once per text and key. The FIRST `migrate`\n * any reader, `TourProvider` or {@link completeTour} passes for a key is remembered for\n * that key (later identities are ignored), so a reader without one, mounted first, no\n * longer hides the old marks from the rest (keksdose B-2). **Still, define the options\n * once, at module level, and hand the provider and every reader the same object.**\n * - **Never overwritten unread** (keksdose B-1): a write (`complete`, the provider's,\n * `completeTour`) on a stored text that is neither empty nor the kit's shape, and that no\n * known `migrate` reads, THROWS instead of replacing the old marks.\n * - **Resetting the device** (settings §11): register the key with `reset: () =>\n * resetTourProgress(storageKey)`, which clears storage and the page's memory and tells\n * every reader in this tab.\n *\n * **The stored shape** (§9.3, §9.6.6), for an app's tests and seeds: the JSON record\n * `{ [id]: ISO instant | \"\" }` under `storageKey`, where `\"\"` is a completion without a\n * date (a migrated record: kastlan's id list). That is the one written form; the hooks\n * and callbacks (`completed`, `TourCompletions`) give `null` for `\"\"`. A `null` read from\n * storage (or returned by `migrate`) is accepted as the same thing and written back as\n * `\"\"`. To write a completion from outside a page, use {@link completeTour}.\n */\nexport interface UseTourProgressOptions {\n storageKey: string;\n migrate?: (raw: unknown) => Record<string, string | null> | undefined;\n}\n\n/** What `complete` takes: a tour id, or the tour (`{id, updated}`, a `TourInfo` fits). */\ntype CompletedTour = string | { id: string; updated?: { date: string } | null };\n\nexport interface TourProgress {\n /** Tour id → the completion's ISO timestamp, or `null` when recorded without a date. */\n completed: TourCompletions;\n /**\n * Record a completion. Keeps the FIRST completion's date, so a replay changes nothing —\n * unless the tour was reset by an update since (§8): pass the tour (`{id, updated}`) and\n * a completion from before `updated.date` is replaced by today's.\n *\n * Returns `true` when this counts as a new completion — the moment to award the tour's\n * points and show the app's toast (§6.1) — and `false` for a replay.\n *\n * A tour started with `start(steps, {tour})` under a `TourProvider` that has `progress`\n * is recorded by the provider (§9.6.2): do not call this from that start's `onFinish`\n * too, or the second write answers `false`.\n */\n complete: (tour: CompletedTour) => boolean;\n /** \"Mark as not done\": forget the completion. */\n reset: (id: string) => void;\n}\n\n/* ── The store: one per key, shared by every hook ───────────────────────────── */\n\nconst EMPTY: TourCompletions = Object.freeze({});\n/** In-tab listeners per key. */\nconst listeners = new Map<string, Set<() => void>>();\n/** What this page wrote where storage refused to keep it (blocked, full): per key. */\nconst memory = new Map<string, string>();\n/**\n * The last raw text read per key and what it parsed to, so a snapshot keeps its identity:\n * the same text gives the same object, whichever reader asks and whatever `migrate` it\n * holds. Several readers of one key with inline `migrate` arrows used to evict each other\n * here and loop (kastlan B1, 0.43).\n */\nconst cache = new Map<string, { raw: string | null; value: TourCompletions; readable: boolean }>();\ntype Migrate = NonNullable<UseTourProgressOptions[\"migrate\"]>;\n/** Each key's migrate: the first one any reader, the provider or `completeTour` passes (B-2). */\nconst migrates = new Map<string, Migrate>();\n\n/**\n * Remember `migrate` for `key` if it is the first one, and return the key's migrate. A\n * snapshot parsed before the key had one is dropped and re-parsed, and the key's readers\n * hear of it after this render (never from inside one).\n * @internal Not part of the barrel; `TourProvider` registers its `progress` with it.\n */\nexport function rememberMigrate(key: string, migrate: UseTourProgressOptions[\"migrate\"]): Migrate | undefined {\n if (migrate && !migrates.has(key)) {\n migrates.set(key, migrate);\n if (cache.delete(key)) queueMicrotask(() => emit(key));\n }\n return migrates.get(key);\n}\n\nfunction emit(key: string) {\n listeners.get(key)?.forEach((listener) => listener());\n}\n\nfunction rawOf(key: string): string | null {\n return memory.get(key) ?? readStored(key);\n}\n\n/** Is this the kit's own shape already: a record whose every value is a string or null? */\nfunction isKitShape(value: unknown): boolean {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return false;\n return Object.values(value as Record<string, unknown>).every((at) => at === null || typeof at === \"string\");\n}\n\n/** The kit's own shape: a record of string or null; anything else is dropped. */\nfunction fromRecord(value: unknown): TourCompletions | undefined {\n if (!value || typeof value !== \"object\" || Array.isArray(value)) return undefined;\n const out: Record<string, string | null> = {};\n for (const [id, at] of Object.entries(value as Record<string, unknown>)) {\n if (at === null || at === \"\") out[id] = null;\n else if (typeof at === \"string\") out[id] = at;\n }\n return out;\n}\n\n/**\n * The record, and whether the text was READ (empty, the kit's shape, or a shape `migrate`\n * knows) rather than dropped: a write over an unread text would lose it (B-1).\n */\nfunction parse(raw: string | null, migrate: UseTourProgressOptions[\"migrate\"]): { value: TourCompletions; readable: boolean } {\n if (raw === null || raw.trim() === \"\") return { value: EMPTY, readable: true };\n let value: unknown = raw;\n try {\n value = JSON.parse(raw);\n } catch {\n // Not JSON: `migrate` still sees the text.\n }\n if (migrate && !isKitShape(value)) {\n try {\n const migrated = fromRecord(migrate(value));\n if (migrated !== undefined) return { value: migrated, readable: true };\n } catch {\n // A migrate that throws reads as \"not a shape it knows\".\n }\n }\n return { value: fromRecord(value) ?? EMPTY, readable: isKitShape(value) };\n}\n\nfunction snapshot(key: string, migrate: UseTourProgressOptions[\"migrate\"]): TourCompletions {\n const known = rememberMigrate(key, migrate);\n const raw = rawOf(key);\n const hit = cache.get(key);\n if (hit && hit.raw === raw) return hit.value;\n const parsed = parse(raw, known);\n cache.set(key, { raw, ...parsed });\n return parsed.value;\n}\n\n/** The record a write builds on; throws rather than overwrite a text nobody could read (B-1). */\nfunction readForWrite(key: string, migrate: UseTourProgressOptions[\"migrate\"], own = false): TourCompletions {\n // `own`: this migrate, even where the key registered another (completeTour's `{migrate}`).\n const known = rememberMigrate(key, migrate);\n const { value, readable } =\n own && migrate && known !== migrate\n ? parse(rawOf(key), migrate)\n : { value: snapshot(key, migrate), readable: cache.get(key)?.readable };\n if (!readable) {\n throw new Error(\n `[ui-kit] tour progress: the text stored under \"${key}\" is not the kit's shape and no migrate reads it; ` +\n \"refusing to overwrite it. Pass the key's migrate (the shared useTourProgress / TourProvider options, \" +\n \"or completeTour's {migrate}).\",\n );\n }\n return value;\n}\n\n/** The one written form (§9.6.6): a completion without a date is stored as `\"\"`, never `null`. */\nfunction write(key: string, record: TourCompletions) {\n const stored: Record<string, string> = {};\n for (const [id, at] of Object.entries(record)) stored[id] = at ?? \"\";\n const raw = JSON.stringify(stored);\n writeStored(key, raw);\n // Storage refused it (blocked, full): keep it for this page's life instead.\n if (readStored(key) === raw) memory.delete(key);\n else memory.set(key, raw);\n emit(key);\n}\n\nfunction subscribe(key: string, listener: () => void): () => void {\n let set = listeners.get(key);\n if (!set) listeners.set(key, (set = new Set()));\n set.add(listener);\n // Another tab wrote (or cleared all storage: `key` null).\n const onStorage = (event: StorageEvent) => {\n if (event.key !== null && event.key !== key) return;\n memory.delete(key);\n listener();\n };\n if (typeof window !== \"undefined\") window.addEventListener(\"storage\", onStorage);\n return () => {\n set.delete(listener);\n if (typeof window !== \"undefined\") window.removeEventListener(\"storage\", onStorage);\n };\n}\n\n/**\n * Forget every tour completion under `storageKey` on this device: the stored key, what the\n * page kept in memory where storage refused it, and every reader in this tab moves at\n * once. For \"Reset this device's settings\" (settings §11):\n *\n * registerDeviceSetting({ key: TOUR_KEY, kind: \"view\", reset: () => resetTourProgress(TOUR_KEY) });\n */\nexport function resetTourProgress(storageKey: string): void {\n removeStored(storageKey);\n memory.delete(storageKey);\n cache.delete(storageKey);\n emit(storageKey);\n}\n\n/**\n * Record a completion under `storageKey`, as {@link TourProgress.complete} does: through\n * the one shared store and snapshot (never a copy), so every reader of the key moves.\n * Returns `true` when it counts anew. `TourProvider` calls this on a tour's last step.\n * @internal Not part of the barrel.\n */\nexport function completeStored(\n storageKey: string,\n migrate: UseTourProgressOptions[\"migrate\"],\n tour: CompletedTour,\n): boolean {\n const subject = typeof tour === \"string\" ? { id: tour } : tour;\n // Read now rather than from a render: two completions in one handler both count.\n const current = readForWrite(storageKey, migrate);\n if (isTourDone(subject, current[subject.id])) return false;\n write(storageKey, { ...current, [subject.id]: new Date().toISOString() });\n return true;\n}\n\n/**\n * Write a completion from outside a page (§9.2): for an app's tests, seeds and scripts,\n * beside {@link resetTourProgress}. It writes the kit's stored shape (`{[id]: ISO | \"\"}`)\n * under `storageKey`, keeps the other tours' marks, and notifies every reader of the key\n * in this tab directly (the `storage` event reaches only OTHER tabs), so a mounted\n * `useTourProgress` or `TourCatalogue` moves at once.\n *\n * Unlike `complete`, it always writes (over a readable text; see below): `at` is the\n * completion's instant (an ISO string or a `Date`, default now), `null` a completion\n * without a date (stored as `\"\"`, read back as `null`).\n *\n * completeTour(TOUR_KEY, \"budget\", \"2026-09-01T10:00:00Z\");\n *\n * A stored text in an app's older shape is read through `opts.migrate`, or else the\n * migrate a reader or the provider registered for the key. If the text is neither empty\n * nor the kit's shape and no migrate reads it, this THROWS and writes nothing (B-1):\n *\n * completeTour(TOUR_KEY, \"budget\", undefined, { migrate: fromZustand });\n */\nexport function completeTour(\n storageKey: string,\n tour: string | { id: string },\n at?: string | Date | null,\n opts?: { migrate?: UseTourProgressOptions[\"migrate\"] },\n): void {\n const id = typeof tour === \"string\" ? tour : tour.id;\n const when = at === null ? null : at instanceof Date ? at.toISOString() : (at ?? new Date().toISOString());\n write(storageKey, { ...readForWrite(storageKey, opts?.migrate, true), [id]: when });\n}\n\n/** The device's tour completions under `storageKey`; see {@link UseTourProgressOptions}. */\nexport function useTourProgress({ storageKey, migrate }: UseTourProgressOptions): TourProgress {\n // Held, not depended on: an inline arrow is a new function every render, and neither the\n // snapshot nor the callbacks may change with it.\n const migrateRef = useRef(migrate);\n useEffect(() => {\n migrateRef.current = migrate;\n });\n const completed = useSyncExternalStore(\n useCallback((listener: () => void) => subscribe(storageKey, listener), [storageKey]),\n () => snapshot(storageKey, migrateRef.current),\n () => EMPTY,\n );\n\n const complete = useCallback(\n (tour: CompletedTour) => completeStored(storageKey, migrateRef.current, tour),\n [storageKey],\n );\n\n const reset = useCallback(\n (id: string) => {\n const current = snapshot(storageKey, migrateRef.current);\n if (current[id] === undefined) return;\n const next = { ...current };\n delete next[id];\n write(storageKey, next);\n },\n [storageKey],\n );\n\n return useMemo(() => ({ completed, complete, reset }), [completed, complete, reset]);\n}\n"],"mappings":"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","names":[]}
|
package/dist/tour.d.ts
CHANGED
|
@@ -3,7 +3,7 @@ export { TourCatalogue, TourCatalogueCard, DEFAULT_TOUR_CATALOGUE_LABELS } from
|
|
|
3
3
|
export type { TourCatalogueProps, TourCatalogueCardProps, TourCatalogueLabels } from "./tour/tour-catalogue.js";
|
|
4
4
|
export { isTourNew, isTourReset, isTourDone, doneCount, earnedPoints } from "./tour/tour-info.js";
|
|
5
5
|
export type { TourInfo, TourGroup, TourUpdate, TourCompletions } from "./tour/tour-info.js";
|
|
6
|
-
export { useTourProgress, resetTourProgress } from "./tour/use-tour-progress.js";
|
|
6
|
+
export { useTourProgress, resetTourProgress, completeTour } from "./tour/use-tour-progress.js";
|
|
7
7
|
export type { UseTourProgressOptions, TourProgress } from "./tour/use-tour-progress.js";
|
|
8
8
|
export { useTourUrlSync, useTourCatalogueUrlState } from "./tour/use-tour-url-sync.js";
|
|
9
9
|
export type { UseTourUrlSyncOptions, TourCatalogueFilter, TourStateFilter } from "./tour/use-tour-url-sync.js";
|
package/dist/tour.js
CHANGED
|
@@ -2,12 +2,13 @@
|
|
|
2
2
|
export * from "./tour/tour.js";
|
|
3
3
|
import { TourCatalogue, TourCatalogueCard, DEFAULT_TOUR_CATALOGUE_LABELS } from "./tour/tour-catalogue.js";
|
|
4
4
|
import { isTourNew, isTourReset, isTourDone, doneCount, earnedPoints } from "./tour/tour-info.js";
|
|
5
|
-
import { useTourProgress, resetTourProgress } from "./tour/use-tour-progress.js";
|
|
5
|
+
import { useTourProgress, resetTourProgress, completeTour } from "./tour/use-tour-progress.js";
|
|
6
6
|
import { useTourUrlSync, useTourCatalogueUrlState } from "./tour/use-tour-url-sync.js";
|
|
7
7
|
export {
|
|
8
8
|
DEFAULT_TOUR_CATALOGUE_LABELS,
|
|
9
9
|
TourCatalogue,
|
|
10
10
|
TourCatalogueCard,
|
|
11
|
+
completeTour,
|
|
11
12
|
doneCount,
|
|
12
13
|
earnedPoints,
|
|
13
14
|
isTourDone,
|
package/dist/tour.js.map
CHANGED
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../src/tour.ts"],"sourcesContent":["// `@eifi1/ui-kit/tour` — guided tours.\n// A re-slicing of the main barrel, not a new API.\nexport * from \"./tour/tour\";\n// 0.43.0: the tours page (docs/tours-harmonization.md §3, §8) — keksdose's catalogue, its\n// card, the NEW and reset rules, device progress and the URL. `useTourUrlSync` and\n// `useTourCatalogueUrlState` need react-router (a peer already, for /shell).\nexport { TourCatalogue, TourCatalogueCard, DEFAULT_TOUR_CATALOGUE_LABELS } from \"./tour/tour-catalogue\";\nexport type { TourCatalogueProps, TourCatalogueCardProps, TourCatalogueLabels } from \"./tour/tour-catalogue\";\nexport { isTourNew, isTourReset, isTourDone, doneCount, earnedPoints } from \"./tour/tour-info\";\nexport type { TourInfo, TourGroup, TourUpdate, TourCompletions } from \"./tour/tour-info\";\nexport { useTourProgress, resetTourProgress } from \"./tour/use-tour-progress\";\nexport type { UseTourProgressOptions, TourProgress } from \"./tour/use-tour-progress\";\nexport { useTourUrlSync, useTourCatalogueUrlState } from \"./tour/use-tour-url-sync\";\nexport type { UseTourUrlSyncOptions, TourCatalogueFilter, TourStateFilter } from \"./tour/use-tour-url-sync\";\n"],"mappings":";AAEA,cAAc;AAId,SAAS,eAAe,mBAAmB,qCAAqC;AAEhF,SAAS,WAAW,aAAa,YAAY,WAAW,oBAAoB;
|
|
1
|
+
{"version":3,"sources":["../src/tour.ts"],"sourcesContent":["// `@eifi1/ui-kit/tour` — guided tours.\n// A re-slicing of the main barrel, not a new API.\nexport * from \"./tour/tour\";\n// 0.43.0: the tours page (docs/tours-harmonization.md §3, §8) — keksdose's catalogue, its\n// card, the NEW and reset rules, device progress and the URL. `useTourUrlSync` and\n// `useTourCatalogueUrlState` need react-router (a peer already, for /shell).\nexport { TourCatalogue, TourCatalogueCard, DEFAULT_TOUR_CATALOGUE_LABELS } from \"./tour/tour-catalogue\";\nexport type { TourCatalogueProps, TourCatalogueCardProps, TourCatalogueLabels } from \"./tour/tour-catalogue\";\nexport { isTourNew, isTourReset, isTourDone, doneCount, earnedPoints } from \"./tour/tour-info\";\nexport type { TourInfo, TourGroup, TourUpdate, TourCompletions } from \"./tour/tour-info\";\n// 0.44.0: the writer outside a page (§9.2).\nexport { useTourProgress, resetTourProgress, completeTour } from \"./tour/use-tour-progress\";\nexport type { UseTourProgressOptions, TourProgress } from \"./tour/use-tour-progress\";\nexport { useTourUrlSync, useTourCatalogueUrlState } from \"./tour/use-tour-url-sync\";\nexport type { UseTourUrlSyncOptions, TourCatalogueFilter, TourStateFilter } from \"./tour/use-tour-url-sync\";\n"],"mappings":";AAEA,cAAc;AAId,SAAS,eAAe,mBAAmB,qCAAqC;AAEhF,SAAS,WAAW,aAAa,YAAY,WAAW,oBAAoB;AAG5E,SAAS,iBAAiB,mBAAmB,oBAAoB;AAEjE,SAAS,gBAAgB,gCAAgC;","names":[]}
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@eifi1/ui-kit",
|
|
3
|
-
"version": "0.
|
|
3
|
+
"version": "0.44.0",
|
|
4
4
|
"type": "module",
|
|
5
5
|
"description": "App-agnostic shared design system: domain-free React components, theme and tokens.",
|
|
6
6
|
"_sideEffects": "The barrel (src/index.ts) re-exports EVERY component, including components/chart, which statically imports recharts. Without this field a bundler must assume each of those modules has side effects, so it cannot drop the unused ones: importing a Button from '@eifi1/ui-kit' dragged all 433KB of recharts into the consumer's ENTRY chunk, and Keksdose was preloading it on every page load, login included. Nothing in src/ imports for side effects (no bare imports, no CSS imports from JS, no module-level global writes) — the token stylesheet is its own './tokens.css' export — so this is safe. CSS is still listed so any future stylesheet survives. Re-checked when `flag-icons` left `dependencies` and tsup started emitting a `\"use client\"` banner: neither touches this. The flag stylesheet was never imported from JS in the first place (that was the finding), and a directive prologue is a string literal every bundler lifts out before it decides whether a module is inert.",
|
|
@@ -39,6 +39,15 @@ export interface OpenProviderPageOptions {
|
|
|
39
39
|
* />
|
|
40
40
|
*
|
|
41
41
|
* Resolves with the way it went.
|
|
42
|
+
*
|
|
43
|
+
* **The test seam is `options.window`** (docs/tours-harmonization.md §9.5, kk 7): an app's
|
|
44
|
+
* test passes a stub with `open` (returning a fake tab, or `null` for a blocked pop-up)
|
|
45
|
+
* and `location.assign`, instead of patching the global `window`:
|
|
46
|
+
*
|
|
47
|
+
* const tab = { opener: {}, closed: false, location: { href: "" }, close: vi.fn() };
|
|
48
|
+
* const win = { open: vi.fn(() => tab), location: { assign: vi.fn() } } as unknown as Window;
|
|
49
|
+
* await openProviderPage(async () => "https://pay.example/portal", { window: win });
|
|
50
|
+
* expect(tab.location.href).toBe("https://pay.example/portal");
|
|
42
51
|
*/
|
|
43
52
|
export async function openProviderPage(
|
|
44
53
|
fetchUrl: () => Promise<string>,
|
package/src/i18n/german.ts
CHANGED
|
@@ -639,12 +639,12 @@ export function germanLabels(numberLocale: string): UiKitLabels {
|
|
|
639
639
|
stateDone: (count) => `Abgeschlossen (${n(count)})`,
|
|
640
640
|
showing: (shown, total) => `${n(shown)} von ${n(total)} Touren`,
|
|
641
641
|
noMatches: "Keine Tour passt",
|
|
642
|
-
noMatchesHint: "
|
|
642
|
+
noMatchesHint: "Anderes Wort versuchen oder Filter zurücksetzen.",
|
|
643
643
|
clearFilters: "Filter zurücksetzen",
|
|
644
644
|
badgeNew: "Neu",
|
|
645
645
|
badgeUpdated: "Aktualisiert",
|
|
646
646
|
resetOn: (date, reason) => (reason ? `Zurückgesetzt am ${date}: ${reason}` : `Zurückgesetzt am ${date}`),
|
|
647
|
-
points: (count) => `+${n(count)} Pkt
|
|
647
|
+
points: (count) => `+${n(count)} Pkt`,
|
|
648
648
|
steps: (count) => (count === 1 ? `${n(count)} Schritt` : `${n(count)} Schritte`),
|
|
649
649
|
duration: (minutes) => `Etwa ${n(minutes)} Min.`,
|
|
650
650
|
completedOn: (date) => `Abgeschlossen am ${date}`,
|
package/src/i18n/locales/en.ts
CHANGED
|
@@ -228,7 +228,7 @@ export function uiKitLabelsEn(numberLocale = "en-US"): UiKitLabels {
|
|
|
228
228
|
groupAll: (count) => `All topics (${n(count)})`,
|
|
229
229
|
groupOption: (group, count) => `${group} (${n(count)})`,
|
|
230
230
|
stateAll: (count) => `All (${n(count)})`,
|
|
231
|
-
stateTodo: (count) => `
|
|
231
|
+
stateTodo: (count) => `Not taken (${n(count)})`,
|
|
232
232
|
stateDone: (count) => `Completed (${n(count)})`,
|
|
233
233
|
showing: (shown, total) => `Showing ${n(shown)} of ${n(total)} tours`,
|
|
234
234
|
points: (count) => `+${n(count)} pts`,
|
package/src/i18n/locales/fr.ts
CHANGED
|
@@ -633,11 +633,11 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
|
|
|
633
633
|
groupOption: (group, count) => `${group} (${n(count)})`,
|
|
634
634
|
stateLabel: "Filtrer les visites",
|
|
635
635
|
stateAll: (count) => `Toutes (${n(count)})`,
|
|
636
|
-
stateTodo: (count) =>
|
|
636
|
+
stateTodo: (count) => `Non suivies (${n(count)})`,
|
|
637
637
|
stateDone: (count) => `Terminées (${n(count)})`,
|
|
638
638
|
showing: (shown, total) => `${n(shown)} visites sur ${n(total)}`,
|
|
639
639
|
noMatches: "Aucune visite ne correspond",
|
|
640
|
-
noMatchesHint: "Essayez un autre mot, ou effacez
|
|
640
|
+
noMatchesHint: "Essayez un autre mot, ou effacez le filtre.",
|
|
641
641
|
clearFilters: "Effacer les filtres",
|
|
642
642
|
badgeNew: "Nouveau",
|
|
643
643
|
badgeUpdated: "Mise à jour",
|
package/src/i18n/locales/it.ts
CHANGED
|
@@ -626,7 +626,7 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
|
|
|
626
626
|
stateDone: (count) => `Completati (${n(count)})`,
|
|
627
627
|
showing: (shown, total) => `${n(shown)} di ${n(total)} tour`,
|
|
628
628
|
noMatches: "Nessun tour corrisponde",
|
|
629
|
-
noMatchesHint: "Provi un’altra parola o cancelli
|
|
629
|
+
noMatchesHint: "Provi un’altra parola o cancelli il filtro.",
|
|
630
630
|
clearFilters: "Cancella filtri",
|
|
631
631
|
badgeNew: "Nuovo",
|
|
632
632
|
badgeUpdated: "Aggiornato",
|
|
@@ -54,11 +54,11 @@ export const DEFAULT_TOUR_CATALOGUE_LABELS: TourCatalogueLabels = {
|
|
|
54
54
|
groupOption: (group, count) => `${group} (${count})`,
|
|
55
55
|
stateLabel: "Filter tours",
|
|
56
56
|
stateAll: (count) => `All (${count})`,
|
|
57
|
-
stateTodo: (count) => `
|
|
57
|
+
stateTodo: (count) => `Not taken (${count})`,
|
|
58
58
|
stateDone: (count) => `Completed (${count})`,
|
|
59
59
|
showing: (shown, total) => `Showing ${shown} of ${total} tours`,
|
|
60
60
|
noMatches: "No tour matches",
|
|
61
|
-
noMatchesHint: "Try another word, or clear the
|
|
61
|
+
noMatchesHint: "Try another word, or clear the filter.",
|
|
62
62
|
clearFilters: "Clear filters",
|
|
63
63
|
badgeNew: "New",
|
|
64
64
|
badgeUpdated: "Updated",
|
package/src/tour/tour-info.ts
CHANGED
|
@@ -59,8 +59,8 @@ export interface TourGroup {
|
|
|
59
59
|
|
|
60
60
|
/**
|
|
61
61
|
* Which tours are completed and when: tour id → the ISO timestamp of the completion, or
|
|
62
|
-
* `null` for one recorded without a date (kastlan's old id list, §6.2
|
|
63
|
-
* not a key is untaken.
|
|
62
|
+
* `null` for one recorded without a date (kastlan's old id list, §6.2; stored as `""`,
|
|
63
|
+
* §9.6.6). A tour that is not a key is untaken.
|
|
64
64
|
*/
|
|
65
65
|
export type TourCompletions = Readonly<Record<string, string | null>>;
|
|
66
66
|
|
package/src/tour/tour.tsx
CHANGED
|
@@ -17,6 +17,8 @@ import { sameRect } from "../hooks/use-anchored-rect";
|
|
|
17
17
|
import { dirOf, horizontalStep } from "../lib/direction";
|
|
18
18
|
import type { Direction } from "../lib/direction";
|
|
19
19
|
import { DEFAULT_TOUR_LABELS } from "./tour-labels";
|
|
20
|
+
import type { TourInfo } from "./tour-info";
|
|
21
|
+
import { completeStored, rememberMigrate, type UseTourProgressOptions } from "./use-tour-progress";
|
|
20
22
|
|
|
21
23
|
/**
|
|
22
24
|
* `start`/`end` follow the reading direction (`end` is the right side in LTR and the
|
|
@@ -67,16 +69,34 @@ export { DEFAULT_TOUR_LABELS };
|
|
|
67
69
|
|
|
68
70
|
interface StartOptions {
|
|
69
71
|
/** Stable id of the tour, surfaced as `activeId` so the app can mirror it to
|
|
70
|
-
* the URL (deep-link / evaluation tracking) and record completion.
|
|
72
|
+
* the URL (deep-link / evaluation tracking) and record completion. Defaults to
|
|
73
|
+
* `tour.id`. */
|
|
71
74
|
id?: string;
|
|
75
|
+
/** The tour being started, as the app lists it (docs/tours-harmonization.md §9.6.1).
|
|
76
|
+
* When its last step is finished, the provider records the completion in its
|
|
77
|
+
* `progress` and hands this to `onComplete`. */
|
|
78
|
+
tour?: TourInfo;
|
|
72
79
|
/** Start at this step index instead of 0 (clamped) — for URL/deep-link resume. */
|
|
73
80
|
startIndex?: number;
|
|
74
81
|
/** Fires synchronously when the tour starts (e.g. enable a mock interceptor). */
|
|
75
82
|
onStart?: () => void;
|
|
83
|
+
/** After the last step: runs after the provider's progress write and `onComplete`. */
|
|
76
84
|
onFinish?: () => void;
|
|
77
85
|
onSkip?: () => void;
|
|
78
86
|
}
|
|
79
87
|
|
|
88
|
+
/** What `TourProvider`'s `onComplete` learns besides the tour (§9.1, §9.6). */
|
|
89
|
+
export interface TourCompletion {
|
|
90
|
+
/**
|
|
91
|
+
* `true` when the completion counts anew (the first time, or the first time after the
|
|
92
|
+
* tour's update, §8): the moment for the app's points toast. `false` for a replay, and
|
|
93
|
+
* when the stored text is in an old shape no `migrate` reads: nothing is overwritten
|
|
94
|
+
* then, and the error goes to `console.error` (keksdose B-1). `undefined` when the provider has no `progress` or the start carried no `tour`, so
|
|
95
|
+
* nothing was recorded.
|
|
96
|
+
*/
|
|
97
|
+
awarded: boolean | undefined;
|
|
98
|
+
}
|
|
99
|
+
|
|
80
100
|
interface TourContextValue {
|
|
81
101
|
active: boolean;
|
|
82
102
|
/** Id of the running tour (from {@link StartOptions.id}), else null. */
|
|
@@ -129,6 +149,11 @@ export function useTourOptional(): TourContextValue | null {
|
|
|
129
149
|
return useContext(TourContext);
|
|
130
150
|
}
|
|
131
151
|
|
|
152
|
+
/** The kit's dev switch (as number-input's): a development build, never under vitest. */
|
|
153
|
+
function devWarnings(): boolean {
|
|
154
|
+
return Boolean(import.meta.env?.DEV) && !import.meta.env?.VITEST;
|
|
155
|
+
}
|
|
156
|
+
|
|
132
157
|
/**
|
|
133
158
|
* Provides the guided-tour engine: a dimmed spotlight overlay + step card driven
|
|
134
159
|
* by a list of {@link TourStep}s. Domain-free — the app supplies the steps (copy,
|
|
@@ -136,13 +161,32 @@ export function useTourOptional(): TourContextValue | null {
|
|
|
136
161
|
* Mount once inside the router; start a tour from anywhere via {@link useTour},
|
|
137
162
|
* which also exposes step control (`next`/`prev`/`goToStep`) and the current
|
|
138
163
|
* `index`/`total` for URL mirroring, `awaitClick` and deep-link resume.
|
|
164
|
+
*
|
|
165
|
+
* **Completion in one place** (docs/tours-harmonization.md §9.6): give the provider the
|
|
166
|
+
* app's `progress` (the same `{storageKey, migrate}` as `useTourProgress`) and start each
|
|
167
|
+
* tour with `start(steps, {tour})`. When the last step is finished the provider writes the
|
|
168
|
+
* completion ONCE, through the store every `useTourProgress` reader of the key shares
|
|
169
|
+
* (never a second copy), then calls `onComplete(tour, {awarded})`, then the start's own
|
|
170
|
+
* `onFinish`. A skip or `stop()` records nothing and calls neither. The app removes its
|
|
171
|
+
* own `complete()` from `onFinish`, or that second write answers `false`.
|
|
172
|
+
*
|
|
173
|
+
* Both props are optional: without `progress` or a `tour` on the start, `onComplete`
|
|
174
|
+
* still fires, with `awarded: undefined` (and `tour` as given, possibly `undefined`). A
|
|
175
|
+
* new `storageKey` (an account switch on a shared PC) is followed on the next
|
|
176
|
+
* completion, without a remount.
|
|
139
177
|
*/
|
|
140
178
|
export function TourProvider({
|
|
141
179
|
children,
|
|
142
180
|
labels,
|
|
181
|
+
progress,
|
|
182
|
+
onComplete,
|
|
143
183
|
}: {
|
|
144
184
|
children: ReactNode;
|
|
145
185
|
labels?: LabelOverride<TourLabels>;
|
|
186
|
+
/** The app's tour progress: the same options its `useTourProgress` readers use. */
|
|
187
|
+
progress?: UseTourProgressOptions;
|
|
188
|
+
/** A tour's last step was finished; see {@link TourCompletion}. */
|
|
189
|
+
onComplete?: (tour: TourInfo | undefined, completion: TourCompletion) => void;
|
|
146
190
|
}) {
|
|
147
191
|
// `labels` over the provider's `tour` over English. Resolved once here and handed
|
|
148
192
|
// down: the step card is portalled, but context crosses portals anyway — this is
|
|
@@ -152,6 +196,20 @@ export function TourProvider({
|
|
|
152
196
|
const [index, setIndex] = useState(0);
|
|
153
197
|
const [activeId, setActiveId] = useState<string | null>(null);
|
|
154
198
|
const optsRef = useRef<StartOptions>({});
|
|
199
|
+
// The latest props, read at the finish: a new `storageKey` or callback needs no remount.
|
|
200
|
+
const progressRef = useRef(progress);
|
|
201
|
+
const onCompleteRef = useRef(onComplete);
|
|
202
|
+
useLayoutEffect(() => {
|
|
203
|
+
progressRef.current = progress;
|
|
204
|
+
onCompleteRef.current = onComplete;
|
|
205
|
+
});
|
|
206
|
+
// The provider's migrate becomes the key's, for readers that pass none (keksdose B-2).
|
|
207
|
+
const storageKey = progress?.storageKey;
|
|
208
|
+
const migrate = progress?.migrate;
|
|
209
|
+
useLayoutEffect(() => {
|
|
210
|
+
if (storageKey !== undefined) rememberMigrate(storageKey, migrate);
|
|
211
|
+
}, [storageKey, migrate]);
|
|
212
|
+
const warnedNoTourRef = useRef(false);
|
|
155
213
|
// Live mirrors so the step-control callbacks can stay stable (identity-safe for
|
|
156
214
|
// consumers) while still reading the current steps/index. Synced in a layout
|
|
157
215
|
// effect — after the commit, before paint and before any passive effect or event
|
|
@@ -169,7 +227,7 @@ export function TourProvider({
|
|
|
169
227
|
optsRef.current = opts ?? {};
|
|
170
228
|
optsRef.current.onStart?.();
|
|
171
229
|
setIndex(Math.min(Math.max(0, opts?.startIndex ?? 0), Math.max(0, s.length - 1)));
|
|
172
|
-
setActiveId(opts?.id ?? null);
|
|
230
|
+
setActiveId(opts?.id ?? opts?.tour?.id ?? null);
|
|
173
231
|
setSteps(s);
|
|
174
232
|
}, []);
|
|
175
233
|
const stop = useCallback(() => {
|
|
@@ -177,7 +235,30 @@ export function TourProvider({
|
|
|
177
235
|
setActiveId(null);
|
|
178
236
|
}, []);
|
|
179
237
|
const finish = useCallback(() => {
|
|
180
|
-
|
|
238
|
+
// Once per tour: a second "Done" before the overlay unmounts records nothing more.
|
|
239
|
+
if (!stepsRef.current) return;
|
|
240
|
+
stepsRef.current = null;
|
|
241
|
+
const { tour, onFinish } = optsRef.current;
|
|
242
|
+
const store = progressRef.current;
|
|
243
|
+
// Exactly one write per completion (§9.6.2), so `awarded` is what `complete(tour)` answered.
|
|
244
|
+
let awarded: boolean | undefined;
|
|
245
|
+
if (store && tour) {
|
|
246
|
+
try {
|
|
247
|
+
awarded = completeStored(store.storageKey, store.migrate, tour);
|
|
248
|
+
} catch (error) {
|
|
249
|
+
// A stored text no migrate reads is left as it is (keksdose B-1); the tour still ends.
|
|
250
|
+
console.error(error);
|
|
251
|
+
awarded = false;
|
|
252
|
+
}
|
|
253
|
+
} else if (store && !tour && devWarnings() && !warnedNoTourRef.current) {
|
|
254
|
+
warnedNoTourRef.current = true;
|
|
255
|
+
console.warn(
|
|
256
|
+
"[ui-kit] TourProvider has `progress`, but a finished tour's start carried no `tour`: " +
|
|
257
|
+
"nothing was recorded and `awarded` is undefined. Start it with `start(steps, { tour })`.",
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
onCompleteRef.current?.(tour, { awarded });
|
|
261
|
+
onFinish?.();
|
|
181
262
|
setSteps(null);
|
|
182
263
|
setActiveId(null);
|
|
183
264
|
}, []);
|