@eifi1/ui-kit 0.43.0 → 0.45.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.
Files changed (134) hide show
  1. package/README.md +11 -7
  2. package/dist/admin/admin-action-log-labels.js +9 -0
  3. package/dist/admin/admin-action-log-labels.js.map +1 -1
  4. package/dist/admin/admin-action-log.d.ts +18 -1
  5. package/dist/admin/admin-action-log.js +25 -16
  6. package/dist/admin/admin-action-log.js.map +1 -1
  7. package/dist/admin/admin-area-labels.d.ts +6 -0
  8. package/dist/admin/admin-area-labels.js +1 -0
  9. package/dist/admin/admin-area-labels.js.map +1 -1
  10. package/dist/admin/mail-test-card-labels.d.ts +3 -0
  11. package/dist/admin/mail-test-card-labels.js +17 -0
  12. package/dist/admin/mail-test-card-labels.js.map +1 -0
  13. package/dist/admin/mail-test-card.d.ts +74 -0
  14. package/dist/admin/mail-test-card.js +80 -0
  15. package/dist/admin/mail-test-card.js.map +1 -0
  16. package/dist/billing/provider-page.d.ts +9 -0
  17. package/dist/billing/provider-page.js.map +1 -1
  18. package/dist/components/data-table-mobile.js +17 -2
  19. package/dist/components/data-table-mobile.js.map +1 -1
  20. package/dist/components/data-table-shared.d.ts +2 -0
  21. package/dist/components/data-table-shared.js +8 -0
  22. package/dist/components/data-table-shared.js.map +1 -1
  23. package/dist/components/data-table.js +1 -6
  24. package/dist/components/data-table.js.map +1 -1
  25. package/dist/eslint/index.js +1 -1
  26. package/dist/i18n/defaults.js +9 -1
  27. package/dist/i18n/defaults.js.map +1 -1
  28. package/dist/i18n/german.js +42 -2
  29. package/dist/i18n/german.js.map +1 -1
  30. package/dist/i18n/kit-labels.d.ts +13 -0
  31. package/dist/i18n/kit-labels.js.map +1 -1
  32. package/dist/i18n/locales/en.js +60 -4
  33. package/dist/i18n/locales/en.js.map +3 -3
  34. package/dist/i18n/locales/es.js +40 -0
  35. package/dist/i18n/locales/es.js.map +1 -1
  36. package/dist/i18n/locales/fr.js +42 -2
  37. package/dist/i18n/locales/fr.js.map +1 -1
  38. package/dist/i18n/locales/hu.js +40 -0
  39. package/dist/i18n/locales/hu.js.map +1 -1
  40. package/dist/i18n/locales/it.js +41 -1
  41. package/dist/i18n/locales/it.js.map +1 -1
  42. package/dist/i18n/locales/zh.js +40 -0
  43. package/dist/i18n/locales/zh.js.map +1 -1
  44. package/dist/i18n/review.js +13 -1
  45. package/dist/i18n/review.js.map +1 -1
  46. package/dist/index.d.ts +2 -0
  47. package/dist/index.js +5 -0
  48. package/dist/index.js.map +1 -1
  49. package/dist/lib/format.d.ts +2 -1
  50. package/dist/lib/format.js +6 -1
  51. package/dist/lib/format.js.map +1 -1
  52. package/dist/offline/pwa-update-prompt-labels.d.ts +2 -0
  53. package/dist/offline/pwa-update-prompt-labels.js +9 -0
  54. package/dist/offline/pwa-update-prompt-labels.js.map +1 -0
  55. package/dist/offline/pwa-update-prompt.d.ts +32 -0
  56. package/dist/offline/pwa-update-prompt.js +36 -0
  57. package/dist/offline/pwa-update-prompt.js.map +1 -0
  58. package/dist/offline/query-persistence.d.ts +100 -0
  59. package/dist/offline/query-persistence.js +179 -0
  60. package/dist/offline/query-persistence.js.map +1 -0
  61. package/dist/offline/shared-computer-labels.d.ts +2 -0
  62. package/dist/offline/shared-computer-labels.js +9 -0
  63. package/dist/offline/shared-computer-labels.js.map +1 -0
  64. package/dist/offline/shared-computer.d.ts +44 -0
  65. package/dist/offline/shared-computer.js +51 -0
  66. package/dist/offline/shared-computer.js.map +1 -0
  67. package/dist/offline/sign-out-pending-dialog-labels.d.ts +2 -0
  68. package/dist/offline/sign-out-pending-dialog-labels.js +14 -0
  69. package/dist/offline/sign-out-pending-dialog-labels.js.map +1 -0
  70. package/dist/offline/sign-out-pending-dialog.d.ts +58 -0
  71. package/dist/offline/sign-out-pending-dialog.js +66 -0
  72. package/dist/offline/sign-out-pending-dialog.js.map +1 -0
  73. package/dist/offline.d.ts +8 -0
  74. package/dist/offline.js +24 -0
  75. package/dist/offline.js.map +1 -0
  76. package/dist/shell/admin-menu.d.ts +24 -0
  77. package/dist/shell/admin-menu.js +12 -3
  78. package/dist/shell/admin-menu.js.map +1 -1
  79. package/dist/shell.d.ts +2 -0
  80. package/dist/shell.js +2 -0
  81. package/dist/shell.js.map +1 -1
  82. package/dist/tour/tour-catalogue-labels.js +2 -2
  83. package/dist/tour/tour-catalogue-labels.js.map +1 -1
  84. package/dist/tour/tour-catalogue.d.ts +7 -1
  85. package/dist/tour/tour-catalogue.js.map +1 -1
  86. package/dist/tour/tour-info.d.ts +2 -2
  87. package/dist/tour/tour-info.js.map +1 -1
  88. package/dist/tour/tour.d.ts +38 -2
  89. package/dist/tour/tour.js +42 -3
  90. package/dist/tour/tour.js.map +1 -1
  91. package/dist/tour/use-tour-progress.d.ts +165 -12
  92. package/dist/tour/use-tour-progress.js +271 -25
  93. package/dist/tour/use-tour-progress.js.map +1 -1
  94. package/dist/tour.d.ts +3 -1
  95. package/dist/tour.js +4 -1
  96. package/dist/tour.js.map +1 -1
  97. package/package.json +25 -1
  98. package/src/admin/admin-action-log-labels.ts +9 -0
  99. package/src/admin/admin-action-log.tsx +41 -5
  100. package/src/admin/admin-area-labels.ts +7 -0
  101. package/src/admin/mail-test-card-labels.ts +19 -0
  102. package/src/admin/mail-test-card.tsx +186 -0
  103. package/src/billing/provider-page.ts +9 -0
  104. package/src/components/data-table-mobile.tsx +26 -3
  105. package/src/components/data-table-shared.tsx +18 -1
  106. package/src/components/data-table.tsx +1 -15
  107. package/src/i18n/defaults.ts +8 -0
  108. package/src/i18n/german.ts +48 -2
  109. package/src/i18n/kit-labels.tsx +13 -0
  110. package/src/i18n/locales/en.ts +13 -1
  111. package/src/i18n/locales/es.ts +46 -0
  112. package/src/i18n/locales/fr.ts +48 -2
  113. package/src/i18n/locales/hu.ts +42 -0
  114. package/src/i18n/locales/it.ts +47 -1
  115. package/src/i18n/locales/zh.ts +40 -0
  116. package/src/i18n/review.ts +12 -0
  117. package/src/index.ts +3 -0
  118. package/src/lib/format.ts +13 -2
  119. package/src/offline/pwa-update-prompt-labels.ts +9 -0
  120. package/src/offline/pwa-update-prompt.tsx +72 -0
  121. package/src/offline/query-persistence.ts +323 -0
  122. package/src/offline/shared-computer-labels.ts +8 -0
  123. package/src/offline/shared-computer.tsx +87 -0
  124. package/src/offline/sign-out-pending-dialog-labels.ts +21 -0
  125. package/src/offline/sign-out-pending-dialog.tsx +135 -0
  126. package/src/offline.ts +26 -0
  127. package/src/shell/admin-menu.tsx +40 -3
  128. package/src/shell.ts +4 -0
  129. package/src/tour/tour-catalogue-labels.ts +2 -2
  130. package/src/tour/tour-catalogue.tsx +7 -1
  131. package/src/tour/tour-info.ts +2 -2
  132. package/src/tour/tour.tsx +87 -3
  133. package/src/tour/use-tour-progress.ts +501 -34
  134. package/src/tour.ts +5 -1
