@eifi1/ui-kit 0.41.0 → 0.43.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 (190) hide show
  1. package/README.md +26 -7
  2. package/dist/admin/admin-area-labels.d.ts +59 -0
  3. package/dist/admin/admin-area-labels.js +35 -0
  4. package/dist/admin/admin-area-labels.js.map +1 -0
  5. package/dist/admin/admin-forbidden.d.ts +35 -0
  6. package/dist/admin/admin-forbidden.js +36 -0
  7. package/dist/admin/admin-forbidden.js.map +1 -0
  8. package/dist/admin/admin-overview.d.ts +110 -0
  9. package/dist/admin/admin-overview.js +92 -0
  10. package/dist/admin/admin-overview.js.map +1 -0
  11. package/dist/admin/admin-sections.d.ts +94 -0
  12. package/dist/admin/admin-sections.js +81 -0
  13. package/dist/admin/admin-sections.js.map +1 -0
  14. package/dist/admin/admin-tool-links.d.ts +48 -0
  15. package/dist/admin/admin-tool-links.js +31 -0
  16. package/dist/admin/admin-tool-links.js.map +1 -0
  17. package/dist/billing/billing-banner.d.ts +33 -2
  18. package/dist/billing/billing-banner.js +29 -3
  19. package/dist/billing/billing-banner.js.map +1 -1
  20. package/dist/billing/billing-labels.d.ts +36 -0
  21. package/dist/billing/billing-labels.js +10 -0
  22. package/dist/billing/billing-labels.js.map +1 -1
  23. package/dist/billing/billing-overview.d.ts +33 -0
  24. package/dist/billing/billing-overview.js +6 -0
  25. package/dist/billing/billing-overview.js.map +1 -1
  26. package/dist/billing/billing-standing.d.ts +8 -0
  27. package/dist/billing/billing-standing.js.map +1 -1
  28. package/dist/billing/cancel-request.d.ts +53 -0
  29. package/dist/billing/cancel-request.js +21 -0
  30. package/dist/billing/cancel-request.js.map +1 -0
  31. package/dist/billing/cancel-subscription.d.ts +46 -0
  32. package/dist/billing/cancel-subscription.js +57 -0
  33. package/dist/billing/cancel-subscription.js.map +1 -0
  34. package/dist/billing/plan-card.d.ts +45 -1
  35. package/dist/billing/plan-card.js +53 -2
  36. package/dist/billing/plan-card.js.map +1 -1
  37. package/dist/billing/plan-switch.d.ts +10 -1
  38. package/dist/billing/plan-switch.js +22 -13
  39. package/dist/billing/plan-switch.js.map +1 -1
  40. package/dist/billing/provider-page.d.ts +40 -0
  41. package/dist/billing/provider-page.js +33 -0
  42. package/dist/billing/provider-page.js.map +1 -0
  43. package/dist/billing/subscription-actions.d.ts +68 -4
  44. package/dist/billing/subscription-actions.js +65 -5
  45. package/dist/billing/subscription-actions.js.map +1 -1
  46. package/dist/components/account-chips.d.ts +50 -2
  47. package/dist/components/account-chips.js +33 -0
  48. package/dist/components/account-chips.js.map +1 -1
  49. package/dist/components/empty-state.d.ts +2 -1
  50. package/dist/components/empty-state.js.map +1 -1
  51. package/dist/eslint/index.js +1 -1
  52. package/dist/feedback/feedback-menu.d.ts +7 -4
  53. package/dist/feedback/feedback-menu.js +4 -2
  54. package/dist/feedback/feedback-menu.js.map +1 -1
  55. package/dist/hooks/use-search-param-state.js +4 -1
  56. package/dist/hooks/use-search-param-state.js.map +1 -1
  57. package/dist/i18n/defaults.js +7 -1
  58. package/dist/i18n/defaults.js.map +1 -1
  59. package/dist/i18n/german.js +113 -0
  60. package/dist/i18n/german.js.map +1 -1
  61. package/dist/i18n/kit-labels.d.ts +12 -0
  62. package/dist/i18n/kit-labels.js.map +1 -1
  63. package/dist/i18n/locales/en.js +142 -1
  64. package/dist/i18n/locales/en.js.map +3 -3
  65. package/dist/i18n/locales/es.js +110 -0
  66. package/dist/i18n/locales/es.js.map +1 -1
  67. package/dist/i18n/locales/fr.js +109 -0
  68. package/dist/i18n/locales/fr.js.map +1 -1
  69. package/dist/i18n/locales/hu.js +110 -0
  70. package/dist/i18n/locales/hu.js.map +1 -1
  71. package/dist/i18n/locales/it.js +110 -0
  72. package/dist/i18n/locales/it.js.map +1 -1
  73. package/dist/i18n/locales/zh.js +109 -0
  74. package/dist/i18n/locales/zh.js.map +1 -1
  75. package/dist/i18n/review.js +34 -1
  76. package/dist/i18n/review.js.map +1 -1
  77. package/dist/index.d.ts +24 -2
  78. package/dist/index.js +29 -1
  79. package/dist/index.js.map +1 -1
  80. package/dist/search/global-search.d.ts +3 -0
  81. package/dist/search/global-search.js +3 -1
  82. package/dist/search/global-search.js.map +1 -1
  83. package/dist/settings/settings-catalogue.d.ts +9 -1
  84. package/dist/settings/settings-catalogue.js +1 -1
  85. package/dist/settings/settings-catalogue.js.map +1 -1
  86. package/dist/settings/settings-layout.d.ts +7 -1
  87. package/dist/settings/settings-layout.js +35 -27
  88. package/dist/settings/settings-layout.js.map +1 -1
  89. package/dist/shell/account-menu-language.d.ts +46 -0
  90. package/dist/shell/account-menu-language.js +64 -0
  91. package/dist/shell/account-menu-language.js.map +1 -0
  92. package/dist/shell/admin-menu.d.ts +69 -0
  93. package/dist/shell/admin-menu.js +64 -0
  94. package/dist/shell/admin-menu.js.map +1 -0
  95. package/dist/shell/app-shell.d.ts +10 -0
  96. package/dist/shell/app-shell.js +22 -4
  97. package/dist/shell/app-shell.js.map +1 -1
  98. package/dist/shell/dev-switchers.d.ts +53 -0
  99. package/dist/shell/dev-switchers.js +107 -0
  100. package/dist/shell/dev-switchers.js.map +1 -0
  101. package/dist/shell/sync-status-indicator.d.ts +86 -0
  102. package/dist/shell/sync-status-indicator.js +170 -0
  103. package/dist/shell/sync-status-indicator.js.map +1 -0
  104. package/dist/shell/top-bar-actions.d.ts +49 -0
  105. package/dist/shell/top-bar-actions.js +31 -0
  106. package/dist/shell/top-bar-actions.js.map +1 -0
  107. package/dist/shell/top-bar-parts-labels.d.ts +90 -0
  108. package/dist/shell/top-bar-parts-labels.js +51 -0
  109. package/dist/shell/top-bar-parts-labels.js.map +1 -0
  110. package/dist/shell/topbar-action-menu.d.ts +13 -1
  111. package/dist/shell/topbar-action-menu.js +5 -2
  112. package/dist/shell/topbar-action-menu.js.map +1 -1
  113. package/dist/shell/topbar-controls.d.ts +16 -3
  114. package/dist/shell/topbar-controls.js +5 -1
  115. package/dist/shell/topbar-controls.js.map +1 -1
  116. package/dist/shell/tour-menu.d.ts +67 -0
  117. package/dist/shell/tour-menu.js +87 -0
  118. package/dist/shell/tour-menu.js.map +1 -0
  119. package/dist/shell.d.ts +16 -0
  120. package/dist/shell.js +21 -0
  121. package/dist/shell.js.map +1 -1
  122. package/dist/tour/tour-catalogue-labels.d.ts +44 -0
  123. package/dist/tour/tour-catalogue-labels.js +32 -0
  124. package/dist/tour/tour-catalogue-labels.js.map +1 -0
  125. package/dist/tour/tour-catalogue.d.ts +68 -0
  126. package/dist/tour/tour-catalogue.js +189 -0
  127. package/dist/tour/tour-catalogue.js.map +1 -0
  128. package/dist/tour/tour-info.d.ts +112 -0
  129. package/dist/tour/tour-info.js +36 -0
  130. package/dist/tour/tour-info.js.map +1 -0
  131. package/dist/tour/use-tour-progress.d.ts +61 -0
  132. package/dist/tour/use-tour-progress.js +116 -0
  133. package/dist/tour/use-tour-progress.js.map +1 -0
  134. package/dist/tour/use-tour-url-sync.d.ts +58 -0
  135. package/dist/tour/use-tour-url-sync.js +110 -0
  136. package/dist/tour/use-tour-url-sync.js.map +1 -0
  137. package/dist/tour.d.ts +8 -0
  138. package/dist/tour.js +18 -0
  139. package/dist/tour.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/admin/admin-area-labels.ts +91 -0
  142. package/src/admin/admin-forbidden.tsx +75 -0
  143. package/src/admin/admin-overview.tsx +217 -0
  144. package/src/admin/admin-sections.tsx +198 -0
  145. package/src/admin/admin-tool-links.tsx +84 -0
  146. package/src/billing/billing-banner.tsx +57 -6
  147. package/src/billing/billing-labels.ts +59 -0
  148. package/src/billing/billing-overview.ts +37 -0
  149. package/src/billing/billing-standing.ts +8 -0
  150. package/src/billing/cancel-request.ts +75 -0
  151. package/src/billing/cancel-subscription.tsx +102 -0
  152. package/src/billing/plan-card.tsx +97 -1
  153. package/src/billing/plan-switch.tsx +16 -0
  154. package/src/billing/provider-page.ts +75 -0
  155. package/src/billing/subscription-actions.tsx +155 -20
  156. package/src/components/account-chips.tsx +89 -2
  157. package/src/components/empty-state.tsx +2 -1
  158. package/src/feedback/feedback-menu.tsx +8 -3
  159. package/src/hooks/use-search-param-state.ts +6 -1
  160. package/src/i18n/defaults.ts +6 -0
  161. package/src/i18n/german.ts +127 -0
  162. package/src/i18n/kit-labels.tsx +12 -0
  163. package/src/i18n/locales/en.ts +32 -0
  164. package/src/i18n/locales/es.ts +121 -0
  165. package/src/i18n/locales/fr.ts +121 -0
  166. package/src/i18n/locales/hu.ts +119 -0
  167. package/src/i18n/locales/it.ts +122 -0
  168. package/src/i18n/locales/zh.ts +114 -0
  169. package/src/i18n/review.ts +33 -0
  170. package/src/index.ts +38 -2
  171. package/src/search/global-search.tsx +5 -0
  172. package/src/settings/settings-catalogue.ts +10 -2
  173. package/src/settings/settings-layout.tsx +58 -34
  174. package/src/shell/account-menu-language.tsx +116 -0
  175. package/src/shell/admin-menu.tsx +146 -0
  176. package/src/shell/app-shell.tsx +44 -3
  177. package/src/shell/dev-switchers.tsx +179 -0
  178. package/src/shell/sync-status-indicator.tsx +301 -0
  179. package/src/shell/top-bar-actions.tsx +79 -0
  180. package/src/shell/top-bar-parts-labels.ts +142 -0
  181. package/src/shell/topbar-action-menu.tsx +15 -2
  182. package/src/shell/topbar-controls.tsx +20 -3
  183. package/src/shell/tour-menu.tsx +179 -0
  184. package/src/shell.ts +27 -0
  185. package/src/tour/tour-catalogue-labels.ts +75 -0
  186. package/src/tour/tour-catalogue.tsx +316 -0
  187. package/src/tour/tour-info.ts +144 -0
  188. package/src/tour/use-tour-progress.ts +198 -0
  189. package/src/tour/use-tour-url-sync.ts +221 -0
  190. package/src/tour.ts +11 -0
