@eifi1/ui-kit 0.28.0 → 0.29.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 (166) hide show
  1. package/README.md +22 -4
  2. package/dist/auth/accept-invitation.d.ts +90 -0
  3. package/dist/auth/accept-invitation.js +117 -0
  4. package/dist/auth/accept-invitation.js.map +1 -0
  5. package/dist/auth/auth-errors.d.ts +60 -0
  6. package/dist/auth/auth-errors.js +32 -0
  7. package/dist/auth/auth-errors.js.map +1 -0
  8. package/dist/auth/complete-name-dialog.d.ts +71 -0
  9. package/dist/auth/complete-name-dialog.js +168 -0
  10. package/dist/auth/complete-name-dialog.js.map +1 -0
  11. package/dist/auth/email-tag.d.ts +60 -0
  12. package/dist/auth/email-tag.js +23 -0
  13. package/dist/auth/email-tag.js.map +1 -0
  14. package/dist/auth/forgot-password-form.d.ts +79 -0
  15. package/dist/auth/forgot-password-form.js +120 -0
  16. package/dist/auth/forgot-password-form.js.map +1 -0
  17. package/dist/auth/form-rules.d.ts +27 -0
  18. package/dist/auth/form-rules.js +23 -0
  19. package/dist/auth/form-rules.js.map +1 -0
  20. package/dist/auth/new-password-fields.d.ts +38 -0
  21. package/dist/auth/new-password-fields.js +54 -0
  22. package/dist/auth/new-password-fields.js.map +1 -0
  23. package/dist/auth/not-found-page.d.ts +57 -0
  24. package/dist/auth/not-found-page.js +40 -0
  25. package/dist/auth/not-found-page.js.map +1 -0
  26. package/dist/auth/register-form.d.ts +86 -0
  27. package/dist/auth/register-form.js +268 -0
  28. package/dist/auth/register-form.js.map +1 -0
  29. package/dist/auth/reset-password-form.d.ts +124 -0
  30. package/dist/auth/reset-password-form.js +164 -0
  31. package/dist/auth/reset-password-form.js.map +1 -0
  32. package/dist/auth/sign-in-form.d.ts +243 -0
  33. package/dist/auth/sign-in-form.js +345 -0
  34. package/dist/auth/sign-in-form.js.map +1 -0
  35. package/dist/auth/status-parts.d.ts +78 -0
  36. package/dist/auth/status-parts.js +83 -0
  37. package/dist/auth/status-parts.js.map +1 -0
  38. package/dist/auth/verify-email.d.ts +121 -0
  39. package/dist/auth/verify-email.js +207 -0
  40. package/dist/auth/verify-email.js.map +1 -0
  41. package/dist/chart.d.ts +17 -2
  42. package/dist/components/amount-input.d.ts +18 -2
  43. package/dist/components/button-group.d.ts +18 -2
  44. package/dist/components/calculator.d.ts +18 -2
  45. package/dist/components/confirm-dialog.d.ts +18 -2
  46. package/dist/components/copy-button.d.ts +18 -2
  47. package/dist/components/danger-confirm.d.ts +18 -2
  48. package/dist/components/facing-pair.d.ts +18 -2
  49. package/dist/components/file-button.d.ts +18 -2
  50. package/dist/components/file-dropzone.d.ts +18 -2
  51. package/dist/components/form-actions.d.ts +18 -2
  52. package/dist/components/iban-input.d.ts +18 -2
  53. package/dist/components/language-select.d.ts +18 -9
  54. package/dist/components/legal-page.d.ts +26 -4
  55. package/dist/components/legal-page.js +24 -5
  56. package/dist/components/legal-page.js.map +1 -1
  57. package/dist/components/legal.d.ts +18 -2
  58. package/dist/components/legal.js +49 -5
  59. package/dist/components/legal.js.map +1 -1
  60. package/dist/components/money-field.d.ts +18 -2
  61. package/dist/components/number-field.d.ts +18 -2
  62. package/dist/components/number-input.d.ts +18 -2
  63. package/dist/components/numpad-sheet.d.ts +18 -2
  64. package/dist/components/phone-input.d.ts +18 -2
  65. package/dist/components/series-chart.d.ts +18 -2
  66. package/dist/components/settings-fields.d.ts +15 -2
  67. package/dist/components/share-card.d.ts +18 -2
  68. package/dist/components/text-link.d.ts +18 -2
  69. package/dist/components/time-input.d.ts +18 -2
  70. package/dist/components/ui.d.ts +17 -1
  71. package/dist/components/user-avatar.d.ts +16 -2
  72. package/dist/components/user-avatar.js +7 -3
  73. package/dist/components/user-avatar.js.map +1 -1
  74. package/dist/feedback/feedback-attachment.d.ts +18 -2
  75. package/dist/feedback/feedback-context.d.ts +18 -2
  76. package/dist/feedback/feedback-crash.d.ts +18 -2
  77. package/dist/feedback/feedback-dialog.d.ts +18 -2
  78. package/dist/feedback/feedback-inbox.d.ts +18 -2
  79. package/dist/feedback/feedback-labels.d.ts +18 -2
  80. package/dist/feedback/feedback-menu.d.ts +18 -2
  81. package/dist/feedback/feedback-record.d.ts +18 -2
  82. package/dist/feedback/feedback-row-detail.d.ts +18 -2
  83. package/dist/feedback/feedback-status-undo.d.ts +18 -2
  84. package/dist/feedback/feedback-submit.d.ts +18 -2
  85. package/dist/feedback/feedback-swipe.d.ts +18 -2
  86. package/dist/feedback/feedback-table.d.ts +18 -2
  87. package/dist/feedback/feedback-thread.d.ts +18 -2
  88. package/dist/feedback.d.ts +18 -2
  89. package/dist/hooks/use-file-drop.d.ts +18 -2
  90. package/dist/i18n/defaults.d.ts +18 -2
  91. package/dist/i18n/defaults.js +18 -0
  92. package/dist/i18n/defaults.js.map +1 -1
  93. package/dist/i18n/german.d.ts +18 -2
  94. package/dist/i18n/german.js +145 -0
  95. package/dist/i18n/german.js.map +1 -1
  96. package/dist/i18n/kit-labels.d.ts +18 -2
  97. package/dist/i18n/kit-labels.js.map +1 -1
  98. package/dist/i18n/languages.d.ts +13 -201
  99. package/dist/i18n/locales/de-CH.d.ts +18 -2
  100. package/dist/i18n/locales/en.d.ts +18 -2
  101. package/dist/i18n/locales/es.d.ts +18 -2
  102. package/dist/i18n/locales/es.js +147 -3
  103. package/dist/i18n/locales/es.js.map +1 -1
  104. package/dist/i18n/locales/fr.d.ts +18 -2
  105. package/dist/i18n/locales/fr.js +147 -0
  106. package/dist/i18n/locales/fr.js.map +1 -1
  107. package/dist/i18n/locales/hu.d.ts +18 -2
  108. package/dist/i18n/locales/hu.js +149 -3
  109. package/dist/i18n/locales/hu.js.map +1 -1
  110. package/dist/i18n/locales/it.d.ts +18 -2
  111. package/dist/i18n/locales/it.js +146 -1
  112. package/dist/i18n/locales/it.js.map +1 -1
  113. package/dist/i18n/locales/zh.d.ts +18 -2
  114. package/dist/i18n/locales/zh.js +146 -1
  115. package/dist/i18n/locales/zh.js.map +1 -1
  116. package/dist/i18n/review.d.ts +18 -2
  117. package/dist/i18n/review.js +17 -7
  118. package/dist/i18n/review.js.map +1 -1
  119. package/dist/index.d.ts +13 -2
  120. package/dist/index.js +49 -1
  121. package/dist/index.js.map +1 -1
  122. package/dist/lib/person-name.d.ts +69 -0
  123. package/dist/lib/person-name.js +65 -0
  124. package/dist/lib/person-name.js.map +1 -0
  125. package/dist/{kit-labels-SBN0biPl.d.ts → register-form-V1OeNlMr.d.ts} +423 -6
  126. package/dist/rhf/fields.d.ts +18 -2
  127. package/dist/rhf/form.d.ts +18 -2
  128. package/dist/rhf.d.ts +18 -2
  129. package/dist/shell/app-shell.d.ts +18 -2
  130. package/dist/shell/company-switcher.d.ts +59 -0
  131. package/dist/shell/company-switcher.js +85 -0
  132. package/dist/shell/company-switcher.js.map +1 -0
  133. package/dist/shell/top-bar-brand.d.ts +18 -2
  134. package/dist/shell/topbar-action-menu.d.ts +1 -0
  135. package/dist/shell.d.ts +12 -2
  136. package/dist/wizard/stepper-nav.d.ts +18 -2
  137. package/dist/wizard.d.ts +18 -2
  138. package/package.json +1 -1
  139. package/src/auth/accept-invitation.tsx +227 -0
  140. package/src/auth/auth-errors.ts +105 -0
  141. package/src/auth/complete-name-dialog.tsx +253 -0
  142. package/src/auth/email-tag.ts +75 -0
  143. package/src/auth/forgot-password-form.tsx +213 -0
  144. package/src/auth/form-rules.ts +40 -0
  145. package/src/auth/new-password-fields.tsx +85 -0
  146. package/src/auth/not-found-page.tsx +98 -0
  147. package/src/auth/register-form.tsx +459 -0
  148. package/src/auth/reset-password-form.tsx +337 -0
  149. package/src/auth/sign-in-form.tsx +687 -0
  150. package/src/auth/status-parts.tsx +138 -0
  151. package/src/auth/verify-email.tsx +358 -0
  152. package/src/components/legal-page.tsx +35 -0
  153. package/src/components/legal.tsx +93 -7
  154. package/src/components/user-avatar.tsx +22 -4
  155. package/src/i18n/defaults.ts +18 -0
  156. package/src/i18n/german.ts +153 -0
  157. package/src/i18n/kit-labels.tsx +27 -0
  158. package/src/i18n/locales/es.ts +156 -3
  159. package/src/i18n/locales/fr.ts +157 -0
  160. package/src/i18n/locales/hu.ts +159 -3
  161. package/src/i18n/locales/it.ts +154 -1
  162. package/src/i18n/locales/zh.ts +149 -1
  163. package/src/i18n/review.ts +15 -6
  164. package/src/index.ts +63 -1
  165. package/src/lib/person-name.ts +157 -0
  166. package/src/shell/company-switcher.tsx +140 -0