@@ -15,20 +15,112 @@ import { type TourCompletions } from "./tour-info.js";
15
15
  * a shape it recognises, `undefined` for any other — the kit's own shape included,
16
16
  * which it then reads itself. keksdose: zustand's `{state: {completed}, version: 1}`;
17
17
  * kastlan: an array of ids, completed without a date (`null`). It runs only on a stored
18
- * text that is not the kit's shape already, once per text and key, whichever reader
19
- * reads it first; its identity is ignored. **Define `migrate` once, at module level**,
20
- * and hand every reader of the key the same one: readers of one key share one snapshot.
18
+ * text that is not the kit's shape already, once per text and key. The FIRST `migrate`
19
+ * any reader, `TourProvider` or {@link completeTour} passes for a key is remembered for
20
+ * that key (later identities are ignored), so a reader without one, mounted first, no
21
+ * longer hides the old marks from the rest (keksdose B-2). **Still, define the options
22
+ * once, at module level, and hand the provider and every reader the same object.**
23
+ * - **Never overwritten unread** (keksdose B-1): a write (`complete`, the provider's,
24
+ * `completeTour`) on a stored text that is neither empty nor the kit's shape, and that no
25
+ * known `migrate` reads, THROWS instead of replacing the old marks.
21
26
  * - **Resetting the device** (settings §11): register the key with `reset: () =>
22
27
  * resetTourProgress(storageKey)`, which clears storage and the page's memory and tells
