@eifi1/ui-kit 0.29.0 → 0.31.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 (307) hide show
  1. package/README.md +65 -6
  2. package/dist/account/account-parts.d.ts +21 -0
  3. package/dist/account/account-parts.js +40 -0
  4. package/dist/account/account-parts.js.map +1 -0
  5. package/dist/account/data-export-setting.d.ts +73 -0
  6. package/dist/account/data-export-setting.js +114 -0
  7. package/dist/account/data-export-setting.js.map +1 -0
  8. package/dist/account/delete-account-setting.d.ts +95 -0
  9. package/dist/account/delete-account-setting.js +200 -0
  10. package/dist/account/delete-account-setting.js.map +1 -0
  11. package/dist/account/email-change-setting.d.ts +136 -0
  12. package/dist/account/email-change-setting.js +278 -0
  13. package/dist/account/email-change-setting.js.map +1 -0
  14. package/dist/account/sessions-setting.d.ts +110 -0
  15. package/dist/account/sessions-setting.js +147 -0
  16. package/dist/account/sessions-setting.js.map +1 -0
  17. package/dist/admin/admin-action-confirm.d.ts +95 -0
  18. package/dist/admin/admin-action-confirm.js +276 -0
  19. package/dist/admin/admin-action-confirm.js.map +1 -0
  20. package/dist/admin/admin-action-log.d.ts +105 -0
  21. package/dist/admin/admin-action-log.js +153 -0
  22. package/dist/admin/admin-action-log.js.map +1 -0
  23. package/dist/admin/admin-parts.d.ts +40 -0
  24. package/dist/admin/admin-parts.js +45 -0
  25. package/dist/admin/admin-parts.js.map +1 -0
  26. package/dist/admin/invitations-panel.d.ts +95 -0
  27. package/dist/admin/invitations-panel.js +290 -0
  28. package/dist/admin/invitations-panel.js.map +1 -0
  29. package/dist/admin/reviewer-scope-editor.d.ts +95 -0
  30. package/dist/admin/reviewer-scope-editor.js +134 -0
  31. package/dist/admin/reviewer-scope-editor.js.map +1 -0
  32. package/dist/admin/role-select.d.ts +95 -0
  33. package/dist/admin/role-select.js +128 -0
  34. package/dist/admin/role-select.js.map +1 -0
  35. package/dist/admin/transfer-ownership-dialog.d.ts +95 -0
  36. package/dist/admin/transfer-ownership-dialog.js +83 -0
  37. package/dist/admin/transfer-ownership-dialog.js.map +1 -0
  38. package/dist/admin/user-roster.d.ts +95 -0
  39. package/dist/admin/user-roster.js +285 -0
  40. package/dist/admin/user-roster.js.map +1 -0
  41. package/dist/auth/auth-errors.d.ts +103 -6
  42. package/dist/auth/auth-errors.js +68 -2
  43. package/dist/auth/auth-errors.js.map +1 -1
  44. package/dist/auth/forgot-password-form.d.ts +10 -3
  45. package/dist/auth/forgot-password-form.js +6 -3
  46. package/dist/auth/forgot-password-form.js.map +1 -1
  47. package/dist/auth/register-form.d.ts +21 -12
  48. package/dist/auth/register-form.js +6 -4
  49. package/dist/auth/register-form.js.map +1 -1
  50. package/dist/auth/reset-password-form.d.ts +10 -3
  51. package/dist/auth/reset-password-form.js +6 -3
  52. package/dist/auth/reset-password-form.js.map +1 -1
  53. package/dist/auth/sign-in-form.d.ts +75 -9
  54. package/dist/auth/sign-in-form.js +105 -15
  55. package/dist/auth/sign-in-form.js.map +1 -1
  56. package/dist/auth/verify-email.d.ts +7 -2
  57. package/dist/auth/verify-email.js +44 -8
  58. package/dist/auth/verify-email.js.map +1 -1
  59. package/dist/chart.d.ts +20 -11
  60. package/dist/components/account-chips.d.ts +47 -7
  61. package/dist/components/account-chips.js +19 -3
  62. package/dist/components/account-chips.js.map +1 -1
  63. package/dist/components/account-settings-labels.d.ts +24 -1
  64. package/dist/components/account-settings-labels.js +6 -2
  65. package/dist/components/account-settings-labels.js.map +1 -1
  66. package/dist/components/account-settings.d.ts +69 -8
  67. package/dist/components/account-settings.js +133 -25
  68. package/dist/components/account-settings.js.map +1 -1
  69. package/dist/components/amount-input.d.ts +21 -12
  70. package/dist/components/button-group.d.ts +21 -12
  71. package/dist/components/calculator.d.ts +21 -12
  72. package/dist/components/confirm-dialog.d.ts +21 -12
  73. package/dist/components/copy-button.d.ts +18 -9
  74. package/dist/components/danger-confirm.d.ts +21 -12
  75. package/dist/components/data-table.js +8 -1
  76. package/dist/components/data-table.js.map +1 -1
  77. package/dist/components/date-picker.js +9 -4
  78. package/dist/components/date-picker.js.map +1 -1
  79. package/dist/components/facing-pair.d.ts +21 -12
  80. package/dist/components/file-button.d.ts +21 -12
  81. package/dist/components/file-dropzone.d.ts +21 -12
  82. package/dist/components/form-actions.d.ts +21 -12
  83. package/dist/components/iban-input.d.ts +21 -12
  84. package/dist/components/language-select.d.ts +21 -12
  85. package/dist/components/legal-page.d.ts +21 -12
  86. package/dist/components/legal.d.ts +21 -12
  87. package/dist/components/money-field.d.ts +21 -12
  88. package/dist/components/number-field.d.ts +21 -12
  89. package/dist/components/number-input.d.ts +21 -12
  90. package/dist/components/numpad-sheet.d.ts +21 -12
  91. package/dist/components/passkeys-setting.js +2 -1
  92. package/dist/components/passkeys-setting.js.map +1 -1
  93. package/dist/components/phone-input.d.ts +21 -12
  94. package/dist/components/series-chart.d.ts +21 -12
  95. package/dist/components/settings-fields.d.ts +55 -13
  96. package/dist/components/settings-fields.js +20 -1
  97. package/dist/components/settings-fields.js.map +1 -1
  98. package/dist/components/share-card.d.ts +20 -11
  99. package/dist/components/text-link.d.ts +21 -12
  100. package/dist/components/time-input.d.ts +21 -12
  101. package/dist/components/ui.d.ts +17 -8
  102. package/dist/{register-form-V1OeNlMr.d.ts → delete-account-setting-DCFS5Vof.d.ts} +6612 -5612
  103. package/dist/demo/demo-banner.d.ts +51 -0
  104. package/dist/demo/demo-banner.js +97 -0
  105. package/dist/demo/demo-banner.js.map +1 -0
  106. package/dist/demo/demo-countdown.d.ts +58 -0
  107. package/dist/demo/demo-countdown.js +79 -0
  108. package/dist/demo/demo-countdown.js.map +1 -0
  109. package/dist/demo/demo-ended.d.ts +45 -0
  110. package/dist/demo/demo-ended.js +42 -0
  111. package/dist/demo/demo-ended.js.map +1 -0
  112. package/dist/demo/demo-labels.d.ts +53 -0
  113. package/dist/demo/demo-labels.js +29 -0
  114. package/dist/demo/demo-labels.js.map +1 -0
  115. package/dist/demo/demo-session.d.ts +30 -0
  116. package/dist/demo/demo-session.js +8 -0
  117. package/dist/demo/demo-session.js.map +1 -0
  118. package/dist/demo/demo-start.d.ts +54 -0
  119. package/dist/demo/demo-start.js +68 -0
  120. package/dist/demo/demo-start.js.map +1 -0
  121. package/dist/feedback/feedback-attachment.d.ts +21 -12
  122. package/dist/feedback/feedback-context.d.ts +21 -12
  123. package/dist/feedback/feedback-crash.d.ts +21 -12
  124. package/dist/feedback/feedback-dialog.d.ts +21 -12
  125. package/dist/feedback/feedback-inbox.d.ts +21 -12
  126. package/dist/feedback/feedback-inbox.js +12 -2
  127. package/dist/feedback/feedback-inbox.js.map +1 -1
  128. package/dist/feedback/feedback-labels.d.ts +21 -12
  129. package/dist/feedback/feedback-labels.js +1 -0
  130. package/dist/feedback/feedback-labels.js.map +1 -1
  131. package/dist/feedback/feedback-menu.d.ts +21 -12
  132. package/dist/feedback/feedback-record.d.ts +21 -12
  133. package/dist/feedback/feedback-record.js +2 -0
  134. package/dist/feedback/feedback-record.js.map +1 -1
  135. package/dist/feedback/feedback-row-detail.d.ts +21 -12
  136. package/dist/feedback/feedback-row-detail.js +7 -0
  137. package/dist/feedback/feedback-row-detail.js.map +1 -1
  138. package/dist/feedback/feedback-status-undo.d.ts +21 -12
  139. package/dist/feedback/feedback-submit.d.ts +21 -12
  140. package/dist/feedback/feedback-swipe.d.ts +22 -13
  141. package/dist/feedback/feedback-swipe.js.map +1 -1
  142. package/dist/feedback/feedback-table.d.ts +18 -9
  143. package/dist/feedback/feedback-thread.d.ts +21 -12
  144. package/dist/feedback.d.ts +21 -12
  145. package/dist/hooks/use-anchored-panel.d.ts +6 -2
  146. package/dist/hooks/use-anchored-panel.js +2 -1
  147. package/dist/hooks/use-anchored-panel.js.map +1 -1
  148. package/dist/hooks/use-file-drop.d.ts +21 -12
  149. package/dist/i18n/defaults.d.ts +21 -12
  150. package/dist/i18n/defaults.js +28 -0
  151. package/dist/i18n/defaults.js.map +1 -1
  152. package/dist/i18n/german.d.ts +21 -12
  153. package/dist/i18n/german.js +298 -12
  154. package/dist/i18n/german.js.map +1 -1
  155. package/dist/i18n/kit-labels.d.ts +14 -5
  156. package/dist/i18n/kit-labels.js.map +1 -1
  157. package/dist/i18n/languages.d.ts +15 -6
  158. package/dist/i18n/locales/de-CH.d.ts +21 -12
  159. package/dist/i18n/locales/en.d.ts +21 -12
  160. package/dist/i18n/locales/es.d.ts +21 -12
  161. package/dist/i18n/locales/es.js +287 -10
  162. package/dist/i18n/locales/es.js.map +1 -1
  163. package/dist/i18n/locales/fr.d.ts +21 -12
  164. package/dist/i18n/locales/fr.js +290 -7
  165. package/dist/i18n/locales/fr.js.map +1 -1
  166. package/dist/i18n/locales/hu.d.ts +21 -12
  167. package/dist/i18n/locales/hu.js +288 -12
  168. package/dist/i18n/locales/hu.js.map +1 -1
  169. package/dist/i18n/locales/it.d.ts +21 -12
  170. package/dist/i18n/locales/it.js +289 -7
  171. package/dist/i18n/locales/it.js.map +1 -1
  172. package/dist/i18n/locales/zh.d.ts +21 -12
  173. package/dist/i18n/locales/zh.js +279 -7
  174. package/dist/i18n/locales/zh.js.map +1 -1
  175. package/dist/i18n/review.d.ts +21 -12
  176. package/dist/i18n/review.js +50 -1
  177. package/dist/i18n/review.js.map +1 -1
  178. package/dist/index.d.ts +31 -4
  179. package/dist/index.js +148 -0
  180. package/dist/index.js.map +1 -1
  181. package/dist/landing/access.d.ts +48 -0
  182. package/dist/landing/access.js +38 -0
  183. package/dist/landing/access.js.map +1 -0
  184. package/dist/landing/landing-actions.d.ts +138 -0
  185. package/dist/landing/landing-actions.js +39 -0
  186. package/dist/landing/landing-actions.js.map +1 -0
  187. package/dist/landing/landing-labels.d.ts +43 -0
  188. package/dist/landing/landing-labels.js +23 -0
  189. package/dist/landing/landing-labels.js.map +1 -0
  190. package/dist/landing/landing-sections.d.ts +218 -0
  191. package/dist/landing/landing-sections.js +167 -0
  192. package/dist/landing/landing-sections.js.map +1 -0
  193. package/dist/landing/page-seo.d.ts +99 -0
  194. package/dist/landing/page-seo.js +121 -0
  195. package/dist/landing/page-seo.js.map +1 -0
  196. package/dist/landing/public-header.d.ts +148 -0
  197. package/dist/landing/public-header.js +49 -0
  198. package/dist/landing/public-header.js.map +1 -0
  199. package/dist/landing/routing.d.ts +218 -0
  200. package/dist/landing/routing.js +81 -0
  201. package/dist/landing/routing.js.map +1 -0
  202. package/dist/rhf/fields.d.ts +21 -12
  203. package/dist/rhf/form.d.ts +21 -12
  204. package/dist/rhf.d.ts +21 -12
  205. package/dist/settings/settings-catalogue.d.ts +84 -0
  206. package/dist/settings/settings-catalogue.js +47 -0
  207. package/dist/settings/settings-catalogue.js.map +1 -0
  208. package/dist/settings/settings-context.d.ts +31 -0
  209. package/dist/settings/settings-context.js +11 -0
  210. package/dist/settings/settings-context.js.map +1 -0
  211. package/dist/settings/settings-heading.d.ts +50 -0
  212. package/dist/settings/settings-heading.js +27 -0
  213. package/dist/settings/settings-heading.js.map +1 -0
  214. package/dist/settings/settings-labels.d.ts +41 -0
  215. package/dist/settings/settings-labels.js +38 -0
  216. package/dist/settings/settings-labels.js.map +1 -0
  217. package/dist/settings/settings-layout.d.ts +77 -0
  218. package/dist/settings/settings-layout.js +242 -0
  219. package/dist/settings/settings-layout.js.map +1 -0
  220. package/dist/settings/settings-section.d.ts +133 -0
  221. package/dist/settings/settings-section.js +63 -0
  222. package/dist/settings/settings-section.js.map +1 -0
  223. package/dist/settings/use-account-language.d.ts +83 -0
  224. package/dist/settings/use-account-language.js +62 -0
  225. package/dist/settings/use-account-language.js.map +1 -0
  226. package/dist/settings/use-settings-focus.d.ts +34 -0
  227. package/dist/settings/use-settings-focus.js +77 -0
  228. package/dist/settings/use-settings-focus.js.map +1 -0
  229. package/dist/settings/use-settings-route.d.ts +118 -0
  230. package/dist/settings/use-settings-route.js +120 -0
  231. package/dist/settings/use-settings-route.js.map +1 -0
  232. package/dist/shell/app-shell.d.ts +27 -12
  233. package/dist/shell/app-shell.js +3 -2
  234. package/dist/shell/app-shell.js.map +1 -1
  235. package/dist/shell/top-bar-brand.d.ts +21 -12
  236. package/dist/shell.d.ts +25 -6
  237. package/dist/shell.js +56 -0
  238. package/dist/shell.js.map +1 -1
  239. package/dist/wizard/stepper-nav.d.ts +21 -12
  240. package/dist/wizard.d.ts +21 -12
  241. package/package.json +2 -1
  242. package/src/account/account-parts.ts +67 -0
  243. package/src/account/data-export-setting.tsx +219 -0
  244. package/src/account/delete-account-setting.tsx +363 -0
  245. package/src/account/email-change-setting.tsx +454 -0
  246. package/src/account/sessions-setting.tsx +284 -0
  247. package/src/admin/admin-action-confirm.tsx +495 -0
  248. package/src/admin/admin-action-log.tsx +297 -0
  249. package/src/admin/admin-parts.ts +82 -0
  250. package/src/admin/invitations-panel.tsx +523 -0
  251. package/src/admin/reviewer-scope-editor.tsx +234 -0
  252. package/src/admin/role-select.tsx +286 -0
  253. package/src/admin/transfer-ownership-dialog.tsx +172 -0
  254. package/src/admin/user-roster.tsx +573 -0
  255. package/src/auth/auth-errors.ts +196 -8
  256. package/src/auth/forgot-password-form.tsx +21 -5
  257. package/src/auth/register-form.tsx +24 -6
  258. package/src/auth/reset-password-form.tsx +19 -5
  259. package/src/auth/sign-in-form.tsx +241 -38
  260. package/src/auth/verify-email.tsx +59 -8
  261. package/src/components/account-chips.tsx +64 -7
  262. package/src/components/account-settings-labels.ts +28 -1
  263. package/src/components/account-settings.tsx +231 -34
  264. package/src/components/data-table.tsx +8 -0
  265. package/src/components/date-picker.tsx +9 -4
  266. package/src/components/passkeys-setting.tsx +2 -1
  267. package/src/components/settings-fields.tsx +50 -1
  268. package/src/demo/demo-banner.tsx +159 -0
  269. package/src/demo/demo-countdown.ts +141 -0
  270. package/src/demo/demo-ended.tsx +89 -0
  271. package/src/demo/demo-labels.ts +78 -0
  272. package/src/demo/demo-session.ts +32 -0
  273. package/src/demo/demo-start.tsx +150 -0
  274. package/src/feedback/feedback-inbox.tsx +21 -8
  275. package/src/feedback/feedback-labels.ts +2 -1
  276. package/src/feedback/feedback-record.ts +10 -7
  277. package/src/feedback/feedback-row-detail.tsx +28 -4
  278. package/src/feedback/feedback-swipe.tsx +1 -1
  279. package/src/hooks/use-anchored-panel.ts +14 -3
  280. package/src/i18n/defaults.ts +28 -0
  281. package/src/i18n/german.ts +327 -5
  282. package/src/i18n/kit-labels.tsx +42 -0
  283. package/src/i18n/locales/es.ts +313 -3
  284. package/src/i18n/locales/fr.ts +316 -0
  285. package/src/i18n/locales/hu.ts +308 -5
  286. package/src/i18n/locales/it.ts +313 -0
  287. package/src/i18n/locales/zh.ts +286 -0
  288. package/src/i18n/review.ts +49 -0
  289. package/src/index.ts +184 -0
  290. package/src/landing/access.ts +97 -0
  291. package/src/landing/landing-actions.tsx +92 -0
  292. package/src/landing/landing-labels.ts +59 -0
  293. package/src/landing/landing-sections.tsx +337 -0
  294. package/src/landing/page-seo.ts +251 -0
  295. package/src/landing/public-header.tsx +120 -0
  296. package/src/landing/routing.tsx +209 -0
  297. package/src/settings/settings-catalogue.ts +132 -0
  298. package/src/settings/settings-context.ts +33 -0
  299. package/src/settings/settings-heading.tsx +65 -0
  300. package/src/settings/settings-labels.ts +75 -0
  301. package/src/settings/settings-layout.tsx +379 -0
  302. package/src/settings/settings-section.tsx +108 -0
  303. package/src/settings/use-account-language.ts +146 -0
  304. package/src/settings/use-settings-focus.ts +120 -0
  305. package/src/settings/use-settings-route.ts +258 -0
  306. package/src/shell/app-shell.tsx +10 -2
  307. package/src/shell.ts +45 -0