@@ -0,0 +1,138 @@
1
+ import { useEffect, useRef } from "react";
2
+ import type { ReactNode } from "react";
3
+ import { cn } from "../lib/cn";
4
+
5
+ /**
6
+ * The pieces the signed-out status pages share — {@link ForgotPasswordForm},
7
+ * {@link ResetPasswordForm}, {@link VerifyEmailStatus}, {@link AcceptInvitation} and
8
+ * {@link NotFoundPage} (docs/auth-harmonization.md §7).
9
+ *
10
+ * @internal Not part of the barrel; the pages are.
11
+ */
12
+
13
+ /** A heading level a page part may take. Default `h2`, under the page's `h1`
14
+ * (`AuthLayout`'s `title`, the app's name), as on `SignInForm`'s steps; deeper for a
15
+ * page embedded in another (a preview, the docs). */
16
+ export type AuthHeadingLevel = "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
17
+
18
+ /** The form pages' heading: start-aligned, the size of `SignInForm`'s step headings. */
19
+ export const FORM_HEADING_CLASS = "text-base font-semibold text-[var(--text-primary)]";
20
+ /** The status pages' heading: centred over the centred outcome below it. */
21
+ export const STATUS_HEADING_CLASS = "text-center text-base font-semibold text-[var(--text-primary)]";
22
+ export const INTRO_CLASS = "text-sm text-[var(--text-secondary)]";
23
+
24
+ /**
25
+ * kastlan's outcome block (verify-email-page, reset-password-page): the kit's
26
+ * `EmptyState` without its dashed box, because it already sits in `AuthLayout`'s card —
27
+ * a box in a box. The icon and the title carry the tone.
28
+ */
29
+ export const OUTCOME_CLASS = "border-0 bg-transparent px-0 py-3";
30
+
31
+ /** `href` with `key=value` added to its query, before any `#fragment`: the reset page's
32
+ * "Sign in" carries the address (`/login?email=ada%40example.com`). */
33
+ export function withQuery(href: string, key: string, value: string): string {
34
+ const hash = href.indexOf("#");
35
+ const base = hash === -1 ? href : href.slice(0, hash);
36
+ const fragment = hash === -1 ? "" : href.slice(hash);
37
+ const separator = base.includes("?") ? (base.endsWith("?") || base.endsWith("&") ? "" : "&") : "?";
38
+ return `${base}${separator}${encodeURIComponent(key)}=${encodeURIComponent(value)}${fragment}`;
39
+ }
40
+
41
+ /** What a callback's promise came to. */
42
+ export type Settled<T> = { ok: true; value: T } | { ok: false; error: unknown };
43
+
44
+ /**
45
+ * Run `start(key)` ONCE per key, and hand what it settles to into `settle` — unless the
46
+ * key moved on in the meantime, when the stale answer is dropped.
47
+ *
48
+ * keksdose's verify-email and join pages fire their request from an effect behind a
49
+ * `useRef` guard, and say why: the token is single-use, and StrictMode's
50
+ * mount → unmount → remount would send it twice and turn a success into "invalid" on
51
+ * the second answer. The guard is the same here; there is deliberately no "unmounted"
52
+ * flag set in the cleanup, because StrictMode's simulated unmount would set it and the
53
+ * remount would never clear the guard — the page would hang on its spinner (the bug
54
+ * keksdose hit on /join with a react-query observer). A state update after a real
55
+ * unmount is a no-op in React 18 and later.
56
+ *
57
+ * `key === null` runs nothing (no token, not signed in yet) and forgets the last key, so
58
+ * the same token runs again once it is back.
59
+ */
60
+ export function useOncePerKey<T>(
61
+ key: string | null,
62
+ start: (key: string) => T | Promise<T>,
63
+ settle: (key: string, result: Settled<T>) => void,
64
+ ): void {
65
+ const latest = useRef({ start, settle });
66
+ useEffect(() => {
67
+ latest.current = { start, settle };
68
+ });
69
+ const current = useRef<string | null>(null);
70
+ useEffect(() => {
71
+ if (key === null) {
72
+ current.current = null;
73
+ return;
74
+ }
75
+ if (current.current === key) return;
76
+ current.current = key;
77
+ const report = (result: Settled<T>) => {
78
+ if (current.current === key) latest.current.settle(key, result);
79
+ };
80
+ let started: T | Promise<T>;
81
+ try {
82
+ started = latest.current.start(key);
83
+ } catch (error) {
84
+ report({ ok: false, error });
85
+ return;
86
+ }
87
+ Promise.resolve(started).then(
88
+ (value) => report({ ok: true, value }),
89
+ (error: unknown) => report({ ok: false, error }),
90
+ );
91
+ }, [key]);
92
+ }
93
+
94
+ /** Whether a callback's return is a promise to wait for. A callback that returns
95
+ * nothing is done when it returns. */
96
+ export function isThenable(value: unknown): value is PromiseLike<unknown> {
97
+ return typeof (value as PromiseLike<unknown> | null)?.then === "function";
98
+ }
99
+
100
+ /**
101
+ * Move focus to `ref` when `active` turns true — the outcome a person's own submit led
102
+ * to ("If an account exists … the link is on its way", "Your password has been
103
+ * changed"). The submit button they pressed is gone with the form, which drops focus to
104
+ * `<body>`; a screen reader then hears nothing at all. Focusing the message (a
105
+ * `tabIndex={-1}` box) reads it out and leaves the next Tab where the page continues.
106
+ */
107
+ export function useFocusWhen(active: boolean, ref: { current: HTMLElement | null }): void {
108
+ useEffect(() => {
109
+ if (active) ref.current?.focus();
110
+ }, [active, ref]);
111
+ }
112
+
113
+ /** The box {@link useFocusWhen} focuses: centred, no focus ring of its own (it is not a
114
+ * control; the ring would read as one). */
115
+ export function OutcomeMessage({
116
+ icon,
117
+ children,
118
+ focusRef,
119
+ className,
120
+ }: {
121
+ icon: ReactNode;
122
+ children: ReactNode;
123
+ focusRef?: { current: HTMLDivElement | null };
124
+ className?: string;
125
+ }) {
126
+ return (
127
+ <div
128
+ ref={focusRef}
129
+ tabIndex={-1}
130
+ className={cn("flex flex-col items-center gap-2 py-2 text-center outline-none", className)}
131
+ >
132
+ <span aria-hidden className="[&_svg]:size-10">
133
+ {icon}
134
+ </span>
135
+ {children}
136
+ </div>
137
+ );
138
+ }
@@ -0,0 +1,358 @@
1
+ import { useEffect, useState } from "react";
2
+ import type { ComponentPropsWithoutRef, ReactNode } from "react";
3
+ import { CheckCircle2, MailWarning, TimerOff, XCircle } from "lucide-react";
4
+ import { cn } from "../lib/cn";
5
+ import { useKitLabels } from "../i18n/kit-labels";
6
+ import { useAnnounce } from "../hooks/use-announce";
7
+ import { AlertBanner } from "../components/alert-banner";
8
+ import { LoadingState } from "../components/loading-state";
9
+ import { Button, EmptyState } from "../components/ui";
10
+ import { OUTCOME_CLASS, STATUS_HEADING_CLASS, isThenable, useOncePerKey } from "./status-parts";
11
+ import type { AuthHeadingLevel } from "./status-parts";
12
+
13
+ /* ── Labels ──────────────────────────────────────────────────────────────── */
14
+
15
+ /** Every string the verification page and banner render — the `verifyEmail` namespace.
16
+ * keksdose's words (`verify_email.*`, and its server's "has expired"). */
17
+ export interface VerifyEmailLabels {
18
+ /** The page's heading. */
19
+ title: string;
20
+ /** While the link is redeemed. */
21
+ verifying: string;
22
+ verified: string;
23
+ /** The way on from the page — into the app, or to sign-in. */
24
+ continue: string;
25
+ invalid: string;
26
+ expired: string;
27
+ /** The page was opened without a `token`. */
28
+ noToken: string;
29
+ /** Under `invalid`: why a link stops working. */
30
+ invalidHint: string;
31
+ /** Under a dead link, signed out: where a new one comes from. */
32
+ requestInApp: string;
33
+ /** Under a dead link, signed in, beside "Send again". */
34
+ requestHere: string;
35
+ /** The resend button, on the page and in the banner. */
36
+ resend: string;
37
+ /** The resend button while a just-sent mail cools down, given the seconds left. */
38
+ resendIn: (seconds: number) => string;
39
+ /** Said (and shown) after a resend went out. */
40
+ resent: string;
41
+ /** Said (and shown) after a resend failed. */
42
+ resendError: string;
43
+ /** The banner's message. */
44
+ banner: string;
45
+ /** The banner's ×: it defers, it does not close anything. */
46
+ dismiss: string;
47
+ }
48
+
49
+ export const DEFAULT_VERIFY_EMAIL_LABELS: VerifyEmailLabels = {
50
+ title: "Confirm your email address",
51
+ verifying: "Confirming…",
52
+ verified: "Your email address is confirmed.",
53
+ continue: "Continue",
54
+ invalid: "This confirmation link is not valid",
55
+ expired: "This confirmation link has expired",
56
+ noToken: "This link is missing its confirmation token.",
57
+ invalidHint: "The link may have expired or already been used.",
58
+ requestInApp: "You can request a new one from inside the app.",
59
+ requestHere: "You can request a new one here.",
60
+ resend: "Send again",
61
+ resendIn: (seconds) => `Send again in ${seconds} s`,
62
+ resent: "Confirmation email sent",
63
+ resendError: "Could not send the confirmation email",
64
+ banner: "Please confirm your email address.",
65
+ dismiss: "Not now",
66
+ };
67
+
68
+ /** A minute: long enough that an impatient second click does not mail twice, short
69
+ * enough that a mail lost to a spam filter can be re-sent without leaving the page. The
70
+ * server's own limit is the real one (kastlan: 3 an hour per address). */
71
+ const DEFAULT_COOLDOWN = 60;
72
+
73
+ /* ── Resend ──────────────────────────────────────────────────────────────── */
74
+
75
+ type ResendPhase = "idle" | "sending" | "sent" | "failed";
76
+
77
+ /**
78
+ * "Send again" with its own memory: busy while the request runs, then — after a mail
79
+ * went out — held for `cooldown` seconds with the countdown on the button, so a second
80
+ * click cannot mail twice and a throttled server is not asked to refuse. A failure frees
81
+ * it at once. The outcome is a polite live region that is always rendered (a region that
82
+ * mounts with its message is often not read), and visible.
83
+ */
84
+ function useResend(onResend: (() => Promise<unknown> | void) | undefined, cooldown: number) {
85
+ const [phase, setPhase] = useState<ResendPhase>("idle");
86
+ const [remaining, setRemaining] = useState(0);
87
+ const counting = remaining > 0;
88
+ useEffect(() => {
89
+ if (!counting) return;
90
+ const id = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);
91
+ return () => clearInterval(id);
92
+ }, [counting]);
93
+
94
+ const sent = () => {
95
+ setPhase("sent");
96
+ setRemaining(Math.max(0, Math.round(cooldown)));
97
+ };
98
+ const send = () => {
99
+ if (!onResend || phase === "sending" || counting) return;
100
+ let result: unknown;
101
+ try {
102
+ result = onResend();
103
+ } catch {
104
+ setPhase("failed");
105
+ return;
106
+ }
107
+ if (!isThenable(result)) {
108
+ sent();
109
+ return;
110
+ }
111
+ setPhase("sending");
112
+ result.then(sent, () => setPhase("failed"));
113
+ };
114
+ return { phase, remaining, send };
115
+ }
116
+
117
+ function ResendButton({
118
+ state,
119
+ labels,
120
+ variant,
121
+ }: {
122
+ state: ReturnType<typeof useResend>;
123
+ labels: VerifyEmailLabels;
124
+ variant: "secondary" | "brand";
125
+ }) {
126
+ return (
127
+ <Button
128
+ type="button"
129
+ variant={variant}
130
+ size={variant === "secondary" ? "sm" : undefined}
131
+ onClick={state.send}
132
+ pending={state.phase === "sending"}
133
+ // `disabledReason`, not `disabled`: the button just pressed has the focus, and a
134
+ // native `disabled` would drop it to <body>. The reason is what just happened.
135
+ disabledReason={state.remaining > 0 ? labels.resent : undefined}
136
+ >
137
+ {state.remaining > 0 ? labels.resendIn(state.remaining) : labels.resend}
138
+ </Button>
139
+ );
140
+ }
141
+
142
+ function ResendStatus({ phase, labels, className }: { phase: ResendPhase; labels: VerifyEmailLabels; className?: string }) {
143
+ return (
144
+ <span
145
+ role="status"
146
+ className={cn(phase === "failed" ? "text-[var(--danger)]" : "text-[var(--success)]", className)}
147
+ >
148
+ {phase === "sent" ? labels.resent : phase === "failed" ? labels.resendError : ""}
149
+ </span>
150
+ );
151
+ }
152
+
153
+ /* ── The page ────────────────────────────────────────────────────────────── */
154
+
155
+ /** What became of a confirmation link that did not verify. */
156
+ export type VerifyEmailFailure = "invalid" | "expired";
157
+
158
+ export interface VerifyEmailStatusProps extends Omit<ComponentPropsWithoutRef<"div">, "children" | "title"> {
159
+ /** The link's token — the app's `?token=`. Empty: the link was cut short, and the
160
+ * page says so without asking anyone. */
161
+ token: string;
162
+ /**
163
+ * Redeem it — `POST /auth/verify-email {token}`, once per token, on mount. A POST and
164
+ * not the link's own GET (§10.10): a mail scanner that opens every link must not
165
+ * confirm an address nobody read. Resolve when it verified (an address that already
166
+ * was counts: keksdose's redeem is idempotent); reject with the client's error.
167
+ */
168
+ onVerify: (token: string) => Promise<unknown>;
169
+ /** Which dead end a rejection is. Default: `invalid` for everything — §5.2's servers
170
+ * answer one `token_invalid`; an app whose server tells expiry apart maps it here. */
171
+ classifyError?: (error: unknown) => VerifyEmailFailure;
172
+ /** Someone is signed in on this device: "Continue" goes into the app, and a dead link
173
+ * offers "Send again" (with `onResend`). */
174
+ signedIn?: boolean;
175
+ /** Send a new confirmation mail (`POST /auth/verify-email/resend`) — offered on a dead
176
+ * link while signed in. */
177
+ onResend?: () => Promise<unknown> | void;
178
+ /** Seconds "Send again" waits after a mail went out. Default 60; `0` never waits. */
179
+ cooldown?: number;
180
+ /** Where "Continue" goes. Default `/` signed in, `/login` signed out. */
181
+ continueHref?: string;
182
+ /** App content under "confirmed" — keksdose's "Others can now share a budget with you
183
+ * directly." */
184
+ verifiedContent?: ReactNode;
185
+ /** The heading. Default `labels.title`; `null` draws none, for a page that puts it in
186
+ * `AuthLayout`'s `title` instead. */
187
+ title?: ReactNode;
188
+ /** The heading's level. Default `h2`, under the page's `h1`. */
189
+ headingAs?: AuthHeadingLevel;
190
+ labels?: Partial<VerifyEmailLabels>;
191
+ }
192
+
193
+ type VerifyOutcome = "verified" | VerifyEmailFailure;
194
+
195
+ /**
196
+ * Redeem an email-confirmation link (`/verify-email?token=…`, docs/auth-harmonization.md
197
+ * §7) — keksdose's `VerifyEmailPage` (features/auth/verify-email-page.tsx), with
198
+ * kastlan's outcome blocks and its separate "expired".
199
+ *
200
+ * Opened from an inbox, often in a browser with no session, so the token is the only
201
+ * credential and the page renders signed-out. It redeems once per token on mount
202
+ * (StrictMode's double effect included — the token is single-use) and shows one of:
203
+ * confirmed; not valid; expired; or the link had no token at all. Verification gates
204
+ * nothing (§2.8), so every outcome ends on "Continue". Signed in, a dead link also
205
+ * offers "Send again", which waits `cooldown` seconds after a mail went out.
206
+ *
207
+ * The outcome replaces the spinner, which says nothing to a screen reader on its own,
208
+ * so it is also announced.
209
+ */
210
+ export function VerifyEmailStatus({
211
+ token,
212
+ onVerify,
213
+ classifyError,
214
+ signedIn = false,
215
+ onResend,
216
+ cooldown = DEFAULT_COOLDOWN,
217
+ continueHref,
218
+ verifiedContent,
219
+ title,
220
+ headingAs: Heading = "h2",
221
+ labels: labelsProp,
222
+ className,
223
+ ...rest
224
+ }: VerifyEmailStatusProps) {
225
+ const labels = useKitLabels("verifyEmail", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);
226
+ const [settled, setSettled] = useState<{ token: string; outcome: VerifyOutcome } | null>(null);
227
+ const outcome: VerifyOutcome | "verifying" =
228
+ token === "" ? "invalid" : settled?.token === token ? settled.outcome : "verifying";
229
+ const { announce, regionProps } = useAnnounce();
230
+ const resend = useResend(onResend, cooldown);
231
+
232
+ useOncePerKey(token === "" ? null : token, onVerify, (key, result) => {
233
+ const next: VerifyOutcome = result.ok ? "verified" : (classifyError?.(result.error) ?? "invalid");
234
+ setSettled({ token: key, outcome: next });
235
+ announce(labels[next]);
236
+ });
237
+
238
+ const href = continueHref ?? (signedIn ? "/" : "/login");
239
+ // `replace`: the token is spent, so Back must not land on this page again (keksdose F1).
240
+ const continueButton = (
241
+ <Button href={href} replace variant={outcome === "verified" ? "brand" : "secondary"}>
242
+ {labels.continue}
243
+ </Button>
244
+ );
245
+ const canResend = signedIn && onResend !== undefined;
246
+
247
+ let body: ReactNode;
248
+ if (outcome === "verifying") {
249
+ body = <LoadingState size="sm" label={labels.verifying} />;
250
+ } else if (outcome === "verified") {
251
+ body = (
252
+ <EmptyState
253
+ tone="success"
254
+ icon={<CheckCircle2 />}
255
+ title={labels.verified}
256
+ hint={verifiedContent}
257
+ action={continueButton}
258
+ className={OUTCOME_CLASS}
259
+ />
260
+ );
261
+ } else {
262
+ const hint = (
263
+ <>
264
+ {outcome === "invalid" && token !== "" ? `${labels.invalidHint} ` : ""}
265
+ {canResend ? labels.requestHere : labels.requestInApp}
266
+ </>
267
+ );
268
+ body = (
269
+ <EmptyState
270
+ tone="danger"
271
+ icon={outcome === "expired" ? <TimerOff /> : <XCircle />}
272
+ title={token === "" ? labels.noToken : labels[outcome]}
273
+ hint={hint}
274
+ action={
275
+ <>
276
+ {canResend && <ResendButton state={resend} labels={labels} variant="brand" />}
277
+ {continueButton}
278
+ </>
279
+ }
280
+ className={OUTCOME_CLASS}
281
+ />
282
+ );
283
+ }
284
+
285
+ return (
286
+ <div
287
+ {...rest}
288
+ data-state={outcome}
289
+ aria-busy={outcome === "verifying" || undefined}
290
+ className={cn("space-y-3", className)}
291
+ >
292
+ {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.title}</Heading>}
293
+ {body}
294
+ {canResend && <ResendStatus phase={resend.phase} labels={labels} className="block text-center text-xs" />}
295
+ {/* Rendered from the start, so the outcome is read when it arrives. */}
296
+ <div {...regionProps} />
297
+ </div>
298
+ );
299
+ }
300
+
301
+ /* ── The banner ──────────────────────────────────────────────────────────── */
302
+
303
+ export interface EmailVerificationBannerProps {
304
+ /** Send a new confirmation mail (`POST /auth/verify-email/resend`). Resolve when it
305
+ * went out; reject and the banner says it could not. */
306
+ onResend: () => Promise<unknown> | void;
307
+ /** Seconds "Send again" waits after a mail went out. Default 60; `0` never waits. */
308
+ cooldown?: number;
309
+ /** The ×, "Not now". The banner does not hide itself: the app stops rendering it —
310
+ * keksdose for the rest of the session. Left out, no ×. */
311
+ onDismiss?: () => void;
312
+ /** `strip` (default): keksdose's band across the top of the shell. `box`: kastlan's
313
+ * callout inside the page. */
314
+ variant?: "strip" | "box";
315
+ /** The app's sentence after the kit's — keksdose's "Until you do, nobody can share a
316
+ * budget with you directly." */
317
+ children?: ReactNode;
318
+ className?: string;
319
+ labels?: Partial<VerifyEmailLabels>;
320
+ }
321
+
322
+ /**
323
+ * The nudge for a signed-in account whose address is not confirmed — keksdose's
324
+ * `VerifyEmailBanner` (features/auth/verify-email-banner.tsx), kastlan's copy of it.
325
+ *
326
+ * A prompt, not a wall: verification gates nothing (§2.8). The app renders it while
327
+ * `/auth/me` says `email_verified: false`, and stops when the user dismisses it or the
328
+ * page above confirms the address. "Send again" waits `cooldown` seconds after a mail
329
+ * went out (the button says how long), and its outcome is written into the banner and
330
+ * read out — no toast needed.
331
+ */
332
+ export function EmailVerificationBanner({
333
+ onResend,
334
+ cooldown = DEFAULT_COOLDOWN,
335
+ onDismiss,
336
+ variant = "strip",
337
+ children,
338
+ className,
339
+ labels: labelsProp,
340
+ }: EmailVerificationBannerProps) {
341
+ const labels = useKitLabels("verifyEmail", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);
342
+ const resend = useResend(onResend, cooldown);
343
+ return (
344
+ <AlertBanner
345
+ tone="warning"
346
+ variant={variant}
347
+ icon={<MailWarning />}
348
+ action={<ResendButton state={resend} labels={labels} variant="secondary" />}
349
+ onDismiss={onDismiss}
350
+ dismissLabel={onDismiss ? labels.dismiss : undefined}
351
+ className={className}
352
+ >
353
+ {labels.banner}
354
+ {children != null && children !== false && <> {children}</>}{" "}
355
+ <ResendStatus phase={resend.phase} labels={labels} className="font-medium" />
356
+ </AlertBanner>
357
+ );
358
+ }
@@ -10,6 +10,7 @@ import { Checkbox } from "./checkbox";
10
10
  import type { CheckboxProps } from "./checkbox";