23
28
  * every reader in this tab.
24
29
  *
25
- * The kit's own shape is the record itself, `{ [id]: ISO | null }`; an empty string reads
26
- * as `null` (kastlan's record of 0.42).
30
+ * **The stored shape** (§9.3, §9.6.6), for an app's tests and seeds: the JSON record
31
+ * `{ [id]: ISO instant | "" }` under `storageKey`, where `""` is a completion without a
32
+ * date (a migrated record: kastlan's id list). That is the one written form; the hooks
33
+ * and callbacks (`completed`, `TourCompletions`) give `null` for `""`. A `null` read from
34
+ * storage (or returned by `migrate`) is accepted as the same thing and written back as
35
+ * `""`. To write a completion from outside a page, use {@link completeTour}.
36
+ *
37
+ * **On the account** (0.45, §10.2–§10.3): with `account`, the device key becomes the
38
+ * account's CACHE — see {@link TourProgressAccount}. Without it, nothing changes from 0.44.
27
39
  */
28
40
  export interface UseTourProgressOptions {
29
41
  storageKey: string;
30
42
  migrate?: (raw: unknown) => Record<string, string | null> | undefined;
43
+ /**
44
+ * 0.45: the account's record behind the device key (§10.2.4). Remembered per key like
45
+ * `migrate` (the FIRST one any reader or the provider passes wins). With it,
46
+ * `storageKey` must be per account (a prefix plus the user id), so build the options per
47
+ * account and keep ONE stable object per key — memoise them in a `Map` by user id — and
48
+ * let `load`/`save` read the current token when they run, never capture one:
49
+ *
50
+ * const byUser = new Map<string, UseTourProgressOptions>();
51
+ * function tourProgressFor(user: { id: string; isDemo: boolean } | null) {
52
+ * if (!user || user.isDemo) return { storageKey: "app.tours.completions" }; // no account
53
+ * let options = byUser.get(user.id);
54
+ * if (!options) byUser.set(user.id, (options = { storageKey: `app.tours.completions:${user.id}`, account }));
55
+ * return options;
56
+ * }
57
+ *
58
+ * Signed out, and in a demo, pass none (and a fixed key): the device record works as in
59
+ * 0.44, and a demo neither PATCHes into a 403 nor leaves an orphan key per demo id.
60
+ */
61
+ account?: TourProgressAccount;
62
+ }
63
+ /** The record on the wire (§10.2.2): tour id → ISO instant, or `""` (done, no date). */
64
+ type TourProgressWire = Record<string, string>;
65
+ /** The `PATCH /tours/progress` body (§10.2.2): what to mark done, and what to forget.
66
+ * An id is never in both. Either may be left out. */
67
+ export interface TourProgressBody {
68
+ completed?: TourProgressWire;
69
+ forget?: string[];
70
+ }
71
+ /**
72
+ * Taking over a device-wide record from before the account (§10.2.5), on the FIRST load
73
+ * of the account key on this page:
74
+ * - `{storageKey, discard: true}` (kastlan): removes the old key and uploads nothing — a
75
+ * shared PC must not hand one person another's marks. Removing an absent key is a no-op.
76
+ * - `{storageKey, migrate?}`: reads the old key once (through `migrate` for an older
77
+ * shape), uploads it to the first account that signs in on this device, then removes it.
78
+ */
79
+ export type TourProgressAdopt = {
80
+ storageKey: string;
81
+ discard: true;
82
+ migrate?: never;
83
+ } | {
84
+ storageKey: string;
85
+ discard?: false;
86
+ migrate?: UseTourProgressOptions["migrate"];
87
+ };
88
+ /**
89
+ * The app's account record (§10.3): `load()` is its `GET /tours/progress`, `save(body)`
90
+ * its `PATCH`, each resolving to the parsed answer, `{completed}` — the whole record.
91
+ *
92
+ * - The page paints from the device key at once (and offline); the account is loaded on
93
+ * the first reader of the key on this page, on `online`, and after each save, and its
94
+ * answer replaces the cache.
95
+ * - A write (`complete`, the provider's finish, `reset`) goes to the cache at once and to
96
+ * a PENDING record beside it, `<storageKey>:pending` (`{synced, completed, forget}`),
97
+ * sent as one PATCH and cleared by its answer. A failure with no answer (offline, 5xx,
98
+ * 401) keeps it for the next load or `online`; a 402 (a lapsed plan) keeps it but sends it
99
+ * no more on this page (the next session or page load tries again); a 403 or 422 drops
100
+ * it and logs.
101
+ * - `awarded` stays the device's answer against the cache (§10.2.4).
102
+ * - The pending record is NOT the app's outbox: leave it out of any "N waiting" count
103
+ * and any sign-out guard (losing a tour mark is acceptable).
104
+ * - A device record never synced (no `:pending` with `synced: true`) is uploaded whole on
105
+ * its first load, by the server's merge rule (later completion wins).
106
+ * - On every end of a session call {@link signOutTourProgress}.
107
+ */
108
+ export interface TourProgressAccount {
109
+ load: () => Promise<{
110
+ completed: TourProgressWire;
111
+ }>;
112
+ save: (body: TourProgressBody) => Promise<{
113
+ completed: TourProgressWire;
114
+ }>;
115
+ adopt?: TourProgressAdopt;
31
116
  }
117
+ /** What `complete` takes: a tour id, or the tour (`{id, updated}`, a `TourInfo` fits). */
118
+ type CompletedTour = string | {
119
+ id: string;
120
+ updated?: {
121
+ date: string;
122
+ } | null;
123
+ };
32
124
  export interface TourProgress {
33
125
  /** Tour id → the completion's ISO timestamp, or `null` when recorded without a date. */
34
126
  completed: TourCompletions;
@@ -39,23 +131,84 @@ export interface TourProgress {
39
131
  *
40
132
  * Returns `true` when this counts as a new completion — the moment to award the tour's
41
133
  * points and show the app's toast (§6.1) — and `false` for a replay.
134
+ *
135
+ * A tour started with `start(steps, {tour})` under a `TourProvider` that has `progress`
136
+ * is recorded by the provider (§9.6.2): do not call this from that start's `onFinish`
137
+ * too, or the second write answers `false`.
42
138
  */
43
- complete: (tour: string | {
44
- id: string;
45
- updated?: {
46
- date: string;
47
- } | null;
48
- }) => boolean;
139
+ complete: (tour: CompletedTour) => boolean;
49
140
  /** "Mark as not done": forget the completion. */
50
141
  reset: (id: string) => void;
51
142
  }
143
+ type Migrate = NonNullable<UseTourProgressOptions["migrate"]>;
144
+ /**
145
+ * Remember `migrate` for `key` if it is the first one, and return the key's migrate. A
146
+ * snapshot parsed before the key had one is dropped and re-parsed, and the key's readers
147
+ * hear of it after this render (never from inside one).
148
+ * @internal Not part of the barrel; `TourProvider` registers its `progress` with it.
149
+ */
150
+ export declare function rememberMigrate(key: string, migrate: UseTourProgressOptions["migrate"]): Migrate | undefined;
151
+ /**
152
+ * Remember `account` for `key` if it is the first one, and return the key's account.
153
+ * @internal Not part of the barrel; `TourProvider` registers its `progress` with it.
154
+ */
155
+ export declare function rememberAccount(key: string, account: TourProgressAccount | undefined): TourProgressAccount | undefined;
156
+ /**
157
+ * The end of a session (§10.2.6) — sign-out, a 401 nothing could renew, the account's
158
+ * deletion, every path: removes the CACHE only (the device key and the page's snapshot;
159
+ * every reader in this tab moves), synchronously and with no request, and KEEPS
160
+ * `<storageKey>:pending`. That record sits under this person's own key, holds tour ids and
161
+ * dates only, and goes out on their next sign-in. An answer still on its way is ignored.
162
+ *
163
+ * function logout() { …; signOutTourProgress(`app.tours:${user.id}`); }
164
+ */
165
+ export declare function signOutTourProgress(storageKey: string): void;
52
166
  /**
53
167
  * Forget every tour completion under `storageKey` on this device: the stored key, what the
54
168
  * page kept in memory where storage refused it, and every reader in this tab moves at
55
169
  * once. For "Reset this device's settings" (settings §11):
56
170
  *
57
171
  * registerDeviceSetting({ key: TOUR_KEY, kind: "view", reset: () => resetTourProgress(TOUR_KEY) });
172
+ *
173
+ * With an account (0.45) it clears the cache only: the account is never written, the
174
+ * pending record stays ("anything waiting to be sent", settings §11.2.2) and the next load
175
+ * refills the cache. Tour progress is never registered with the device reset (§10.2.8).
58
176
  */
59
177
  export declare function resetTourProgress(storageKey: string): void;
178
+ /**
179
+ * Record a completion under `storageKey`, as {@link TourProgress.complete} does: through
180
+ * the one shared store and snapshot (never a copy), so every reader of the key moves.
181
+ * Returns `true` when it counts anew. `TourProvider` calls this on a tour's last step.
182
+ * @internal Not part of the barrel.
183
+ */
184
+ export declare function completeStored(storageKey: string, migrate: UseTourProgressOptions["migrate"], tour: CompletedTour): boolean;
185
+ /**
186
+ * Write a completion from outside a page (§9.2): for an app's tests, seeds and scripts,
187
+ * beside {@link resetTourProgress}. It writes the kit's stored shape (`{[id]: ISO | ""}`)
188
+ * under `storageKey`, keeps the other tours' marks, and notifies every reader of the key
189
+ * in this tab directly (the `storage` event reaches only OTHER tabs), so a mounted
190
+ * `useTourProgress` or `TourCatalogue` moves at once.
191
+ *
192
+ * Unlike `complete`, it always writes (over a readable text; see below): `at` is the
193
+ * completion's instant (an ISO string or a `Date`, default now), `null` a completion
194
+ * without a date (stored as `""`, read back as `null`).
195
+ *
196
+ * completeTour(TOUR_KEY, "budget", "2026-09-01T10:00:00Z");
197
+ *
198
+ * A stored text in an app's older shape is read through `opts.migrate`, or else the
199
+ * migrate a reader or the provider registered for the key. If the text is neither empty
200
+ * nor the kit's shape and no migrate reads it, this THROWS and writes nothing (B-1):
201
+ *
202
+ * completeTour(TOUR_KEY, "budget", undefined, { migrate: fromZustand });
203
+ *
204
+ * With an account on the key (0.45) it stays a device write — no request — and is marked
205
+ * pending, so the next load sends it.
206
+ */
207
+ export declare function completeTour(storageKey: string, tour: string | {
208
+ id: string;
209
+ }, at?: string | Date | null, opts?: {
210
+ migrate?: UseTourProgressOptions["migrate"];
211
+ }): void;
60
212
  /** The device's tour completions under `storageKey`; see {@link UseTourProgressOptions}. */
61
- export declare function useTourProgress({ storageKey, migrate }: UseTourProgressOptions): TourProgress;
213
+ export declare function useTourProgress({ storageKey, migrate, account }: UseTourProgressOptions): TourProgress;
214
+ export {};
@@ -1,11 +1,20 @@
1
1
  "use client";
2
2
  import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
3
3
  import { readStored, removeStored, writeStored } from "../lib/safe-storage.js";
4
+ import { httpStatusOf } from "../auth/auth-errors.js";
4
5
  import { isTourDone } from "./tour-info.js";
5
6
  const EMPTY = Object.freeze({});
6
7
  const listeners = /* @__PURE__ */ new Map();
7
8
  const memory = /* @__PURE__ */ new Map();
8
9
  const cache = /* @__PURE__ */ new Map();
10
+ const migrates = /* @__PURE__ */ new Map();
11
+ function rememberMigrate(key, migrate) {
12
+ if (migrate && !migrates.has(key)) {
13
+ migrates.set(key, migrate);
14
+ if (cache.delete(key)) queueMicrotask(() => emit(key));
15
+ }
16
+ return migrates.get(key);
17
+ }
9
18
  function emit(key) {
10
19
  listeners.get(key)?.forEach((listener) => listener());
11
20
  }
@@ -26,7 +35,7 @@ function fromRecord(value) {
26
35
  return out;
27
36
  }
28
37
  function parse(raw, migrate) {
29
- if (raw === null) return EMPTY;
38
+ if (raw === null || raw.trim() === "") return { value: EMPTY, readable: true };
30
39
  let value = raw;
31
40
  try {
32
41
  value = JSON.parse(raw);
@@ -34,28 +43,238 @@ function parse(raw, migrate) {
34
43
  }
35
44
  if (migrate && !isKitShape(value)) {
36
45
  try {
37
- const migrated = migrate(value);
38
- if (migrated !== void 0) return fromRecord(migrated) ?? EMPTY;
46
+ const migrated = fromRecord(migrate(value));
47
+ if (migrated !== void 0) return { value: migrated, readable: true };
39
48
  } catch {
40
49
  }
41
50
  }
42
- return fromRecord(value) ?? EMPTY;
51
+ return { value: fromRecord(value) ?? EMPTY, readable: isKitShape(value) };
43
52
  }
44
53
  function snapshot(key, migrate) {
54
+ const known = rememberMigrate(key, migrate);
45
55
  const raw = rawOf(key);
46
56
  const hit = cache.get(key);
47
57
  if (hit && hit.raw === raw) return hit.value;
48
- const value = parse(raw, migrate);
49
- cache.set(key, { raw, value });
58
+ const parsed = parse(raw, known);
59
+ cache.set(key, { raw, ...parsed });
60
+ return parsed.value;
61
+ }
62
+ function readForWrite(key, migrate, own = false) {
63
+ const known = rememberMigrate(key, migrate);
64
+ const { value, readable } = own && migrate && known !== migrate ? parse(rawOf(key), migrate) : { value: snapshot(key, migrate), readable: cache.get(key)?.readable };
65
+ if (!readable) {
66
+ throw new Error(
67
+ `[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}).`
68
+ );
69
+ }
50
70
  return value;
51
71
  }
52
72
  function write(key, record) {
53
- const raw = JSON.stringify(record);
73
+ const stored = {};
74
+ for (const [id, at] of Object.entries(record)) stored[id] = at ?? "";
75
+ const raw = JSON.stringify(stored);
54
76
  writeStored(key, raw);
55
77
  if (readStored(key) === raw) memory.delete(key);
56
78
  else memory.set(key, raw);
57
79
  emit(key);
58
80
  }
81
+ const accounts = /* @__PURE__ */ new Map();
82
+ const loaded = /* @__PURE__ */ new Set();
83
+ const running = /* @__PURE__ */ new Set();
84
+ const rerun = /* @__PURE__ */ new Set();
85
+ const queued = /* @__PURE__ */ new Set();
86
+ const epochs = /* @__PURE__ */ new Map();
87
+ const parked = /* @__PURE__ */ new Set();
88
+ const pendingMemory = /* @__PURE__ */ new Map();
89
+ let onlineListening = false;
90
+ const pendingKey = (key) => `${key}:pending`;
91
+ function devWarn(message) {
92
+ if (import.meta.env?.DEV) console.warn(message);
93
+ }
94
+ function rememberAccount(key, account) {
95
+ if (account && !accounts.has(key)) {
96
+ accounts.set(key, account);
97
+ if (account.adopt && account.adopt.storageKey === key) {
98
+ devWarn(
99
+ `[ui-kit] tour progress: the account key "${key}" is the key \`adopt\` takes over. With an account the key must be per account (a prefix plus the user id), apart from the old device-wide key.`
100
+ );
101
+ }
102
+ if (!onlineListening && typeof window !== "undefined") {
103
+ onlineListening = true;
104
+ window.addEventListener("online", () => {
105
+ for (const known of loaded) void sync(known);
106
+ });
107
+ }
108
+ }
109
+ return accounts.get(key);
110
+ }
111
+ function readPending(key) {
112
+ const raw = pendingMemory.get(key) ?? readStored(pendingKey(key));
113
+ if (raw === null) return null;
114
+ try {
115
+ const value = JSON.parse(raw);
116
+ if (!value || typeof value !== "object") return null;
117
+ const completed = {};
118
+ for (const [id, at] of Object.entries(value.completed ?? {})) {
119
+ if (typeof at === "string") completed[id] = at;
120
+ else if (at === null) completed[id] = "";
121
+ }
122
+ const forget = Array.isArray(value.forget) ? value.forget.filter((id) => typeof id === "string") : [];
123
+ return { synced: value.synced === true, completed, forget };
124
+ } catch {
125
+ return null;
126
+ }
127
+ }
128
+ function writePending(key, record) {
129
+ const raw = JSON.stringify(record);
130
+ writeStored(pendingKey(key), raw);
131
+ if (readStored(pendingKey(key)) === raw) pendingMemory.delete(key);
132
+ else pendingMemory.set(key, raw);
133
+ }
134
+ const hasWork = (record) => Object.keys(record.completed).length > 0 || record.forget.length > 0;
135
+ function later(a, b) {
136
+ if (a === void 0 || a === "") return b;
137
+ if (b === "") return a;
138
+ return Date.parse(b) > Date.parse(a) ? b : a;
139
+ }
140
+ function markPending(key, id, at, forget = false) {
141
+ const record = readPending(key) ?? { synced: false, completed: {}, forget: [] };
142
+ if (forget) {
143
+ delete record.completed[id];
144
+ if (!record.forget.includes(id)) record.forget.push(id);
145
+ } else {
146
+ record.completed[id] = at ?? "";
147
+ record.forget = record.forget.filter((other) => other !== id);
148
+ }
149
+ writePending(key, record);
150
+ }
151
+ function clearSent(key, sent, synced) {
152
+ const record = readPending(key) ?? { synced: false, completed: {}, forget: [] };
153
+ for (const [id, at] of Object.entries(sent.completed ?? {})) {
154
+ if (record.completed[id] === at) delete record.completed[id];
155
+ }
156
+ const forgotten = new Set(sent.forget ?? []);
157
+ record.forget = record.forget.filter((id) => !forgotten.has(id));
158
+ record.synced = record.synced || synced;
159
+ writePending(key, record);
160
+ }
161
+ function refill(key, answer) {
162
+ const next = {};
163
+ for (const [id, at] of Object.entries(fromRecord(answer?.completed) ?? {})) next[id] = at ?? "";
164
+ const waiting = readPending(key);
165
+ if (waiting) {
166
+ for (const [id, at] of Object.entries(waiting.completed)) next[id] = later(next[id], at);
167
+ for (const id of waiting.forget) delete next[id];
168
+ }
169
+ const record = {};
170
+ for (const [id, at] of Object.entries(next)) record[id] = at === "" ? null : at;
171
+ write(key, record);
172
+ }
173
+ function adoptOldRecord(key, account) {
174
+ const adopt = account.adopt;
175
+ if (!adopt || adopt.storageKey === key) return;
176
+ const old = adopt.storageKey;
177
+ if (!adopt.discard) {
178
+ const { value } = parse(rawOf(old), adopt.migrate ?? migrates.get(old));
179
+ const entries = Object.entries(value);
180
+ if (entries.length > 0) {
181
+ const record = readPending(key) ?? { synced: false, completed: {}, forget: [] };
182
+ for (const [id, at] of entries) record.completed[id] = later(record.completed[id], at ?? "");
183
+ writePending(key, record);
184
+ }
185
+ }
186
+ removeStored(old);
187
+ memory.delete(old);
188
+ cache.delete(old);
189
+ emit(old);
190
+ }
191
+ function uploadUnsynced(key) {
192
+ const record = readPending(key);
193
+ if (record?.synced) return;
194
+ const device = snapshot(key, migrates.get(key));
195
+ const entries = Object.entries(device);
196
+ const next = record ?? { synced: false, completed: {}, forget: [] };
197
+ if (entries.length === 0 && record) return;
198
+ for (const [id, at] of entries) {
199
+ if (!next.forget.includes(id)) next.completed[id] = later(next.completed[id], at ?? "");
200
+ }
201
+ writePending(key, next);
202
+ }
203
+ function prepare(key) {
204
+ loaded.add(key);
205
+ adoptOldRecord(key, accounts.get(key));
206
+ uploadUnsynced(key);
207
+ }
208
+ function queueSync(key) {
209
+ if (!loaded.has(key)) prepare(key);
210
+ if (queued.has(key)) return;
211
+ queued.add(key);
212
+ queueMicrotask(() => {
213
+ queued.delete(key);
214
+ void sync(key);
215
+ });
216
+ }
217
+ function ensureLoaded(key) {
218
+ if (!accounts.has(key) || loaded.has(key)) return;
219
+ prepare(key);
220
+ void sync(key);
221
+ }
222
+ async function sync(key) {
223
+ const account = accounts.get(key);
224
+ if (!account || !loaded.has(key)) return;
225
+ if (running.has(key)) {
226
+ rerun.add(key);
227
+ return;
228
+ }
229
+ if (typeof navigator !== "undefined" && navigator.onLine === false) return;
230
+ running.add(key);
231
+ const epoch = epochs.get(key) ?? 0;
232
+ try {
233
+ const waiting = readPending(key);
234
+ if (waiting && hasWork(waiting)) {
235
+ if (parked.has(key)) return;
236
+ const body = {};
237
+ if (Object.keys(waiting.completed).length > 0) body.completed = { ...waiting.completed };
238
+ if (waiting.forget.length > 0) body.forget = [...waiting.forget];
239
+ let answer;
240
+ try {
241
+ answer = await account.save(body);
242
+ } catch (error) {
243
+ const status = httpStatusOf(error);
244
+ if (status === 403 || status === 422) {
245
+ if ((epochs.get(key) ?? 0) === epoch) clearSent(key, body, false);
246
+ console.warn(`[ui-kit] tour progress: the account refused the pending record (${status}); dropped.`, error);
247
+ } else if (status === 402) {
248
+ parked.add(key);
249
+ }
250
+ return;
251
+ }
252
+ if ((epochs.get(key) ?? 0) !== epoch) return;
253
+ clearSent(key, body, true);
254
+ refill(key, answer);
255
+ } else {
256
+ const answer = await account.load();
257
+ if ((epochs.get(key) ?? 0) !== epoch) return;
258
+ if (waiting && !waiting.synced) writePending(key, { ...waiting, synced: true });
259
+ else if (!waiting) writePending(key, { synced: true, completed: {}, forget: [] });
260
+ refill(key, answer);
261
+ }
262
+ } catch {
263
+ } finally {
264
+ running.delete(key);
265
+ if (rerun.delete(key)) void sync(key);
266
+ }
267
+ }
268
+ function signOutTourProgress(storageKey) {
269
+ epochs.set(storageKey, (epochs.get(storageKey) ?? 0) + 1);
270
+ loaded.delete(storageKey);
271
+ rerun.delete(storageKey);
272
+ parked.delete(storageKey);
273
+ removeStored(storageKey);
274
+ memory.delete(storageKey);
275
+ cache.delete(storageKey);
276
+ emit(storageKey);
277
+ }
59
278
  function subscribe(key, listener) {
60
279
  let set = listeners.get(key);
61
280
  if (!set) listeners.set(key, set = /* @__PURE__ */ new Set());
@@ -72,45 +291,72 @@ function subscribe(key, listener) {
72
291
  };
73
292
  }
74
293
  function resetTourProgress(storageKey) {
294
+ if (accounts.has(storageKey)) loaded.delete(storageKey);
75
295
  removeStored(storageKey);
76
296
  memory.delete(storageKey);
77
297
  cache.delete(storageKey);
78
298
  emit(storageKey);
79
299
  }
80
- function useTourProgress({ storageKey, migrate }) {
300
+ function completeStored(storageKey, migrate, tour) {
301
+ const subject = typeof tour === "string" ? { id: tour } : tour;
302
+ const current = readForWrite(storageKey, migrate);
303
+ if (isTourDone(subject, current[subject.id])) return false;
304
+ const at = (/* @__PURE__ */ new Date()).toISOString();
305
+ write(storageKey, { ...current, [subject.id]: at });
306
+ if (accounts.has(storageKey)) {
307
+ markPending(storageKey, subject.id, at);
308
+ queueSync(storageKey);
309
+ }
310
+ return true;
311
+ }
312
+ function resetStored(storageKey, migrate, id) {
313
+ const current = snapshot(storageKey, migrate);
314
+ if (current[id] === void 0) return;
315
+ const next = { ...current };
316
+ delete next[id];
317
+ write(storageKey, next);
318
+ if (accounts.has(storageKey)) {
319
+ markPending(storageKey, id, void 0, true);
320
+ queueSync(storageKey);
321
+ }
322
+ }
323
+ function completeTour(storageKey, tour, at, opts) {
324
+ const id = typeof tour === "string" ? tour : tour.id;
325
+ const when = at === null ? null : at instanceof Date ? at.toISOString() : at ?? (/* @__PURE__ */ new Date()).toISOString();
326
+ write(storageKey, { ...readForWrite(storageKey, opts?.migrate, true), [id]: when });
327
+ if (accounts.has(storageKey)) markPending(storageKey, id, when);
328
+ }
329
+ function useTourProgress({ storageKey, migrate, account }) {
81
330
  const migrateRef = useRef(migrate);
82
331
  useEffect(() => {
83
332
  migrateRef.current = migrate;
84
333
  });
334
+ const accountRef = useRef(account);
335
+ useEffect(() => {
336
+ accountRef.current = account;
337
+ });
338
+ useEffect(() => {
339
+ if (rememberAccount(storageKey, accountRef.current)) ensureLoaded(storageKey);
340
+ }, [storageKey]);
85
341
  const completed = useSyncExternalStore(
86
342
  useCallback((listener) => subscribe(storageKey, listener), [storageKey]),
87
343
  () => snapshot(storageKey, migrateRef.current),
88
344
  () => EMPTY
89
345
  );
90
346
  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
- },
98
- [storageKey]
99
- );
100
- const reset = useCallback(
101
- (id) => {
102
- const current = snapshot(storageKey, migrateRef.current);
103
- if (current[id] === void 0) return;
104
- const next = { ...current };
105
- delete next[id];
106
- write(storageKey, next);
107
- },
347
+ (tour) => completeStored(storageKey, migrateRef.current, tour),
108
348
  [storageKey]
109
349
  );
350
+ const reset = useCallback((id) => resetStored(storageKey, migrateRef.current, id), [storageKey]);
110
351
  return useMemo(() => ({ completed, complete, reset }), [completed, complete, reset]);
111
352
  }
112
353
  export {
354
+ completeStored,
355
+ completeTour,
356
+ rememberAccount,
357
+ rememberMigrate,
113
358
  resetTourProgress,
359
+ signOutTourProgress,
114
360
  useTourProgress
115
361
  };
116
362
  //# sourceMappingURL=use-tour-progress.js.map