@eifi1/ui-kit 0.47.0 → 0.48.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 +5 -3
- package/dist/admin/admin-sections.d.ts +8 -0
- package/dist/admin/admin-sections.js +2 -1
- package/dist/admin/admin-sections.js.map +1 -1
- package/dist/auth/verify-email.d.ts +9 -1
- package/dist/auth/verify-email.js +22 -13
- package/dist/auth/verify-email.js.map +1 -1
- package/dist/components/alert-banner.d.ts +29 -1
- package/dist/components/alert-banner.js +84 -7
- package/dist/components/alert-banner.js.map +1 -1
- package/dist/components/button-look.d.ts +4 -0
- package/dist/components/button-look.js +2 -0
- package/dist/components/button-look.js.map +1 -1
- package/dist/components/combobox-core.js +5 -1
- package/dist/components/combobox-core.js.map +1 -1
- package/dist/components/data-table-column-settings.d.ts +6 -1
- package/dist/components/data-table-column-settings.js +12 -2
- package/dist/components/data-table-column-settings.js.map +1 -1
- package/dist/components/data-table-fit.d.ts +44 -0
- package/dist/components/data-table-fit.js +103 -0
- package/dist/components/data-table-fit.js.map +1 -0
- package/dist/components/data-table-groups.d.ts +95 -0
- package/dist/components/data-table-groups.js +165 -0
- package/dist/components/data-table-groups.js.map +1 -0
- package/dist/components/data-table-head.js +3 -1
- package/dist/components/data-table-head.js.map +1 -1
- package/dist/components/data-table-labels.d.ts +7 -0
- package/dist/components/data-table-labels.js +3 -0
- package/dist/components/data-table-labels.js.map +1 -1
- package/dist/components/data-table-leading-row.d.ts +17 -0
- package/dist/components/data-table-leading-row.js +41 -0
- package/dist/components/data-table-leading-row.js.map +1 -0
- package/dist/components/data-table-resize.js +1 -1
- package/dist/components/data-table-resize.js.map +1 -1
- package/dist/components/data-table.d.ts +78 -2
- package/dist/components/data-table.js +348 -257
- package/dist/components/data-table.js.map +1 -1
- package/dist/components/disclosure.d.ts +3 -1
- package/dist/components/disclosure.js +10 -4
- package/dist/components/disclosure.js.map +1 -1
- package/dist/components/dropdown.js +8 -1
- package/dist/components/dropdown.js.map +1 -1
- package/dist/components/floating-panel.d.ts +14 -1
- package/dist/components/floating-panel.js +7 -2
- package/dist/components/floating-panel.js.map +1 -1
- package/dist/components/hover-menu.js +5 -1
- package/dist/components/hover-menu.js.map +1 -1
- package/dist/components/icon-button.js +2 -1
- package/dist/components/icon-button.js.map +1 -1
- package/dist/components/popover.js +5 -1
- package/dist/components/popover.js.map +1 -1
- package/dist/components/table-skeleton.d.ts +35 -0
- package/dist/components/table-skeleton.js +75 -0
- package/dist/components/table-skeleton.js.map +1 -0
- package/dist/components/tabs.d.ts +10 -12
- package/dist/components/tabs.js +20 -10
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/use-table-state.d.ts +8 -7
- package/dist/components/use-table-state.js +37 -3
- package/dist/components/use-table-state.js.map +1 -1
- package/dist/data-table.d.ts +1 -0
- package/dist/data-table.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-table.d.ts +5 -0
- package/dist/feedback/feedback-table.js +30 -7
- package/dist/feedback/feedback-table.js.map +1 -1
- package/dist/hooks/use-dismiss.d.ts +30 -1
- package/dist/hooks/use-dismiss.js +30 -2
- package/dist/hooks/use-dismiss.js.map +1 -1
- package/dist/i18n/german.js +6 -1
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +4 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +6 -1
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +6 -1
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +6 -1
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +6 -1
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +6 -1
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +6 -1
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/shared-labels.js +3 -1
- package/dist/i18n/shared-labels.js.map +1 -1
- package/dist/index.d.ts +2 -0
- package/dist/index.js +2 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/float-clearance.d.ts +8 -0
- package/dist/lib/float-clearance.js +29 -0
- package/dist/lib/float-clearance.js.map +1 -0
- package/dist/settings/settings-catalogue.d.ts +10 -0
- package/dist/settings/settings-catalogue.js.map +1 -1
- package/dist/settings/settings-layout.js +10 -3
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/shell/app-shell.d.ts +5 -0
- package/dist/shell/app-shell.js +5 -3
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/option-switcher-menu.d.ts +2 -0
- package/dist/shell/option-switcher-menu.js +6 -2
- package/dist/shell/option-switcher-menu.js.map +1 -1
- package/dist/shell/topbar-action-menu.js +2 -1
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.js +5 -3
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/tour/tour.js +1 -0
- package/dist/tour/tour.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-sections.tsx +8 -0
- package/src/auth/verify-email.tsx +34 -9
- package/src/components/alert-banner.tsx +131 -7
- package/src/components/button-look.ts +6 -0
- package/src/components/combobox-core.tsx +7 -1
- package/src/components/data-table-column-settings.tsx +23 -2
- package/src/components/data-table-fit.ts +187 -0
- package/src/components/data-table-groups.tsx +275 -0
- package/src/components/data-table-head.tsx +4 -1
- package/src/components/data-table-labels.ts +13 -0
- package/src/components/data-table-leading-row.tsx +55 -0
- package/src/components/data-table-resize.ts +2 -1
- package/src/components/data-table.tsx +246 -43
- package/src/components/disclosure.tsx +22 -5
- package/src/components/dropdown.tsx +11 -1
- package/src/components/floating-panel.tsx +24 -1
- package/src/components/hover-menu.tsx +6 -1
- package/src/components/icon-button.tsx +3 -3
- package/src/components/popover.tsx +7 -1
- package/src/components/table-skeleton.tsx +133 -0
- package/src/components/tabs.tsx +67 -37
- package/src/components/use-table-state.ts +54 -3
- package/src/data-table.ts +2 -0
- package/src/feedback/feedback-table.tsx +33 -1
- package/src/hooks/use-dismiss.ts +65 -3
- package/src/i18n/german.ts +5 -0
- package/src/i18n/kit-labels.tsx +4 -0
- package/src/i18n/locales/es.ts +5 -0
- package/src/i18n/locales/fr.ts +5 -0
- package/src/i18n/locales/hu.ts +5 -0
- package/src/i18n/locales/it.ts +5 -0
- package/src/i18n/locales/zh.ts +5 -0
- package/src/i18n/shared-labels.ts +2 -0
- package/src/index.ts +3 -0
- package/src/lib/float-clearance.ts +54 -0
- package/src/settings/settings-catalogue.ts +10 -0
- package/src/settings/settings-layout.tsx +18 -4
- package/src/shell/app-shell.tsx +17 -3
- package/src/shell/option-switcher-menu.tsx +16 -4
- package/src/shell/topbar-action-menu.tsx +22 -10
- package/src/shell/topbar-controls.tsx +18 -12
- package/src/tour/tour.tsx +5 -0
package/README.md
CHANGED
|
@@ -559,17 +559,17 @@ columns, strings and permissions (see the note at the top of `src/feedback/feedb
|
|
|
559
559
|
|
|
560
560
|
<!-- BEGIN GENERATED: exports — node scripts/gen-export-inventory.mjs -->
|
|
561
561
|
|
|
562
|
-
**
|
|
562
|
+
**2147 names from 352 modules** — 991 values and 1156 types. _Italic_ is a type-only export.
|
|
563
563
|
|
|
564
564
|
Generated from `dist/index.d.ts` by `node scripts/gen-export-inventory.mjs`; the count
|
|
565
565
|
is pinned by `src/__tests__/public-surface.test.ts`. Do not edit between the markers.
|
|
566
566
|
|
|
567
567
|
| Entry point | Names |
|
|
568
568
|
|---|---|
|
|
569
|
-
| `@eifi1/ui-kit` |
|
|
569
|
+
| `@eifi1/ui-kit` | 2147 |
|
|
570
570
|
| `@eifi1/ui-kit/chart` | 92 |
|
|
571
571
|
| `@eifi1/ui-kit/shell` | 114 |
|
|
572
|
-
| `@eifi1/ui-kit/data-table` |
|
|
572
|
+
| `@eifi1/ui-kit/data-table` | 55 |
|
|
573
573
|
| `@eifi1/ui-kit/wizard` | 24 |
|
|
574
574
|
| `@eifi1/ui-kit/tour` | 37 |
|
|
575
575
|
| `@eifi1/ui-kit/feedback` | 212 |
|
|
@@ -760,6 +760,7 @@ re-slicing of it, never a second API.
|
|
|
760
760
|
| `components/line-items` | `DEFAULT_LINE_ITEMS_LABELS`, `LineItems`, _`LineItemCellContext`_, _`LineItemsColumn`_, _`LineItemsFieldLabels`_, _`LineItemsLabels`_, _`LineItemsProps`_, _`LineItemsRemoveAlign`_, _`LineItemsRemovePlacement`_, _`LineItemsRowProps`_, _`LineItemsSummary`_, _`LineItemsSummaryTone`_ |
|
|
761
761
|
| `components/progress-bar` | `DEFAULT_PROGRESS_BAR_LABELS`, `ProgressBar`, _`ProgressBarLabels`_, _`ProgressBarProps`_, _`ProgressBarSegment`_, _`ProgressBarSize`_, _`ProgressBarTone`_ |
|
|
762
762
|
| `components/skeleton` | `Skeleton`, _`SkeletonProps`_, _`SkeletonShape`_ |
|
|
763
|
+
| `components/table-skeleton` | `TableSkeleton`, _`TableSkeletonProps`_ |
|
|
763
764
|
| `components/loading-state` | `LoadingState`, _`LoadingStateLabelVisibility`_, _`LoadingStateProps`_, _`LoadingStateSize`_ |
|
|
764
765
|
| `components/signed-amount` | `DEFAULT_SIGNED_AMOUNT_LABELS`, `Delta`, `SignedAmount`, `Tone`, `toneTextClass`, _`DeltaProps`_, _`GoodDirection`_, _`SignedAmountLabels`_, _`SignedAmountProps`_, _`SignedAmountTone`_, _`TextTone`_, _`ToneProps`_, _`VerdictPalette`_ |
|
|
765
766
|
| `components/error-boundary` | `crashFingerprint`, `DEFAULT_ERROR_BOUNDARY_LABELS`, `describeThrown`, `ErrorBoundary`, `formatCrashReport`, `isChunkLoadError`, _`CrashReport`_, _`CrashReportResult`_, _`ErrorBoundaryDetails`_, _`ErrorBoundaryFallbackProps`_, _`ErrorBoundaryLabels`_, _`ErrorBoundaryProps`_ |
|
|
@@ -803,6 +804,7 @@ re-slicing of it, never a second API.
|
|
|
803
804
|
| `components/data-table-filter-popover` | `FilterPopover` |
|
|
804
805
|
| `components/data-table` | `DataTable`, `DEFAULT_DATA_TABLE_SORT_LABELS`, _`DataTableCellProps`_, _`DataTableChrome`_, _`DataTableColumn`_, _`DataTableDensity`_, _`DataTableDesktopSwipeActions`_, _`DataTableHeadProps`_, _`DataTableProps`_, _`DataTableRowAction`_, _`DataTableSortLabels`_, _`FilterState`_, _`MobileSwipeActions`_, _`ServerPagination`_ |
|
|
805
806
|
| `components/data-table-row-actions` | `swipePlanRowActions` |
|
|
807
|
+
| `components/data-table-groups` | _`DataTableGroupBy`_, _`DataTableLoadMore`_ |
|
|
806
808
|
| `components/data-table-cells` | `booleanColumn`, `BooleanMark`, _`BooleanColumnOptions`_, _`BooleanMarkProps`_ |
|
|
807
809
|
| `components/use-table-state` | `readTableUrlState`, `useTableUrlState`, _`TableUrlState`_, _`TableUrlStateProps`_, _`TableUrlSync`_, _`UseTableUrlStateOptions`_, _`UseTableUrlStateReturn`_ |
|
|
808
810
|
| `components/translation-review-editor` | `TranslationReviewEditor`, _`TranslationReviewEditorProps`_ |
|
|
@@ -1,3 +1,4 @@
|
|
|
1
|
+
import type { ReactNode } from "react";
|
|
1
2
|
import type { LucideIcon } from "lucide-react";
|
|
2
3
|
import type { SettingsGroup } from "../settings/settings-catalogue.js";
|
|
3
4
|
import type { AdminAreaLabels, AdminSharedSection } from "./admin-area-labels.js";
|
|
@@ -51,6 +52,13 @@ export interface AdminSectionsOptions<L extends AdminSharedSection = AdminShared
|
|
|
51
52
|
* whose path then acts as an unknown one.
|
|
52
53
|
*/
|
|
53
54
|
visible?: Partial<Record<AdminSharedSection, boolean>>;
|
|
55
|
+
/**
|
|
56
|
+
* 0.48 (keksdose live #450): a shared section's {@link SettingsGroup.badge} — the
|
|
57
|
+
* waiting work the account menu's badge counts, on the row of the section that holds
|
|
58
|
+
* it: `{ overview: <AdminWaitingBadge count={waiting} /> }`. An app's own section
|
|
59
|
+
* carries its `badge` itself.
|
|
60
|
+
*/
|
|
61
|
+
badges?: Partial<Record<AdminSharedSection, ReactNode>>;
|
|
54
62
|
}
|
|
55
63
|
/** The shared sections an app keeps when it leaves out `L`. A `leaveOut` typed as the
|
|
56
64
|
* whole union (a variable of `AdminSharedSection[]`) says nothing about which, so it
|
|
@@ -69,7 +69,8 @@ function adminSections(labels, own = [], options = {}) {
|
|
|
69
69
|
// 0.43 (kastlan): the overview is a page of tiles, not of setting cards — it shows
|
|
70
70
|
// with no entry of its own.
|
|
71
71
|
...id === "overview" ? { standalone: true } : null,
|
|
72
|
-
...visible === void 0 ? null : { visible }
|
|
72
|
+
...visible === void 0 ? null : { visible },
|
|
73
|
+
...options.badges?.[id] == null ? null : { badge: options.badges[id] }
|
|
73
74
|
};
|
|
74
75
|
});
|
|
75
76
|
}
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/admin/admin-sections.tsx"],"sourcesContent":["import { createElement } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { History, LayoutDashboard, Mail, Receipt, Users } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport type { SettingsGroup } from \"../settings/settings-catalogue\";\nimport { DEFAULT_ADMIN_AREA_LABELS } from \"./admin-area-labels\";\nimport type { AdminAreaLabels, AdminSharedSection } from \"./admin-area-labels\";\n\n/**\n * The admin area's sections (0.42.0, docs/admin-harmonization.md §2.3, §6.8): the five\n * every app shares, in the template's order, and the helper that builds an app's\n * `SettingsLayout` catalogue groups from them and the app's own.\n */\n\n/** One shared section: its path segment and its icon. The words are `adminArea.sections`. */\nexport interface AdminSectionDefinition {\n id: AdminSharedSection;\n icon: LucideIcon;\n}\n\n/**\n * The shared sections in the template's order (§2.3 decision 10, §6.13): Overview, Users,\n * Invitations, Billing, Activity. An app's own sections go between them\n * ({@link adminSections}).\n */\nexport const ADMIN_SECTIONS: readonly AdminSectionDefinition[] = Object.freeze([\n { id: \"overview\", icon: LayoutDashboard },\n { id: \"users\", icon: Users },\n { id: \"invitations\", icon: Mail },\n { id: \"billing\", icon: Receipt },\n { id: \"activity\", icon: History },\n]);\n\n/**\n * One of the app's own sections: a catalogue group with its words, plus where it goes.\n * `after` names the section it follows — a shared one or another of the app's own. Left\n * out, it goes where the template puts an app's sections, after Invitations and before\n * Billing, in the order given.\n */\nexport interface AdminOwnSection<G extends string = string> extends SettingsGroup<G> {\n after?: AdminSharedSection | G;\n}\n\n/** Where one section goes, without its words — what {@link adminSectionOrder} takes. An\n * {@link AdminOwnSection} is one. */\nexport interface AdminSectionPlace<G extends string = string> {\n id: G;\n after?: AdminSharedSection | G;\n}\n\n/** The order's options: the shared sections an app does not have. */\nexport interface AdminSectionOrderOptions<L extends AdminSharedSection = AdminSharedSection> {\n /**\n * Shared sections this app does not have — keksdose and Kurvenschmiede have no billing\n * view (`[\"billing\"]`). Left out, every shared section is in the catalogue. Since 0.43\n * the result's type leaves them out too, so no cast is needed to drop `\"billing\"` —\n * when TypeScript infers the type arguments. With them written out, name the left-out\n * ones as the second (`adminSections<Own, \"billing\">(…)`); `adminSections<Own>(…)`\n * keeps every shared id in the type, as before.\n */\n leaveOut?: readonly L[];\n}\n\nexport interface AdminSectionsOptions<L extends AdminSharedSection = AdminSharedSection> extends AdminSectionOrderOptions<L> {\n /**\n * A shared section that is in the catalogue but hidden for now, as\n * {@link SettingsGroup.visible}: kastlan's `{ billing: billingEnabled }` (settings §5),\n * whose path then acts as an unknown one.\n */\n visible?: Partial<Record<AdminSharedSection, boolean>>;\n}\n\n/** The shared sections an app keeps when it leaves out `L`. A `leaveOut` typed as the\n * whole union (a variable of `AdminSharedSection[]`) says nothing about which, so it\n * narrows nothing. */\nexport type AdminKeptSection<L extends AdminSharedSection = AdminSharedSection> = [AdminSharedSection] extends [L]\n ? AdminSharedSection\n : Exclude<AdminSharedSection, L>;\n\n/** The shared section after which the app's sections go when they name no place. */\nconst OWN_DEFAULT_AFTER: AdminSharedSection = \"invitations\";\n\n/** The shared and own sections in their order — a shared one as its definition, an own\n * one as given. Throws as {@link adminSections} documents. */\nfunction orderSections<S extends AdminSectionPlace<string>>(\n own: readonly S[],\n leaveOutIds: readonly AdminSharedSection[] | undefined,\n): ({ shared: AdminSectionDefinition } | { own: S })[] {\n const leaveOut = new Set<string>(leaveOutIds ?? []);\n const shared = new Set<string>(ADMIN_SECTIONS.map((s) => s.id));\n const ownIds = new Set<string>();\n for (const section of own) {\n if (shared.has(section.id)) throw new Error(`adminSections: \"${section.id}\" is a shared section's id`);\n if (ownIds.has(section.id)) throw new Error(`adminSections: \"${section.id}\" is given twice`);\n ownIds.add(section.id);\n }\n for (const section of own) {\n const after = section.after;\n if (after !== undefined && !shared.has(after) && !ownIds.has(after)) {\n throw new Error(`adminSections: \"${section.id}\" goes after \"${after}\", which is no section`);\n }\n }\n\n // Each section's followers, in the order given: the shared ones, then the own ones\n // under their `after`, recursively, so an own section may follow another own one.\n const followers = new Map<string, S[]>();\n for (const section of own) {\n const after = section.after ?? OWN_DEFAULT_AFTER;\n followers.set(after, [...(followers.get(after) ?? []), section]);\n }\n\n const out: ({ shared: AdminSectionDefinition } | { own: S })[] = [];\n const placed = new Set<string>();\n const placeAfter = (id: string) => {\n for (const section of followers.get(id) ?? []) {\n out.push({ own: section });\n placed.add(section.id);\n placeAfter(section.id);\n }\n };\n for (const definition of ADMIN_SECTIONS) {\n if (!leaveOut.has(definition.id)) out.push({ shared: definition });\n // A left-out section's followers keep its place.\n placeAfter(definition.id);\n }\n const stuck = own.filter((s) => !placed.has(s.id)).map((s) => s.id);\n if (stuck.length) throw new Error(`adminSections: ${stuck.join(\", \")} follow each other in a circle`);\n return out;\n}\n\n/**\n * The admin area's section ids in the template's order (0.43, keksdose) — what\n * {@link adminSections} builds its groups in, without the words: for an app that needs the\n * ORDER only (a router's list of paths, a test), outside a component, with no labels.\n *\n * adminSectionOrder([{ id: \"metrics\", after: \"overview\" }], { leaveOut: [\"billing\"] })\n * // [\"overview\", \"metrics\", \"users\", \"invitations\", \"activity\"]\n *\n * Throws as {@link adminSections} does.\n */\nexport function adminSectionOrder<G extends string = never, L extends AdminSharedSection = AdminSharedSection>(\n own: readonly AdminSectionPlace<G>[] = [],\n options: AdminSectionOrderOptions<L> = {},\n): (AdminKeptSection<L> | G)[] {\n return orderSections(own, options.leaveOut).map((s) =>\n \"shared\" in s ? (s.shared.id as AdminKeptSection<L>) : s.own.id,\n );\n}\n\n/**\n * An app's admin catalogue groups, in the template's order (§2.3 decision 10, §6.8): the\n * shared sections with the kit's icons and words, and the app's own at the places they\n * name. The result is `SettingsLayout`'s `groups`; the entries stay the app's (a group\n * with no visible entry is hidden, as on any settings page — except, since 0.43, the\n * Overview, a page of tiles that needs no entry: it is `standalone`). Since 0.43 the\n * result's type leaves out the `leaveOut` sections, and {@link adminSectionOrder} gives\n * the same order as ids, without words.\n *\n * const groups = adminSections(useAdminAreaLabels(), [\n * { id: \"metrics\", icon: <BarChart3 />, title: t(\"…\"), help: t(\"…\"), after: \"overview\" },\n * { id: \"messaging\", icon: <MessageSquare />, title: t(\"…\"), help: t(\"…\") },\n * { id: \"health\", icon: <HeartPulse />, title: t(\"…\"), help: t(\"…\"), after: \"billing\" },\n * ], { leaveOut: [\"billing\"] });\n *\n * gives keksdose's overview, metrics, users, invitations, messaging, health, activity.\n * An `after` that names a left-out section takes that section's place. Kurvenschmiede's\n * Teams needs no `after`: between Invitations and Activity is the default.\n *\n * `labels` is the `adminArea` namespace, resolved — {@link useAdminAreaLabels} inside a\n * component, or the app's catalogue for the language; `undefined` is English. Throws for\n * an own section whose id is a shared one, an id given twice, or an `after` that names no\n * section: the catalogue is built once, and a section quietly missing from it is worse.\n */\nexport function adminSections<G extends string = never, L extends AdminSharedSection = AdminSharedSection>(\n labels: AdminAreaLabels | undefined,\n own: readonly AdminOwnSection<G>[] = [],\n options: AdminSectionsOptions<L> = {},\n): SettingsGroup<AdminKeptSection<L> | G>[] {\n const words = labels ?? DEFAULT_ADMIN_AREA_LABELS;\n return orderSections(own, options.leaveOut).map((s): SettingsGroup<AdminKeptSection<L> | G> => {\n if (\"own\" in s) {\n const { after: _after, ...group } = s.own;\n return group;\n }\n const { id, icon } = s.shared;\n const visible = options.visible?.[id];\n return {\n id: id as AdminKeptSection<L>,\n icon: createElement(icon, { \"aria-hidden\": true }) as ReactNode,\n title: words.sections[id].title,\n help: words.sections[id].help,\n // 0.43 (kastlan): the overview is a page of tiles, not of setting cards — it shows\n // with no entry of its own.\n ...(id === \"overview\" ? { standalone: true } : null),\n ...(visible === undefined ? null : { visible }),\n };\n });\n}\n"],"mappings":";AAAA,SAAS,qBAAqB;AAE9B,SAAS,SAAS,iBAAiB,MAAM,SAAS,aAAa;AAG/D,SAAS,iCAAiC;AAoBnC,MAAM,iBAAoD,OAAO,OAAO;AAAA,EAC7E,EAAE,IAAI,YAAY,MAAM,gBAAgB;AAAA,EACxC,EAAE,IAAI,SAAS,MAAM,MAAM;AAAA,EAC3B,EAAE,IAAI,eAAe,MAAM,KAAK;AAAA,EAChC,EAAE,IAAI,WAAW,MAAM,QAAQ;AAAA,EAC/B,EAAE,IAAI,YAAY,MAAM,QAAQ;AAClC,CAAC;AAiDD,MAAM,oBAAwC;AAI9C,SAAS,cACP,KACA,aACqD;AACrD,QAAM,WAAW,IAAI,IAAY,eAAe,CAAC,CAAC;AAClD,QAAM,SAAS,IAAI,IAAY,eAAe,IAAI,CAAC,MAAM,EAAE,EAAE,CAAC;AAC9D,QAAM,SAAS,oBAAI,IAAY;AAC/B,aAAW,WAAW,KAAK;AACzB,QAAI,OAAO,IAAI,QAAQ,EAAE,EAAG,OAAM,IAAI,MAAM,mBAAmB,QAAQ,EAAE,4BAA4B;AACrG,QAAI,OAAO,IAAI,QAAQ,EAAE,EAAG,OAAM,IAAI,MAAM,mBAAmB,QAAQ,EAAE,kBAAkB;AAC3F,WAAO,IAAI,QAAQ,EAAE;AAAA,EACvB;AACA,aAAW,WAAW,KAAK;AACzB,UAAM,QAAQ,QAAQ;AACtB,QAAI,UAAU,UAAa,CAAC,OAAO,IAAI,KAAK,KAAK,CAAC,OAAO,IAAI,KAAK,GAAG;AACnE,YAAM,IAAI,MAAM,mBAAmB,QAAQ,EAAE,iBAAiB,KAAK,wBAAwB;AAAA,IAC7F;AAAA,EACF;AAIA,QAAM,YAAY,oBAAI,IAAiB;AACvC,aAAW,WAAW,KAAK;AACzB,UAAM,QAAQ,QAAQ,SAAS;AAC/B,cAAU,IAAI,OAAO,CAAC,GAAI,UAAU,IAAI,KAAK,KAAK,CAAC,GAAI,OAAO,CAAC;AAAA,EACjE;AAEA,QAAM,MAA2D,CAAC;AAClE,QAAM,SAAS,oBAAI,IAAY;AAC/B,QAAM,aAAa,CAAC,OAAe;AACjC,eAAW,WAAW,UAAU,IAAI,EAAE,KAAK,CAAC,GAAG;AAC7C,UAAI,KAAK,EAAE,KAAK,QAAQ,CAAC;AACzB,aAAO,IAAI,QAAQ,EAAE;AACrB,iBAAW,QAAQ,EAAE;AAAA,IACvB;AAAA,EACF;AACA,aAAW,cAAc,gBAAgB;AACvC,QAAI,CAAC,SAAS,IAAI,WAAW,EAAE,EAAG,KAAI,KAAK,EAAE,QAAQ,WAAW,CAAC;AAEjE,eAAW,WAAW,EAAE;AAAA,EAC1B;AACA,QAAM,QAAQ,IAAI,OAAO,CAAC,MAAM,CAAC,OAAO,IAAI,EAAE,EAAE,CAAC,EAAE,IAAI,CAAC,MAAM,EAAE,EAAE;AAClE,MAAI,MAAM,OAAQ,OAAM,IAAI,MAAM,kBAAkB,MAAM,KAAK,IAAI,CAAC,gCAAgC;AACpG,SAAO;AACT;AAYO,SAAS,kBACd,MAAuC,CAAC,GACxC,UAAuC,CAAC,GACX;AAC7B,SAAO,cAAc,KAAK,QAAQ,QAAQ,EAAE;AAAA,IAAI,CAAC,MAC/C,YAAY,IAAK,EAAE,OAAO,KAA6B,EAAE,IAAI;AAAA,EAC/D;AACF;AA0BO,SAAS,cACd,QACA,MAAqC,CAAC,GACtC,UAAmC,CAAC,GACM;AAC1C,QAAM,QAAQ,UAAU;AACxB,SAAO,cAAc,KAAK,QAAQ,QAAQ,EAAE,IAAI,CAAC,MAA8C;AAC7F,QAAI,SAAS,GAAG;AACd,YAAM,EAAE,OAAO,QAAQ,GAAG,MAAM,IAAI,EAAE;AACtC,aAAO;AAAA,IACT;AACA,UAAM,EAAE,IAAI,KAAK,IAAI,EAAE;AACvB,UAAM,UAAU,QAAQ,UAAU,EAAE;AACpC,WAAO;AAAA,MACL;AAAA,MACA,MAAM,cAAc,MAAM,EAAE,eAAe,KAAK,CAAC;AAAA,MACjD,OAAO,MAAM,SAAS,EAAE,EAAE;AAAA,MAC1B,MAAM,MAAM,SAAS,EAAE,EAAE;AAAA;AAAA;AAAA,MAGzB,GAAI,OAAO,aAAa,EAAE,YAAY,KAAK,IAAI;AAAA,MAC/C,GAAI,YAAY,SAAY,OAAO,EAAE,QAAQ;AAAA,IAC/C;AAAA,EACF,CAAC;AACH;","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/admin/admin-sections.tsx"],"sourcesContent":["import { createElement } from \"react\";\nimport type { ReactNode } from \"react\";\nimport { History, LayoutDashboard, Mail, Receipt, Users } from \"lucide-react\";\nimport type { LucideIcon } from \"lucide-react\";\nimport type { SettingsGroup } from \"../settings/settings-catalogue\";\nimport { DEFAULT_ADMIN_AREA_LABELS } from \"./admin-area-labels\";\nimport type { AdminAreaLabels, AdminSharedSection } from \"./admin-area-labels\";\n\n/**\n * The admin area's sections (0.42.0, docs/admin-harmonization.md §2.3, §6.8): the five\n * every app shares, in the template's order, and the helper that builds an app's\n * `SettingsLayout` catalogue groups from them and the app's own.\n */\n\n/** One shared section: its path segment and its icon. The words are `adminArea.sections`. */\nexport interface AdminSectionDefinition {\n id: AdminSharedSection;\n icon: LucideIcon;\n}\n\n/**\n * The shared sections in the template's order (§2.3 decision 10, §6.13): Overview, Users,\n * Invitations, Billing, Activity. An app's own sections go between them\n * ({@link adminSections}).\n */\nexport const ADMIN_SECTIONS: readonly AdminSectionDefinition[] = Object.freeze([\n { id: \"overview\", icon: LayoutDashboard },\n { id: \"users\", icon: Users },\n { id: \"invitations\", icon: Mail },\n { id: \"billing\", icon: Receipt },\n { id: \"activity\", icon: History },\n]);\n\n/**\n * One of the app's own sections: a catalogue group with its words, plus where it goes.\n * `after` names the section it follows — a shared one or another of the app's own. Left\n * out, it goes where the template puts an app's sections, after Invitations and before\n * Billing, in the order given.\n */\nexport interface AdminOwnSection<G extends string = string> extends SettingsGroup<G> {\n after?: AdminSharedSection | G;\n}\n\n/** Where one section goes, without its words — what {@link adminSectionOrder} takes. An\n * {@link AdminOwnSection} is one. */\nexport interface AdminSectionPlace<G extends string = string> {\n id: G;\n after?: AdminSharedSection | G;\n}\n\n/** The order's options: the shared sections an app does not have. */\nexport interface AdminSectionOrderOptions<L extends AdminSharedSection = AdminSharedSection> {\n /**\n * Shared sections this app does not have — keksdose and Kurvenschmiede have no billing\n * view (`[\"billing\"]`). Left out, every shared section is in the catalogue. Since 0.43\n * the result's type leaves them out too, so no cast is needed to drop `\"billing\"` —\n * when TypeScript infers the type arguments. With them written out, name the left-out\n * ones as the second (`adminSections<Own, \"billing\">(…)`); `adminSections<Own>(…)`\n * keeps every shared id in the type, as before.\n */\n leaveOut?: readonly L[];\n}\n\nexport interface AdminSectionsOptions<L extends AdminSharedSection = AdminSharedSection> extends AdminSectionOrderOptions<L> {\n /**\n * A shared section that is in the catalogue but hidden for now, as\n * {@link SettingsGroup.visible}: kastlan's `{ billing: billingEnabled }` (settings §5),\n * whose path then acts as an unknown one.\n */\n visible?: Partial<Record<AdminSharedSection, boolean>>;\n /**\n * 0.48 (keksdose live #450): a shared section's {@link SettingsGroup.badge} — the\n * waiting work the account menu's badge counts, on the row of the section that holds\n * it: `{ overview: <AdminWaitingBadge count={waiting} /> }`. An app's own section\n * carries its `badge` itself.\n */\n badges?: Partial<Record<AdminSharedSection, ReactNode>>;\n}\n\n/** The shared sections an app keeps when it leaves out `L`. A `leaveOut` typed as the\n * whole union (a variable of `AdminSharedSection[]`) says nothing about which, so it\n * narrows nothing. */\nexport type AdminKeptSection<L extends AdminSharedSection = AdminSharedSection> = [AdminSharedSection] extends [L]\n ? AdminSharedSection\n : Exclude<AdminSharedSection, L>;\n\n/** The shared section after which the app's sections go when they name no place. */\nconst OWN_DEFAULT_AFTER: AdminSharedSection = \"invitations\";\n\n/** The shared and own sections in their order — a shared one as its definition, an own\n * one as given. Throws as {@link adminSections} documents. */\nfunction orderSections<S extends AdminSectionPlace<string>>(\n own: readonly S[],\n leaveOutIds: readonly AdminSharedSection[] | undefined,\n): ({ shared: AdminSectionDefinition } | { own: S })[] {\n const leaveOut = new Set<string>(leaveOutIds ?? []);\n const shared = new Set<string>(ADMIN_SECTIONS.map((s) => s.id));\n const ownIds = new Set<string>();\n for (const section of own) {\n if (shared.has(section.id)) throw new Error(`adminSections: \"${section.id}\" is a shared section's id`);\n if (ownIds.has(section.id)) throw new Error(`adminSections: \"${section.id}\" is given twice`);\n ownIds.add(section.id);\n }\n for (const section of own) {\n const after = section.after;\n if (after !== undefined && !shared.has(after) && !ownIds.has(after)) {\n throw new Error(`adminSections: \"${section.id}\" goes after \"${after}\", which is no section`);\n }\n }\n\n // Each section's followers, in the order given: the shared ones, then the own ones\n // under their `after`, recursively, so an own section may follow another own one.\n const followers = new Map<string, S[]>();\n for (const section of own) {\n const after = section.after ?? OWN_DEFAULT_AFTER;\n followers.set(after, [...(followers.get(after) ?? []), section]);\n }\n\n const out: ({ shared: AdminSectionDefinition } | { own: S })[] = [];\n const placed = new Set<string>();\n const placeAfter = (id: string) => {\n for (const section of followers.get(id) ?? []) {\n out.push({ own: section });\n placed.add(section.id);\n placeAfter(section.id);\n }\n };\n for (const definition of ADMIN_SECTIONS) {\n if (!leaveOut.has(definition.id)) out.push({ shared: definition });\n // A left-out section's followers keep its place.\n placeAfter(definition.id);\n }\n const stuck = own.filter((s) => !placed.has(s.id)).map((s) => s.id);\n if (stuck.length) throw new Error(`adminSections: ${stuck.join(\", \")} follow each other in a circle`);\n return out;\n}\n\n/**\n * The admin area's section ids in the template's order (0.43, keksdose) — what\n * {@link adminSections} builds its groups in, without the words: for an app that needs the\n * ORDER only (a router's list of paths, a test), outside a component, with no labels.\n *\n * adminSectionOrder([{ id: \"metrics\", after: \"overview\" }], { leaveOut: [\"billing\"] })\n * // [\"overview\", \"metrics\", \"users\", \"invitations\", \"activity\"]\n *\n * Throws as {@link adminSections} does.\n */\nexport function adminSectionOrder<G extends string = never, L extends AdminSharedSection = AdminSharedSection>(\n own: readonly AdminSectionPlace<G>[] = [],\n options: AdminSectionOrderOptions<L> = {},\n): (AdminKeptSection<L> | G)[] {\n return orderSections(own, options.leaveOut).map((s) =>\n \"shared\" in s ? (s.shared.id as AdminKeptSection<L>) : s.own.id,\n );\n}\n\n/**\n * An app's admin catalogue groups, in the template's order (§2.3 decision 10, §6.8): the\n * shared sections with the kit's icons and words, and the app's own at the places they\n * name. The result is `SettingsLayout`'s `groups`; the entries stay the app's (a group\n * with no visible entry is hidden, as on any settings page — except, since 0.43, the\n * Overview, a page of tiles that needs no entry: it is `standalone`). Since 0.43 the\n * result's type leaves out the `leaveOut` sections, and {@link adminSectionOrder} gives\n * the same order as ids, without words.\n *\n * const groups = adminSections(useAdminAreaLabels(), [\n * { id: \"metrics\", icon: <BarChart3 />, title: t(\"…\"), help: t(\"…\"), after: \"overview\" },\n * { id: \"messaging\", icon: <MessageSquare />, title: t(\"…\"), help: t(\"…\") },\n * { id: \"health\", icon: <HeartPulse />, title: t(\"…\"), help: t(\"…\"), after: \"billing\" },\n * ], { leaveOut: [\"billing\"] });\n *\n * gives keksdose's overview, metrics, users, invitations, messaging, health, activity.\n * An `after` that names a left-out section takes that section's place. Kurvenschmiede's\n * Teams needs no `after`: between Invitations and Activity is the default.\n *\n * `labels` is the `adminArea` namespace, resolved — {@link useAdminAreaLabels} inside a\n * component, or the app's catalogue for the language; `undefined` is English. Throws for\n * an own section whose id is a shared one, an id given twice, or an `after` that names no\n * section: the catalogue is built once, and a section quietly missing from it is worse.\n */\nexport function adminSections<G extends string = never, L extends AdminSharedSection = AdminSharedSection>(\n labels: AdminAreaLabels | undefined,\n own: readonly AdminOwnSection<G>[] = [],\n options: AdminSectionsOptions<L> = {},\n): SettingsGroup<AdminKeptSection<L> | G>[] {\n const words = labels ?? DEFAULT_ADMIN_AREA_LABELS;\n return orderSections(own, options.leaveOut).map((s): SettingsGroup<AdminKeptSection<L> | G> => {\n if (\"own\" in s) {\n const { after: _after, ...group } = s.own;\n return group;\n }\n const { id, icon } = s.shared;\n const visible = options.visible?.[id];\n return {\n id: id as AdminKeptSection<L>,\n icon: createElement(icon, { \"aria-hidden\": true }) as ReactNode,\n title: words.sections[id].title,\n help: words.sections[id].help,\n // 0.43 (kastlan): the overview is a page of tiles, not of setting cards — it shows\n // with no entry of its own.\n ...(id === \"overview\" ? { standalone: true } : null),\n ...(visible === undefined ? null : { visible }),\n ...(options.badges?.[id] == null ? null : { badge: options.badges[id] }),\n };\n });\n}\n"],"mappings":"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","names":[]}
|
|
@@ -114,6 +114,14 @@ export interface EmailVerificationBannerProps {
|
|
|
114
114
|
children?: ReactNode;
|
|
115
115
|
className?: string;
|
|
116
116
|
labels?: LabelOverride<VerifyEmailLabels>;
|
|
117
|
+
/**
|
|
118
|
+
* 0.48: on a phone hold the message to one line with a More / Less toggle —
|
|
119
|
+
* {@link AlertBanner}'s `clampMobile`, passed through (kastlan S2: its banner took ~110px
|
|
120
|
+
* of a 390px phone). The resend's outcome ("Sent", the error) then moves out of the
|
|
121
|
+
* message to sit after "Send again", at every width, so the line that answers the press
|
|
122
|
+
* is never hidden behind "More". Default `false`: the outcome stays in the message.
|
|
123
|
+
*/
|
|
124
|
+
clampMobile?: boolean;
|
|
117
125
|
}
|
|
118
126
|
/**
|
|
119
127
|
* The nudge for a signed-in account whose address is not confirmed — keksdose's
|
|
@@ -125,4 +133,4 @@ export interface EmailVerificationBannerProps {
|
|
|
125
133
|
* went out (the button says how long), and its outcome is written into the banner and
|
|
126
134
|
* read out — no toast needed.
|
|
127
135
|
*/
|
|
128
|
-
export declare function EmailVerificationBanner({ onResend, describeError, cooldown, onDismiss, variant, children, className, labels: labelsProp, }: EmailVerificationBannerProps): import("react").JSX.Element;
|
|
136
|
+
export declare function EmailVerificationBanner({ onResend, describeError, cooldown, onDismiss, variant, children, className, labels: labelsProp, clampMobile, }: EmailVerificationBannerProps): import("react").JSX.Element;
|
|
@@ -184,19 +184,35 @@ function EmailVerificationBanner({
|
|
|
184
184
|
variant = "strip",
|
|
185
185
|
children,
|
|
186
186
|
className,
|
|
187
|
-
labels: labelsProp
|
|
187
|
+
labels: labelsProp,
|
|
188
|
+
clampMobile = false
|
|
188
189
|
}) {
|
|
189
190
|
const labels = useKitLabels("verifyEmail", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);
|
|
190
191
|
const resend = useResend(onResend, cooldown);
|
|
192
|
+
const status = /* @__PURE__ */ jsx(
|
|
193
|
+
ResendStatus,
|
|
194
|
+
{
|
|
195
|
+
phase: resend.phase,
|
|
196
|
+
error: resend.error,
|
|
197
|
+
describeError,
|
|
198
|
+
labels,
|
|
199
|
+
className: "font-medium"
|
|
200
|
+
}
|
|
201
|
+
);
|
|
202
|
+
const button = /* @__PURE__ */ jsx(ResendButton, { state: resend, labels, variant: "secondary" });
|
|
191
203
|
return /* @__PURE__ */ jsxs(
|
|
192
204
|
AlertBanner,
|
|
193
205
|
{
|
|
194
206
|
tone: "warning",
|
|
195
207
|
variant,
|
|
196
208
|
icon: /* @__PURE__ */ jsx(MailWarning, {}),
|
|
197
|
-
action: /* @__PURE__ */
|
|
209
|
+
action: clampMobile ? /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
210
|
+
button,
|
|
211
|
+
status
|
|
212
|
+
] }) : button,
|
|
198
213
|
onDismiss,
|
|
199
214
|
dismissLabel: onDismiss ? labels.dismiss : void 0,
|
|
215
|
+
clampMobile,
|
|
200
216
|
className,
|
|
201
217
|
children: [
|
|
202
218
|
labels.banner,
|
|
@@ -204,17 +220,10 @@ function EmailVerificationBanner({
|
|
|
204
220
|
" ",
|
|
205
221
|
children
|
|
206
222
|
] }),
|
|
207
|
-
|
|
208
|
-
|
|
209
|
-
|
|
210
|
-
|
|
211
|
-
phase: resend.phase,
|
|
212
|
-
error: resend.error,
|
|
213
|
-
describeError,
|
|
214
|
-
labels,
|
|
215
|
-
className: "font-medium"
|
|
216
|
-
}
|
|
217
|
-
)
|
|
223
|
+
!clampMobile && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
224
|
+
" ",
|
|
225
|
+
status
|
|
226
|
+
] })
|
|
218
227
|
]
|
|
219
228
|
}
|
|
220
229
|
);
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/auth/verify-email.tsx"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { CheckCircle2, MailWarning, TimerOff, XCircle } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { LoadingState } from \"../components/loading-state\";\nimport { Button } from \"../components/button\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { OUTCOME_CLASS, STATUS_HEADING_CLASS, isThenable, useOncePerKey } from \"./status-parts\";\nimport { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from \"./auth-errors\";\nimport type { AuthHeadingLevel } from \"./status-parts\";\nimport { DEFAULT_VERIFY_EMAIL_LABELS } from \"./verify-email-labels\";\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the verification page and banner render — the `verifyEmail` namespace.\n * keksdose's words (`verify_email.*`, and its server's \"has expired\"). */\nexport interface VerifyEmailLabels {\n /** The page's heading. */\n title: string;\n /** While the link is redeemed. */\n verifying: string;\n verified: string;\n /** The way on from the page — into the app, or to sign-in. */\n continue: string;\n invalid: string;\n expired: string;\n /** The page was opened without a `token`. */\n noToken: string;\n /** Under `invalid`: why a link stops working. */\n invalidHint: string;\n /** Under a dead link, signed out: where a new one comes from. */\n requestInApp: string;\n /** Under a dead link, signed in, beside \"Send again\". */\n requestHere: string;\n /** The resend button, on the page and in the banner. */\n resend: string;\n /** The resend button while a just-sent mail cools down, given the seconds left. */\n resendIn: (seconds: number) => string;\n /** Said (and shown) after a resend went out. */\n resent: string;\n /** Said (and shown) after a resend failed. */\n resendError: string;\n /** 0.31.1: a resend refused as throttled — HTTP 429 (`isRateLimited`) — given the\n * `Retry-After` seconds, when the app's `describeError` has no words of its own\n * (keksdose borrowed the sign-in namespace's). */\n rateLimited?: (seconds?: number) => string;\n /** The banner's message. */\n banner: string;\n /** The banner's ×: it defers, it does not close anything. */\n dismiss: string;\n}\n\n// Defined in ./verify-email-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_VERIFY_EMAIL_LABELS };\n\n/** A minute: long enough that an impatient second click does not mail twice, short\n * enough that a mail lost to a spam filter can be re-sent without leaving the page. The\n * server's own limit is the real one (kastlan: 3 an hour per address). */\nconst DEFAULT_COOLDOWN = 60;\n\n/* ── Resend ──────────────────────────────────────────────────────────────── */\n\ntype ResendPhase = \"idle\" | \"sending\" | \"sent\" | \"failed\";\n\n/**\n * \"Send again\" with its own memory: busy while the request runs, then — after a mail\n * went out — held for `cooldown` seconds with the countdown on the button, so a second\n * click cannot mail twice and a throttled server is not asked to refuse. A failure frees\n * it at once. The outcome is a polite live region that is always rendered (a region that\n * mounts with its message is often not read), and visible.\n */\nfunction useResend(onResend: (() => Promise<unknown> | void) | undefined, cooldown: number) {\n const [phase, setPhase] = useState<ResendPhase>(\"idle\");\n // The refusal itself, for `describeError` (0.30.0): the server's own sentence — keksdose's\n // per-address throttle, \"try again in N minutes\" — beats a fixed \"could not send\".\n const [error, setError] = useState<unknown>(undefined);\n const [remaining, setRemaining] = useState(0);\n const counting = remaining > 0;\n useEffect(() => {\n if (!counting) return;\n const id = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);\n return () => clearInterval(id);\n }, [counting]);\n\n const sent = () => {\n setPhase(\"sent\");\n setRemaining(Math.max(0, Math.round(cooldown)));\n };\n const send = () => {\n if (!onResend || phase === \"sending\" || counting) return;\n let result: unknown;\n try {\n result = onResend();\n } catch (e) {\n setError(e);\n setPhase(\"failed\");\n return;\n }\n if (!isThenable(result)) {\n sent();\n return;\n }\n setPhase(\"sending\");\n result.then(sent, (e: unknown) => {\n setError(e);\n setPhase(\"failed\");\n });\n };\n return { phase, remaining, send, error };\n}\n\nfunction ResendButton({\n state,\n labels,\n variant,\n}: {\n state: ReturnType<typeof useResend>;\n labels: VerifyEmailLabels;\n variant: \"secondary\" | \"brand\";\n}) {\n return (\n <Button\n type=\"button\"\n variant={variant}\n size={variant === \"secondary\" ? \"sm\" : undefined}\n onClick={state.send}\n pending={state.phase === \"sending\"}\n // `disabledReason`, not `disabled`: the button just pressed has the focus, and a\n // native `disabled` would drop it to <body>. The reason is what just happened.\n disabledReason={state.remaining > 0 ? labels.resent : undefined}\n >\n {state.remaining > 0 ? labels.resendIn(state.remaining) : labels.resend}\n </Button>\n );\n}\n\nfunction ResendStatus({\n phase,\n error,\n describeError,\n labels,\n className,\n}: {\n phase: ResendPhase;\n error?: unknown;\n describeError?: (error: unknown) => string | undefined;\n labels: VerifyEmailLabels;\n className?: string;\n}) {\n // The app's words first, then the throttle's, then the kit's catch-all.\n const failed =\n phase === \"failed\"\n ? (describeError?.(error) ??\n (isRateLimited(error)\n ? (labels.rateLimited ?? englishRateLimited)(retryAfterSeconds(error))\n : labels.resendError))\n : \"\";\n return (\n <span\n role=\"status\"\n className={cn(phase === \"failed\" ? \"text-danger\" : \"text-success\", className)}\n >\n {phase === \"sent\" ? labels.resent : failed}\n </span>\n );\n}\n\n/* ── The page ────────────────────────────────────────────────────────────── */\n\n/** What became of a confirmation link that did not verify. */\nexport type VerifyEmailFailure = \"invalid\" | \"expired\";\n\n/** The default when the app gives no `classifyError`: the coded refusal decides. */\nfunction defaultVerifyFailure(error: unknown): VerifyEmailFailure {\n return authErrorCode(error) === \"token_expired\" ? \"expired\" : \"invalid\";\n}\n\nexport interface VerifyEmailStatusProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"title\"> {\n /** The link's token — the app's `?token=`. Empty: the link was cut short, and the\n * page says so without asking anyone. */\n token: string;\n /**\n * Redeem it — `POST /auth/verify-email {token}`, once per token, on mount. A POST and\n * not the link's own GET (§10.10): a mail scanner that opens every link must not\n * confirm an address nobody read. Resolve when it verified (an address that already\n * was counts: keksdose's redeem is idempotent); reject with the client's error.\n */\n onVerify: (token: string) => Promise<unknown>;\n /** Which dead end a rejection is. Default: `invalid` for everything — §5.2's servers\n * answer one `token_invalid`; an app whose server tells expiry apart maps it here. */\n classifyError?: (error: unknown) => VerifyEmailFailure;\n /** 0.30.0: the words for a refused resend — the server's own sentence (a throttle's\n * \"try again in N minutes\"), else the kit's `resendError`. */\n describeError?: (error: unknown) => string | undefined;\n /** Someone is signed in on this device: \"Continue\" goes into the app, and a dead link\n * offers \"Send again\" (with `onResend`). */\n signedIn?: boolean;\n /** Send a new confirmation mail (`POST /auth/verify-email/resend`) — offered on a dead\n * link while signed in. */\n onResend?: () => Promise<unknown> | void;\n /** Seconds \"Send again\" waits after a mail went out. Default 60; `0` never waits. */\n cooldown?: number;\n /** Where \"Continue\" goes. Default `/` signed in, `/login` signed out. */\n continueHref?: string;\n /** App content under \"confirmed\" — keksdose's \"Others can now share a budget with you\n * directly.\" */\n verifiedContent?: ReactNode;\n /** The heading. Default `labels.title`; `null` draws none, for a page that puts it in\n * `AuthLayout`'s `title` instead. */\n title?: ReactNode;\n /** The heading's level. Default `h2`, under the page's `h1`. */\n headingAs?: AuthHeadingLevel;\n labels?: LabelOverride<VerifyEmailLabels>;\n}\n\ntype VerifyOutcome = \"verified\" | VerifyEmailFailure;\n\n/**\n * Redeem an email-confirmation link (`/verify-email?token=…`, docs/auth-harmonization.md\n * §7) — keksdose's `VerifyEmailPage` (features/auth/verify-email-page.tsx), with\n * kastlan's outcome blocks and its separate \"expired\".\n *\n * Opened from an inbox, often in a browser with no session, so the token is the only\n * credential and the page renders signed-out. It redeems once per token on mount\n * (StrictMode's double effect included — the token is single-use) and shows one of:\n * confirmed; not valid; expired; or the link had no token at all. Verification gates\n * nothing (§2.8), so every outcome ends on \"Continue\". Signed in, a dead link also\n * offers \"Send again\", which waits `cooldown` seconds after a mail went out.\n *\n * The outcome replaces the spinner, which says nothing to a screen reader on its own,\n * so it is also announced.\n */\nexport function VerifyEmailStatus({\n token,\n onVerify,\n classifyError,\n describeError,\n signedIn = false,\n onResend,\n cooldown = DEFAULT_COOLDOWN,\n continueHref,\n verifiedContent,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: VerifyEmailStatusProps) {\n const labels = useKitLabels(\"verifyEmail\", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);\n const [settled, setSettled] = useState<{ token: string; outcome: VerifyOutcome } | null>(null);\n const outcome: VerifyOutcome | \"verifying\" =\n token === \"\" ? \"invalid\" : settled?.token === token ? settled.outcome : \"verifying\";\n const { announce, regionProps } = useAnnounce();\n const resend = useResend(onResend, cooldown);\n\n useOncePerKey(token === \"\" ? null : token, onVerify, (key, result) => {\n // Without a classifier, the coded refusal decides (0.30.0): `token_expired` is\n // \"expired\", anything else \"invalid\".\n const next: VerifyOutcome = result.ok\n ? \"verified\"\n : (classifyError ?? defaultVerifyFailure)(result.error);\n setSettled({ token: key, outcome: next });\n announce(labels[next]);\n });\n\n const href = continueHref ?? (signedIn ? \"/\" : \"/login\");\n // `replace`: the token is spent, so Back must not land on this page again (keksdose F1).\n const continueButton = (\n <Button href={href} replace variant={outcome === \"verified\" ? \"brand\" : \"secondary\"}>\n {labels.continue}\n </Button>\n );\n const canResend = signedIn && onResend !== undefined;\n\n let body: ReactNode;\n if (outcome === \"verifying\") {\n body = <LoadingState size=\"sm\" label={labels.verifying} />;\n } else if (outcome === \"verified\") {\n body = (\n <EmptyState\n tone=\"success\"\n icon={<CheckCircle2 />}\n title={labels.verified}\n hint={verifiedContent}\n action={continueButton}\n className={OUTCOME_CLASS}\n />\n );\n } else {\n const hint = (\n <>\n {outcome === \"invalid\" && token !== \"\" ? `${labels.invalidHint} ` : \"\"}\n {canResend ? labels.requestHere : labels.requestInApp}\n </>\n );\n body = (\n <EmptyState\n tone=\"danger\"\n icon={outcome === \"expired\" ? <TimerOff /> : <XCircle />}\n title={token === \"\" ? labels.noToken : labels[outcome]}\n hint={hint}\n action={\n <>\n {canResend && <ResendButton state={resend} labels={labels} variant=\"brand\" />}\n {continueButton}\n </>\n }\n className={OUTCOME_CLASS}\n />\n );\n }\n\n return (\n <div\n {...rest}\n data-state={outcome}\n aria-busy={outcome === \"verifying\" || undefined}\n className={cn(\"space-y-3\", className)}\n >\n {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.title}</Heading>}\n {body}\n {canResend && (\n <ResendStatus\n phase={resend.phase}\n error={resend.error}\n describeError={describeError}\n labels={labels}\n className=\"block text-center text-xs\"\n />\n )}\n {/* Rendered from the start, so the outcome is read when it arrives. */}\n <div {...regionProps} />\n </div>\n );\n}\n\n/* ── The banner ──────────────────────────────────────────────────────────── */\n\nexport interface EmailVerificationBannerProps {\n /** Send a new confirmation mail (`POST /auth/verify-email/resend`). Resolve when it\n * went out; reject and the banner says it could not. */\n onResend: () => Promise<unknown> | void;\n /** 0.30.0: the words for a refused resend — the server's own sentence, else the kit's. */\n describeError?: (error: unknown) => string | undefined;\n /** Seconds \"Send again\" waits after a mail went out. Default 60; `0` never waits. */\n cooldown?: number;\n /** The ×, \"Not now\". The banner does not hide itself: the app stops rendering it —\n * keksdose for the rest of the session. Left out, no ×. */\n onDismiss?: () => void;\n /** `strip` (default): keksdose's band across the top of the shell. `box`: kastlan's\n * callout inside the page. */\n variant?: \"strip\" | \"box\";\n /** The app's sentence after the kit's — keksdose's \"Until you do, nobody can share a\n * budget with you directly.\" */\n children?: ReactNode;\n className?: string;\n labels?: LabelOverride<VerifyEmailLabels>;\n}\n\n/**\n * The nudge for a signed-in account whose address is not confirmed — keksdose's\n * `VerifyEmailBanner` (features/auth/verify-email-banner.tsx), kastlan's copy of it.\n *\n * A prompt, not a wall: verification gates nothing (§2.8). The app renders it while\n * `/auth/me` says `email_verified: false`, and stops when the user dismisses it or the\n * page above confirms the address. \"Send again\" waits `cooldown` seconds after a mail\n * went out (the button says how long), and its outcome is written into the banner and\n * read out — no toast needed.\n */\nexport function EmailVerificationBanner({\n onResend,\n describeError,\n cooldown = DEFAULT_COOLDOWN,\n onDismiss,\n variant = \"strip\",\n children,\n className,\n labels: labelsProp,\n}: EmailVerificationBannerProps) {\n const labels = useKitLabels(\"verifyEmail\", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);\n const resend = useResend(onResend, cooldown);\n return (\n <AlertBanner\n tone=\"warning\"\n variant={variant}\n icon={<MailWarning />}\n action={<ResendButton state={resend} labels={labels} variant=\"secondary\" />}\n onDismiss={onDismiss}\n dismissLabel={onDismiss ? labels.dismiss : undefined}\n className={className}\n >\n {labels.banner}\n {children != null && children !== false && <> {children}</>}{\" \"}\n <ResendStatus\n phase={resend.phase}\n error={resend.error}\n describeError={describeError}\n labels={labels}\n className=\"font-medium\"\n />\n </AlertBanner>\n );\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/auth/verify-email.tsx"],"sourcesContent":["import { useEffect, useState } from \"react\";\nimport type { ComponentPropsWithRef, ReactNode } from \"react\";\nimport { CheckCircle2, MailWarning, TimerOff, XCircle } from \"lucide-react\";\nimport { cn } from \"../lib/cn\";\nimport { useKitLabels, type LabelOverride } from \"../i18n/kit-labels\";\nimport { useAnnounce } from \"../hooks/use-announce\";\nimport { AlertBanner } from \"../components/alert-banner\";\nimport { LoadingState } from \"../components/loading-state\";\nimport { Button } from \"../components/button\";\nimport { EmptyState } from \"../components/empty-state\";\nimport { OUTCOME_CLASS, STATUS_HEADING_CLASS, isThenable, useOncePerKey } from \"./status-parts\";\nimport { authErrorCode, englishRateLimited, isRateLimited, retryAfterSeconds } from \"./auth-errors\";\nimport type { AuthHeadingLevel } from \"./status-parts\";\nimport { DEFAULT_VERIFY_EMAIL_LABELS } from \"./verify-email-labels\";\n\n/* ── Labels ──────────────────────────────────────────────────────────────── */\n\n/** Every string the verification page and banner render — the `verifyEmail` namespace.\n * keksdose's words (`verify_email.*`, and its server's \"has expired\"). */\nexport interface VerifyEmailLabels {\n /** The page's heading. */\n title: string;\n /** While the link is redeemed. */\n verifying: string;\n verified: string;\n /** The way on from the page — into the app, or to sign-in. */\n continue: string;\n invalid: string;\n expired: string;\n /** The page was opened without a `token`. */\n noToken: string;\n /** Under `invalid`: why a link stops working. */\n invalidHint: string;\n /** Under a dead link, signed out: where a new one comes from. */\n requestInApp: string;\n /** Under a dead link, signed in, beside \"Send again\". */\n requestHere: string;\n /** The resend button, on the page and in the banner. */\n resend: string;\n /** The resend button while a just-sent mail cools down, given the seconds left. */\n resendIn: (seconds: number) => string;\n /** Said (and shown) after a resend went out. */\n resent: string;\n /** Said (and shown) after a resend failed. */\n resendError: string;\n /** 0.31.1: a resend refused as throttled — HTTP 429 (`isRateLimited`) — given the\n * `Retry-After` seconds, when the app's `describeError` has no words of its own\n * (keksdose borrowed the sign-in namespace's). */\n rateLimited?: (seconds?: number) => string;\n /** The banner's message. */\n banner: string;\n /** The banner's ×: it defers, it does not close anything. */\n dismiss: string;\n}\n\n// Defined in ./verify-email-labels, which holds no component code (0.34, KS U8).\nexport { DEFAULT_VERIFY_EMAIL_LABELS };\n\n/** A minute: long enough that an impatient second click does not mail twice, short\n * enough that a mail lost to a spam filter can be re-sent without leaving the page. The\n * server's own limit is the real one (kastlan: 3 an hour per address). */\nconst DEFAULT_COOLDOWN = 60;\n\n/* ── Resend ──────────────────────────────────────────────────────────────── */\n\ntype ResendPhase = \"idle\" | \"sending\" | \"sent\" | \"failed\";\n\n/**\n * \"Send again\" with its own memory: busy while the request runs, then — after a mail\n * went out — held for `cooldown` seconds with the countdown on the button, so a second\n * click cannot mail twice and a throttled server is not asked to refuse. A failure frees\n * it at once. The outcome is a polite live region that is always rendered (a region that\n * mounts with its message is often not read), and visible.\n */\nfunction useResend(onResend: (() => Promise<unknown> | void) | undefined, cooldown: number) {\n const [phase, setPhase] = useState<ResendPhase>(\"idle\");\n // The refusal itself, for `describeError` (0.30.0): the server's own sentence — keksdose's\n // per-address throttle, \"try again in N minutes\" — beats a fixed \"could not send\".\n const [error, setError] = useState<unknown>(undefined);\n const [remaining, setRemaining] = useState(0);\n const counting = remaining > 0;\n useEffect(() => {\n if (!counting) return;\n const id = setInterval(() => setRemaining((r) => Math.max(0, r - 1)), 1000);\n return () => clearInterval(id);\n }, [counting]);\n\n const sent = () => {\n setPhase(\"sent\");\n setRemaining(Math.max(0, Math.round(cooldown)));\n };\n const send = () => {\n if (!onResend || phase === \"sending\" || counting) return;\n let result: unknown;\n try {\n result = onResend();\n } catch (e) {\n setError(e);\n setPhase(\"failed\");\n return;\n }\n if (!isThenable(result)) {\n sent();\n return;\n }\n setPhase(\"sending\");\n result.then(sent, (e: unknown) => {\n setError(e);\n setPhase(\"failed\");\n });\n };\n return { phase, remaining, send, error };\n}\n\nfunction ResendButton({\n state,\n labels,\n variant,\n}: {\n state: ReturnType<typeof useResend>;\n labels: VerifyEmailLabels;\n variant: \"secondary\" | \"brand\";\n}) {\n return (\n <Button\n type=\"button\"\n variant={variant}\n size={variant === \"secondary\" ? \"sm\" : undefined}\n onClick={state.send}\n pending={state.phase === \"sending\"}\n // `disabledReason`, not `disabled`: the button just pressed has the focus, and a\n // native `disabled` would drop it to <body>. The reason is what just happened.\n disabledReason={state.remaining > 0 ? labels.resent : undefined}\n >\n {state.remaining > 0 ? labels.resendIn(state.remaining) : labels.resend}\n </Button>\n );\n}\n\nfunction ResendStatus({\n phase,\n error,\n describeError,\n labels,\n className,\n}: {\n phase: ResendPhase;\n error?: unknown;\n describeError?: (error: unknown) => string | undefined;\n labels: VerifyEmailLabels;\n className?: string;\n}) {\n // The app's words first, then the throttle's, then the kit's catch-all.\n const failed =\n phase === \"failed\"\n ? (describeError?.(error) ??\n (isRateLimited(error)\n ? (labels.rateLimited ?? englishRateLimited)(retryAfterSeconds(error))\n : labels.resendError))\n : \"\";\n return (\n <span\n role=\"status\"\n className={cn(phase === \"failed\" ? \"text-danger\" : \"text-success\", className)}\n >\n {phase === \"sent\" ? labels.resent : failed}\n </span>\n );\n}\n\n/* ── The page ────────────────────────────────────────────────────────────── */\n\n/** What became of a confirmation link that did not verify. */\nexport type VerifyEmailFailure = \"invalid\" | \"expired\";\n\n/** The default when the app gives no `classifyError`: the coded refusal decides. */\nfunction defaultVerifyFailure(error: unknown): VerifyEmailFailure {\n return authErrorCode(error) === \"token_expired\" ? \"expired\" : \"invalid\";\n}\n\nexport interface VerifyEmailStatusProps extends Omit<ComponentPropsWithRef<\"div\">, \"children\" | \"title\"> {\n /** The link's token — the app's `?token=`. Empty: the link was cut short, and the\n * page says so without asking anyone. */\n token: string;\n /**\n * Redeem it — `POST /auth/verify-email {token}`, once per token, on mount. A POST and\n * not the link's own GET (§10.10): a mail scanner that opens every link must not\n * confirm an address nobody read. Resolve when it verified (an address that already\n * was counts: keksdose's redeem is idempotent); reject with the client's error.\n */\n onVerify: (token: string) => Promise<unknown>;\n /** Which dead end a rejection is. Default: `invalid` for everything — §5.2's servers\n * answer one `token_invalid`; an app whose server tells expiry apart maps it here. */\n classifyError?: (error: unknown) => VerifyEmailFailure;\n /** 0.30.0: the words for a refused resend — the server's own sentence (a throttle's\n * \"try again in N minutes\"), else the kit's `resendError`. */\n describeError?: (error: unknown) => string | undefined;\n /** Someone is signed in on this device: \"Continue\" goes into the app, and a dead link\n * offers \"Send again\" (with `onResend`). */\n signedIn?: boolean;\n /** Send a new confirmation mail (`POST /auth/verify-email/resend`) — offered on a dead\n * link while signed in. */\n onResend?: () => Promise<unknown> | void;\n /** Seconds \"Send again\" waits after a mail went out. Default 60; `0` never waits. */\n cooldown?: number;\n /** Where \"Continue\" goes. Default `/` signed in, `/login` signed out. */\n continueHref?: string;\n /** App content under \"confirmed\" — keksdose's \"Others can now share a budget with you\n * directly.\" */\n verifiedContent?: ReactNode;\n /** The heading. Default `labels.title`; `null` draws none, for a page that puts it in\n * `AuthLayout`'s `title` instead. */\n title?: ReactNode;\n /** The heading's level. Default `h2`, under the page's `h1`. */\n headingAs?: AuthHeadingLevel;\n labels?: LabelOverride<VerifyEmailLabels>;\n}\n\ntype VerifyOutcome = \"verified\" | VerifyEmailFailure;\n\n/**\n * Redeem an email-confirmation link (`/verify-email?token=…`, docs/auth-harmonization.md\n * §7) — keksdose's `VerifyEmailPage` (features/auth/verify-email-page.tsx), with\n * kastlan's outcome blocks and its separate \"expired\".\n *\n * Opened from an inbox, often in a browser with no session, so the token is the only\n * credential and the page renders signed-out. It redeems once per token on mount\n * (StrictMode's double effect included — the token is single-use) and shows one of:\n * confirmed; not valid; expired; or the link had no token at all. Verification gates\n * nothing (§2.8), so every outcome ends on \"Continue\". Signed in, a dead link also\n * offers \"Send again\", which waits `cooldown` seconds after a mail went out.\n *\n * The outcome replaces the spinner, which says nothing to a screen reader on its own,\n * so it is also announced.\n */\nexport function VerifyEmailStatus({\n token,\n onVerify,\n classifyError,\n describeError,\n signedIn = false,\n onResend,\n cooldown = DEFAULT_COOLDOWN,\n continueHref,\n verifiedContent,\n title,\n headingAs: Heading = \"h2\",\n labels: labelsProp,\n className,\n ...rest\n}: VerifyEmailStatusProps) {\n const labels = useKitLabels(\"verifyEmail\", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);\n const [settled, setSettled] = useState<{ token: string; outcome: VerifyOutcome } | null>(null);\n const outcome: VerifyOutcome | \"verifying\" =\n token === \"\" ? \"invalid\" : settled?.token === token ? settled.outcome : \"verifying\";\n const { announce, regionProps } = useAnnounce();\n const resend = useResend(onResend, cooldown);\n\n useOncePerKey(token === \"\" ? null : token, onVerify, (key, result) => {\n // Without a classifier, the coded refusal decides (0.30.0): `token_expired` is\n // \"expired\", anything else \"invalid\".\n const next: VerifyOutcome = result.ok\n ? \"verified\"\n : (classifyError ?? defaultVerifyFailure)(result.error);\n setSettled({ token: key, outcome: next });\n announce(labels[next]);\n });\n\n const href = continueHref ?? (signedIn ? \"/\" : \"/login\");\n // `replace`: the token is spent, so Back must not land on this page again (keksdose F1).\n const continueButton = (\n <Button href={href} replace variant={outcome === \"verified\" ? \"brand\" : \"secondary\"}>\n {labels.continue}\n </Button>\n );\n const canResend = signedIn && onResend !== undefined;\n\n let body: ReactNode;\n if (outcome === \"verifying\") {\n body = <LoadingState size=\"sm\" label={labels.verifying} />;\n } else if (outcome === \"verified\") {\n body = (\n <EmptyState\n tone=\"success\"\n icon={<CheckCircle2 />}\n title={labels.verified}\n hint={verifiedContent}\n action={continueButton}\n className={OUTCOME_CLASS}\n />\n );\n } else {\n const hint = (\n <>\n {outcome === \"invalid\" && token !== \"\" ? `${labels.invalidHint} ` : \"\"}\n {canResend ? labels.requestHere : labels.requestInApp}\n </>\n );\n body = (\n <EmptyState\n tone=\"danger\"\n icon={outcome === \"expired\" ? <TimerOff /> : <XCircle />}\n title={token === \"\" ? labels.noToken : labels[outcome]}\n hint={hint}\n action={\n <>\n {canResend && <ResendButton state={resend} labels={labels} variant=\"brand\" />}\n {continueButton}\n </>\n }\n className={OUTCOME_CLASS}\n />\n );\n }\n\n return (\n <div\n {...rest}\n data-state={outcome}\n aria-busy={outcome === \"verifying\" || undefined}\n className={cn(\"space-y-3\", className)}\n >\n {title === null ? null : <Heading className={STATUS_HEADING_CLASS}>{title ?? labels.title}</Heading>}\n {body}\n {canResend && (\n <ResendStatus\n phase={resend.phase}\n error={resend.error}\n describeError={describeError}\n labels={labels}\n className=\"block text-center text-xs\"\n />\n )}\n {/* Rendered from the start, so the outcome is read when it arrives. */}\n <div {...regionProps} />\n </div>\n );\n}\n\n/* ── The banner ──────────────────────────────────────────────────────────── */\n\nexport interface EmailVerificationBannerProps {\n /** Send a new confirmation mail (`POST /auth/verify-email/resend`). Resolve when it\n * went out; reject and the banner says it could not. */\n onResend: () => Promise<unknown> | void;\n /** 0.30.0: the words for a refused resend — the server's own sentence, else the kit's. */\n describeError?: (error: unknown) => string | undefined;\n /** Seconds \"Send again\" waits after a mail went out. Default 60; `0` never waits. */\n cooldown?: number;\n /** The ×, \"Not now\". The banner does not hide itself: the app stops rendering it —\n * keksdose for the rest of the session. Left out, no ×. */\n onDismiss?: () => void;\n /** `strip` (default): keksdose's band across the top of the shell. `box`: kastlan's\n * callout inside the page. */\n variant?: \"strip\" | \"box\";\n /** The app's sentence after the kit's — keksdose's \"Until you do, nobody can share a\n * budget with you directly.\" */\n children?: ReactNode;\n className?: string;\n labels?: LabelOverride<VerifyEmailLabels>;\n /**\n * 0.48: on a phone hold the message to one line with a More / Less toggle —\n * {@link AlertBanner}'s `clampMobile`, passed through (kastlan S2: its banner took ~110px\n * of a 390px phone). The resend's outcome (\"Sent\", the error) then moves out of the\n * message to sit after \"Send again\", at every width, so the line that answers the press\n * is never hidden behind \"More\". Default `false`: the outcome stays in the message.\n */\n clampMobile?: boolean;\n}\n\n/**\n * The nudge for a signed-in account whose address is not confirmed — keksdose's\n * `VerifyEmailBanner` (features/auth/verify-email-banner.tsx), kastlan's copy of it.\n *\n * A prompt, not a wall: verification gates nothing (§2.8). The app renders it while\n * `/auth/me` says `email_verified: false`, and stops when the user dismisses it or the\n * page above confirms the address. \"Send again\" waits `cooldown` seconds after a mail\n * went out (the button says how long), and its outcome is written into the banner and\n * read out — no toast needed.\n */\nexport function EmailVerificationBanner({\n onResend,\n describeError,\n cooldown = DEFAULT_COOLDOWN,\n onDismiss,\n variant = \"strip\",\n children,\n className,\n labels: labelsProp,\n clampMobile = false,\n}: EmailVerificationBannerProps) {\n const labels = useKitLabels(\"verifyEmail\", DEFAULT_VERIFY_EMAIL_LABELS, labelsProp);\n const resend = useResend(onResend, cooldown);\n // One live region either way, mounted from the start (a region added later is often not\n // read out): in the message, or — clamped — after the button, outside the clamp.\n const status = (\n <ResendStatus\n phase={resend.phase}\n error={resend.error}\n describeError={describeError}\n labels={labels}\n className=\"font-medium\"\n />\n );\n const button = <ResendButton state={resend} labels={labels} variant=\"secondary\" />;\n return (\n <AlertBanner\n tone=\"warning\"\n variant={variant}\n icon={<MailWarning />}\n action={\n clampMobile ? (\n <>\n {button}\n {status}\n </>\n ) : (\n button\n )\n }\n onDismiss={onDismiss}\n dismissLabel={onDismiss ? labels.dismiss : undefined}\n clampMobile={clampMobile}\n className={className}\n >\n {labels.banner}\n {children != null && children !== false && <> {children}</>}\n {!clampMobile && <> {status}</>}\n </AlertBanner>\n );\n}\n"],"mappings":"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","names":[]}
|
|
@@ -97,6 +97,34 @@ interface AlertBannerBaseProps extends Omit<ComponentPropsWithoutRef<"div">, "on
|
|
|
97
97
|
/** Accessible name of the ×. Default: `common.dismiss` from the
|
|
98
98
|
* {@link UiKitProvider}, else "Dismiss". */
|
|
99
99
|
dismissLabel?: string;
|
|
100
|
+
/**
|
|
101
|
+
* 0.48: on a phone (below the kit's text-size-aware `md`) hold the message to ONE line,
|
|
102
|
+
* with a "More" / "Less" toggle after it (`aria-expanded`, `aria-controls` the message;
|
|
103
|
+
* `common.showMore` / `common.showLess`). From `md` up nothing changes. The toggle only
|
|
104
|
+
* shows while the line actually cuts the message off.
|
|
105
|
+
*
|
|
106
|
+
* keksdose dev#611 (UX-1.2): its beta notice took three to four lines (~100px) above
|
|
107
|
+
* every phone screen, and the app built the clamp and the button around the banner's
|
|
108
|
+
* children by hand. Ignored on a whole-row banner (`href` / `onClick`): a toggle cannot
|
|
109
|
+
* sit inside the row's own link or button.
|
|
110
|
+
*/
|
|
111
|
+
clampMobile?: boolean;
|
|
112
|
+
/**
|
|
113
|
+
* Where a box's {@link action} goes on a phone (below `sm`). 0.48.
|
|
114
|
+
*
|
|
115
|
+
* `own-line` (default): a line of its own under the message, lined up with it
|
|
116
|
+
* (kastlan 53 — a long message beside a long button squeezed into a column).
|
|
117
|
+
*
|
|
118
|
+
* `flow`: on the message's line while the message (with its glyph) keeps 10rem, else at the END of the
|
|
119
|
+
* next line — for a short message and a short action, which a line of their own each
|
|
120
|
+
* would stack for nothing. keksdose dev #623: the budget card's "5 underfunded
|
|
121
|
+
* categories" + "Assign" built this with descendant overrides on the kit's classes.
|
|
122
|
+
* The basis is in rem, so it grows with the text size: at Large the action drops a
|
|
123
|
+
* line sooner, by itself. The × stays at the top end either way; `clampMobile` works
|
|
124
|
+
* alongside. From `sm` up nothing changes. Ignored by `strip` (which flows like this
|
|
125
|
+
* already) and `inline`.
|
|
126
|
+
*/
|
|
127
|
+
actionPlacement?: "own-line" | "flow";
|
|
100
128
|
children: ReactNode;
|
|
101
129
|
/**
|
|
102
130
|
* The banner's frame — where the rest of the props go (0.34, API-2, refactor audit
|
|
@@ -130,5 +158,5 @@ export type AlertBannerProps = AlertBannerBaseProps & ({
|
|
|
130
158
|
});
|
|
131
159
|
/** Warning/danger/info callout box with the shared frame and an icon — or, with
|
|
132
160
|
* `variant="inline"`, the same message as a frameless line of toned text. */
|
|
133
|
-
export declare function AlertBanner({ tone, variant, size, block, live, elevated, action, icon, onDismiss, dismissLabel, href, onClick, className, children, role, ref, ...rest }: AlertBannerProps): import("react").JSX.Element;
|
|
161
|
+
export declare function AlertBanner({ tone, variant, size, block, live, elevated, action, icon, onDismiss, dismissLabel, clampMobile, actionPlacement, href, onClick, className, children, role, ref, ...rest }: AlertBannerProps): import("react").JSX.Element;
|
|
134
162
|
export {};
|
|
@@ -1,5 +1,6 @@
|
|
|
1
1
|
"use client";
|
|
2
2
|
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useId, useLayoutEffect, useRef, useState } from "react";
|
|
3
4
|
import { AlertTriangle, CheckCircle2, ChevronRight, Info, X } from "lucide-react";
|
|
4
5
|
import { cn } from "../lib/cn.js";
|
|
5
6
|
import { FOCUS_RING } from "./focus-ring.js";
|
|
@@ -65,6 +66,8 @@ function AlertBanner({
|
|
|
65
66
|
icon,
|
|
66
67
|
onDismiss,
|
|
67
68
|
dismissLabel,
|
|
69
|
+
clampMobile = false,
|
|
70
|
+
actionPlacement = "own-line",
|
|
68
71
|
href,
|
|
69
72
|
onClick,
|
|
70
73
|
className,
|
|
@@ -80,6 +83,20 @@ function AlertBanner({
|
|
|
80
83
|
const interactive = href !== void 0 || onClick !== void 0;
|
|
81
84
|
const inlineRow = inline && !block;
|
|
82
85
|
const resolvedRole = role ?? (inline && live ? tone === "danger" ? "alert" : "status" : void 0);
|
|
86
|
+
const clamp = clampMobile && !interactive;
|
|
87
|
+
const bodyId = useId();
|
|
88
|
+
const bodyRef = useRef(null);
|
|
89
|
+
const [expanded, setExpanded] = useState(false);
|
|
90
|
+
const [fits, setFits] = useState(false);
|
|
91
|
+
useLayoutEffect(() => {
|
|
92
|
+
const el = bodyRef.current;
|
|
93
|
+
if (!clamp || expanded || !el) return;
|
|
94
|
+
const check = () => setFits(el.clientHeight > 0 && el.scrollHeight <= el.clientHeight + 1);
|
|
95
|
+
check();
|
|
96
|
+
const ro = typeof ResizeObserver === "undefined" ? null : new ResizeObserver(check);
|
|
97
|
+
ro?.observe(el);
|
|
98
|
+
return () => ro?.disconnect();
|
|
99
|
+
}, [clamp, expanded, children]);
|
|
83
100
|
const Glyph = TONE_GLYPH[tone];
|
|
84
101
|
const nudge = inlineRow || strip ? void 0 : sm ? "mt-px" : "mt-0.5";
|
|
85
102
|
const glyph = icon === null ? null : icon !== void 0 ? /* @__PURE__ */ jsx(
|
|
@@ -90,7 +107,11 @@ function AlertBanner({
|
|
|
90
107
|
children: icon
|
|
91
108
|
}
|
|
92
109
|
) : /* @__PURE__ */ jsx(Glyph, { "aria-hidden": true, className: cn(sm ? "size-3.5" : "size-4", "shrink-0", nudge, TONE_ICON[tone]) });
|
|
93
|
-
const
|
|
110
|
+
const boxAction = !inline && !strip && action !== void 0 && action !== null;
|
|
111
|
+
const flow = boxAction && actionPlacement === "flow";
|
|
112
|
+
const boxWraps = boxAction && !flow;
|
|
113
|
+
const flowRow = cn("flex min-w-0 flex-1 items-center max-sm:flex-wrap", sm ? "gap-1.5" : "gap-2");
|
|
114
|
+
const flowBasis = flow ? "max-sm:basis-40" : void 0;
|
|
94
115
|
const row = cn(
|
|
95
116
|
inlineRow ? "inline-flex items-center" : strip ? "flex flex-wrap items-center gap-y-1" : cn("flex items-start", boxWraps && "max-sm:flex-wrap max-sm:gap-y-2"),
|
|
96
117
|
sm ? "gap-1.5 text-xs" : inline ? "gap-1.5 text-sm" : "gap-2 text-sm"
|
|
@@ -102,9 +123,48 @@ function AlertBanner({
|
|
|
102
123
|
elevated && cn(ELEVATED, TONE_ELEVATED[tone])
|
|
103
124
|
);
|
|
104
125
|
const focusRing = cn(FOCUS_RING, "focus-visible:ring-offset-1 focus-visible:ring-offset-surface");
|
|
126
|
+
const contentBody = clamp ? (
|
|
127
|
+
// Below `md` a row: the message, clamped, then the toggle on its baseline. From
|
|
128
|
+
// `md` up a plain span with the message in it, as without the prop.
|
|
129
|
+
/* @__PURE__ */ jsxs(
|
|
130
|
+
"span",
|
|
131
|
+
{
|
|
132
|
+
className: cn(
|
|
133
|
+
!inlineRow && grow,
|
|
134
|
+
"min-w-0 text-start max-md:flex max-md:items-baseline max-md:gap-2"
|
|
135
|
+
),
|
|
136
|
+
children: [
|
|
137
|
+
/* @__PURE__ */ jsx(
|
|
138
|
+
"span",
|
|
139
|
+
{
|
|
140
|
+
id: bodyId,
|
|
141
|
+
ref: bodyRef,
|
|
142
|
+
"data-truncate-ok": "",
|
|
143
|
+
className: cn("max-md:min-w-0", !expanded && "max-md:line-clamp-1"),
|
|
144
|
+
children
|
|
145
|
+
}
|
|
146
|
+
),
|
|
147
|
+
(expanded || !fits) && /* @__PURE__ */ jsx(
|
|
148
|
+
"button",
|
|
149
|
+
{
|
|
150
|
+
type: "button",
|
|
151
|
+
"aria-expanded": expanded,
|
|
152
|
+
"aria-controls": bodyId,
|
|
153
|
+
onClick: () => setExpanded((v) => !v),
|
|
154
|
+
className: cn(
|
|
155
|
+
"relative z-10 shrink-0 rounded-sm font-medium underline underline-offset-2 md:hidden",
|
|
156
|
+
focusRing
|
|
157
|
+
),
|
|
158
|
+
children: expanded ? common.showLess : common.showMore
|
|
159
|
+
}
|
|
160
|
+
)
|
|
161
|
+
]
|
|
162
|
+
}
|
|
163
|
+
)
|
|
164
|
+
) : /* @__PURE__ */ jsx("span", { className: cn(!inlineRow && (nested ? "flex-1" : grow), "min-w-0 text-start"), children });
|
|
105
165
|
const content = /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
106
166
|
glyph,
|
|
107
|
-
|
|
167
|
+
contentBody,
|
|
108
168
|
interactive && /* @__PURE__ */ jsx(
|
|
109
169
|
ChevronRight,
|
|
110
170
|
{
|
|
@@ -138,7 +198,7 @@ function AlertBanner({
|
|
|
138
198
|
className: cn(
|
|
139
199
|
"relative z-10 flex max-w-full shrink-0 flex-wrap items-center gap-2",
|
|
140
200
|
!strip && !inlineRow && "self-center",
|
|
141
|
-
strip && "ms-auto",
|
|
201
|
+
(strip || flow) && "ms-auto",
|
|
142
202
|
// Last, on a line of its own, lined up with the message (past the glyph).
|
|
143
203
|
boxWraps && cn("max-sm:order-last max-sm:basis-full", glyph && (sm ? "max-sm:ps-5" : "max-sm:ps-6"))
|
|
144
204
|
),
|
|
@@ -155,8 +215,16 @@ function AlertBanner({
|
|
|
155
215
|
role: resolvedRole,
|
|
156
216
|
className: cn(row, frame, TONE_TEXT[tone], className),
|
|
157
217
|
children: [
|
|
158
|
-
|
|
159
|
-
|
|
218
|
+
flow ? /* @__PURE__ */ jsx(Fragment, { children: /* @__PURE__ */ jsxs("div", { className: flowRow, "data-action-flow": "", children: [
|
|
219
|
+
/* @__PURE__ */ jsxs("span", { className: cn("flex min-w-0 flex-1 items-start", flowBasis, sm ? "gap-1.5" : "gap-2"), children: [
|
|
220
|
+
glyph,
|
|
221
|
+
contentBody
|
|
222
|
+
] }),
|
|
223
|
+
trailing
|
|
224
|
+
] }) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
225
|
+
content,
|
|
226
|
+
trailing
|
|
227
|
+
] }),
|
|
160
228
|
dismiss
|
|
161
229
|
]
|
|
162
230
|
}
|
|
@@ -167,6 +235,7 @@ function AlertBanner({
|
|
|
167
235
|
const actionClass = split ? cn(
|
|
168
236
|
"flex min-w-0 text-start",
|
|
169
237
|
grow,
|
|
238
|
+
flowBasis,
|
|
170
239
|
sm ? "gap-1.5" : "gap-2",
|
|
171
240
|
inlineRow || strip ? "items-center" : "items-start",
|
|
172
241
|
"outline-none after:absolute after:inset-0 after:content-['']",
|
|
@@ -207,8 +276,16 @@ function AlertBanner({
|
|
|
207
276
|
role: resolvedRole,
|
|
208
277
|
className: cn("relative", row, frame, TONE_TEXT[tone], hover, className),
|
|
209
278
|
children: [
|
|
210
|
-
|
|
211
|
-
|
|
279
|
+
flow ? (
|
|
280
|
+
// Not positioned: the row's stretched `after:` target must still cover the frame.
|
|
281
|
+
/* @__PURE__ */ jsxs("div", { className: flowRow, "data-action-flow": "", children: [
|
|
282
|
+
rowAction,
|
|
283
|
+
trailing
|
|
284
|
+
] })
|
|
285
|
+
) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
286
|
+
rowAction,
|
|
287
|
+
trailing
|
|
288
|
+
] }),
|
|
212
289
|
dismiss
|
|
213
290
|
]
|
|
214
291
|
}
|