11
11
  import {
12
12
  LEGAL_HREFS,
13
+ baseLanguage,
13
14
  LegalFooter,
14
15
  LegalLayout,
15
16
  LegalPageContext,
@@ -33,6 +34,7 @@ import type {
33
34
  LegalTextSectionLabels,
34
35
  } from "./legal";
35
36
  import { TextLink } from "./text-link";
37
+ import { Button } from "./ui";
36
38
 
37
39
  /**
38
40
  * The legal pages' frame and the kit's own sections — docs/legal-harmonization.md §3
@@ -107,6 +109,8 @@ export const LEGAL_SKELETON: Readonly<Record<LegalPageKey, LegalSkeletonPage>> =
107
109
  { key: "warranty", owner: "kit" },
108
110
  { key: "liability", owner: "kit" },
109
111
  { key: "changes", owner: "kit" },
112
+ // 0.28.1: which language's version prevails (the operator's `bindingLanguage`).
113
+ { key: "language", owner: "kit" },
110
114
  { key: "law", owner: "kit" },
111
115
  ] as const),
112
116
  }),
@@ -125,6 +129,7 @@ export type LegalKitSectionKey =
125
129
  | "warranty"
126
130
  | "liability"
127
131
  | "changes"
132
+ | "language"
128
133
  | "law";
129
134
 
130
135
  /** The kit's sections of one page, in the skeleton's order. */
@@ -228,6 +233,11 @@ export function LegalKitSection(props: LegalKitSectionProps) {
228
233
  `LegalKitSection "${section}" names the operator: render it inside <LegalPage operator={…}>, or pass operator.`,
229
234
  );
230
235
  }
