@eifi1/ui-kit 0.42.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 +13 -7
- package/dist/admin/admin-area-labels.d.ts +4 -0
- package/dist/admin/admin-area-labels.js +3 -1
- package/dist/admin/admin-area-labels.js.map +1 -1
- package/dist/admin/admin-overview.d.ts +25 -8
- package/dist/admin/admin-overview.js +26 -3
- package/dist/admin/admin-overview.js.map +1 -1
- package/dist/admin/admin-sections.d.ts +36 -5
- package/dist/admin/admin-sections.js +35 -18
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/billing/billing-banner.d.ts +8 -0
- package/dist/billing/billing-banner.js +25 -2
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +4 -0
- package/dist/billing/billing-labels.js +1 -0
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/cancel-request.d.ts +53 -0
- package/dist/billing/cancel-request.js +21 -0
- package/dist/billing/cancel-request.js.map +1 -0
- package/dist/billing/provider-page.d.ts +49 -0
- package/dist/billing/provider-page.js +33 -0
- package/dist/billing/provider-page.js.map +1 -0
- package/dist/billing/subscription-actions.d.ts +32 -1
- package/dist/billing/subscription-actions.js +57 -4
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/components/account-chips.d.ts +8 -1
- package/dist/components/account-chips.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-menu.d.ts +7 -4
- package/dist/feedback/feedback-menu.js +4 -2
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/hooks/use-search-param-state.js +4 -1
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/defaults.js +2 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +36 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +3 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +51 -2
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +36 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +36 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +36 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +36 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +36 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +15 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -0
- package/dist/index.js +6 -0
- package/dist/index.js.map +1 -1
- package/dist/search/global-search.d.ts +3 -0
- package/dist/search/global-search.js +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/settings/settings-catalogue.d.ts +9 -1
- package/dist/settings/settings-catalogue.js +1 -1
- package/dist/settings/settings-catalogue.js.map +1 -1
- package/dist/shell/admin-menu.d.ts +26 -3
- package/dist/shell/admin-menu.js +8 -4
- package/dist/shell/admin-menu.js.map +1 -1
- package/dist/shell/dev-switchers.js +5 -0
- package/dist/shell/dev-switchers.js.map +1 -1
- package/dist/shell/top-bar-parts-labels.d.ts +6 -0
- package/dist/shell/top-bar-parts-labels.js +4 -1
- package/dist/shell/top-bar-parts-labels.js.map +1 -1
- package/dist/shell/tour-menu.d.ts +19 -10
- package/dist/shell/tour-menu.js +20 -6
- package/dist/shell/tour-menu.js.map +1 -1
- package/dist/shell.d.ts +2 -0
- package/dist/shell.js +2 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour-catalogue-labels.d.ts +44 -0
- package/dist/tour/tour-catalogue-labels.js +32 -0
- package/dist/tour/tour-catalogue-labels.js.map +1 -0
- package/dist/tour/tour-catalogue.d.ts +68 -0
- package/dist/tour/tour-catalogue.js +189 -0
- package/dist/tour/tour-catalogue.js.map +1 -0
- package/dist/tour/tour-info.d.ts +112 -0
- package/dist/tour/tour-info.js +36 -0
- package/dist/tour/tour-info.js.map +1 -0
- 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 +116 -0
- package/dist/tour/use-tour-progress.js +146 -0
- package/dist/tour/use-tour-progress.js.map +1 -0
- package/dist/tour/use-tour-url-sync.d.ts +58 -0
- package/dist/tour/use-tour-url-sync.js +110 -0
- package/dist/tour/use-tour-url-sync.js.map +1 -0
- package/dist/tour.d.ts +8 -0
- package/dist/tour.js +19 -0
- package/dist/tour.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-area-labels.ts +7 -1
- package/src/admin/admin-overview.tsx +61 -10
- package/src/admin/admin-sections.tsx +109 -48
- package/src/billing/billing-banner.tsx +25 -2
- package/src/billing/billing-labels.ts +6 -0
- package/src/billing/cancel-request.ts +75 -0
- package/src/billing/provider-page.ts +84 -0
- package/src/billing/subscription-actions.tsx +97 -11
- package/src/components/account-chips.tsx +8 -1
- package/src/feedback/feedback-menu.tsx +8 -3
- package/src/hooks/use-search-param-state.ts +6 -1
- package/src/i18n/defaults.ts +2 -0
- package/src/i18n/german.ts +36 -1
- package/src/i18n/kit-labels.tsx +3 -0
- package/src/i18n/locales/en.ts +13 -0
- package/src/i18n/locales/es.ts +36 -1
- package/src/i18n/locales/fr.ts +36 -1
- package/src/i18n/locales/hu.ts +36 -1
- package/src/i18n/locales/it.ts +36 -1
- package/src/i18n/locales/zh.ts +36 -1
- package/src/i18n/review.ts +14 -0
- package/src/index.ts +8 -0
- package/src/search/global-search.tsx +5 -0
- package/src/settings/settings-catalogue.ts +10 -2
- package/src/shell/admin-menu.tsx +34 -5
- package/src/shell/dev-switchers.tsx +8 -0
- package/src/shell/top-bar-parts-labels.ts +9 -0
- package/src/shell/tour-menu.tsx +50 -15
- package/src/shell.ts +3 -0
- package/src/tour/tour-catalogue-labels.ts +75 -0
- package/src/tour/tour-catalogue.tsx +316 -0
- package/src/tour/tour-info.ts +144 -0
- package/src/tour/tour.tsx +84 -3
- package/src/tour/use-tour-progress.ts +299 -0
- package/src/tour/use-tour-url-sync.ts +221 -0
- package/src/tour.ts +12 -0
|
@@ -0,0 +1,299 @@
|
|
|
1
|
+
import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
|
|
2
|
+
import { readStored, removeStored, writeStored } from "../lib/safe-storage";
|
|
3
|
+
import { isTourDone, type TourCompletions } from "./tour-info";
|
|
4
|
+
|
|
5
|
+
/**
|
|
6
|
+
* Which tours this device has completed (docs/tours-harmonization.md §2.6, §6.2) —
|
|
7
|
+
* keksdose's `tour-progress-store` and kastlan's `completions.ts`, as one hook.
|
|
8
|
+
*
|
|
9
|
+
* - **Device storage, the app's key.** `storageKey` is always the app's: an app on shared
|
|
10
|
+
* devices scopes it per account (KS, bench PCs), and an app that lists its keys
|
|
11
|
+
* (KS `storage-keys.ts`) spells it there. Every access is guarded: a blocked or full
|
|
12
|
+
* storage keeps the marks for this page's life instead of throwing.
|
|
13
|
+
* - **One store, every reader.** A change notifies every hook on the same key in this tab
|
|
14
|
+
* (an in-tab emitter) and in other tabs (the `storage` event), so the tours page and
|
|
15
|
+
* `TourMenu` agree without a reload (ka F3).
|
|
16
|
+
* - **`migrate(raw)`** reads an older shape, so nobody loses their done marks at adoption:
|
|
17
|
+
* it gets the stored JSON (or the raw text, if it is no JSON) and returns the record for
|
|
18
|
+
* a shape it recognises, `undefined` for any other — the kit's own shape included,
|
|
19
|
+
* which it then reads itself. keksdose: zustand's `{state: {completed}, version: 1}`;
|
|
20
|
+
* kastlan: an array of ids, completed without a date (`null`). It runs only on a stored
|
|
21
|
+
* text that is not the kit's shape already, once per text and key. The FIRST `migrate`
|
|
22
|
+
* any reader, `TourProvider` or {@link completeTour} passes for a key is remembered for
|
|
23
|
+
* that key (later identities are ignored), so a reader without one, mounted first, no
|
|
24
|
+
* longer hides the old marks from the rest (keksdose B-2). **Still, define the options
|
|
25
|
+
* once, at module level, and hand the provider and every reader the same object.**
|
|
26
|
+
* - **Never overwritten unread** (keksdose B-1): a write (`complete`, the provider's,
|
|
27
|
+
* `completeTour`) on a stored text that is neither empty nor the kit's shape, and that no
|
|
28
|
+
* known `migrate` reads, THROWS instead of replacing the old marks.
|
|
29
|
+
* - **Resetting the device** (settings §11): register the key with `reset: () =>
|
|
30
|
+
* resetTourProgress(storageKey)`, which clears storage and the page's memory and tells
|
|
31
|
+
* every reader in this tab.
|
|
32
|
+
*
|
|
33
|
+
* **The stored shape** (§9.3, §9.6.6), for an app's tests and seeds: the JSON record
|
|
34
|
+
* `{ [id]: ISO instant | "" }` under `storageKey`, where `""` is a completion without a
|
|
35
|
+
* date (a migrated record: kastlan's id list). That is the one written form; the hooks
|
|
36
|
+
* and callbacks (`completed`, `TourCompletions`) give `null` for `""`. A `null` read from
|
|
37
|
+
* storage (or returned by `migrate`) is accepted as the same thing and written back as
|
|
38
|
+
* `""`. To write a completion from outside a page, use {@link completeTour}.
|
|
39
|
+
*/
|
|
40
|
+
export interface UseTourProgressOptions {
|
|
41
|
+
storageKey: string;
|
|
42
|
+
migrate?: (raw: unknown) => Record<string, string | null> | undefined;
|
|
43
|
+
}
|
|
44
|
+
|
|
45
|
+
/** What `complete` takes: a tour id, or the tour (`{id, updated}`, a `TourInfo` fits). */
|
|
46
|
+
type CompletedTour = string | { id: string; updated?: { date: string } | null };
|
|
47
|
+
|
|
48
|
+
export interface TourProgress {
|
|
49
|
+
/** Tour id → the completion's ISO timestamp, or `null` when recorded without a date. */
|
|
50
|
+
completed: TourCompletions;
|
|
51
|
+
/**
|
|
52
|
+
* Record a completion. Keeps the FIRST completion's date, so a replay changes nothing —
|
|
53
|
+
* unless the tour was reset by an update since (§8): pass the tour (`{id, updated}`) and
|
|
54
|
+
* a completion from before `updated.date` is replaced by today's.
|
|
55
|
+
*
|
|
56
|
+
* Returns `true` when this counts as a new completion — the moment to award the tour's
|
|
57
|
+
* points and show the app's toast (§6.1) — and `false` for a replay.
|
|
58
|
+
*
|
|
59
|
+
* A tour started with `start(steps, {tour})` under a `TourProvider` that has `progress`
|
|
60
|
+
* is recorded by the provider (§9.6.2): do not call this from that start's `onFinish`
|
|
61
|
+
* too, or the second write answers `false`.
|
|
62
|
+
*/
|
|
63
|
+
complete: (tour: CompletedTour) => boolean;
|
|
64
|
+
/** "Mark as not done": forget the completion. */
|
|
65
|
+
reset: (id: string) => void;
|
|
66
|
+
}
|
|
67
|
+
|
|
68
|
+
/* ── The store: one per key, shared by every hook ───────────────────────────── */
|
|
69
|
+
|
|
70
|
+
const EMPTY: TourCompletions = Object.freeze({});
|
|
71
|
+
/** In-tab listeners per key. */
|
|
72
|
+
const listeners = new Map<string, Set<() => void>>();
|
|
73
|
+
/** What this page wrote where storage refused to keep it (blocked, full): per key. */
|
|
74
|
+
const memory = new Map<string, string>();
|
|
75
|
+
/**
|
|
76
|
+
* The last raw text read per key and what it parsed to, so a snapshot keeps its identity:
|
|
77
|
+
* the same text gives the same object, whichever reader asks and whatever `migrate` it
|
|
78
|
+
* holds. Several readers of one key with inline `migrate` arrows used to evict each other
|
|
79
|
+
* here and loop (kastlan B1, 0.43).
|
|
80
|
+
*/
|
|
81
|
+
const cache = new Map<string, { raw: string | null; value: TourCompletions; readable: boolean }>();
|
|
82
|
+
type Migrate = NonNullable<UseTourProgressOptions["migrate"]>;
|
|
83
|
+
/** Each key's migrate: the first one any reader, the provider or `completeTour` passes (B-2). */
|
|
84
|
+
const migrates = new Map<string, Migrate>();
|
|
85
|
+
|
|
86
|
+
/**
|
|
87
|
+
* Remember `migrate` for `key` if it is the first one, and return the key's migrate. A
|
|
88
|
+
* snapshot parsed before the key had one is dropped and re-parsed, and the key's readers
|
|
89
|
+
* hear of it after this render (never from inside one).
|
|
90
|
+
* @internal Not part of the barrel; `TourProvider` registers its `progress` with it.
|
|
91
|
+
*/
|
|
92
|
+
export function rememberMigrate(key: string, migrate: UseTourProgressOptions["migrate"]): Migrate | undefined {
|
|
93
|
+
if (migrate && !migrates.has(key)) {
|
|
94
|
+
migrates.set(key, migrate);
|
|
95
|
+
if (cache.delete(key)) queueMicrotask(() => emit(key));
|
|
96
|
+
}
|
|
97
|
+
return migrates.get(key);
|
|
98
|
+
}
|
|
99
|
+
|
|
100
|
+
function emit(key: string) {
|
|
101
|
+
listeners.get(key)?.forEach((listener) => listener());
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function rawOf(key: string): string | null {
|
|
105
|
+
return memory.get(key) ?? readStored(key);
|
|
106
|
+
}
|
|
107
|
+
|
|
108
|
+
/** Is this the kit's own shape already: a record whose every value is a string or null? */
|
|
109
|
+
function isKitShape(value: unknown): boolean {
|
|
110
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return false;
|
|
111
|
+
return Object.values(value as Record<string, unknown>).every((at) => at === null || typeof at === "string");
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
/** The kit's own shape: a record of string or null; anything else is dropped. */
|
|
115
|
+
function fromRecord(value: unknown): TourCompletions | undefined {
|
|
116
|
+
if (!value || typeof value !== "object" || Array.isArray(value)) return undefined;
|
|
117
|
+
const out: Record<string, string | null> = {};
|
|
118
|
+
for (const [id, at] of Object.entries(value as Record<string, unknown>)) {
|
|
119
|
+
if (at === null || at === "") out[id] = null;
|
|
120
|
+
else if (typeof at === "string") out[id] = at;
|
|
121
|
+
}
|
|
122
|
+
return out;
|
|
123
|
+
}
|
|
124
|
+
|
|
125
|
+
/**
|
|
126
|
+
* The record, and whether the text was READ (empty, the kit's shape, or a shape `migrate`
|
|
127
|
+
* knows) rather than dropped: a write over an unread text would lose it (B-1).
|
|
128
|
+
*/
|
|
129
|
+
function parse(raw: string | null, migrate: UseTourProgressOptions["migrate"]): { value: TourCompletions; readable: boolean } {
|
|
130
|
+
if (raw === null || raw.trim() === "") return { value: EMPTY, readable: true };
|
|
131
|
+
let value: unknown = raw;
|
|
132
|
+
try {
|
|
133
|
+
value = JSON.parse(raw);
|
|
134
|
+
} catch {
|
|
135
|
+
// Not JSON: `migrate` still sees the text.
|
|
136
|
+
}
|
|
137
|
+
if (migrate && !isKitShape(value)) {
|
|
138
|
+
try {
|
|
139
|
+
const migrated = fromRecord(migrate(value));
|
|
140
|
+
if (migrated !== undefined) return { value: migrated, readable: true };
|
|
141
|
+
} catch {
|
|
142
|
+
// A migrate that throws reads as "not a shape it knows".
|
|
143
|
+
}
|
|
144
|
+
}
|
|
145
|
+
return { value: fromRecord(value) ?? EMPTY, readable: isKitShape(value) };
|
|
146
|
+
}
|
|
147
|
+
|
|
148
|
+
function snapshot(key: string, migrate: UseTourProgressOptions["migrate"]): TourCompletions {
|
|
149
|
+
const known = rememberMigrate(key, migrate);
|
|
150
|
+
const raw = rawOf(key);
|
|
151
|
+
const hit = cache.get(key);
|
|
152
|
+
if (hit && hit.raw === raw) return hit.value;
|
|
153
|
+
const parsed = parse(raw, known);
|
|
154
|
+
cache.set(key, { raw, ...parsed });
|
|
155
|
+
return parsed.value;
|
|
156
|
+
}
|
|
157
|
+
|
|
158
|
+
/** The record a write builds on; throws rather than overwrite a text nobody could read (B-1). */
|
|
159
|
+
function readForWrite(key: string, migrate: UseTourProgressOptions["migrate"], own = false): TourCompletions {
|
|
160
|
+
// `own`: this migrate, even where the key registered another (completeTour's `{migrate}`).
|
|
161
|
+
const known = rememberMigrate(key, migrate);
|
|
162
|
+
const { value, readable } =
|
|
163
|
+
own && migrate && known !== migrate
|
|
164
|
+
? parse(rawOf(key), migrate)
|
|
165
|
+
: { value: snapshot(key, migrate), readable: cache.get(key)?.readable };
|
|
166
|
+
if (!readable) {
|
|
167
|
+
throw new Error(
|
|
168
|
+
`[ui-kit] tour progress: the text stored under "${key}" is not the kit's shape and no migrate reads it; ` +
|
|
169
|
+
"refusing to overwrite it. Pass the key's migrate (the shared useTourProgress / TourProvider options, " +
|
|
170
|
+
"or completeTour's {migrate}).",
|
|
171
|
+
);
|
|
172
|
+
}
|
|
173
|
+
return value;
|
|
174
|
+
}
|
|
175
|
+
|
|
176
|
+
/** The one written form (§9.6.6): a completion without a date is stored as `""`, never `null`. */
|
|
177
|
+
function write(key: string, record: TourCompletions) {
|
|
178
|
+
const stored: Record<string, string> = {};
|
|
179
|
+
for (const [id, at] of Object.entries(record)) stored[id] = at ?? "";
|
|
180
|
+
const raw = JSON.stringify(stored);
|
|
181
|
+
writeStored(key, raw);
|
|
182
|
+
// Storage refused it (blocked, full): keep it for this page's life instead.
|
|
183
|
+
if (readStored(key) === raw) memory.delete(key);
|
|
184
|
+
else memory.set(key, raw);
|
|
185
|
+
emit(key);
|
|
186
|
+
}
|
|
187
|
+
|
|
188
|
+
function subscribe(key: string, listener: () => void): () => void {
|
|
189
|
+
let set = listeners.get(key);
|
|
190
|
+
if (!set) listeners.set(key, (set = new Set()));
|
|
191
|
+
set.add(listener);
|
|
192
|
+
// Another tab wrote (or cleared all storage: `key` null).
|
|
193
|
+
const onStorage = (event: StorageEvent) => {
|
|
194
|
+
if (event.key !== null && event.key !== key) return;
|
|
195
|
+
memory.delete(key);
|
|
196
|
+
listener();
|
|
197
|
+
};
|
|
198
|
+
if (typeof window !== "undefined") window.addEventListener("storage", onStorage);
|
|
199
|
+
return () => {
|
|
200
|
+
set.delete(listener);
|
|
201
|
+
if (typeof window !== "undefined") window.removeEventListener("storage", onStorage);
|
|
202
|
+
};
|
|
203
|
+
}
|
|
204
|
+
|
|
205
|
+
/**
|
|
206
|
+
* Forget every tour completion under `storageKey` on this device: the stored key, what the
|
|
207
|
+
* page kept in memory where storage refused it, and every reader in this tab moves at
|
|
208
|
+
* once. For "Reset this device's settings" (settings §11):
|
|
209
|
+
*
|
|
210
|
+
* registerDeviceSetting({ key: TOUR_KEY, kind: "view", reset: () => resetTourProgress(TOUR_KEY) });
|
|
211
|
+
*/
|
|
212
|
+
export function resetTourProgress(storageKey: string): void {
|
|
213
|
+
removeStored(storageKey);
|
|
214
|
+
memory.delete(storageKey);
|
|
215
|
+
cache.delete(storageKey);
|
|
216
|
+
emit(storageKey);
|
|
217
|
+
}
|
|
218
|
+
|
|
219
|
+
/**
|
|
220
|
+
* Record a completion under `storageKey`, as {@link TourProgress.complete} does: through
|
|
221
|
+
* the one shared store and snapshot (never a copy), so every reader of the key moves.
|
|
222
|
+
* Returns `true` when it counts anew. `TourProvider` calls this on a tour's last step.
|
|
223
|
+
* @internal Not part of the barrel.
|
|
224
|
+
*/
|
|
225
|
+
export function completeStored(
|
|
226
|
+
storageKey: string,
|
|
227
|
+
migrate: UseTourProgressOptions["migrate"],
|
|
228
|
+
tour: CompletedTour,
|
|
229
|
+
): boolean {
|
|
230
|
+
const subject = typeof tour === "string" ? { id: tour } : tour;
|
|
231
|
+
// Read now rather than from a render: two completions in one handler both count.
|
|
232
|
+
const current = readForWrite(storageKey, migrate);
|
|
233
|
+
if (isTourDone(subject, current[subject.id])) return false;
|
|
234
|
+
write(storageKey, { ...current, [subject.id]: new Date().toISOString() });
|
|
235
|
+
return true;
|
|
236
|
+
}
|
|
237
|
+
|
|
238
|
+
/**
|
|
239
|
+
* Write a completion from outside a page (§9.2): for an app's tests, seeds and scripts,
|
|
240
|
+
* beside {@link resetTourProgress}. It writes the kit's stored shape (`{[id]: ISO | ""}`)
|
|
241
|
+
* under `storageKey`, keeps the other tours' marks, and notifies every reader of the key
|
|
242
|
+
* in this tab directly (the `storage` event reaches only OTHER tabs), so a mounted
|
|
243
|
+
* `useTourProgress` or `TourCatalogue` moves at once.
|
|
244
|
+
*
|
|
245
|
+
* Unlike `complete`, it always writes (over a readable text; see below): `at` is the
|
|
246
|
+
* completion's instant (an ISO string or a `Date`, default now), `null` a completion
|
|
247
|
+
* without a date (stored as `""`, read back as `null`).
|
|
248
|
+
*
|
|
249
|
+
* completeTour(TOUR_KEY, "budget", "2026-09-01T10:00:00Z");
|
|
250
|
+
*
|
|
251
|
+
* A stored text in an app's older shape is read through `opts.migrate`, or else the
|
|
252
|
+
* migrate a reader or the provider registered for the key. If the text is neither empty
|
|
253
|
+
* nor the kit's shape and no migrate reads it, this THROWS and writes nothing (B-1):
|
|
254
|
+
*
|
|
255
|
+
* completeTour(TOUR_KEY, "budget", undefined, { migrate: fromZustand });
|
|
256
|
+
*/
|
|
257
|
+
export function completeTour(
|
|
258
|
+
storageKey: string,
|
|
259
|
+
tour: string | { id: string },
|
|
260
|
+
at?: string | Date | null,
|
|
261
|
+
opts?: { migrate?: UseTourProgressOptions["migrate"] },
|
|
262
|
+
): void {
|
|
263
|
+
const id = typeof tour === "string" ? tour : tour.id;
|
|
264
|
+
const when = at === null ? null : at instanceof Date ? at.toISOString() : (at ?? new Date().toISOString());
|
|
265
|
+
write(storageKey, { ...readForWrite(storageKey, opts?.migrate, true), [id]: when });
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
/** The device's tour completions under `storageKey`; see {@link UseTourProgressOptions}. */
|
|
269
|
+
export function useTourProgress({ storageKey, migrate }: UseTourProgressOptions): TourProgress {
|
|
270
|
+
// Held, not depended on: an inline arrow is a new function every render, and neither the
|
|
271
|
+
// snapshot nor the callbacks may change with it.
|
|
272
|
+
const migrateRef = useRef(migrate);
|
|
273
|
+
useEffect(() => {
|
|
274
|
+
migrateRef.current = migrate;
|
|
275
|
+
});
|
|
276
|
+
const completed = useSyncExternalStore(
|
|
277
|
+
useCallback((listener: () => void) => subscribe(storageKey, listener), [storageKey]),
|
|
278
|
+
() => snapshot(storageKey, migrateRef.current),
|
|
279
|
+
() => EMPTY,
|
|
280
|
+
);
|
|
281
|
+
|
|
282
|
+
const complete = useCallback(
|
|
283
|
+
(tour: CompletedTour) => completeStored(storageKey, migrateRef.current, tour),
|
|
284
|
+
[storageKey],
|
|
285
|
+
);
|
|
286
|
+
|
|
287
|
+
const reset = useCallback(
|
|
288
|
+
(id: string) => {
|
|
289
|
+
const current = snapshot(storageKey, migrateRef.current);
|
|
290
|
+
if (current[id] === undefined) return;
|
|
291
|
+
const next = { ...current };
|
|
292
|
+
delete next[id];
|
|
293
|
+
write(storageKey, next);
|
|
294
|
+
},
|
|
295
|
+
[storageKey],
|
|
296
|
+
);
|
|
297
|
+
|
|
298
|
+
return useMemo(() => ({ completed, complete, reset }), [completed, complete, reset]);
|
|
299
|
+
}
|
|
@@ -0,0 +1,221 @@
|
|
|
1
|
+
import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type Context } from "react";
|
|
2
|
+
import * as ReactRouter from "react-router";
|
|
3
|
+
import { useLocation, useNavigate, type Navigator } from "react-router";
|
|
4
|
+
import { useSearchParamsState } from "../hooks/use-search-param-state";
|
|
5
|
+
import { useTourOptional } from "./tour";
|
|
6
|
+
|
|
7
|
+
/* ── useTourUrlSync ─────────────────────────────────────────────────────────── */
|
|
8
|
+
|
|
9
|
+
export interface UseTourUrlSyncOptions {
|
|
10
|
+
/**
|
|
11
|
+
* Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the
|
|
12
|
+
* steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points
|
|
13
|
+
* toast) to `useTour().start`. An unknown id should do nothing. May return a promise:
|
|
14
|
+
* the parameters are kept until it settles.
|
|
15
|
+
*/
|
|
16
|
+
launch: (id: string, options: { startIndex: number }) => void | Promise<unknown>;
|
|
17
|
+
/**
|
|
18
|
+
* Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the
|
|
19
|
+
* caches a branching tour reads)? A deep link waits for it, keeping its parameters, so
|
|
20
|
+
* an F5 mid-tour resumes the right variant at the right step. Default `true`.
|
|
21
|
+
*/
|
|
22
|
+
ready?: boolean;
|
|
23
|
+
/**
|
|
24
|
+
* Older names of `tourStep`, read when it is absent and removed with it — kastlan's
|
|
25
|
+
* `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.
|
|
26
|
+
*/
|
|
27
|
+
stepAliases?: readonly string[];
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
type Place = { pathname: string; search: string; hash: string };
|
|
31
|
+
|
|
32
|
+
/**
|
|
33
|
+
* Has the router already moved on from the location React is still rendering?
|
|
34
|
+
* keksdose's check (tour-url-sync.tsx, tours audit 2026-10-02): a mirror write built
|
|
35
|
+
* from the page being LEFT is a replace back onto it, which cancels a step's `go()`.
|
|
36
|
+
*
|
|
37
|
+
* - A data router (`createBrowserRouter`, kastlan): its navigator has no location (build
|
|
38
|
+
* review 0.43, kastlan A2), so the router's own state answers — a navigation still
|
|
39
|
+
* loading, or a location it has committed that React has not rendered yet.
|
|
40
|
+
* - A declarative router (`BrowserRouter`, keksdose): its navigator is its history and
|
|
41
|
+
* carries where it is.
|
|
42
|
+
* - Anything else answers "not in flight".
|
|
43
|
+
*/
|
|
44
|
+
function navigationInFlight(history: Navigator | undefined, dataRouter: DataRouterProbe, rendered: Place) {
|
|
45
|
+
const state = dataRouter?.router?.state;
|
|
46
|
+
if (state && (state.navigation?.state ?? "idle") !== "idle") return true;
|
|
47
|
+
const live = state?.location ?? (history as (Navigator & { location?: Partial<Place> }) | undefined)?.location;
|
|
48
|
+
if (!live) return false;
|
|
49
|
+
return (
|
|
50
|
+
live.pathname !== rendered.pathname || (live.search ?? "") !== rendered.search || (live.hash ?? "") !== rendered.hash
|
|
51
|
+
);
|
|
52
|
+
}
|
|
53
|
+
|
|
54
|
+
type NavigationProbe = { navigator?: Navigator } | null;
|
|
55
|
+
const NO_NAVIGATION = createContext<NavigationProbe>(null);
|
|
56
|
+
/** The data router's state, as `useSearchParamState` reads it. */
|
|
57
|
+
type DataRouterProbe = {
|
|
58
|
+
router?: { state?: { navigation?: { state?: string }; location?: Partial<Place> } };
|
|
59
|
+
} | null;
|
|
60
|
+
const NO_DATA_ROUTER = createContext<DataRouterProbe>(null);
|
|
61
|
+
const DataRouterContext: Context<DataRouterProbe> = (() => {
|
|
62
|
+
try {
|
|
63
|
+
return (
|
|
64
|
+
(ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??
|
|
65
|
+
NO_DATA_ROUTER
|
|
66
|
+
);
|
|
67
|
+
} catch {
|
|
68
|
+
return NO_DATA_ROUTER;
|
|
69
|
+
}
|
|
70
|
+
})();
|
|
71
|
+
/** Read off the namespace inside a `try`, as `useSearchParamState` reads the data router:
|
|
72
|
+
* a test's partial `vi.mock("react-router")` throws on an export it does not define. */
|
|
73
|
+
const NavigationContext: Context<NavigationProbe> = (() => {
|
|
74
|
+
try {
|
|
75
|
+
return (
|
|
76
|
+
(ReactRouter as unknown as { UNSAFE_NavigationContext?: Context<NavigationProbe> }).UNSAFE_NavigationContext ??
|
|
77
|
+
NO_NAVIGATION
|
|
78
|
+
);
|
|
79
|
+
} catch {
|
|
80
|
+
return NO_NAVIGATION;
|
|
81
|
+
}
|
|
82
|
+
})();
|
|
83
|
+
|
|
84
|
+
/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */
|
|
85
|
+
function stepOf(params: URLSearchParams, aliases: readonly string[]): number {
|
|
86
|
+
const step = Number(params.get("tourStep"));
|
|
87
|
+
if (params.has("tourStep")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;
|
|
88
|
+
for (const alias of aliases) {
|
|
89
|
+
const value = Number(params.get(alias));
|
|
90
|
+
if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
|
|
91
|
+
}
|
|
92
|
+
return 0;
|
|
93
|
+
}
|
|
94
|
+
|
|
95
|
+
/**
|
|
96
|
+
* Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,
|
|
97
|
+
* on the kit's engine (`useTourOptional`):
|
|
98
|
+
*
|
|
99
|
+
* - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app
|
|
100
|
+
* navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —
|
|
101
|
+
* resuming at `&tourStep=<n>` (1-based). It waits for `ready`.
|
|
102
|
+
* - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied
|
|
103
|
+
* link pins the step (kk K-3). A step that navigates drops them; this puts them back.
|
|
104
|
+
* - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.
|
|
105
|
+
*
|
|
106
|
+
* Mount it once inside the router and the `TourProvider`. Without a provider it still
|
|
107
|
+
* launches through `launch` and never mirrors.
|
|
108
|
+
*/
|
|
109
|
+
export function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTourUrlSyncOptions): void {
|
|
110
|
+
const navigate = useNavigate();
|
|
111
|
+
const location = useLocation();
|
|
112
|
+
const { pathname, search, hash } = location;
|
|
113
|
+
const history = useContext(NavigationContext)?.navigator;
|
|
114
|
+
const dataRouter = useContext(DataRouterContext);
|
|
115
|
+
const engine = useTourOptional();
|
|
116
|
+
const active = engine?.active ?? false;
|
|
117
|
+
const activeId = engine?.activeId ?? null;
|
|
118
|
+
const index = engine?.index ?? 0;
|
|
119
|
+
const urlTour = new URLSearchParams(search).get("tour");
|
|
120
|
+
const aliases = stepAliases.join("\n");
|
|
121
|
+
|
|
122
|
+
const launchRef = useRef(launch);
|
|
123
|
+
const activeIdRef = useRef(activeId);
|
|
124
|
+
useLayoutEffect(() => {
|
|
125
|
+
launchRef.current = launch;
|
|
126
|
+
activeIdRef.current = active ? activeId : null;
|
|
127
|
+
});
|
|
128
|
+
/** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */
|
|
129
|
+
const seenRef = useRef<string | null>(null);
|
|
130
|
+
/** A deep link waiting for `ready`. */
|
|
131
|
+
const pendingRef = useRef<{ id: string; startIndex: number } | null>(null);
|
|
132
|
+
/** A launch under way (an app's `prepare()`). */
|
|
133
|
+
const launchingRef = useRef(false);
|
|
134
|
+
/** Bumped when a launch settles, so the mirror looks again. */
|
|
135
|
+
const [settled, setSettled] = useState(0);
|
|
136
|
+
|
|
137
|
+
useEffect(() => {
|
|
138
|
+
if (urlTour !== seenRef.current) {
|
|
139
|
+
seenRef.current = urlTour;
|
|
140
|
+
// Its own mirror writing the running tour back is no new link.
|
|
141
|
+
pendingRef.current =
|
|
142
|
+
urlTour && urlTour !== activeIdRef.current
|
|
143
|
+
? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split("\n") : []) }
|
|
144
|
+
: null;
|
|
145
|
+
}
|
|
146
|
+
const pending = pendingRef.current;
|
|
147
|
+
if (!pending || !ready) return;
|
|
148
|
+
pendingRef.current = null;
|
|
149
|
+
launchingRef.current = true;
|
|
150
|
+
void Promise.resolve()
|
|
151
|
+
.then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }))
|
|
152
|
+
.catch(() => undefined)
|
|
153
|
+
.finally(() => {
|
|
154
|
+
launchingRef.current = false;
|
|
155
|
+
setSettled((n) => n + 1);
|
|
156
|
+
});
|
|
157
|
+
// Only a change of the parameter, or readiness, starts a tour.
|
|
158
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
159
|
+
}, [urlTour, ready]);
|
|
160
|
+
|
|
161
|
+
useEffect(() => {
|
|
162
|
+
if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;
|
|
163
|
+
// A link not yet used keeps its parameters: a reload in that window resumes.
|
|
164
|
+
if (pendingRef.current || launchingRef.current) return;
|
|
165
|
+
const tourId = active && activeId ? activeId : null;
|
|
166
|
+
const own = ["tour", "tourStep", ...(aliases ? aliases.split("\n") : [])];
|
|
167
|
+
const current = new URLSearchParams(search);
|
|
168
|
+
// Nothing to mirror and nothing to strip: never touch the URL (a no-op replace
|
|
169
|
+
// clobbers a page's own just-issued navigation, keksdose feedback #4).
|
|
170
|
+
if (!tourId && !own.some((key) => current.has(key))) return;
|
|
171
|
+
const next = new URLSearchParams(search);
|
|
172
|
+
// `set` keeps a parameter where it stands; only the aliases (and, at the end, the
|
|
173
|
+
// tour's own) go.
|
|
174
|
+
for (const key of tourId ? own.slice(2) : own) next.delete(key);
|
|
175
|
+
if (tourId) {
|
|
176
|
+
next.set("tour", tourId);
|
|
177
|
+
next.set("tourStep", String(index + 1));
|
|
178
|
+
}
|
|
179
|
+
if (next.toString() === current.toString()) return;
|
|
180
|
+
// Spelled out, not `setSearchParams` ("?…" drops the hash, keksdose dev#495).
|
|
181
|
+
void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });
|
|
182
|
+
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
183
|
+
}, [active, activeId, index, pathname, search, hash, settled, aliases]);
|
|
184
|
+
}
|
|
185
|
+
|
|
186
|
+
/* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */
|
|
187
|
+
|
|
188
|
+
/** The catalogue's state filter. */
|
|
189
|
+
export type TourStateFilter = "all" | "todo" | "done";
|
|
190
|
+
|
|
191
|
+
/** The catalogue's filters: the search, the group (`null`: every group), the state. */
|
|
192
|
+
export interface TourCatalogueFilter {
|
|
193
|
+
query: string;
|
|
194
|
+
group: string | null;
|
|
195
|
+
state: TourStateFilter;
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
const STATES: readonly TourStateFilter[] = ["all", "todo", "done"];
|
|
199
|
+
|
|
200
|
+
/**
|
|
201
|
+
* The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each
|
|
202
|
+
* written with `replace` (typing does not bury the Back button), the defaults as the
|
|
203
|
+
* clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.
|
|
204
|
+
* `params` renames them (kastlan's `?group`). Requires a react-router Router.
|
|
205
|
+
*/
|
|
206
|
+
export function useTourCatalogueUrlState(
|
|
207
|
+
params: { query?: string; group?: string; state?: string } = {},
|
|
208
|
+
): TourCatalogueFilter & { onChange: (patch: Partial<TourCatalogueFilter>) => void } {
|
|
209
|
+
const [filter, setFilter] = useSearchParamsState<TourCatalogueFilter>({
|
|
210
|
+
query: { param: params.query ?? "q", default: "", history: "replace" },
|
|
211
|
+
group: { param: params.group ?? "topic", default: null, history: "replace", parse: (raw) => raw || undefined },
|
|
212
|
+
state: {
|
|
213
|
+
param: params.state ?? "state",
|
|
214
|
+
default: "all",
|
|
215
|
+
history: "replace",
|
|
216
|
+
parse: (raw) => STATES.find((x) => x === raw),
|
|
217
|
+
},
|
|
218
|
+
});
|
|
219
|
+
// `group: null` is a value to the setter (it clears `?topic`); `undefined` leaves it.
|
|
220
|
+
return { ...filter, onChange: setFilter };
|
|
221
|
+
}
|
package/src/tour.ts
CHANGED
|
@@ -1,3 +1,15 @@
|
|
|
1
1
|
// `@eifi1/ui-kit/tour` — guided tours.
|
|
2
2
|
// A re-slicing of the main barrel, not a new API.
|
|
3
3
|
export * from "./tour/tour";
|
|
4
|
+
// 0.43.0: the tours page (docs/tours-harmonization.md §3, §8) — keksdose's catalogue, its
|
|
5
|
+
// card, the NEW and reset rules, device progress and the URL. `useTourUrlSync` and
|
|
6
|
+
// `useTourCatalogueUrlState` need react-router (a peer already, for /shell).
|
|
7
|
+
export { TourCatalogue, TourCatalogueCard, DEFAULT_TOUR_CATALOGUE_LABELS } from "./tour/tour-catalogue";
|
|
8
|
+
export type { TourCatalogueProps, TourCatalogueCardProps, TourCatalogueLabels } from "./tour/tour-catalogue";
|
|
9
|
+
export { isTourNew, isTourReset, isTourDone, doneCount, earnedPoints } from "./tour/tour-info";
|
|
10
|
+
export type { TourInfo, TourGroup, TourUpdate, TourCompletions } from "./tour/tour-info";
|
|
11
|
+
// 0.44.0: the writer outside a page (§9.2).
|
|
12
|
+
export { useTourProgress, resetTourProgress, completeTour } from "./tour/use-tour-progress";
|
|
13
|
+
export type { UseTourProgressOptions, TourProgress } from "./tour/use-tour-progress";
|
|
14
|
+
export { useTourUrlSync, useTourCatalogueUrlState } from "./tour/use-tour-url-sync";
|
|
15
|
+
export type { UseTourUrlSyncOptions, TourCatalogueFilter, TourStateFilter } from "./tour/use-tour-url-sync";
|