@@ -0,0 +1,116 @@
1
+ "use client";
2
+ import { useCallback, useEffect, useMemo, useRef, useSyncExternalStore } from "react";
3
+ import { readStored, removeStored, writeStored } from "../lib/safe-storage.js";
4
+ import { isTourDone } from "./tour-info.js";
5
+ const EMPTY = Object.freeze({});
6
+ const listeners = /* @__PURE__ */ new Map();
7
+ const memory = /* @__PURE__ */ new Map();
8
+ const cache = /* @__PURE__ */ new Map();
9
+ function emit(key) {
10
+ listeners.get(key)?.forEach((listener) => listener());
11
+ }
12
+ function rawOf(key) {
13
+ return memory.get(key) ?? readStored(key);
14
+ }
15
+ function isKitShape(value) {
16
+ if (!value || typeof value !== "object" || Array.isArray(value)) return false;
17
+ return Object.values(value).every((at) => at === null || typeof at === "string");
18
+ }
19
+ function fromRecord(value) {
20
+ if (!value || typeof value !== "object" || Array.isArray(value)) return void 0;
21
+ const out = {};
22
+ for (const [id, at] of Object.entries(value)) {
23
+ if (at === null || at === "") out[id] = null;
24
+ else if (typeof at === "string") out[id] = at;
25
+ }
26
+ return out;
27
+ }
28
+ function parse(raw, migrate) {
29
+ if (raw === null) return EMPTY;
30
+ let value = raw;
31
+ try {
32
+ value = JSON.parse(raw);
33
+ } catch {
34
+ }
35
+ if (migrate && !isKitShape(value)) {
36
+ try {
37
+ const migrated = migrate(value);
38
+ if (migrated !== void 0) return fromRecord(migrated) ?? EMPTY;
39
+ } catch {
40
+ }
41
+ }
42
+ return fromRecord(value) ?? EMPTY;
43
+ }
44
+ function snapshot(key, migrate) {
45
+ const raw = rawOf(key);
46
+ const hit = cache.get(key);
47
+ if (hit && hit.raw === raw) return hit.value;
48
+ const value = parse(raw, migrate);
49
+ cache.set(key, { raw, value });
50
+ return value;
51
+ }
52
+ function write(key, record) {
53
+ const raw = JSON.stringify(record);
54
+ writeStored(key, raw);
55
+ if (readStored(key) === raw) memory.delete(key);
56
+ else memory.set(key, raw);
57
+ emit(key);
58
+ }
59
+ function subscribe(key, listener) {
60
+ let set = listeners.get(key);
61
+ if (!set) listeners.set(key, set = /* @__PURE__ */ new Set());
62
+ set.add(listener);
63
+ const onStorage = (event) => {
64
+ if (event.key !== null && event.key !== key) return;
65
+ memory.delete(key);
66
+ listener();
67
+ };
68
+ if (typeof window !== "undefined") window.addEventListener("storage", onStorage);
69
+ return () => {
70
+ set.delete(listener);
71
+ if (typeof window !== "undefined") window.removeEventListener("storage", onStorage);
72
+ };
73
+ }
74
+ function resetTourProgress(storageKey) {
75
+ removeStored(storageKey);
76
+ memory.delete(storageKey);
77
+ cache.delete(storageKey);
78
+ emit(storageKey);
79
+ }
80
+ function useTourProgress({ storageKey, migrate }) {
81
+ const migrateRef = useRef(migrate);
82
+ useEffect(() => {
83
+ migrateRef.current = migrate;
84
+ });
85
+ const completed = useSyncExternalStore(
86
+ useCallback((listener) => subscribe(storageKey, listener), [storageKey]),
87
+ () => snapshot(storageKey, migrateRef.current),
88
+ () => EMPTY
89
+ );
90
+ 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
+ },
108
+ [storageKey]
109
+ );
110
+ return useMemo(() => ({ completed, complete, reset }), [completed, complete, reset]);
111
+ }
112
+ export {
113
+ resetTourProgress,
114
+ useTourProgress
115
+ };
116
+ //# sourceMappingURL=use-tour-progress.js.map
@@ -0,0 +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":[]}
@@ -0,0 +1,58 @@
1
+ export interface UseTourUrlSyncOptions {
2
+ /**
3
+ * Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the
4
+ * steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points
5
+ * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:
6
+ * the parameters are kept until it settles.
7
+ */
8
+ launch: (id: string, options: {
9
+ startIndex: number;
10
+ }) => void | Promise<unknown>;
11
+ /**
12
+ * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the
13
+ * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so
14
+ * an F5 mid-tour resumes the right variant at the right step. Default `true`.
15
+ */
16
+ ready?: boolean;
17
+ /**
18
+ * Older names of `tourStep`, read when it is absent and removed with it — kastlan's
19
+ * `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.
20
+ */
21
+ stepAliases?: readonly string[];
22
+ }
23
+ /**
24
+ * Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,
25
+ * on the kit's engine (`useTourOptional`):
26
+ *
27
+ * - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app
28
+ * navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —
29
+ * resuming at `&tourStep=<n>` (1-based). It waits for `ready`.
30
+ * - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied
31
+ * link pins the step (kk K-3). A step that navigates drops them; this puts them back.
32
+ * - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.
33
+ *
34
+ * Mount it once inside the router and the `TourProvider`. Without a provider it still
35
+ * launches through `launch` and never mirrors.
36
+ */
37
+ export declare function useTourUrlSync({ launch, ready, stepAliases }: UseTourUrlSyncOptions): void;
38
+ /** The catalogue's state filter. */
39
+ export type TourStateFilter = "all" | "todo" | "done";
40
+ /** The catalogue's filters: the search, the group (`null`: every group), the state. */
41
+ export interface TourCatalogueFilter {
42
+ query: string;
43
+ group: string | null;
44
+ state: TourStateFilter;
45
+ }
46
+ /**
47
+ * The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each
48
+ * written with `replace` (typing does not bury the Back button), the defaults as the
49
+ * clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.
50
+ * `params` renames them (kastlan's `?group`). Requires a react-router Router.
51
+ */
52
+ export declare function useTourCatalogueUrlState(params?: {
53
+ query?: string;
54
+ group?: string;
55
+ state?: string;
56
+ }): TourCatalogueFilter & {
57
+ onChange: (patch: Partial<TourCatalogueFilter>) => void;
58
+ };
@@ -0,0 +1,110 @@
1
+ "use client";
2
+ import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState } from "react";
3
+ import * as ReactRouter from "react-router";
4
+ import { useLocation, useNavigate } from "react-router";
5
+ import { useSearchParamsState } from "../hooks/use-search-param-state.js";
6
+ import { useTourOptional } from "./tour.js";
7
+ function navigationInFlight(history, dataRouter, rendered) {
8
+ const state = dataRouter?.router?.state;
9
+ if (state && (state.navigation?.state ?? "idle") !== "idle") return true;
10
+ const live = state?.location ?? history?.location;
11
+ if (!live) return false;
12
+ return live.pathname !== rendered.pathname || (live.search ?? "") !== rendered.search || (live.hash ?? "") !== rendered.hash;
13
+ }
14
+ const NO_NAVIGATION = createContext(null);
15
+ const NO_DATA_ROUTER = createContext(null);
16
+ const DataRouterContext = (() => {
17
+ try {
18
+ return ReactRouter.UNSAFE_DataRouterContext ?? NO_DATA_ROUTER;
19
+ } catch {
20
+ return NO_DATA_ROUTER;
21
+ }
22
+ })();
23
+ const NavigationContext = (() => {
24
+ try {
25
+ return ReactRouter.UNSAFE_NavigationContext ?? NO_NAVIGATION;
26
+ } catch {
27
+ return NO_NAVIGATION;
28
+ }
29
+ })();
30
+ function stepOf(params, aliases) {
31
+ const step = Number(params.get("tourStep"));
32
+ if (params.has("tourStep")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;
33
+ for (const alias of aliases) {
34
+ const value = Number(params.get(alias));
35
+ if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;
36
+ }
37
+ return 0;
38
+ }
39
+ function useTourUrlSync({ launch, ready = true, stepAliases = [] }) {
40
+ const navigate = useNavigate();
41
+ const location = useLocation();
42
+ const { pathname, search, hash } = location;
43
+ const history = useContext(NavigationContext)?.navigator;
44
+ const dataRouter = useContext(DataRouterContext);
45
+ const engine = useTourOptional();
46
+ const active = engine?.active ?? false;
47
+ const activeId = engine?.activeId ?? null;
48
+ const index = engine?.index ?? 0;
49
+ const urlTour = new URLSearchParams(search).get("tour");
50
+ const aliases = stepAliases.join("\n");
51
+ const launchRef = useRef(launch);
52
+ const activeIdRef = useRef(activeId);
53
+ useLayoutEffect(() => {
54
+ launchRef.current = launch;
55
+ activeIdRef.current = active ? activeId : null;
56
+ });
57
+ const seenRef = useRef(null);
58
+ const pendingRef = useRef(null);
59
+ const launchingRef = useRef(false);
60
+ const [settled, setSettled] = useState(0);
61
+ useEffect(() => {
62
+ if (urlTour !== seenRef.current) {
63
+ seenRef.current = urlTour;
64
+ pendingRef.current = urlTour && urlTour !== activeIdRef.current ? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split("\n") : []) } : null;
65
+ }
66
+ const pending = pendingRef.current;
67
+ if (!pending || !ready) return;
68
+ pendingRef.current = null;
69
+ launchingRef.current = true;
70
+ void Promise.resolve().then(() => launchRef.current(pending.id, { startIndex: pending.startIndex })).catch(() => void 0).finally(() => {
71
+ launchingRef.current = false;
72
+ setSettled((n) => n + 1);
73
+ });
74
+ }, [urlTour, ready]);
75
+ useEffect(() => {
76
+ if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;
77
+ if (pendingRef.current || launchingRef.current) return;
78
+ const tourId = active && activeId ? activeId : null;
79
+ const own = ["tour", "tourStep", ...aliases ? aliases.split("\n") : []];
80
+ const current = new URLSearchParams(search);
81
+ if (!tourId && !own.some((key) => current.has(key))) return;
82
+ const next = new URLSearchParams(search);
83
+ for (const key of tourId ? own.slice(2) : own) next.delete(key);
84
+ if (tourId) {
85
+ next.set("tour", tourId);
86
+ next.set("tourStep", String(index + 1));
87
+ }
88
+ if (next.toString() === current.toString()) return;
89
+ void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });
90
+ }, [active, activeId, index, pathname, search, hash, settled, aliases]);
91
+ }
92
+ const STATES = ["all", "todo", "done"];
93
+ function useTourCatalogueUrlState(params = {}) {
94
+ const [filter, setFilter] = useSearchParamsState({
95
+ query: { param: params.query ?? "q", default: "", history: "replace" },
96
+ group: { param: params.group ?? "topic", default: null, history: "replace", parse: (raw) => raw || void 0 },
97
+ state: {
98
+ param: params.state ?? "state",
99
+ default: "all",
100
+ history: "replace",
101
+ parse: (raw) => STATES.find((x) => x === raw)
102
+ }
103
+ });
104
+ return { ...filter, onChange: setFilter };
105
+ }
106
+ export {
107
+ useTourCatalogueUrlState,
108
+ useTourUrlSync
109
+ };
110
+ //# sourceMappingURL=use-tour-url-sync.js.map
@@ -0,0 +1 @@
1
+ {"version":3,"sources":["../../src/tour/use-tour-url-sync.ts"],"sourcesContent":["import { createContext, useContext, useEffect, useLayoutEffect, useRef, useState, type Context } from \"react\";\nimport * as ReactRouter from \"react-router\";\nimport { useLocation, useNavigate, type Navigator } from \"react-router\";\nimport { useSearchParamsState } from \"../hooks/use-search-param-state\";\nimport { useTourOptional } from \"./tour\";\n\n/* ── useTourUrlSync ─────────────────────────────────────────────────────────── */\n\nexport interface UseTourUrlSyncOptions {\n /**\n * Start the tour `id` at `startIndex` (0-based) — the app's launcher, which builds the\n * steps, awaits its own `prepare()` and passes `onFinish` (the completion, the points\n * toast) to `useTour().start`. An unknown id should do nothing. May return a promise:\n * the parameters are kept until it settles.\n */\n launch: (id: string, options: { startIndex: number }) => void | Promise<unknown>;\n /**\n * Are the facts a tour's steps are built from loaded yet (keksdose's `ready`: the\n * caches a branching tour reads)? A deep link waits for it, keeping its parameters, so\n * an F5 mid-tour resumes the right variant at the right step. Default `true`.\n */\n ready?: boolean;\n /**\n * Older names of `tourStep`, read when it is absent and removed with it — kastlan's\n * `ts` (§2.7, for one minor). Their value is a 0-based step index, as kastlan wrote it.\n */\n stepAliases?: readonly string[];\n}\n\ntype Place = { pathname: string; search: string; hash: string };\n\n/**\n * Has the router already moved on from the location React is still rendering?\n * keksdose's check (tour-url-sync.tsx, tours audit 2026-10-02): a mirror write built\n * from the page being LEFT is a replace back onto it, which cancels a step's `go()`.\n *\n * - A data router (`createBrowserRouter`, kastlan): its navigator has no location (build\n * review 0.43, kastlan A2), so the router's own state answers — a navigation still\n * loading, or a location it has committed that React has not rendered yet.\n * - A declarative router (`BrowserRouter`, keksdose): its navigator is its history and\n * carries where it is.\n * - Anything else answers \"not in flight\".\n */\nfunction navigationInFlight(history: Navigator | undefined, dataRouter: DataRouterProbe, rendered: Place) {\n const state = dataRouter?.router?.state;\n if (state && (state.navigation?.state ?? \"idle\") !== \"idle\") return true;\n const live = state?.location ?? (history as (Navigator & { location?: Partial<Place> }) | undefined)?.location;\n if (!live) return false;\n return (\n live.pathname !== rendered.pathname || (live.search ?? \"\") !== rendered.search || (live.hash ?? \"\") !== rendered.hash\n );\n}\n\ntype NavigationProbe = { navigator?: Navigator } | null;\nconst NO_NAVIGATION = createContext<NavigationProbe>(null);\n/** The data router's state, as `useSearchParamState` reads it. */\ntype DataRouterProbe = {\n router?: { state?: { navigation?: { state?: string }; location?: Partial<Place> } };\n} | null;\nconst NO_DATA_ROUTER = createContext<DataRouterProbe>(null);\nconst DataRouterContext: Context<DataRouterProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_DataRouterContext?: Context<DataRouterProbe> }).UNSAFE_DataRouterContext ??\n NO_DATA_ROUTER\n );\n } catch {\n return NO_DATA_ROUTER;\n }\n})();\n/** Read off the namespace inside a `try`, as `useSearchParamState` reads the data router:\n * a test's partial `vi.mock(\"react-router\")` throws on an export it does not define. */\nconst NavigationContext: Context<NavigationProbe> = (() => {\n try {\n return (\n (ReactRouter as unknown as { UNSAFE_NavigationContext?: Context<NavigationProbe> }).UNSAFE_NavigationContext ??\n NO_NAVIGATION\n );\n } catch {\n return NO_NAVIGATION;\n }\n})();\n\n/** The step a link asks for, 0-based: `tourStep` is 1-based (keksdose), an alias 0-based. */\nfunction stepOf(params: URLSearchParams, aliases: readonly string[]): number {\n const step = Number(params.get(\"tourStep\"));\n if (params.has(\"tourStep\")) return Number.isFinite(step) && step > 0 ? step - 1 : 0;\n for (const alias of aliases) {\n const value = Number(params.get(alias));\n if (params.has(alias)) return Number.isFinite(value) && value > 0 ? Math.floor(value) : 0;\n }\n return 0;\n}\n\n/**\n * Tours and the URL (docs/tours-harmonization.md §2.7, §6.4) — keksdose's `TourUrlSync`,\n * on the kit's engine (`useTourOptional`):\n *\n * - **`?tour=<id>` starts that tour whenever it APPEARS** — on load, and on in-app\n * navigation too (kastlan's assistant links to `/help/tours?tour=<id>`, ka F4) —\n * resuming at `&tourStep=<n>` (1-based). It waits for `ready`.\n * - **While a tour runs, `?tour=&tourStep=` mirror where it is**, so a reload or a copied\n * link pins the step (kk K-3). A step that navigates drops them; this puts them back.\n * - **At the end it strips only its own parameters**; `?q=`, `?state=` and the hash stay.\n *\n * Mount it once inside the router and the `TourProvider`. Without a provider it still\n * launches through `launch` and never mirrors.\n */\nexport function useTourUrlSync({ launch, ready = true, stepAliases = [] }: UseTourUrlSyncOptions): void {\n const navigate = useNavigate();\n const location = useLocation();\n const { pathname, search, hash } = location;\n const history = useContext(NavigationContext)?.navigator;\n const dataRouter = useContext(DataRouterContext);\n const engine = useTourOptional();\n const active = engine?.active ?? false;\n const activeId = engine?.activeId ?? null;\n const index = engine?.index ?? 0;\n const urlTour = new URLSearchParams(search).get(\"tour\");\n const aliases = stepAliases.join(\"\\n\");\n\n const launchRef = useRef(launch);\n const activeIdRef = useRef(activeId);\n useLayoutEffect(() => {\n launchRef.current = launch;\n activeIdRef.current = active ? activeId : null;\n });\n /** The `tour` value last seen in the URL: a tour starts when it CHANGES to an id. */\n const seenRef = useRef<string | null>(null);\n /** A deep link waiting for `ready`. */\n const pendingRef = useRef<{ id: string; startIndex: number } | null>(null);\n /** A launch under way (an app's `prepare()`). */\n const launchingRef = useRef(false);\n /** Bumped when a launch settles, so the mirror looks again. */\n const [settled, setSettled] = useState(0);\n\n useEffect(() => {\n if (urlTour !== seenRef.current) {\n seenRef.current = urlTour;\n // Its own mirror writing the running tour back is no new link.\n pendingRef.current =\n urlTour && urlTour !== activeIdRef.current\n ? { id: urlTour, startIndex: stepOf(new URLSearchParams(search), aliases ? aliases.split(\"\\n\") : []) }\n : null;\n }\n const pending = pendingRef.current;\n if (!pending || !ready) return;\n pendingRef.current = null;\n launchingRef.current = true;\n void Promise.resolve()\n .then(() => launchRef.current(pending.id, { startIndex: pending.startIndex }))\n .catch(() => undefined)\n .finally(() => {\n launchingRef.current = false;\n setSettled((n) => n + 1);\n });\n // Only a change of the parameter, or readiness, starts a tour.\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [urlTour, ready]);\n\n useEffect(() => {\n if (navigationInFlight(history, dataRouter, { pathname, search, hash })) return;\n // A link not yet used keeps its parameters: a reload in that window resumes.\n if (pendingRef.current || launchingRef.current) return;\n const tourId = active && activeId ? activeId : null;\n const own = [\"tour\", \"tourStep\", ...(aliases ? aliases.split(\"\\n\") : [])];\n const current = new URLSearchParams(search);\n // Nothing to mirror and nothing to strip: never touch the URL (a no-op replace\n // clobbers a page's own just-issued navigation, keksdose feedback #4).\n if (!tourId && !own.some((key) => current.has(key))) return;\n const next = new URLSearchParams(search);\n // `set` keeps a parameter where it stands; only the aliases (and, at the end, the\n // tour's own) go.\n for (const key of tourId ? own.slice(2) : own) next.delete(key);\n if (tourId) {\n next.set(\"tour\", tourId);\n next.set(\"tourStep\", String(index + 1));\n }\n if (next.toString() === current.toString()) return;\n // Spelled out, not `setSearchParams` (\"?…\" drops the hash, keksdose dev#495).\n void navigate({ pathname, search: next.toString(), hash }, { replace: true, state: location.state });\n // eslint-disable-next-line react-hooks/exhaustive-deps\n }, [active, activeId, index, pathname, search, hash, settled, aliases]);\n}\n\n/* ── useTourCatalogueUrlState ───────────────────────────────────────────────── */\n\n/** The catalogue's state filter. */\nexport type TourStateFilter = \"all\" | \"todo\" | \"done\";\n\n/** The catalogue's filters: the search, the group (`null`: every group), the state. */\nexport interface TourCatalogueFilter {\n query: string;\n group: string | null;\n state: TourStateFilter;\n}\n\nconst STATES: readonly TourStateFilter[] = [\"all\", \"todo\", \"done\"];\n\n/**\n * The catalogue's filters in the URL (§2.8) — keksdose's `?q`, `?topic`, `?state`, each\n * written with `replace` (typing does not bury the Back button), the defaults as the\n * clean URL. Spread it onto the catalogue: `<TourCatalogue {...useTourCatalogueUrlState()} … />`.\n * `params` renames them (kastlan's `?group`). Requires a react-router Router.\n */\nexport function useTourCatalogueUrlState(\n params: { query?: string; group?: string; state?: string } = {},\n): TourCatalogueFilter & { onChange: (patch: Partial<TourCatalogueFilter>) => void } {\n const [filter, setFilter] = useSearchParamsState<TourCatalogueFilter>({\n query: { param: params.query ?? \"q\", default: \"\", history: \"replace\" },\n group: { param: params.group ?? \"topic\", default: null, history: \"replace\", parse: (raw) => raw || undefined },\n state: {\n param: params.state ?? \"state\",\n default: \"all\",\n history: \"replace\",\n parse: (raw) => STATES.find((x) => x === raw),\n },\n });\n // `group: null` is a value to the setter (it clears `?topic`); `undefined` leaves it.\n return { ...filter, onChange: setFilter };\n}\n"],"mappings":"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","names":[]}
package/dist/tour.d.ts CHANGED
@@ -1 +1,9 @@
1
1
  export * from "./tour/tour.js";