@@ -0,0 +1,251 @@
1
+ import { useEffect } from "react";
2
+
3
+ /**
4
+ * Search-engine helpers for the public landing page (docs/landing-demo-harmonization.md
5
+ * §4.3): the hook that declares a page's canonical URL, title, description and JSON-LD,
6
+ * and the two pure helpers each app's SEO test is built from.
7
+ */
8
+
9
+ /**
10
+ * Declares the current page's canonical URL — and optionally its title and meta
11
+ * description — while the calling component is mounted (feedback #129).
12
+ *
13
+ * Client-side because there is nowhere else to put it: the SPA is served by
14
+ * Caddy's `try_files … /index.html`, so every route shares one static head.
15
+ * Googlebot renders JS and reads the canonical from the RENDERED DOM, which is
16
+ * what makes this work; non-rendering scrapers (WhatsApp, Slack) only ever see
17
+ * index.html's static tags, which is why those stay in place as the fallback.
18
+ *
19
+ * The href is resolved against `window.location.origin` rather than hardcoded, so
20
+ * a preview deployment or the VPS host stays self-referential instead of pointing
21
+ * every copy of the app at production.
22
+ *
23
+ * The cleanup matters for the same reason as in {@link useNoIndex}: an SPA
24
+ * navigation from the landing page into the app must not leave the landing's
25
+ * canonical (or title) behind on an app page.
26
+ *
27
+ * 0.31 (§4.3): keksdose's `usePageSeo` (shared/hooks/use-page-seo.ts), verbatim. The
28
+ * landing calls it with `canonicalPath: "/"` on both `/` and `/welcome`, so the two URLs
29
+ * of one page are one result; there are no per-language URLs and no hreflang.
30
+ */
31
+ export function usePageSeo({
32
+ canonicalPath,
33
+ title,
34
+ description,
35
+ jsonLd,
36
+ }: {
37
+ canonicalPath: string;
38
+ title?: string;
39
+ description?: string;
40
+ /** Structured data for this page, as the object (not a string). Injected as an
41
+ * `application/ld+json` DATA BLOCK — a script element the browser never executes,
42
+ * which is also why it can be added here rather than in `index.html`: the CSP
43
+ * carries a HASH of the one inline script that file is allowed to have, and
44
+ * `csp-inline-script.test.ts` fails the build on a second one (live #250). */
45
+ jsonLd?: Record<string, unknown>;
46
+ }): void {
47
+ useEffect(() => {
48
+ const link = document.createElement("link");
49
+ link.rel = "canonical";
50
+ link.href = new URL(canonicalPath, window.location.origin).toString();
51
+ document.head.appendChild(link);
52
+
53
+ const previousTitle = document.title;
54
+ if (title) document.title = title;
55
+
56
+ // The description tag ships in index.html, so upsert rather than append —
57
+ // two `meta[name=description]` tags would be a coin flip for the scraper.
58
+ const meta = description
59
+ ? document.head.querySelector<HTMLMetaElement>('meta[name="description"]')
60
+ : null;
61
+ const previousDescription = meta?.content;
62
+ if (meta && description) meta.content = description;
63
+
64
+ let ld: HTMLScriptElement | null = null;
65
+ if (jsonLd) {
66
+ ld = document.createElement("script");
67
+ ld.type = "application/ld+json";
68
+ ld.textContent = JSON.stringify(jsonLd);
69
+ document.head.appendChild(ld);
70
+ }
71
+
72
+ return () => {
73
+ link.remove();
74
+ ld?.remove();
75
+ if (title) document.title = previousTitle;
76
+ if (meta && previousDescription !== undefined) meta.content = previousDescription;
77
+ };
78
+ // `jsonLd` is built inline by the caller, so it is a new object every render and
79
+ // cannot be a dependency — it is derived from `title`/`description`, which are.
80
+ // eslint-disable-next-line react-hooks/exhaustive-deps
81
+ }, [canonicalPath, title, description]);
82
+ }
83
+
84
+ /* ── The SEO test's helpers ──────────────────────────────────────────────── */
85
+
86
+ /** What a search result shows of the landing, in one language. */
87
+ export interface SeoCopy {
88
+ /** The page title (`landing.seo.title`): "Keksdose — envelope budgeting". */
89
+ title: string;
90
+ /** The meta description. */
91
+ description: string;
92
+ /** The product name the title carries, before or after its tagline. */
93
+ brand: string;
94
+ }
95
+
96
+ /** The width budgets, as characters. */
97
+ export interface SeoCopyLimits {
98
+ /** Default 60: a result's title is cut around 580px. */
99
+ maxTitle?: number;
100
+ /** Default 155: a snippet is cut around 920px. */
101
+ maxDescription?: number;
102
+ }
103
+
104
+ export type SeoCopyProblemCode =
105
+ | "title-empty"
106
+ | "description-empty"
107
+ | "title-too-long"
108
+ | "description-too-long"
109
+ | "title-without-brand"
110
+ | "description-repeats-title";
111
+
112
+ /** One thing wrong with a language's copy. `message` is for the test's failure output. */
113
+ export interface SeoCopyProblem {
114
+ code: SeoCopyProblemCode;
115
+ message: string;
116
+ }
117
+
118
+ /** Characters as a reader counts them: code points, so an emoji or a rare CJK character
119
+ * is one, not two UTF-16 units. */
120
+ const length = (text: string) => Array.from(text).length;
121
+
122
+ const escapeRegExp = (text: string) => text.replace(/[.*+?^${}()|[\]\\]/g, "\\$&");
123
+
124
+ /** The separators a title puts between the brand and its tagline. */
125
+ const SEPARATOR = "\\s*[—–\\-|:·•]\\s*";
126
+
127
+ /** The title without the brand and its separator, or `null` when the brand is in
128
+ * neither place. */
129
+ function taglineOf(title: string, brand: string): string | null {
130
+ const name = escapeRegExp(brand.trim());
131
+ const leading = new RegExp(`^${name}${SEPARATOR}`, "i");
132
+ if (leading.test(title)) return title.replace(leading, "").trim();
133
+ const trailing = new RegExp(`${SEPARATOR}${name}$`, "i");
134
+ if (trailing.test(title)) return title.replace(trailing, "").trim();
135
+ return null;
136
+ }
137
+
138
+ /**
139
+ * What is wrong with one language's search-result copy (§4.3) — an empty list when
140
+ * nothing is. keksdose's landing-seo.test.ts (live #305), made reusable:
141
+ *
142
+ * - the title fits `maxTitle` characters (60) and the description `maxDescription`
143
+ * (155) — conservative stand-ins for Google's pixel budgets; German is the language
144
+ * most likely to blow through them, and what gets cut is always the end;
145
+ * - the title carries the brand, before or after its tagline ("Keksdose — …");
146
+ * - the description does not open with the title's tagline again — a snippet that
147
+ * repeats the line above it spends its first forty characters saying nothing new.
148
+ *
149
+ * ```ts
150
+ * for (const [locale, bundle] of Object.entries(LOCALES)) {
151
+ * it(`fits ${locale} into a search result`, () => {
152
+ * expect(seoCopyProblems({ ...bundle.landing.seo, brand: "Keksdose" })).toEqual([]);
153
+ * });
154
+ * }
155
+ * ```
156
+ */
157
+ export function seoCopyProblems(copy: SeoCopy, limits: SeoCopyLimits = {}): SeoCopyProblem[] {
158
+ const { maxTitle = 60, maxDescription = 155 } = limits;
159
+ const title = copy.title.replace(/\s+/g, " ").trim();
160
+ const description = copy.description.replace(/\s+/g, " ").trim();
161
+ const problems: SeoCopyProblem[] = [];
162
+ if (!title) problems.push({ code: "title-empty", message: "the title is empty" });
163
+ if (!description) problems.push({ code: "description-empty", message: "the description is empty" });
164
+ if (length(title) > maxTitle) {
165
+ problems.push({
166
+ code: "title-too-long",
167
+ message: `the title is ${length(title)} characters, over ${maxTitle}: "${title}"`,
168
+ });
169
+ }
170
+ if (length(description) > maxDescription) {
171
+ problems.push({
172
+ code: "description-too-long",
173
+ message: `the description is ${length(description)} characters, over ${maxDescription}: "${description}"`,
174
+ });
175
+ }
176
+ if (!title) return problems;
177
+ const tagline = taglineOf(title, copy.brand);
178
+ if (tagline === null) {
179
+ problems.push({
180
+ code: "title-without-brand",
181
+ message: `the title does not start or end with "${copy.brand}" and a separator: "${title}"`,
182
+ });
183
+ }
184
+ const repeated = (tagline ?? title).toLowerCase();
185
+ if (repeated && description.toLowerCase().startsWith(repeated)) {
186
+ problems.push({
187
+ code: "description-repeats-title",
188
+ message: `the description opens with the title's tagline "${tagline ?? title}"`,
189
+ });
190
+ }
191
+ return problems;
192
+ }
193
+
194
+ /** The five named references and the numeric ones — enough for a meta tag's text. */
195
+ function decodeEntities(text: string): string {
196
+ const named: Record<string, string> = { amp: "&", lt: "<", gt: ">", quot: '"', apos: "'", nbsp: " " };
197
+ return text.replace(/&(#x[\da-f]+|#\d+|[a-z]+);/gi, (whole, ref: string) => {
198
+ if (ref[0] === "#") {
199
+ const code = ref[1] === "x" || ref[1] === "X" ? parseInt(ref.slice(2), 16) : parseInt(ref.slice(1), 10);
200
+ return Number.isFinite(code) ? String.fromCodePoint(code) : whole;
201
+ }
202
+ return named[ref.toLowerCase()] ?? whole;
203
+ });
204
+ }
205
+
206
+ /** A tag's attributes, names lower-cased; values double-, single- or un-quoted. */
207
+ function attributesOf(tag: string): Map<string, string> {
208
+ const found = new Map<string, string>();
209
+ for (const m of tag.matchAll(/([^\s=/<>"']+)\s*=\s*(?:"([^"]*)"|'([^']*)'|([^\s"'=<>`]+))/g)) {
210
+ found.set(m[1]!.toLowerCase(), m[2] ?? m[3] ?? m[4] ?? "");
211
+ }
212
+ return found;
213
+ }
214
+
215
+ /**
216
+ * The `content` of the first `<meta>` in `html` that `selector` picks, whitespace
217
+ * collapsed and entities decoded — or `undefined` when there is none. For each app's
218
+ * parity test (§4.3): `index.html`'s static description and `og:` tags must equal the
219
+ * default language's strings, because a scraper that runs no script (WhatsApp, Slack)
220
+ * sees only those.
221
+ *
222
+ * `selector` names attributes, as in CSS — `meta[name="description"]`,
223
+ * `[property="og:title"]` or just `property="og:description"` — and every one named must
224
+ * match. A `RegExp` is matched against the tag's source instead (keksdose's form).
225
+ * String parsing, not a DOM: the test runs under `@vitest-environment node`.
226
+ *
227
+ * ```ts
228
+ * expect(metaContent(html, 'meta[name="description"]')).toBe(de.landing.seo.description);
229
+ * ```
230
+ */
231
+ export function metaContent(html: string, selector: string | RegExp): string | undefined {
232
+ const wanted =
233
+ typeof selector === "string"
234
+ ? Array.from(selector.matchAll(/([\w:.-]+)\s*=\s*(?:"([^"]*)"|'([^']*)')/g), (m) => [
235
+ m[1]!.toLowerCase(),
236
+ m[2] ?? m[3] ?? "",
237
+ ])
238
+ : null;
239
+ if (wanted !== null && wanted.length === 0) {
240
+ throw new Error(`metaContent: the selector names no attribute: ${selector as string}`);
241
+ }
242
+ const pattern = selector instanceof RegExp ? new RegExp(selector.source, selector.flags.replace("g", "")) : null;
243
+ for (const tag of html.match(/<meta\b[^>]*>/gi) ?? []) {
244
+ const attrs = attributesOf(tag);
245
+ const matches = pattern ? pattern.test(tag) : wanted!.every(([name, value]) => attrs.get(name!) === value);
246
+ if (!matches) continue;
247
+ const content = attrs.get("content");
248
+ return content === undefined ? undefined : decodeEntities(content).replace(/\s+/g, " ").trim();
249
+ }
250
+ return undefined;
251
+ }
@@ -0,0 +1,120 @@
1
+ import type { ReactNode } from "react";
2
+
3
+ import { Button, PHONE_QUERY } from "../components/ui";
4
+ import { useMediaQuery } from "../hooks/use-media-query";
5
+ import { TopBar } from "../shell/top-bar";
6
+ import type { TopBarProps } from "../shell/top-bar";
7
+ import { TopBarBrand } from "../shell/top-bar-brand";
8
+ import { useAccessAction } from "./access";
9
+ import type { AccessChoice } from "./access";
10
+ import { useLandingLabels } from "./landing-labels";
11
+ import type { LandingLabels } from "./landing-labels";
12
+ import type { LandingSession } from "./landing-actions";
13
+
14
+ /** The header's brand: what `TopBarBrand` draws. */
15
+ export interface PublicHeaderBrand {
16
+ /** The brand mark — an SVG or an `<img alt="">`. */
17
+ logo: ReactNode;
18
+ /** The product name beside it, and the link's accessible name. */
19
+ name: ReactNode;
20
+ /** See `TopBarBrand`. Default `sm`: the logo alone on a phone. */
21
+ collapseBelow?: "sm" | "md" | false;
22
+ }
23
+
24
+ export interface PublicHeaderProps extends Omit<TopBarProps, "brand" | "actions" | "children"> {
25
+ brand: PublicHeaderBrand;
26
+ /** Where the brand links. Default `/` without a session and `/welcome` with one —
27
+ * `/` would resume into the app, `/welcome` is always the landing (§3.1). */
28
+ homeHref?: string;
29
+ session: LandingSession;
30
+ access: AccessChoice;
31
+ /** "Sign in". Default `/login`. */
32
+ signInHref?: string;
33
+ /** "Open app" for a real session — the resume target. Default `/`, which resumes
34
+ * through `RootEntry`. */
35
+ openAppHref?: string;
36
+ /** "Continue the demo" for a demo session. Default `/demo`, which continues a live
37
+ * demo (§3.1). */
38
+ demoHref?: string;
39
+ /** The app's `LanguageMenu`, before the actions. */
40
+ languageMenu?: ReactNode;
41
+ /** The app's `ThemeToggle`, before the language menu. */
42
+ themeToggle?: ReactNode;
43
+ labels?: Partial<LandingLabels>;
44
+ }
45
+
46
+ /**
47
+ * The header of every public page (docs/landing-demo-harmonization.md §4.1): the
48
+ * landing, the auth pages, the legal pages and the 404 — keksdose's `LandingHeader`, on
49
+ * the kit's `TopBar` and `TopBarBrand`, with its hard-coded teal turned into `--brand`.
50
+ *
51
+ * | session | actions |
52
+ * |---|---|
53
+ * | none | Sign in (secondary, hidden below `sm`), **Request access** (primary) |
54
+ * | demo | Continue the demo (secondary), **Request access** (primary) |
55
+ * | user | **Open app** (primary) |
56
+ *
57
+ * A demo session is not "signed in" here: it is still offered access, which is the point
58
+ * of the demo (§5.6). On a phone (`PHONE_QUERY`) the actions take the kit's `sm` size, so
59
+ * the brand mark, the two controls and two actions fit 390px; "Sign in" gives way below
60
+ * `sm` as in keksdose — the sign-in page is one tap away from "Request access" anyway.
61
+ */
62
+ export function PublicHeader({
63
+ brand,
64
+ homeHref,
65
+ session,
66
+ access,
67
+ signInHref = "/login",
68
+ openAppHref = "/",
69
+ demoHref = "/demo",
70
+ languageMenu,
71
+ themeToggle,
72
+ labels: labelsProp,
73
+ className,
74
+ ...rest
75
+ }: PublicHeaderProps) {
76
+ const labels = useLandingLabels(labelsProp);
77
+ const accessLink = useAccessAction(access, labelsProp);
78
+ const phone = useMediaQuery(PHONE_QUERY, false);
79
+ const size = phone ? "sm" : "md";
80
+ const home = homeHref ?? (session === "none" ? "/" : "/welcome");
81
+
82
+ const actions =
83
+ session === "user" ? (
84
+ <Button href={openAppHref} variant="brand" size={size}>
85
+ {labels.openApp}
86
+ </Button>
87
+ ) : (
88
+ <>
89
+ {session === "demo" ? (
90
+ <Button href={demoHref} variant="secondary" size={size}>
91
+ {labels.continueDemo}
92
+ </Button>
93
+ ) : (
94
+ <Button href={signInHref} variant="secondary" size={size} className="max-sm:hidden">
95
+ {labels.signIn}
96
+ </Button>
97
+ )}
98
+ <Button href={accessLink.href} variant="brand" size={size}>
99
+ {accessLink.label}
100
+ </Button>
101
+ </>
102
+ );
103
+
104
+ return (
105
+ <TopBar
106
+ {...rest}
107
+ className={className}
108
+ brand={<TopBarBrand logo={brand.logo} name={brand.name} to={home} collapseBelow={brand.collapseBelow} />}
109
+ actions={
110
+ <>
111
+ {themeToggle}
112
+ {languageMenu}
113
+ <span data-slot="public-header-actions" className="ms-1 flex items-center gap-1.5 sm:gap-2">
114
+ {actions}
115
+ </span>
116
+ </>
117
+ }
118
+ />
119
+ );
120
+ }
@@ -0,0 +1,209 @@
1
+ import { useEffect } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { Navigate, Outlet, useLocation } from "react-router";
4
+
5
+ import { readStored, writeStored } from "../lib/safe-storage";
6
+ import type { LandingSession } from "./landing-actions";
7
+
8
+ /**
9
+ * The public routes' logic (docs/landing-demo-harmonization.md §3): what `/` shows,
10
+ * which pages a session skips, and the page a session resumes on — keksdose's
11
+ * `RootEntry`, `RedirectIfAuthed` and use-last-visited-page.ts, for every app.
12
+ * Router-aware (react-router), so they belong to the `./shell` entry.
13
+ */
14
+
15
+ /* ── ?next= ──────────────────────────────────────────────────────────────── */
16
+
17
+ /** The query parameter that carries "where I was actually trying to go" through the
18
+ * sign-in bounce (keksdose's `NEXT_PARAM`). */
19
+ export const NEXT_PARAM = "next";
20
+
21
+ /**
22
+ * A `?next=` value that is safe to navigate to, or null.
23
+ *
24
+ * Same-origin PATHS only, decided by the URL PARSER rather than by string prefix —
25
+ * this is the open-redirect check, and it is the reason this lives in one place
26
+ * instead of at each call site.
27
+ *
28
+ * The prefix version ("starts with `/`, does not start with `//`") let two shapes
29
+ * through that the browser resolves cross-origin, because the WHATWG parser does not
30
+ * read them as text: a backslash after the leading slash counts as a second slash for
31
+ * a special scheme, so `/\evil.example` resolves to `https://evil.example/`; and tab,
32
+ * LF and CR are STRIPPED anywhere in the input before parsing, so `/<TAB>/evil.example`
33
+ * becomes `//evil.example`. Asking the same parser the browser will use closes every
34
+ * such shape at once, including ones nobody has thought of yet, and it normalises what
35
+ * comes back instead of echoing the caller's spelling.
36
+ *
37
+ * keksdose's (shared/hooks/use-last-visited-page.ts), verbatim but for the fallback
38
+ * origin.
39
+ */
40
+ export function safeNextPath(raw: string | null | undefined): string | null {
41
+ if (!raw) return null;
42
+ const base = typeof window !== "undefined" ? window.location.origin : "https://kit.invalid";
43
+ try {
44
+ const url = new URL(raw, base);
45
+ if (url.origin !== base) return null;
46
+ return `${url.pathname}${url.search}${url.hash}`;
47
+ } catch {
48
+ return null;
49
+ }
50
+ }
51
+
52
+ /* ── The last visited page (§3.2) ────────────────────────────────────────── */
53
+
54
+ /** A path to leave out: a string is the path and everything under it (`/demo` is
55
+ * `/demo` and `/demo/ended`; `/` is only `/`), a RegExp is tested on the pathname. */
56
+ export type PathPattern = string | RegExp;
57
+
58
+ /**
59
+ * The pages never recorded as a resume target (§3.2): the landing (`/`, `/welcome`), the
60
+ * auth pages, the demo's own (`/demo*`) and the legal pages. The 404 has no path of its
61
+ * own — call {@link useLastVisitedPage} inside the signed-in layout, as keksdose does,
62
+ * and it never sees one. The app adds its own one-shot routes (keksdose:
63
+ * `/banks/callback`, `/join`, `/share-target`) through `exclude`.
64
+ */
65
+ export const DEFAULT_LAST_VISITED_EXCLUDES: readonly PathPattern[] = Object.freeze([
66
+ "/",
67
+ "/welcome",
68
+ "/login",
69
+ "/register",
70
+ "/forgot-password",
71
+ "/reset-password",
72
+ "/verify-email",
73
+ "/demo",
74
+ "/impressum",
75
+ "/privacy",
76
+ "/terms",
77
+ ]);
78
+
79
+ function matchesPath(pathname: string, pattern: PathPattern): boolean {
80
+ if (typeof pattern !== "string") return new RegExp(pattern.source, pattern.flags.replace("g", "")).test(pathname);
81
+ if (pattern === "/") return pathname === "/";
82
+ const base = pattern.endsWith("/") ? pattern.slice(0, -1) : pattern;
83
+ return pathname === base || pathname.startsWith(`${base}/`);
84
+ }
85
+
86
+ /** Whether `pathname` is one of the defaults or the app's `exclude`. */
87
+ function isExcluded(pathname: string, exclude: readonly PathPattern[]): boolean {
88
+ return [...DEFAULT_LAST_VISITED_EXCLUDES, ...exclude].some((pattern) => matchesPath(pathname, pattern));
89
+ }
90
+
91
+ export interface LastVisitedPageOptions {
92
+ /** The device key: `<app>.lastVisitedPage` (§3.2), one per app on the origin. */
93
+ key: string;
94
+ /** The app's own paths to leave out, ON TOP of {@link DEFAULT_LAST_VISITED_EXCLUDES}. */
95
+ exclude?: readonly PathPattern[];
96
+ /** `false` records nothing — while the session is still being restored, say. */
97
+ enabled?: boolean;
98
+ }
99
+
100
+ /**
101
+ * Remember the page a session is on (path and query), on every navigation, so `/` — a
102
+ * revisit, a fresh sign-in, the installed PWA, whose `start_url` is `/` — resumes there
103
+ * (§3.2, keksdose feedback #59). Device-local, in `localStorage` under `key`; a blocked
104
+ * storage records nothing.
105
+ *
106
+ * Call it once in the signed-in layout. The excluded paths are never written, so a
107
+ * one-shot page (a bank callback, an invitation link) never becomes the place the next
108
+ * launch opens on.
109
+ */
110
+ export function useLastVisitedPage({ key, exclude = [], enabled = true }: LastVisitedPageOptions): void {
111
+ const { pathname, search } = useLocation();
112
+ // Decided here, so the effect depends on the answer and not on an `exclude` array the
113
+ // caller likely builds inline.
114
+ const skip = !enabled || isExcluded(pathname, exclude);
115
+ useEffect(() => {
116
+ if (skip) return;
117
+ writeStored(key, pathname + search);
118
+ }, [key, pathname, search, skip]);
119
+ }
120
+
121
+ /**
122
+ * The page {@link useLastVisitedPage} remembered under `key` — checked by
123
+ * {@link safeNextPath}, so a value someone planted in storage cannot leave the origin —
124
+ * or `fallback` (default `null`) when there is none or storage is blocked. A string
125
+ * fallback narrows the answer to a string, for react-router's `to`.
126
+ */
127
+ export function readLastVisitedPage(key: string, fallback: string): string;
128
+ export function readLastVisitedPage(key: string, fallback?: null): string | null;
129
+ export function readLastVisitedPage(key: string, fallback: string | null = null): string | null {
130
+ return safeNextPath(readStored(key)) ?? fallback;
131
+ }
132
+
133
+ /** Forget the remembered page — on an explicit sign-out, so the next account does not
134
+ * resume on the previous one's page. */
135
+ export function clearLastVisitedPage(key: string): void {
136
+ if (typeof window === "undefined") return;
137
+ try {
138
+ window.localStorage.removeItem(key);
139
+ } catch {
140
+ // storage blocked: there is nothing to forget
141
+ }
142
+ }
143
+
144
+ /* ── RootEntry ───────────────────────────────────────────────────────────── */
145
+
146
+ export interface RootEntryProps {
147
+ /** Without a session `/` is the landing; with one (real or demo) it resumes. */
148
+ session: LandingSession;
149
+ /** The remembered page — `readLastVisitedPage(key)`. */
150
+ resumePath?: string | null;
151
+ /** The landing page element. */
152
+ landing: ReactNode;
153
+ /** The app's home: where a session goes with nothing to resume (keksdose `/budget`,
154
+ * kastlan and Kurvenschmiede `/dashboard`). */
155
+ home: string;
156
+ }
157
+
158
+ /**
159
+ * The `/` route (§3.1): the landing for a visitor, and for a session — real or demo —
160
+ * the last visited page, else the app's home. Indexed and canonical `/` for the visitor;
161
+ * a crawler never has a session, so for it `/` is stable.
162
+ *
163
+ * keksdose showed a session with nothing remembered the landing; the contract sends it
164
+ * home instead, because a session at `/` came to use the app. The landing stays one tap
165
+ * away at `/welcome`, where the signed-in brand link points.
166
+ */
167
+ export function RootEntry({ session, resumePath, landing, home }: RootEntryProps) {
168
+ if (session === "none") return <>{landing}</>;
169
+ const resume = safeNextPath(resumePath);
170
+ return <Navigate to={resume && resume !== "/" ? resume : home} replace />;
171
+ }
172
+
173
+ /* ── RedirectIfAuthed ────────────────────────────────────────────────────── */
174
+
175
+ export interface RedirectIfAuthedProps {
176
+ session: LandingSession;
177
+ /**
178
+ * Let a DEMO session through (§5.6): on `/login`, `/register` and the app's invitation
179
+ * pages, so a demo visitor can sign in or accept an invitation — a successful one
180
+ * replaces the demo session. Default `false`: on `/demo` a live demo continues instead
181
+ * of starting a second one (§5.2), and on `/forgot-password` a demo has no password.
182
+ */
183
+ allowDemo?: boolean;
184
+ /** Where a redirected session goes: the resume target —
185
+ * `readLastVisitedPage(key, home)`. A safe `?next=` goes first. */
186
+ fallback: string;
187
+ /** The page. Left out, an `<Outlet />`: the component is a layout route around the
188
+ * public pages, as in keksdose. */
189
+ children?: ReactNode;
190
+ }
191
+
192
+ /**
193
+ * Public pages a session skips (§3.1): a signed-in visitor at `/login` goes back into
194
+ * the app, to a safe `?next=` when the URL carries one (a share invitation or a push
195
+ * deep link opened with a live session in another tab — keksdose) and else to
196
+ * `fallback`.
197
+ *
198
+ * A demo session passes where `allowDemo` says so (§5.6). Mount one wrapper with
199
+ * `allowDemo` around `/login`, `/register` and the invitation pages, and one without
200
+ * around `/demo` (and `/forgot-password`).
201
+ */
202
+ export function RedirectIfAuthed({ session, allowDemo = false, fallback, children }: RedirectIfAuthedProps) {
203
+ const { search } = useLocation();
204
+ if (session === "none" || (session === "demo" && allowDemo)) {
205
+ return children === undefined ? <Outlet /> : <>{children}</>;
206
+ }
207
+ const next = safeNextPath(new URLSearchParams(search).get(NEXT_PARAM));
208
+ return <Navigate to={next ?? fallback} replace />;
209
+ }