@eifi1/ui-kit 0.40.1 → 0.42.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 +19 -6
- package/dist/admin/admin-area-labels.d.ts +55 -0
- package/dist/admin/admin-area-labels.js +33 -0
- package/dist/admin/admin-area-labels.js.map +1 -0
- package/dist/admin/admin-forbidden.d.ts +35 -0
- package/dist/admin/admin-forbidden.js +36 -0
- package/dist/admin/admin-forbidden.js.map +1 -0
- package/dist/admin/admin-overview.d.ts +93 -0
- package/dist/admin/admin-overview.js +69 -0
- package/dist/admin/admin-overview.js.map +1 -0
- package/dist/admin/admin-sections.d.ts +63 -0
- package/dist/admin/admin-sections.js +64 -0
- package/dist/admin/admin-sections.js.map +1 -0
- package/dist/admin/admin-tool-links.d.ts +48 -0
- package/dist/admin/admin-tool-links.js +31 -0
- package/dist/admin/admin-tool-links.js.map +1 -0
- package/dist/billing/billing-banner.d.ts +25 -2
- package/dist/billing/billing-banner.js +5 -2
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +32 -0
- package/dist/billing/billing-labels.js +9 -0
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/billing-overview.d.ts +33 -0
- package/dist/billing/billing-overview.js +6 -0
- package/dist/billing/billing-overview.js.map +1 -1
- package/dist/billing/billing-standing.d.ts +8 -0
- package/dist/billing/billing-standing.js.map +1 -1
- package/dist/billing/cancel-subscription.d.ts +46 -0
- package/dist/billing/cancel-subscription.js +57 -0
- package/dist/billing/cancel-subscription.js.map +1 -0
- package/dist/billing/plan-card.d.ts +45 -1
- package/dist/billing/plan-card.js +53 -2
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/billing/plan-switch.d.ts +10 -1
- package/dist/billing/plan-switch.js +22 -13
- package/dist/billing/plan-switch.js.map +1 -1
- package/dist/billing/subscription-actions.d.ts +37 -4
- package/dist/billing/subscription-actions.js +10 -3
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/components/account-chips.d.ts +42 -1
- package/dist/components/account-chips.js +33 -0
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/empty-state.d.ts +2 -1
- package/dist/components/empty-state.js.map +1 -1
- package/dist/components/nav-pills.d.ts +2 -2
- package/dist/components/nav-pills.js.map +1 -1
- package/dist/components/tabs.d.ts +4 -32
- package/dist/components/tabs.js +30 -66
- package/dist/components/tabs.js.map +1 -1
- package/dist/components/translation-review-parts.d.ts +2 -2
- package/dist/components/translation-review-parts.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-menu-labels.js +0 -2
- package/dist/feedback/feedback-menu-labels.js.map +1 -1
- package/dist/feedback/feedback-menu.d.ts +0 -6
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/i18n/defaults.js +5 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +79 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +9 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +93 -3
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +76 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +75 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +76 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +76 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +75 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +20 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +18 -2
- package/dist/index.js +23 -1
- package/dist/index.js.map +1 -1
- package/dist/settings/settings-layout.d.ts +7 -1
- package/dist/settings/settings-layout.js +35 -27
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/shell/account-menu-language.d.ts +46 -0
- package/dist/shell/account-menu-language.js +64 -0
- package/dist/shell/account-menu-language.js.map +1 -0
- package/dist/shell/admin-menu.d.ts +46 -0
- package/dist/shell/admin-menu.js +60 -0
- package/dist/shell/admin-menu.js.map +1 -0
- package/dist/shell/app-shell.d.ts +10 -0
- package/dist/shell/app-shell.js +22 -4
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/dev-switchers.d.ts +53 -0
- package/dist/shell/dev-switchers.js +102 -0
- package/dist/shell/dev-switchers.js.map +1 -0
- package/dist/shell/sync-status-indicator.d.ts +86 -0
- package/dist/shell/sync-status-indicator.js +170 -0
- package/dist/shell/sync-status-indicator.js.map +1 -0
- package/dist/shell/top-bar-actions.d.ts +49 -0
- package/dist/shell/top-bar-actions.js +31 -0
- package/dist/shell/top-bar-actions.js.map +1 -0
- package/dist/shell/top-bar-parts-labels.d.ts +84 -0
- package/dist/shell/top-bar-parts-labels.js +48 -0
- package/dist/shell/top-bar-parts-labels.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +13 -1
- package/dist/shell/topbar-action-menu.js +5 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +16 -3
- package/dist/shell/topbar-controls.js +5 -1
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell/tour-menu.d.ts +58 -0
- package/dist/shell/tour-menu.js +73 -0
- package/dist/shell/tour-menu.js.map +1 -0
- package/dist/shell.d.ts +14 -0
- package/dist/shell.js +19 -0
- package/dist/shell.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-area-labels.ts +85 -0
- package/src/admin/admin-forbidden.tsx +75 -0
- package/src/admin/admin-overview.tsx +166 -0
- package/src/admin/admin-sections.tsx +137 -0
- package/src/admin/admin-tool-links.tsx +84 -0
- package/src/billing/billing-banner.tsx +32 -4
- package/src/billing/billing-labels.ts +53 -0
- package/src/billing/billing-overview.ts +37 -0
- package/src/billing/billing-standing.ts +8 -0
- package/src/billing/cancel-subscription.tsx +102 -0
- package/src/billing/plan-card.tsx +97 -1
- package/src/billing/plan-switch.tsx +16 -0
- package/src/billing/subscription-actions.tsx +64 -15
- package/src/components/account-chips.tsx +81 -1
- package/src/components/empty-state.tsx +2 -1
- package/src/components/nav-pills.tsx +2 -2
- package/src/components/tabs.tsx +19 -110
- package/src/components/translation-review-parts.tsx +2 -2
- package/src/feedback/feedback-menu-labels.ts +0 -2
- package/src/feedback/feedback-menu.tsx +0 -6
- package/src/i18n/defaults.ts +4 -0
- package/src/i18n/german.ts +92 -2
- package/src/i18n/kit-labels.tsx +9 -0
- package/src/i18n/locales/en.ts +19 -0
- package/src/i18n/locales/es.ts +86 -2
- package/src/i18n/locales/fr.ts +86 -2
- package/src/i18n/locales/hu.ts +84 -2
- package/src/i18n/locales/it.ts +87 -2
- package/src/i18n/locales/zh.ts +79 -2
- package/src/i18n/review.ts +19 -0
- package/src/index.ts +30 -2
- package/src/settings/settings-layout.tsx +58 -34
- package/src/shell/account-menu-language.tsx +116 -0
- package/src/shell/admin-menu.tsx +117 -0
- package/src/shell/app-shell.tsx +44 -3
- package/src/shell/dev-switchers.tsx +171 -0
- package/src/shell/sync-status-indicator.tsx +301 -0
- package/src/shell/top-bar-actions.tsx +79 -0
- package/src/shell/top-bar-parts-labels.ts +133 -0
- package/src/shell/topbar-action-menu.tsx +15 -2
- package/src/shell/topbar-controls.tsx +20 -3
- package/src/shell/tour-menu.tsx +144 -0
- package/src/shell.ts +24 -0
|
@@ -54,6 +54,43 @@ export interface BillingOverview {
|
|
|
54
54
|
* server-kit before 0.13 still type-checks; absent, read it as false.
|
|
55
55
|
*/
|
|
56
56
|
switching?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* 0.42 (server-kit 0.14, §16.3): who may open the provider's portal. `"buyer_only"`:
|
|
59
|
+
* the row has a buyer (`provider_payer_user_id`) and the viewer isn't them — every
|
|
60
|
+
* portal target would answer `403 billing_portal_buyer_only`, so "Payment and
|
|
61
|
+
* invoices" is a caption naming the buyer and "Cancel subscription" is the in-app
|
|
62
|
+
* cancel (`POST /billing/cancel`). `"open"`, or absent (server-kit before 0.14): as
|
|
63
|
+
* before. Pass it to `SubscriptionActions`' `portal`.
|
|
64
|
+
*/
|
|
65
|
+
portal?: BillingPortalAccess;
|
|
66
|
+
/**
|
|
67
|
+
* 0.42 (§16.3, §16.7 G): the buyer's display name, for the caption under
|
|
68
|
+
* `"buyer_only"`; `null` when the buyer is gone or no longer a member of the company,
|
|
69
|
+
* and the words then say "the admin who bought the subscription".
|
|
70
|
+
*/
|
|
71
|
+
buyer_name?: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 0.42 (§16.3): `BillingOverview.portal` and `BillingStanding.portal` — server-kit's
|
|
75
|
+
* `Literal["open", "buyer_only"]`. */
|
|
76
|
+
export type BillingPortalAccess = "open" | "buyer_only";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 0.42 (§16.2 decision 5, §16.7 E): whether the payer's free grant runs WITHOUT AN END —
|
|
80
|
+
* an operator's grant (`source: "manual"`) with `comped_until` empty. A purchase under
|
|
81
|
+
* it leaves the row `comped` on the grant's plan (§12.11): only the paid price moves,
|
|
82
|
+
* so the payer would pay for a plan that never applies. Pass it to `PlanPicker`'s and
|
|
83
|
+
* `PlanSwitchConfirm`'s `permanentGrant`, which warn before the checkout or the switch.
|
|
84
|
+
*
|
|
85
|
+
* `source` is checked explicitly, not implied by the missing date: Kurvenschmiede's
|
|
86
|
+
* pre-launch beta rows are `comped` with `comped_until` null too (their launch-plus-12-
|
|
87
|
+
* months end isn't written yet, §3.2), and they are not permanent. A grant with an end
|
|
88
|
+
* gets no warning either: the bought plan applies from that end on.
|
|
89
|
+
*/
|
|
90
|
+
export function isPermanentGrant(
|
|
91
|
+
overview: Pick<BillingOverview, "status" | "source"> & { comped_until?: BillingOverview["comped_until"] },
|
|
92
|
+
): boolean {
|
|
93
|
+
return overview.status === "comped" && overview.source === "manual" && overview.comped_until == null;
|
|
57
94
|
}
|
|
58
95
|
|
|
59
96
|
/** 0.39: what the payer pays at the provider (server-kit's `PaidPrice`, §15.2.10),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { toDate } from "../lib/format";
|
|
2
2
|
import type { DateInput } from "../lib/format";
|
|
3
3
|
import type { SubscriptionStatus } from "./billing-labels";
|
|
4
|
+
import type { BillingPortalAccess } from "./billing-overview";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* What a device knows of the payer's standing — the fields of `GET /billing/overview`
|
|
@@ -16,6 +17,13 @@ export interface BillingStanding {
|
|
|
16
17
|
/** When a free grant ends: a beta user's 12 months, an operator's grant. Empty for a
|
|
17
18
|
* grant without an end (§12.8). */
|
|
18
19
|
comped_until?: DateInput;
|
|
20
|
+
/** 0.42 (server-kit 0.14, §16.7 A): who may open the provider's portal, as the
|
|
21
|
+
* overview's `portal` — `GET /billing/status` says it too, for the payment-failed
|
|
22
|
+
* banner (`BillingBanner`'s `portal`). Absent: `"open"`. */
|
|
23
|
+
portal?: BillingPortalAccess;
|
|
24
|
+
/** 0.42 (§16.7 A): the buyer's display name, as the overview's `buyer_name`; `null`
|
|
25
|
+
* when the buyer is gone or has left the company. */
|
|
26
|
+
buyer_name?: string | null;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
/**
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react";
|
|
2
|
+
|
|
3
|
+
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
4
|
+
import type { LabelOverride } from "../i18n/kit-labels";
|
|
5
|
+
import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
|
|
6
|
+
import { formatDate } from "../lib/format";
|
|
7
|
+
import type { DateInput } from "../lib/format";
|
|
8
|
+
import { Button } from "../components/button";
|
|
9
|
+
import { DialogFrame } from "../components/dialog-frame";
|
|
10
|
+
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
|
|
11
|
+
import { useBillingLabels } from "./billing-labels";
|
|
12
|
+
import type { BillingLabels } from "./billing-labels";
|
|
13
|
+
|
|
14
|
+
export interface CancelSubscriptionConfirmProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
/** `false` when the admin dismisses it — Back, Escape, the backdrop. Confirm leaves it
|
|
17
|
+
* open: close it once `POST /billing/cancel` answers. */
|
|
18
|
+
onOpenChange: (open: boolean) => void;
|
|
19
|
+
/** The overview's `current_period_end`: the day the subscription ends. Formatted with
|
|
20
|
+
* the kit's date formatter (`formatDate`, `"medium"`). */
|
|
21
|
+
periodEnd: DateInput;
|
|
22
|
+
/** Confirm: the app's `POST /billing/cancel` (server-kit 0.14, §16.3). */
|
|
23
|
+
onConfirm: () => void;
|
|
24
|
+
/** The cancel is under way: Confirm's spinner, no second press, and Escape and Back
|
|
25
|
+
* don't close. */
|
|
26
|
+
pending?: boolean;
|
|
27
|
+
/** The date's locale. Default: the provider's. */
|
|
28
|
+
locale?: string;
|
|
29
|
+
/** Prop > `<UiKitProvider labels={{ billing }}>` > English. */
|
|
30
|
+
labels?: LabelOverride<BillingLabels>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The in-app cancel's confirm (docs/billing-harmonization.md §16.2 decision 2, §16.4,
|
|
35
|
+
* ui-kit 0.42): "Cancel subscription" for an admin the provider's portal doesn't open for
|
|
36
|
+
* (`SubscriptionActions`' `portal="buyer_only"` → `onCancelInApp`). It names the day the
|
|
37
|
+
* subscription ends — "Your subscription ends on 9 Nov 2026. Until then everything works
|
|
38
|
+
* as before." — and its button reads "Cancel subscription now" (German "Jetzt kündigen",
|
|
39
|
+
* the confirmation button of §312k BGB).
|
|
40
|
+
*
|
|
41
|
+
* <CancelSubscriptionConfirm
|
|
42
|
+
* open={asking}
|
|
43
|
+
* onOpenChange={setAsking}
|
|
44
|
+
* periodEnd={overview.current_period_end}
|
|
45
|
+
* pending={cancel.isPending}
|
|
46
|
+
* onConfirm={() => cancel.mutate()}
|
|
47
|
+
* />
|
|
48
|
+
*
|
|
49
|
+
* The cancel is at the period's end (Paddle's `cancel`, not immediately); the row follows
|
|
50
|
+
* by webhook (`cancel_at_period_end`), as after a cancel in the portal, and
|
|
51
|
+
* `SubscriptionActions` then offers "Keep subscription" (`onResume`).
|
|
52
|
+
*
|
|
53
|
+
* It takes no billing lock (`COMMIT_EXCEPT_BILLING` built in, §3.3): a read-only payer
|
|
54
|
+
* may always cancel. A demo lock still holds it, as the server's demo guard does. The kit
|
|
55
|
+
* sends no request.
|
|
56
|
+
*/
|
|
57
|
+
export function CancelSubscriptionConfirm({
|
|
58
|
+
open,
|
|
59
|
+
onOpenChange,
|
|
60
|
+
periodEnd,
|
|
61
|
+
onConfirm,
|
|
62
|
+
pending = false,
|
|
63
|
+
locale: localeProp,
|
|
64
|
+
labels: labelsProp,
|
|
65
|
+
}: CancelSubscriptionConfirmProps) {
|
|
66
|
+
const labels = useBillingLabels(labelsProp);
|
|
67
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
68
|
+
const locale = useKitLocale(localeProp);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<DialogFrame
|
|
72
|
+
open={open}
|
|
73
|
+
title={labels.cancelConfirmTitle}
|
|
74
|
+
description={labels.cancelConfirmBody(formatDate(periodEnd, "medium", { locale }))}
|
|
75
|
+
onClose={() => {
|
|
76
|
+
if (!pending) onOpenChange(false);
|
|
77
|
+
}}
|
|
78
|
+
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
|
79
|
+
if (pending && e.key === "Escape") e.preventDefault();
|
|
80
|
+
}}
|
|
81
|
+
aria-busy={pending || undefined}
|
|
82
|
+
data-cancel-subscription-confirm=""
|
|
83
|
+
actions={(close) => (
|
|
84
|
+
<>
|
|
85
|
+
<Button variant="secondary" onClick={close} disabled={pending}>
|
|
86
|
+
{common.back}
|
|
87
|
+
</Button>
|
|
88
|
+
<Button
|
|
89
|
+
variant="danger"
|
|
90
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
91
|
+
pending={pending}
|
|
92
|
+
onClick={() => {
|
|
93
|
+
if (!pending) onConfirm();
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{labels.cancelConfirmAction}
|
|
97
|
+
</Button>
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -11,6 +11,7 @@ import type { ChoiceCardProps } from "../components/choice-card";
|
|
|
11
11
|
import { Chip } from "../components/chip";
|
|
12
12
|
import { ToggleGroup } from "../components/toggle-group";
|
|
13
13
|
import { Button } from "../components/button";
|
|
14
|
+
import { DialogFrame } from "../components/dialog-frame";
|
|
14
15
|
import { useBillingLabels } from "./billing-labels";
|
|
15
16
|
import type { BillingLabels } from "./billing-labels";
|
|
16
17
|
import { BILLING_INTERVALS, billingCurrencyFor, formatPlanPrice, planPrice } from "./plan-price";
|
|
@@ -263,6 +264,24 @@ export interface PlanPickerProps extends Omit<PlanDisplayProps, "currency" | "in
|
|
|
263
264
|
onChoose: (choice: PlanChoice) => void;
|
|
264
265
|
/** The checkout is being opened: the button's spinner, no second press. */
|
|
265
266
|
pending?: boolean;
|
|
267
|
+
/**
|
|
268
|
+
* 0.42 (§16.2 decision 5, §16.8 B): the payer's free grant runs without an end —
|
|
269
|
+
* `isPermanentGrant(overview)`. A purchase under it changes nothing the payer gets
|
|
270
|
+
* while the grant runs (§12.11), so the button first opens a confirm —
|
|
271
|
+
* `grantWarningTitle`, `grantWarningBody`, "Buy anyway" (`grantWarningAction`) and
|
|
272
|
+
* "Back" — and {@link onChoose} runs only on "Buy anyway". Warned, not refused.
|
|
273
|
+
*
|
|
274
|
+
* Not while {@link switching}: then the pick opens `PlanSwitchConfirm`, which takes
|
|
275
|
+
* the same `permanentGrant` and shows the warning above its figures — one warning,
|
|
276
|
+
* in the dialog where the payer agrees, rather than a confirm before a confirm.
|
|
277
|
+
*/
|
|
278
|
+
permanentGrant?: boolean;
|
|
279
|
+
/**
|
|
280
|
+
* 0.42: the overview's `switching` — the app sends this pick to `PlanSwitchConfirm`
|
|
281
|
+
* (§15.2.10), not the checkout. Only decides where {@link permanentGrant}'s warning
|
|
282
|
+
* shows; the button and {@link onChoose} are the same either way.
|
|
283
|
+
*/
|
|
284
|
+
switching?: boolean;
|
|
266
285
|
/** The provider's localised price for a plan, or `undefined` for the catalogue's —
|
|
267
286
|
* see {@link PlanCardProps.pricePreview}. */
|
|
268
287
|
pricePreview?: (plan: BillingPlan, choice: Omit<PlanChoice, "plan">) => ReactNode | undefined;
|
|
@@ -308,6 +327,11 @@ function pricedIntervals(plans: readonly BillingPlan[]): BillingInterval[] {
|
|
|
308
327
|
* The button never takes the write lock: billing itself always works, a read-only payer
|
|
309
328
|
* must be able to pay (§3.3). It is off, with the reason on hover and focus, while
|
|
310
329
|
* nothing is picked or the current plan is.
|
|
330
|
+
*
|
|
331
|
+
* 0.42 (§16.8 B): under a grant without an end (`permanentGrant`) the payer is warned
|
|
332
|
+
* before paying for a plan that won't apply — here, in a confirm before `onChoose`, on
|
|
333
|
+
* the way to the checkout; on the way to a switch (`switching`) in `PlanSwitchConfirm`
|
|
334
|
+
* instead, so the warning shows once on either path.
|
|
311
335
|
*/
|
|
312
336
|
export function PlanPicker({
|
|
313
337
|
plans,
|
|
@@ -324,6 +348,8 @@ export function PlanPicker({
|
|
|
324
348
|
currencies: currenciesProp,
|
|
325
349
|
onChoose,
|
|
326
350
|
pending,
|
|
351
|
+
permanentGrant = false,
|
|
352
|
+
switching = false,
|
|
327
353
|
pricePreview,
|
|
328
354
|
legend,
|
|
329
355
|
name,
|
|
@@ -338,6 +364,8 @@ export function PlanPicker({
|
|
|
338
364
|
const labels = useBillingLabels(labelsProp);
|
|
339
365
|
const locale = useKitLocale(localeProp);
|
|
340
366
|
const generated = useId();
|
|
367
|
+
/** The choice the grant warning holds back until "Buy anyway". */
|
|
368
|
+
const [warned, setWarned] = useState<PlanChoice | null>(null);
|
|
341
369
|
const groupName = name ?? generated;
|
|
342
370
|
const legendId = `${generated}-legend`;
|
|
343
371
|
|
|
@@ -447,11 +475,79 @@ export function PlanPicker({
|
|
|
447
475
|
className="max-sm:w-full"
|
|
448
476
|
disabledReason={blocked}
|
|
449
477
|
pending={pending}
|
|
450
|
-
onClick={() =>
|
|
478
|
+
onClick={() => {
|
|
479
|
+
if (!picked) return;
|
|
480
|
+
const choice = { plan: picked.code, interval, currency };
|
|
481
|
+
if (permanentGrant && !switching) setWarned(choice);
|
|
482
|
+
else onChoose(choice);
|
|
483
|
+
}}
|
|
451
484
|
>
|
|
452
485
|
{buttonText}
|
|
453
486
|
</Button>
|
|
454
487
|
</div>
|
|
488
|
+
{permanentGrant && !switching && (
|
|
489
|
+
<GrantWarningConfirm
|
|
490
|
+
open={warned !== null}
|
|
491
|
+
onOpenChange={(open) => {
|
|
492
|
+
if (!open) setWarned(null);
|
|
493
|
+
}}
|
|
494
|
+
onConfirm={() => {
|
|
495
|
+
if (warned) onChoose(warned);
|
|
496
|
+
}}
|
|
497
|
+
labels={labelsProp}
|
|
498
|
+
/>
|
|
499
|
+
)}
|
|
455
500
|
</div>
|
|
456
501
|
);
|
|
457
502
|
}
|
|
503
|
+
|
|
504
|
+
export interface GrantWarningConfirmProps {
|
|
505
|
+
/** Shown, controlled. */
|
|
506
|
+
open: boolean;
|
|
507
|
+
/** `false` on "Back", Escape, the backdrop — and after "Buy anyway". */
|
|
508
|
+
onOpenChange: (open: boolean) => void;
|
|
509
|
+
/** "Buy anyway": the app opens its checkout. The dialog closes itself after it. */
|
|
510
|
+
onConfirm: () => void;
|
|
511
|
+
/** Prop > `<UiKitProvider labels={{ billing }}>` > English: `grantWarningTitle`,
|
|
512
|
+
* `grantWarningBody`, `grantWarningAction`. "Back" is `common.back`. */
|
|
513
|
+
labels?: LabelOverride<BillingLabels>;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* 0.42 (§16.8 B): the permanent-grant warning before a checkout — `grantWarningTitle`,
|
|
518
|
+
* `grantWarningBody`, "Back" and "Buy anyway" (`grantWarningAction`). {@link PlanPicker}
|
|
519
|
+
* shows it itself under `permanentGrant` when not `switching`. Exported for the path the
|
|
520
|
+
* picker cannot see: an app that meant to switch, learned there is no subscription to
|
|
521
|
+
* switch (keksdose: `/billing/plan/preview` → 409 `billing_not_subscribed`) and falls back
|
|
522
|
+
* to a checkout — show this before that checkout, so a permanent-grant holder is still
|
|
523
|
+
* warned. Carries `data-grant-warning`.
|
|
524
|
+
*/
|
|
525
|
+
export function GrantWarningConfirm({ open, onOpenChange, onConfirm, labels: labelsProp }: GrantWarningConfirmProps) {
|
|
526
|
+
const labels = useBillingLabels(labelsProp);
|
|
527
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
528
|
+
return (
|
|
529
|
+
<DialogFrame
|
|
530
|
+
open={open}
|
|
531
|
+
title={labels.grantWarningTitle}
|
|
532
|
+
description={labels.grantWarningBody}
|
|
533
|
+
onClose={() => onOpenChange(false)}
|
|
534
|
+
data-grant-warning=""
|
|
535
|
+
actions={(close) => (
|
|
536
|
+
<>
|
|
537
|
+
<Button variant="secondary" onClick={close}>
|
|
538
|
+
{common.back}
|
|
539
|
+
</Button>
|
|
540
|
+
<Button
|
|
541
|
+
variant="primary"
|
|
542
|
+
onClick={() => {
|
|
543
|
+
onConfirm();
|
|
544
|
+
onOpenChange(false);
|
|
545
|
+
}}
|
|
546
|
+
>
|
|
547
|
+
{labels.grantWarningAction}
|
|
548
|
+
</Button>
|
|
549
|
+
</>
|
|
550
|
+
)}
|
|
551
|
+
/>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
@@ -6,6 +6,7 @@ import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
|
|
|
6
6
|
import { pluralForm } from "../i18n/plurals";
|
|
7
7
|
import type { PluralForms } from "../i18n/plurals";
|
|
8
8
|
import { formatDate, formatMoney, formatNumber } from "../lib/format";
|
|
9
|
+
import { AlertBanner } from "../components/alert-banner";
|
|
9
10
|
import { Button } from "../components/button";
|
|
10
11
|
import { DialogFrame } from "../components/dialog-frame";
|
|
11
12
|
import { Spinner } from "../components/spinner";
|
|
@@ -67,6 +68,15 @@ export interface PlanSwitchConfirmProps {
|
|
|
67
68
|
onConfirm: () => void;
|
|
68
69
|
/** The figures' and the date's locale. Default: the provider's. */
|
|
69
70
|
locale?: string;
|
|
71
|
+
/**
|
|
72
|
+
* 0.42 (§16.2 decision 5, §16.8 B): the payer's free grant runs without an end —
|
|
73
|
+
* `isPermanentGrant(overview)`. The switch changes the PAID plan, not the applied one
|
|
74
|
+
* (§12.11), so the dialog says so above its figures (`grantWarningTitle`,
|
|
75
|
+
* `grantWarningBody`) before the payer agrees. Confirm stays Confirm: the warning is
|
|
76
|
+
* this dialog's own, and `PlanPicker` — told `switching` — shows none of its own on
|
|
77
|
+
* the way here, so the payer reads it once.
|
|
78
|
+
*/
|
|
79
|
+
permanentGrant?: boolean;
|
|
70
80
|
/** Prop > `<UiKitProvider labels={{ billing }}>` > English. */
|
|
71
81
|
labels?: LabelOverride<BillingLabels>;
|
|
72
82
|
}
|
|
@@ -105,6 +115,7 @@ export function PlanSwitchConfirm({
|
|
|
105
115
|
formatLimit,
|
|
106
116
|
onConfirm,
|
|
107
117
|
locale: localeProp,
|
|
118
|
+
permanentGrant = false,
|
|
108
119
|
labels: labelsProp,
|
|
109
120
|
}: PlanSwitchConfirmProps) {
|
|
110
121
|
const labels = useBillingLabels(labelsProp);
|
|
@@ -164,6 +175,11 @@ export function PlanSwitchConfirm({
|
|
|
164
175
|
</>
|
|
165
176
|
)}
|
|
166
177
|
>
|
|
178
|
+
{permanentGrant && (
|
|
179
|
+
<AlertBanner tone="warning" size="sm" data-grant-warning="">
|
|
180
|
+
<strong className="block font-medium">{labels.grantWarningTitle}</strong> {labels.grantWarningBody}
|
|
181
|
+
</AlertBanner>
|
|
182
|
+
)}
|
|
167
183
|
{preview === undefined ? (
|
|
168
184
|
<div className="flex justify-center py-4">
|
|
169
185
|
<Spinner showLabel />
|
|
@@ -5,7 +5,7 @@ import { cn } from "../lib/cn";
|
|
|
5
5
|
import { Button } from "../components/button";
|
|
6
6
|
import { useBillingLabels } from "./billing-labels";
|
|
7
7
|
import type { BillingLabels } from "./billing-labels";
|
|
8
|
-
import type { PortalTarget } from "./billing-overview";
|
|
8
|
+
import type { BillingPortalAccess, PortalTarget } from "./billing-overview";
|
|
9
9
|
|
|
10
10
|
export interface SubscriptionActionsProps extends Omit<ComponentPropsWithRef<"div">, "children"> {
|
|
11
11
|
/** "Payment and invoices", as a link — or {@link onManage}. Without either, none. */
|
|
@@ -35,8 +35,35 @@ export interface SubscriptionActionsProps extends Omit<ComponentPropsWithRef<"di
|
|
|
35
35
|
* (§12.26). Default true (0.32's behaviour).
|
|
36
36
|
*/
|
|
37
37
|
atProvider?: boolean;
|
|
38
|
-
/**
|
|
39
|
-
|
|
38
|
+
/**
|
|
39
|
+
* 0.42 (§16.2, server-kit 0.14): the overview's `portal`. `"buyer_only"` — the row has
|
|
40
|
+
* a buyer and the viewer isn't them, so the portal would answer `403
|
|
41
|
+
* billing_portal_buyer_only`: "Payment and invoices" becomes a caption naming the buyer
|
|
42
|
+
* (`buyerHolds`, no button), and "Cancel subscription" calls {@link onCancelInApp}
|
|
43
|
+
* (no portal at all — `cancelHref`, `onCancel` and `onPortal` are not used). `"open"`
|
|
44
|
+
* (default): as before.
|
|
45
|
+
*/
|
|
46
|
+
portal?: BillingPortalAccess;
|
|
47
|
+
/** 0.42: the overview's `buyer_name`, for the caption under `"buyer_only"`. Empty: "…with
|
|
48
|
+
* the admin who bought the subscription". */
|
|
49
|
+
buyerName?: string | null;
|
|
50
|
+
/**
|
|
51
|
+
* 0.42 (§16.2 decision 2): "Cancel subscription" in the app — open
|
|
52
|
+
* `CancelSubscriptionConfirm`, whose Confirm is `POST /billing/cancel`. The cancel under
|
|
53
|
+
* `"buyer_only"`. Under `"open"` it is the last resort, used only when no portal cancel
|
|
54
|
+
* (`cancelHref`, `onCancel`, `onPortal`) is given — a person-payer app that mounts the
|
|
55
|
+
* route and not the portal's cancel.
|
|
56
|
+
*/
|
|
57
|
+
onCancelInApp?: () => void;
|
|
58
|
+
/** 0.42 (§16.7 C): the overview's `cancel_at_period_end` — a cancel is scheduled. With
|
|
59
|
+
* {@link onResume}, "Keep subscription" takes the cancel's place. */
|
|
60
|
+
cancelAtPeriodEnd?: boolean;
|
|
61
|
+
/** 0.42 (§16.7 C): "Keep subscription", the app's `POST /billing/resume` — shown in
|
|
62
|
+
* the cancel's place while {@link cancelAtPeriodEnd} is true. Without it, the cancel
|
|
63
|
+
* stays as before. */
|
|
64
|
+
onResume?: () => void;
|
|
65
|
+
/** Which one is under way: its spinner, no second press. `"resume"` since 0.42. */
|
|
66
|
+
pending?: "manage" | "cancel" | "resume" | null;
|
|
40
67
|
/** Prop > `<UiKitProvider labels={{ billing }}>` > English. */
|
|
41
68
|
labels?: LabelOverride<BillingLabels>;
|
|
42
69
|
}
|
|
@@ -56,6 +83,12 @@ export interface SubscriptionActionsProps extends Omit<ComponentPropsWithRef<"di
|
|
|
56
83
|
* Before the payer has reached the provider (`atProvider={false}`, a trial without a
|
|
57
84
|
* card, a beta) there is no portal to open: the part says so in a caption instead of
|
|
58
85
|
* offering two buttons that could only end in `409 billing_not_at_provider`.
|
|
86
|
+
*
|
|
87
|
+
* 0.42 (§16): where the company's buyer is another admin (`portal="buyer_only"`), the
|
|
88
|
+
* portal stays the buyer's — "Payment and invoices" is a caption that names them, and
|
|
89
|
+
* "Cancel subscription" is the in-app cancel (`onCancelInApp`). While a cancel is
|
|
90
|
+
* scheduled (`cancelAtPeriodEnd`) and `onResume` is given, "Keep subscription" stands in
|
|
91
|
+
* the cancel's place, whoever the viewer is.
|
|
59
92
|
*/
|
|
60
93
|
export function SubscriptionActions({
|
|
61
94
|
manageHref,
|
|
@@ -64,6 +97,11 @@ export function SubscriptionActions({
|
|
|
64
97
|
onCancel,
|
|
65
98
|
onPortal,
|
|
66
99
|
atProvider = true,
|
|
100
|
+
portal = "open",
|
|
101
|
+
buyerName,
|
|
102
|
+
onCancelInApp,
|
|
103
|
+
cancelAtPeriodEnd = false,
|
|
104
|
+
onResume,
|
|
67
105
|
pending,
|
|
68
106
|
labels: labelsProp,
|
|
69
107
|
className,
|
|
@@ -77,20 +115,31 @@ export function SubscriptionActions({
|
|
|
77
115
|
</div>
|
|
78
116
|
);
|
|
79
117
|
}
|
|
118
|
+
const buyerOnly = portal === "buyer_only";
|
|
80
119
|
const manageCall = onManage ?? (onPortal ? () => onPortal("overview") : undefined);
|
|
81
|
-
const
|
|
82
|
-
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
91
|
-
|
|
120
|
+
const portalCancel = onCancel ?? (onPortal ? () => onPortal("cancel") : undefined);
|
|
121
|
+
// Under "buyer_only" the in-app cancel only; under "open" the portal's first, the
|
|
122
|
+
// in-app cancel only where no way to the portal's is given.
|
|
123
|
+
const cancelCall = buyerOnly ? onCancelInApp : (portalCancel ?? (cancelHref === undefined ? onCancelInApp : undefined));
|
|
124
|
+
const manage = buyerOnly ? (
|
|
125
|
+
<p className="basis-full text-sm text-muted" data-buyer-holds="">
|
|
126
|
+
{labels.buyerHolds(buyerName || undefined)}
|
|
127
|
+
</p>
|
|
128
|
+
) : manageHref !== undefined ? (
|
|
129
|
+
<Button href={manageHref} variant="secondary">
|
|
130
|
+
{labels.manage}
|
|
131
|
+
</Button>
|
|
132
|
+
) : manageCall !== undefined ? (
|
|
133
|
+
<Button type="button" variant="secondary" onClick={manageCall} pending={pending === "manage"}>
|
|
134
|
+
{labels.manage}
|
|
135
|
+
</Button>
|
|
136
|
+
) : null;
|
|
92
137
|
const cancel =
|
|
93
|
-
|
|
138
|
+
cancelAtPeriodEnd && onResume !== undefined ? (
|
|
139
|
+
<Button type="button" variant="link" onClick={onResume} pending={pending === "resume"} data-keep-subscription="">
|
|
140
|
+
{labels.keepSubscription}
|
|
141
|
+
</Button>
|
|
142
|
+
) : cancelHref !== undefined && !buyerOnly ? (
|
|
94
143
|
<Button href={cancelHref} variant="link" tone="danger">
|
|
95
144
|
{labels.cancel}
|
|
96
145
|
</Button>
|
|
@@ -6,7 +6,7 @@ import type { DateInput, FormatDateStyle, FormatRelativeTimeOptions } from "../l
|
|
|
6
6
|
import { useKitDateFormatter, useKitLabels, useKitLocale, type LabelOverride } from "../i18n/kit-labels";
|
|
7
7
|
import { toLocalIso } from "../lib/dates";
|
|
8
8
|
import { Chip } from "./chip";
|
|
9
|
-
import type { ChipShape, ChipSize, ChipTone, ChipVariant } from "./chip";
|
|
9
|
+
import type { ChipLinkProps, ChipShape, ChipSize, ChipTone, ChipVariant } from "./chip";
|
|
10
10
|
import type { DataTableColumn } from "./data-table";
|
|
11
11
|
import { DEFAULT_ACCOUNT_STATE_LABELS } from "./account-chips-labels";
|
|
12
12
|
|
|
@@ -49,6 +49,20 @@ export interface RoleDefinition {
|
|
|
49
49
|
tone?: ChipTone;
|
|
50
50
|
/** A lucide component or an element — Kurvenschmiede marks its admins with a shield. */
|
|
51
51
|
icon?: LucideIcon | ReactElement;
|
|
52
|
+
/**
|
|
53
|
+
* 0.42.0 (docs/admin-harmonization.md §2.1, settings §12.3): the role's tool — an
|
|
54
|
+
* admin's `/admin`, a reviewer's `/translations`. {@link AccountRoleChip} is a link to
|
|
55
|
+
* it; a role without one gets a plain chip. {@link RoleChip} ignores it: a roster's
|
|
56
|
+
* chips name a role, they are not a way in.
|
|
57
|
+
*/
|
|
58
|
+
href?: string;
|
|
59
|
+
/**
|
|
60
|
+
* 0.42.0 (keksdose build review): this role's look in {@link AccountRoleChip} — keksdose
|
|
61
|
+
* draws only ADMIN `solid` and its REVIEWER and MEMBER `soft`. Default `solid`, the
|
|
62
|
+
* header's look; the chip's own `variant` prop wins over it. {@link RoleChip} ignores
|
|
63
|
+
* it: a roster's column keeps one look, set on the column.
|
|
64
|
+
*/
|
|
65
|
+
variant?: ChipVariant;
|
|
52
66
|
}
|
|
53
67
|
|
|
54
68
|
/**
|
|
@@ -107,6 +121,72 @@ export function RoleChip<R extends string = string>({
|
|
|
107
121
|
);
|
|
108
122
|
}
|
|
109
123
|
|
|
124
|
+
/* ── AccountRoleChip ───────────────────────────────────────────────────────── */
|
|
125
|
+
|
|
126
|
+
export interface AccountRoleChipProps<R extends string = string> extends RoleChipProps<R> {
|
|
127
|
+
/**
|
|
128
|
+
* Runs on the way to the role's tool — the account menu's `close`, so the panel does
|
|
129
|
+
* not stay open over the page it just navigated to (keksdose F7). Only called on a
|
|
130
|
+
* link chip.
|
|
131
|
+
*/
|
|
132
|
+
onNavigate?: () => void;
|
|
133
|
+
/** Your router's link for the role's `href`. Default: the `<UiKitProvider
|
|
134
|
+
* linkComponent>`, then `<a>` — {@link Chip}'s `renderLink`. */
|
|
135
|
+
renderLink?: (props: ChipLinkProps) => ReactElement;
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
/**
|
|
139
|
+
* The role in the account menu's header (0.42.0, docs/admin-harmonization.md §2.1, §7):
|
|
140
|
+
* {@link RoleChip} in keksdose's look — `solid`, `sm`, `square`, `caps`, the role's icon
|
|
141
|
+
* and tone — made for `TopBarActionMenu`'s `header.extra`:
|
|
142
|
+
*
|
|
143
|
+
* extra: (close) => <AccountRoleChip value={me.role} roles={ROLES} onNavigate={close} />
|
|
144
|
+
*
|
|
145
|
+
* A role whose {@link RoleDefinition.href} is set is a shortcut to its tool: the chip is a
|
|
146
|
+
* link (through `renderLink`, else the provider's link, as {@link Chip} draws links) and
|
|
147
|
+
* calls `onNavigate` on the click. Any other role is a plain chip — every account shows
|
|
148
|
+
* its role (§7, "Always a badge"). The words and tones are the app's vocabulary; the kit
|
|
149
|
+
* adds none. `solid` stays the default, so a role the app gives no look still reads as
|
|
150
|
+
* the header's tag; a role that should sit quieter (keksdose's MEMBER and REVIEWER) sets
|
|
151
|
+
* {@link RoleDefinition.variant} `soft` in the vocabulary. Carries `data-role` with the key.
|
|
152
|
+
*/
|
|
153
|
+
export function AccountRoleChip<R extends string = string>({
|
|
154
|
+
value: role,
|
|
155
|
+
roles,
|
|
156
|
+
onNavigate,
|
|
157
|
+
renderLink,
|
|
158
|
+
size = "sm",
|
|
159
|
+
variant,
|
|
160
|
+
shape = "square",
|
|
161
|
+
caps = true,
|
|
162
|
+
className,
|
|
163
|
+
...rest
|
|
164
|
+
}: AccountRoleChipProps<R>) {
|
|
165
|
+
if (role === null || role === undefined || role === "") return null;
|
|
166
|
+
const def = roles[role];
|
|
167
|
+
const look = {
|
|
168
|
+
...rest,
|
|
169
|
+
"data-role": role,
|
|
170
|
+
tone: def?.tone ?? "neutral",
|
|
171
|
+
icon: def?.icon,
|
|
172
|
+
size,
|
|
173
|
+
// The prop, then the role's own look, then the header's `solid`.
|
|
174
|
+
variant: variant ?? def?.variant ?? "solid",
|
|
175
|
+
shape,
|
|
176
|
+
caps,
|
|
177
|
+
className,
|
|
178
|
+
} as const;
|
|
179
|
+
const label = def?.label ?? role;
|
|
180
|
+
if (def?.href) {
|
|
181
|
+
return (
|
|
182
|
+
<Chip {...look} href={def.href} renderLink={renderLink} onClick={onNavigate ? () => onNavigate() : undefined}>
|
|
183
|
+
{label}
|
|
184
|
+
</Chip>
|
|
185
|
+
);
|
|
186
|
+
}
|
|
187
|
+
return <Chip {...look}>{label}</Chip>;
|
|
188
|
+
}
|
|
189
|
+
|
|
110
190
|
/* ── AccountStateChip ──────────────────────────────────────────────────────── */
|
|
111
191
|
|
|
112
192
|
/**
|
|
@@ -17,8 +17,9 @@ export interface EmptyStateProps extends Omit<ComponentPropsWithRef<"div">, "chi
|
|
|
17
17
|
* keksdose's error boundary IS the page when it trips, and its `<h2>` went missing
|
|
18
18
|
* when it moved onto EmptyState — so the page's heading outline lost the one line
|
|
19
19
|
* that says what happened. Only the element changes; the look is the box's.
|
|
20
|
+
* `h1` (0.42.0) for an empty state that is the whole page — `AdminForbidden`.
|
|
20
21
|
*/
|
|
21
|
-
headingAs?: "h2" | "h3" | "h4" | "h5" | "h6";
|
|
22
|
+
headingAs?: "h1" | "h2" | "h3" | "h4" | "h5" | "h6";
|
|
22
23
|
/** A glyph ABOVE the title — kastlan's InboxEmptyState (an inbox), keksdose's
|
|
23
24
|
* offline card (a cloud with a slash). Sized by the box (`[&_svg]:size-8`) and
|
|
24
25
|
* muted, so five call sites cannot pick five sizes; hidden from assistive tech,
|
|
@@ -73,8 +73,8 @@ const PILL_DISABLED = "cursor-not-allowed opacity-50 hover:bg-transparent hover:
|
|
|
73
73
|
* keksdose's swipe-settings card (swipe-settings-card:152) hand-rolled this: four
|
|
74
74
|
* surface names that do not fit a 406px phone, and a sideways scroll would hide the
|
|
75
75
|
* ones that do not fit behind a gesture nothing announces (live #332: *"Have the tabs
|
|
76
|
-
* in more rows, rather than scrolling sideways"*). It is not {@link Tabs}
|
|
77
|
-
* because its items are not tabs: a `role="tab"` promises a tabpanel, a roving tab
|
|
76
|
+
* in more rows, rather than scrolling sideways"*). It is not {@link Tabs} (which
|
|
77
|
+
* scrolls; its multirow `wrap` went in 0.41) because its items are not tabs: a `role="tab"` promises a tabpanel, a roving tab
|
|
78
78
|
* stop and arrow keys, and a set of links to pages (or of buttons that switch the rest
|
|
79
79
|
* of a card) promises none of that. So every pill is its own tab stop, the set is a
|
|
80
80
|
* list (a screen reader hears "list, 4 items"), and "current" is `aria-current` — the
|