2
+ export { TourCatalogue, TourCatalogueCard, DEFAULT_TOUR_CATALOGUE_LABELS } from "./tour/tour-catalogue.js";
3
+ export type { TourCatalogueProps, TourCatalogueCardProps, TourCatalogueLabels } from "./tour/tour-catalogue.js";
4
+ export { isTourNew, isTourReset, isTourDone, doneCount, earnedPoints } from "./tour/tour-info.js";
5
+ export type { TourInfo, TourGroup, TourUpdate, TourCompletions } from "./tour/tour-info.js";
6
+ export { useTourProgress, resetTourProgress } from "./tour/use-tour-progress.js";
7
+ export type { UseTourProgressOptions, TourProgress } from "./tour/use-tour-progress.js";
8
+ export { useTourUrlSync, useTourCatalogueUrlState } from "./tour/use-tour-url-sync.js";
9
+ export type { UseTourUrlSyncOptions, TourCatalogueFilter, TourStateFilter } from "./tour/use-tour-url-sync.js";
package/dist/tour.js CHANGED
@@ -1,3 +1,21 @@
1
1
  "use client";
2
2
  export * from "./tour/tour.js";
3
+ import { TourCatalogue, TourCatalogueCard, DEFAULT_TOUR_CATALOGUE_LABELS } from "./tour/tour-catalogue.js";
4
+ import { isTourNew, isTourReset, isTourDone, doneCount, earnedPoints } from "./tour/tour-info.js";
5
+ import { useTourProgress, resetTourProgress } from "./tour/use-tour-progress.js";
6
+ import { useTourUrlSync, useTourCatalogueUrlState } from "./tour/use-tour-url-sync.js";
7
+ export {
8
+ DEFAULT_TOUR_CATALOGUE_LABELS,
9
+ TourCatalogue,
10
+ TourCatalogueCard,
11
+ doneCount,
12
+ earnedPoints,
13
+ isTourDone,
14
+ isTourNew,
15
+ isTourReset,
16
+ resetTourProgress,
17
+ useTourCatalogueUrlState,
18
+ useTourProgress,
19
+ useTourUrlSync
20
+ };
3
21
  //# sourceMappingURL=tour.js.map
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"],"mappings":";AAEA,cAAc;","names":[]}
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;AAE5E,SAAS,iBAAiB,yBAAyB;AAEnD,SAAS,gBAAgB,gCAAgC;","names":[]}
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@eifi1/ui-kit",
3
- "version": "0.41.0",
3
+ "version": "0.43.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.",
@@ -0,0 +1,91 @@
1
+ import { useKitLabels, type LabelOverride } from "../i18n/kit-labels";
2
+ import type { SettingsGroupLabels } from "../settings/settings-labels";
3
+
4
+ /**
5
+ * The `adminArea` namespace (0.42, docs/admin-harmonization.md §3.1): the kit's words of
6
+ * the admin area every app shares — the account menu's "Administration" row, the 403
7
+ * page, the shared sections' names and help lines, the overview's tiles and the Tools
8
+ * list. A module of words with no component code, like every `*-labels.ts` (0.34, KS U8).
9
+ * An app's own sections and tiles bring their own words.
10
+ */
11
+
12
+ /** The sections every app's admin area shares, in the template's order (§2.3). */
13
+ export type AdminSharedSection = "overview" | "users" | "invitations" | "billing" | "activity";
14
+
15
+ /** The overview's shared tiles (§2.4, §6.1). */
16
+ export interface AdminTileLabels {
17
+ /** `feedback_waiting`: OPEN reports, crash reports included (§6.1). */
18
+ feedbackWaiting: string;
19
+ /** `deletion_requests`. */
20
+ deletionRequests: string;
21
+ /** `invitations_pending`. */
22
+ invitationsPending: string;
23
+ /** `users_total`. */
24
+ users: string;
25
+ /** `users_new_7d`, the line under the users tile. */
26
+ usersNew: (count: number) => string;
27
+ }
28
+
29
+ export interface AdminAreaLabels {
30
+ /** "Administration": the account menu's row, and the admin page's heading. */
31
+ title: string;
32
+ /** The row's waiting count, for a reader ("Administration, 3 waiting"); the eye sees
33
+ * the number in the badge. */
34
+ waiting: (count: number) => string;
35
+ /** The 403 page's heading (§2.2 decision 8). */
36
+ forbiddenTitle: string;
37
+ /**
38
+ * Under it. Says only that THIS area is closed to the account — never that the person
39
+ * administers nothing anywhere (§6.7: a kastlan superuser who isn't ADMIN in the acting
40
+ * company gets this page).
41
+ */
42
+ forbiddenBody: (appName: string) => string;
43
+ /** The button back to the app's start, unless the app names its own (`homeLabel`). */
44
+ forbiddenHome: string;
45
+ /** A reviewer's second way out, to the translations review. */
46
+ forbiddenReviewer: string;
47
+ /** The shared sections' names and help lines (§2.3 decision 11). */
48
+ sections: Record<AdminSharedSection, SettingsGroupLabels>;
49
+ /** The overview's tiles. */
50
+ tiles: AdminTileLabels;
51
+ /** 0.43: `AdminOverview`'s `error` — the counts could not be loaded. */
52
+ overviewError: string;
53
+ /** 0.43: its button, which calls `onRetry`. */
54
+ retry: string;
55
+ /** The Tools list's heading and name (§2.5 decision 16). */
56
+ tools: string;
57
+ }
58
+
59
+ export const DEFAULT_ADMIN_AREA_LABELS: AdminAreaLabels = {
60
+ title: "Administration",
61
+ waiting: (count) => `${count} waiting`,
62
+ forbiddenTitle: "Only for administrators",
63
+ forbiddenBody: (appName) => `This is where administrators run ${appName}. Your account cannot open this page.`,
64
+ forbiddenHome: "Go to the start page",
65
+ forbiddenReviewer: "Review translations",
66
+ sections: {
67
+ overview: { title: "Overview", help: "What is waiting, at a glance." },
68
+ users: { title: "Users", help: "Who has an account, and what each may do." },
69
+ invitations: { title: "Invitations", help: "Invite people by e-mail in their language; resend or revoke what’s still open." },
70
+ billing: { title: "Billing", help: "The plan, the payment and the invoices." },
71
+ activity: { title: "Activity", help: "What the admins did, newest first." },
72
+ },
73
+ tiles: {
74
+ feedbackWaiting: "To triage",
75
+ deletionRequests: "Deletion requests",
76
+ invitationsPending: "Pending invitations",
77
+ users: "Users",
78
+ usersNew: (count) => `${count} new in 7 days`,
79
+ },
80
+ overviewError: "The overview couldn’t be loaded.",
81
+ retry: "Try again",
82
+ tools: "Tools",
83
+ };
84
+
85
+ /**
86
+ * The `adminArea` namespace, resolved — English, then the provider, then `labels`. What
87
+ * an app reads to hand {@link adminSections} its words from inside a component.
88
+ */
89
+ export function useAdminAreaLabels(labels?: LabelOverride<AdminAreaLabels>): AdminAreaLabels {
90
+ return useKitLabels("adminArea", DEFAULT_ADMIN_AREA_LABELS, labels);
91
+ }
@@ -0,0 +1,75 @@
1
+ import type { ComponentPropsWithRef } from "react";
2
+ import { ShieldAlert } from "lucide-react";
3
+ import { cn } from "../lib/cn";
4
+ import { Button } from "../components/button";
5
+ import { EmptyState } from "../components/empty-state";
6
+ import type { LabelOverride } from "../i18n/kit-labels";
7
+ import { useAdminAreaLabels } from "./admin-area-labels";
8
+ import type { AdminAreaLabels } from "./admin-area-labels";
9
+
10
+ export interface AdminForbiddenProps extends Omit<ComponentPropsWithRef<"div">, "children" | "title"> {
11
+ /** The app's name, in the sentence: "This is where administrators run Kastlan." */
12
+ appName: string;
13
+ /** The button's target: the app's start page (keksdose `/budget`, kastlan `/dashboard`). */
14
+ homeHref: string;
15
+ /** The button's words, where the app names its start ("Back to the budget"). Default
16
+ * `adminArea.forbiddenHome`, "Go to the start page". */
17
+ homeLabel?: string;
18
+ /** Pass it to a translation reviewer: a second button, "Review translations", to the
19
+ * tool their role does have (§2.2 decision 8). */
20
+ reviewerHref?: string;
21
+ /** The heading's level. Default `h1`: the page stands alone inside the app shell. */
22
+ headingAs?: "h1" | "h2" | "h3";
23
+ /** Prop > `<UiKitProvider labels={{ adminArea }}>` > English. */
24
+ labels?: LabelOverride<AdminAreaLabels>;
25
+ }
26
+
27
+ /**
28
+ * The admin area's 403 page (0.42.0, docs/admin-harmonization.md §2.2 decision 8, §6.7),
29
+ * keksdose's `AdminForbidden`: "Only for administrators" / "This is where administrators
30
+ * run {app}. Your account cannot open this page.", and a button back to the app's start.
31
+ *
32
+ * It RENDERS, it does not redirect: the app's client gate shows it in place of the admin
33
+ * page, on the same admin check the account menu uses, inside the lazy route — so a
34
+ * bookmark keeps its address, and signing in as an admin opens the page that was asked
35
+ * for. The server's gate stays the rule. kastlan shows it on its admin tools (`/admin`,
36
+ * `/import`, `/feedback`); its work pages keep their generic 403.
37
+ *
38
+ * The words say only that this area is closed to this account, never that the person
39
+ * administers nothing anywhere: a kastlan superuser who isn't ADMIN in the acting company
40
+ * gets this page too.
41
+ */
42
+ export function AdminForbidden({
43
+ appName,
44
+ homeHref,
45
+ homeLabel,
46
+ reviewerHref,
47
+ headingAs = "h1",
48
+ labels: labelsProp,
49
+ className,
50
+ ...rest
51
+ }: AdminForbiddenProps) {
52
+ const labels = useAdminAreaLabels(labelsProp);
53
+ return (
54
+ <div {...rest} data-slot="admin-forbidden" className={cn("mx-auto w-full max-w-xl p-4 md:p-6", className)}>
55
+ <EmptyState
56
+ icon={<ShieldAlert />}
57
+ headingAs={headingAs}
58
+ title={labels.forbiddenTitle}
59
+ hint={labels.forbiddenBody(appName)}
60
+ action={
61
+ <>
62
+ <Button href={homeHref} variant="secondary">
63
+ {homeLabel ?? labels.forbiddenHome}
64
+ </Button>
65
+ {reviewerHref !== undefined && (
66
+ <Button href={reviewerHref} variant="secondary">
67
+ {labels.forbiddenReviewer}
68
+ </Button>
69
+ )}
70
+ </>
71
+ }
72
+ />
73
+ </div>
74
+ );
75
+ }