236
+ if (section === "language" && !operator.bindingLanguage) {
237
+ throw new Error(
238
+ `LegalKitSection "language" names the binding language: give the operator a bindingLanguage ("de-CH").`,
239
+ );
240
+ }
231
241
  return body(operator);
232
242
  };
233
243
 
@@ -303,6 +313,12 @@ export interface LegalPageProps {
303
313
  /** Default: the kit's beta notice on the imprint and the terms, its privacy notice on
304
314
  * the privacy policy (§3.2). `null` for none. */
305
315
  notice?: ReactNode;
316
+ /**
317
+ * 0.28.1: switches the app to the operator's `bindingLanguage` — the button after the
318
+ * translation note on a page read in another language. Without it the note stands
319
+ * alone; the app's own language switcher is in its header anyway.
320
+ */
321
+ onShowBindingLanguage?: () => void;
306
322
  /** The language the operator's country is named in. Default: the `<UiKitProvider
307
323
  * locale>`, else English. */
308
324
  locale?: string;
@@ -434,6 +450,7 @@ export function LegalPage({
434
450
  footer,
435
451
  homeHref = "/",
436
452
  notice,
453
+ onShowBindingLanguage,
437
454
  locale: localeProp,
438
455
  headingAs = "h1",
439
456
  landmark = true,
@@ -462,6 +479,23 @@ export function LegalPage({
462
479
  const Main = landmark ? "main" : "div";
463
480
  const shownNotice =
464
481
  notice !== undefined ? notice : page === "privacy" ? labels.notice.privacy : labels.notice.beta;
482
+ // 0.28.1 (Marcel): one language is binding and the lawyer reviews that one; a page read
483
+ // in any other says so, with a way to the binding version.
484
+ const translated =
485
+ operator.bindingLanguage !== undefined && baseLanguage(operator.bindingLanguage) !== baseLanguage(locale);
486
+ const translationNote = translated ? (
487
+ <p className="text-sm text-[var(--text-secondary)]">
488
+ {labels.translation.note(operatorText)}
489
+ {onShowBindingLanguage && (
490
+ <>
491
+ {" "}
492
+ <Button type="button" variant="link" size="sm" className="h-auto p-0 align-baseline" onClick={onShowBindingLanguage}>
493
+ {labels.translation.show(operatorText)}
494
+ </Button>
495
+ </>
496
+ )}
497
+ </p>
498
+ ) : undefined;
465
499
 
466
500
  return (
467
501
  <UiKitProvider labels={overrides}>
@@ -477,6 +511,7 @@ export function LegalPage({
477
511
  }
478
512
  title={labels.titles[page]}
479
513
  notice={shownNotice}
514
+ translationNote={translationNote}
480
515
  headingAs={headingAs}
481
516
  >
482
517
  {nodes}