@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.
- package/README.md +65 -6
- package/dist/account/account-parts.d.ts +21 -0
- package/dist/account/account-parts.js +40 -0
- package/dist/account/account-parts.js.map +1 -0
- package/dist/account/data-export-setting.d.ts +73 -0
- package/dist/account/data-export-setting.js +114 -0
- package/dist/account/data-export-setting.js.map +1 -0
- package/dist/account/delete-account-setting.d.ts +95 -0
- package/dist/account/delete-account-setting.js +200 -0
- package/dist/account/delete-account-setting.js.map +1 -0
- package/dist/account/email-change-setting.d.ts +136 -0
- package/dist/account/email-change-setting.js +278 -0
- package/dist/account/email-change-setting.js.map +1 -0
- package/dist/account/sessions-setting.d.ts +110 -0
- package/dist/account/sessions-setting.js +147 -0
- package/dist/account/sessions-setting.js.map +1 -0
- package/dist/admin/admin-action-confirm.d.ts +95 -0
- package/dist/admin/admin-action-confirm.js +276 -0
- package/dist/admin/admin-action-confirm.js.map +1 -0
- package/dist/admin/admin-action-log.d.ts +105 -0
- package/dist/admin/admin-action-log.js +153 -0
- package/dist/admin/admin-action-log.js.map +1 -0
- package/dist/admin/admin-parts.d.ts +40 -0
- package/dist/admin/admin-parts.js +45 -0
- package/dist/admin/admin-parts.js.map +1 -0
- package/dist/admin/invitations-panel.d.ts +95 -0
- package/dist/admin/invitations-panel.js +290 -0
- package/dist/admin/invitations-panel.js.map +1 -0
- package/dist/admin/reviewer-scope-editor.d.ts +95 -0
- package/dist/admin/reviewer-scope-editor.js +134 -0
- package/dist/admin/reviewer-scope-editor.js.map +1 -0
- package/dist/admin/role-select.d.ts +95 -0
- package/dist/admin/role-select.js +128 -0
- package/dist/admin/role-select.js.map +1 -0
- package/dist/admin/transfer-ownership-dialog.d.ts +95 -0
- package/dist/admin/transfer-ownership-dialog.js +83 -0
- package/dist/admin/transfer-ownership-dialog.js.map +1 -0
- package/dist/admin/user-roster.d.ts +95 -0
- package/dist/admin/user-roster.js +285 -0
- package/dist/admin/user-roster.js.map +1 -0
- package/dist/auth/auth-errors.d.ts +103 -6
- package/dist/auth/auth-errors.js +68 -2
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/auth/forgot-password-form.d.ts +10 -3
- package/dist/auth/forgot-password-form.js +6 -3
- package/dist/auth/forgot-password-form.js.map +1 -1
- package/dist/auth/register-form.d.ts +21 -12
- package/dist/auth/register-form.js +6 -4
- package/dist/auth/register-form.js.map +1 -1
- package/dist/auth/reset-password-form.d.ts +10 -3
- package/dist/auth/reset-password-form.js +6 -3
- package/dist/auth/reset-password-form.js.map +1 -1
- package/dist/auth/sign-in-form.d.ts +75 -9
- package/dist/auth/sign-in-form.js +105 -15
- package/dist/auth/sign-in-form.js.map +1 -1
- package/dist/auth/verify-email.d.ts +7 -2
- package/dist/auth/verify-email.js +44 -8
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/chart.d.ts +20 -11
- package/dist/components/account-chips.d.ts +47 -7
- package/dist/components/account-chips.js +19 -3
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/account-settings-labels.d.ts +24 -1
- package/dist/components/account-settings-labels.js +6 -2
- package/dist/components/account-settings-labels.js.map +1 -1
- package/dist/components/account-settings.d.ts +69 -8
- package/dist/components/account-settings.js +133 -25
- package/dist/components/account-settings.js.map +1 -1
- package/dist/components/amount-input.d.ts +21 -12
- package/dist/components/button-group.d.ts +21 -12
- package/dist/components/calculator.d.ts +21 -12
- package/dist/components/confirm-dialog.d.ts +21 -12
- package/dist/components/copy-button.d.ts +18 -9
- package/dist/components/danger-confirm.d.ts +21 -12
- package/dist/components/data-table.js +8 -1
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/date-picker.js +9 -4
- package/dist/components/date-picker.js.map +1 -1
- package/dist/components/facing-pair.d.ts +21 -12
- package/dist/components/file-button.d.ts +21 -12
- package/dist/components/file-dropzone.d.ts +21 -12
- package/dist/components/form-actions.d.ts +21 -12
- package/dist/components/iban-input.d.ts +21 -12
- package/dist/components/language-select.d.ts +21 -12
- package/dist/components/legal-page.d.ts +21 -12
- package/dist/components/legal.d.ts +21 -12
- package/dist/components/money-field.d.ts +21 -12
- package/dist/components/number-field.d.ts +21 -12
- package/dist/components/number-input.d.ts +21 -12
- package/dist/components/numpad-sheet.d.ts +21 -12
- package/dist/components/passkeys-setting.js +2 -1
- package/dist/components/passkeys-setting.js.map +1 -1
- package/dist/components/phone-input.d.ts +21 -12
- package/dist/components/series-chart.d.ts +21 -12
- package/dist/components/settings-fields.d.ts +55 -13
- package/dist/components/settings-fields.js +20 -1
- package/dist/components/settings-fields.js.map +1 -1
- package/dist/components/share-card.d.ts +20 -11
- package/dist/components/text-link.d.ts +21 -12
- package/dist/components/time-input.d.ts +21 -12
- package/dist/components/ui.d.ts +17 -8
- package/dist/{register-form-V1OeNlMr.d.ts → delete-account-setting-DCFS5Vof.d.ts} +6612 -5612
- package/dist/demo/demo-banner.d.ts +51 -0
- package/dist/demo/demo-banner.js +97 -0
- package/dist/demo/demo-banner.js.map +1 -0
- package/dist/demo/demo-countdown.d.ts +58 -0
- package/dist/demo/demo-countdown.js +79 -0
- package/dist/demo/demo-countdown.js.map +1 -0
- package/dist/demo/demo-ended.d.ts +45 -0
- package/dist/demo/demo-ended.js +42 -0
- package/dist/demo/demo-ended.js.map +1 -0
- package/dist/demo/demo-labels.d.ts +53 -0
- package/dist/demo/demo-labels.js +29 -0
- package/dist/demo/demo-labels.js.map +1 -0
- package/dist/demo/demo-session.d.ts +30 -0
- package/dist/demo/demo-session.js +8 -0
- package/dist/demo/demo-session.js.map +1 -0
- package/dist/demo/demo-start.d.ts +54 -0
- package/dist/demo/demo-start.js +68 -0
- package/dist/demo/demo-start.js.map +1 -0
- package/dist/feedback/feedback-attachment.d.ts +21 -12
- package/dist/feedback/feedback-context.d.ts +21 -12
- package/dist/feedback/feedback-crash.d.ts +21 -12
- package/dist/feedback/feedback-dialog.d.ts +21 -12
- package/dist/feedback/feedback-inbox.d.ts +21 -12
- package/dist/feedback/feedback-inbox.js +12 -2
- package/dist/feedback/feedback-inbox.js.map +1 -1
- package/dist/feedback/feedback-labels.d.ts +21 -12
- package/dist/feedback/feedback-labels.js +1 -0
- package/dist/feedback/feedback-labels.js.map +1 -1
- package/dist/feedback/feedback-menu.d.ts +21 -12
- package/dist/feedback/feedback-record.d.ts +21 -12
- package/dist/feedback/feedback-record.js +2 -0
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-row-detail.d.ts +21 -12
- package/dist/feedback/feedback-row-detail.js +7 -0
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-status-undo.d.ts +21 -12
- package/dist/feedback/feedback-submit.d.ts +21 -12
- package/dist/feedback/feedback-swipe.d.ts +22 -13
- package/dist/feedback/feedback-swipe.js.map +1 -1
- package/dist/feedback/feedback-table.d.ts +18 -9
- package/dist/feedback/feedback-thread.d.ts +21 -12
- package/dist/feedback.d.ts +21 -12
- package/dist/hooks/use-anchored-panel.d.ts +6 -2
- package/dist/hooks/use-anchored-panel.js +2 -1
- package/dist/hooks/use-anchored-panel.js.map +1 -1
- package/dist/hooks/use-file-drop.d.ts +21 -12
- package/dist/i18n/defaults.d.ts +21 -12
- package/dist/i18n/defaults.js +28 -0
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.d.ts +21 -12
- package/dist/i18n/german.js +298 -12
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +14 -5
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/languages.d.ts +15 -6
- package/dist/i18n/locales/de-CH.d.ts +21 -12
- package/dist/i18n/locales/en.d.ts +21 -12
- package/dist/i18n/locales/es.d.ts +21 -12
- package/dist/i18n/locales/es.js +287 -10
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.d.ts +21 -12
- package/dist/i18n/locales/fr.js +290 -7
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.d.ts +21 -12
- package/dist/i18n/locales/hu.js +288 -12
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.d.ts +21 -12
- package/dist/i18n/locales/it.js +289 -7
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.d.ts +21 -12
- package/dist/i18n/locales/zh.js +279 -7
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.d.ts +21 -12
- package/dist/i18n/review.js +50 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +31 -4
- package/dist/index.js +148 -0
- package/dist/index.js.map +1 -1
- package/dist/landing/access.d.ts +48 -0
- package/dist/landing/access.js +38 -0
- package/dist/landing/access.js.map +1 -0
- package/dist/landing/landing-actions.d.ts +138 -0
- package/dist/landing/landing-actions.js +39 -0
- package/dist/landing/landing-actions.js.map +1 -0
- package/dist/landing/landing-labels.d.ts +43 -0
- package/dist/landing/landing-labels.js +23 -0
- package/dist/landing/landing-labels.js.map +1 -0
- package/dist/landing/landing-sections.d.ts +218 -0
- package/dist/landing/landing-sections.js +167 -0
- package/dist/landing/landing-sections.js.map +1 -0
- package/dist/landing/page-seo.d.ts +99 -0
- package/dist/landing/page-seo.js +121 -0
- package/dist/landing/page-seo.js.map +1 -0
- package/dist/landing/public-header.d.ts +148 -0
- package/dist/landing/public-header.js +49 -0
- package/dist/landing/public-header.js.map +1 -0
- package/dist/landing/routing.d.ts +218 -0
- package/dist/landing/routing.js +81 -0
- package/dist/landing/routing.js.map +1 -0
- package/dist/rhf/fields.d.ts +21 -12
- package/dist/rhf/form.d.ts +21 -12
- package/dist/rhf.d.ts +21 -12
- package/dist/settings/settings-catalogue.d.ts +84 -0
- package/dist/settings/settings-catalogue.js +47 -0
- package/dist/settings/settings-catalogue.js.map +1 -0
- package/dist/settings/settings-context.d.ts +31 -0
- package/dist/settings/settings-context.js +11 -0
- package/dist/settings/settings-context.js.map +1 -0
- package/dist/settings/settings-heading.d.ts +50 -0
- package/dist/settings/settings-heading.js +27 -0
- package/dist/settings/settings-heading.js.map +1 -0
- package/dist/settings/settings-labels.d.ts +41 -0
- package/dist/settings/settings-labels.js +38 -0
- package/dist/settings/settings-labels.js.map +1 -0
- package/dist/settings/settings-layout.d.ts +77 -0
- package/dist/settings/settings-layout.js +242 -0
- package/dist/settings/settings-layout.js.map +1 -0
- package/dist/settings/settings-section.d.ts +133 -0
- package/dist/settings/settings-section.js +63 -0
- package/dist/settings/settings-section.js.map +1 -0
- package/dist/settings/use-account-language.d.ts +83 -0
- package/dist/settings/use-account-language.js +62 -0
- package/dist/settings/use-account-language.js.map +1 -0
- package/dist/settings/use-settings-focus.d.ts +34 -0
- package/dist/settings/use-settings-focus.js +77 -0
- package/dist/settings/use-settings-focus.js.map +1 -0
- package/dist/settings/use-settings-route.d.ts +118 -0
- package/dist/settings/use-settings-route.js +120 -0
- package/dist/settings/use-settings-route.js.map +1 -0
- package/dist/shell/app-shell.d.ts +27 -12
- package/dist/shell/app-shell.js +3 -2
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/top-bar-brand.d.ts +21 -12
- package/dist/shell.d.ts +25 -6
- package/dist/shell.js +56 -0
- package/dist/shell.js.map +1 -1
- package/dist/wizard/stepper-nav.d.ts +21 -12
- package/dist/wizard.d.ts +21 -12
- package/package.json +2 -1
- package/src/account/account-parts.ts +67 -0
- package/src/account/data-export-setting.tsx +219 -0
- package/src/account/delete-account-setting.tsx +363 -0
- package/src/account/email-change-setting.tsx +454 -0
- package/src/account/sessions-setting.tsx +284 -0
- package/src/admin/admin-action-confirm.tsx +495 -0
- package/src/admin/admin-action-log.tsx +297 -0
- package/src/admin/admin-parts.ts +82 -0
- package/src/admin/invitations-panel.tsx +523 -0
- package/src/admin/reviewer-scope-editor.tsx +234 -0
- package/src/admin/role-select.tsx +286 -0
- package/src/admin/transfer-ownership-dialog.tsx +172 -0
- package/src/admin/user-roster.tsx +573 -0
- package/src/auth/auth-errors.ts +196 -8
- package/src/auth/forgot-password-form.tsx +21 -5
- package/src/auth/register-form.tsx +24 -6
- package/src/auth/reset-password-form.tsx +19 -5
- package/src/auth/sign-in-form.tsx +241 -38
- package/src/auth/verify-email.tsx +59 -8
- package/src/components/account-chips.tsx +64 -7
- package/src/components/account-settings-labels.ts +28 -1
- package/src/components/account-settings.tsx +231 -34
- package/src/components/data-table.tsx +8 -0
- package/src/components/date-picker.tsx +9 -4
- package/src/components/passkeys-setting.tsx +2 -1
- package/src/components/settings-fields.tsx +50 -1
- package/src/demo/demo-banner.tsx +159 -0
- package/src/demo/demo-countdown.ts +141 -0
- package/src/demo/demo-ended.tsx +89 -0
- package/src/demo/demo-labels.ts +78 -0
- package/src/demo/demo-session.ts +32 -0
- package/src/demo/demo-start.tsx +150 -0
- package/src/feedback/feedback-inbox.tsx +21 -8
- package/src/feedback/feedback-labels.ts +2 -1
- package/src/feedback/feedback-record.ts +10 -7
- package/src/feedback/feedback-row-detail.tsx +28 -4
- package/src/feedback/feedback-swipe.tsx +1 -1
- package/src/hooks/use-anchored-panel.ts +14 -3
- package/src/i18n/defaults.ts +28 -0
- package/src/i18n/german.ts +327 -5
- package/src/i18n/kit-labels.tsx +42 -0
- package/src/i18n/locales/es.ts +313 -3
- package/src/i18n/locales/fr.ts +316 -0
- package/src/i18n/locales/hu.ts +308 -5
- package/src/i18n/locales/it.ts +313 -0
- package/src/i18n/locales/zh.ts +286 -0
- package/src/i18n/review.ts +49 -0
- package/src/index.ts +184 -0
- package/src/landing/access.ts +97 -0
- package/src/landing/landing-actions.tsx +92 -0
- package/src/landing/landing-labels.ts +59 -0
- package/src/landing/landing-sections.tsx +337 -0
- package/src/landing/page-seo.ts +251 -0
- package/src/landing/public-header.tsx +120 -0
- package/src/landing/routing.tsx +209 -0
- package/src/settings/settings-catalogue.ts +132 -0
- package/src/settings/settings-context.ts +33 -0
- package/src/settings/settings-heading.tsx +65 -0
- package/src/settings/settings-labels.ts +75 -0
- package/src/settings/settings-layout.tsx +379 -0
- package/src/settings/settings-section.tsx +108 -0
- package/src/settings/use-account-language.ts +146 -0
- package/src/settings/use-settings-focus.ts +120 -0
- package/src/settings/use-settings-route.ts +258 -0
- package/src/shell/app-shell.tsx +10 -2
- 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
|
+
}
|