@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.
Files changed (159) hide show
  1. package/README.md +19 -6
  2. package/dist/admin/admin-area-labels.d.ts +55 -0
  3. package/dist/admin/admin-area-labels.js +33 -0
  4. package/dist/admin/admin-area-labels.js.map +1 -0
  5. package/dist/admin/admin-forbidden.d.ts +35 -0
  6. package/dist/admin/admin-forbidden.js +36 -0
  7. package/dist/admin/admin-forbidden.js.map +1 -0
  8. package/dist/admin/admin-overview.d.ts +93 -0
  9. package/dist/admin/admin-overview.js +69 -0
  10. package/dist/admin/admin-overview.js.map +1 -0
  11. package/dist/admin/admin-sections.d.ts +63 -0
  12. package/dist/admin/admin-sections.js +64 -0
  13. package/dist/admin/admin-sections.js.map +1 -0
  14. package/dist/admin/admin-tool-links.d.ts +48 -0
  15. package/dist/admin/admin-tool-links.js +31 -0
  16. package/dist/admin/admin-tool-links.js.map +1 -0
  17. package/dist/billing/billing-banner.d.ts +25 -2
  18. package/dist/billing/billing-banner.js +5 -2
  19. package/dist/billing/billing-banner.js.map +1 -1
  20. package/dist/billing/billing-labels.d.ts +32 -0
  21. package/dist/billing/billing-labels.js +9 -0
  22. package/dist/billing/billing-labels.js.map +1 -1
  23. package/dist/billing/billing-overview.d.ts +33 -0
  24. package/dist/billing/billing-overview.js +6 -0
  25. package/dist/billing/billing-overview.js.map +1 -1
  26. package/dist/billing/billing-standing.d.ts +8 -0
  27. package/dist/billing/billing-standing.js.map +1 -1
  28. package/dist/billing/cancel-subscription.d.ts +46 -0
  29. package/dist/billing/cancel-subscription.js +57 -0
  30. package/dist/billing/cancel-subscription.js.map +1 -0
  31. package/dist/billing/plan-card.d.ts +45 -1
  32. package/dist/billing/plan-card.js +53 -2
  33. package/dist/billing/plan-card.js.map +1 -1
  34. package/dist/billing/plan-switch.d.ts +10 -1
  35. package/dist/billing/plan-switch.js +22 -13
  36. package/dist/billing/plan-switch.js.map +1 -1
  37. package/dist/billing/subscription-actions.d.ts +37 -4
  38. package/dist/billing/subscription-actions.js +10 -3
  39. package/dist/billing/subscription-actions.js.map +1 -1
  40. package/dist/components/account-chips.d.ts +42 -1
  41. package/dist/components/account-chips.js +33 -0
  42. package/dist/components/account-chips.js.map +1 -1
  43. package/dist/components/empty-state.d.ts +2 -1
  44. package/dist/components/empty-state.js.map +1 -1
  45. package/dist/components/nav-pills.d.ts +2 -2
  46. package/dist/components/nav-pills.js.map +1 -1
  47. package/dist/components/tabs.d.ts +4 -32
  48. package/dist/components/tabs.js +30 -66
  49. package/dist/components/tabs.js.map +1 -1
  50. package/dist/components/translation-review-parts.d.ts +2 -2
  51. package/dist/components/translation-review-parts.js.map +1 -1
  52. package/dist/eslint/index.js +1 -1
  53. package/dist/feedback/feedback-menu-labels.js +0 -2
  54. package/dist/feedback/feedback-menu-labels.js.map +1 -1
  55. package/dist/feedback/feedback-menu.d.ts +0 -6
  56. package/dist/feedback/feedback-menu.js.map +1 -1
  57. package/dist/i18n/defaults.js +5 -1
  58. package/dist/i18n/defaults.js.map +1 -1
  59. package/dist/i18n/german.js +79 -2
  60. package/dist/i18n/german.js.map +1 -1
  61. package/dist/i18n/kit-labels.d.ts +9 -0
  62. package/dist/i18n/kit-labels.js.map +1 -1
  63. package/dist/i18n/locales/en.js +93 -3
  64. package/dist/i18n/locales/en.js.map +3 -3
  65. package/dist/i18n/locales/es.js +76 -2
  66. package/dist/i18n/locales/es.js.map +1 -1
  67. package/dist/i18n/locales/fr.js +75 -2
  68. package/dist/i18n/locales/fr.js.map +1 -1
  69. package/dist/i18n/locales/hu.js +76 -2
  70. package/dist/i18n/locales/hu.js.map +1 -1
  71. package/dist/i18n/locales/it.js +76 -2
  72. package/dist/i18n/locales/it.js.map +1 -1
  73. package/dist/i18n/locales/zh.js +75 -2
  74. package/dist/i18n/locales/zh.js.map +1 -1
  75. package/dist/i18n/review.js +20 -1
  76. package/dist/i18n/review.js.map +1 -1
  77. package/dist/index.d.ts +18 -2
  78. package/dist/index.js +23 -1
  79. package/dist/index.js.map +1 -1
  80. package/dist/settings/settings-layout.d.ts +7 -1
  81. package/dist/settings/settings-layout.js +35 -27
  82. package/dist/settings/settings-layout.js.map +1 -1
  83. package/dist/shell/account-menu-language.d.ts +46 -0
  84. package/dist/shell/account-menu-language.js +64 -0
  85. package/dist/shell/account-menu-language.js.map +1 -0
  86. package/dist/shell/admin-menu.d.ts +46 -0
  87. package/dist/shell/admin-menu.js +60 -0
  88. package/dist/shell/admin-menu.js.map +1 -0
  89. package/dist/shell/app-shell.d.ts +10 -0
  90. package/dist/shell/app-shell.js +22 -4
  91. package/dist/shell/app-shell.js.map +1 -1
  92. package/dist/shell/dev-switchers.d.ts +53 -0
  93. package/dist/shell/dev-switchers.js +102 -0
  94. package/dist/shell/dev-switchers.js.map +1 -0
  95. package/dist/shell/sync-status-indicator.d.ts +86 -0
  96. package/dist/shell/sync-status-indicator.js +170 -0
  97. package/dist/shell/sync-status-indicator.js.map +1 -0
  98. package/dist/shell/top-bar-actions.d.ts +49 -0
  99. package/dist/shell/top-bar-actions.js +31 -0
  100. package/dist/shell/top-bar-actions.js.map +1 -0
  101. package/dist/shell/top-bar-parts-labels.d.ts +84 -0
  102. package/dist/shell/top-bar-parts-labels.js +48 -0
  103. package/dist/shell/top-bar-parts-labels.js.map +1 -0
  104. package/dist/shell/topbar-action-menu.d.ts +13 -1
  105. package/dist/shell/topbar-action-menu.js +5 -2
  106. package/dist/shell/topbar-action-menu.js.map +1 -1
  107. package/dist/shell/topbar-controls.d.ts +16 -3
  108. package/dist/shell/topbar-controls.js +5 -1
  109. package/dist/shell/topbar-controls.js.map +1 -1
  110. package/dist/shell/tour-menu.d.ts +58 -0
  111. package/dist/shell/tour-menu.js +73 -0
  112. package/dist/shell/tour-menu.js.map +1 -0
  113. package/dist/shell.d.ts +14 -0
  114. package/dist/shell.js +19 -0
  115. package/dist/shell.js.map +1 -1
  116. package/package.json +1 -1
  117. package/src/admin/admin-area-labels.ts +85 -0
  118. package/src/admin/admin-forbidden.tsx +75 -0
  119. package/src/admin/admin-overview.tsx +166 -0
  120. package/src/admin/admin-sections.tsx +137 -0
  121. package/src/admin/admin-tool-links.tsx +84 -0
  122. package/src/billing/billing-banner.tsx +32 -4
  123. package/src/billing/billing-labels.ts +53 -0
  124. package/src/billing/billing-overview.ts +37 -0
  125. package/src/billing/billing-standing.ts +8 -0
  126. package/src/billing/cancel-subscription.tsx +102 -0
  127. package/src/billing/plan-card.tsx +97 -1
  128. package/src/billing/plan-switch.tsx +16 -0
  129. package/src/billing/subscription-actions.tsx +64 -15
  130. package/src/components/account-chips.tsx +81 -1
  131. package/src/components/empty-state.tsx +2 -1
  132. package/src/components/nav-pills.tsx +2 -2
  133. package/src/components/tabs.tsx +19 -110
  134. package/src/components/translation-review-parts.tsx +2 -2
  135. package/src/feedback/feedback-menu-labels.ts +0 -2
  136. package/src/feedback/feedback-menu.tsx +0 -6
  137. package/src/i18n/defaults.ts +4 -0
  138. package/src/i18n/german.ts +92 -2
  139. package/src/i18n/kit-labels.tsx +9 -0
  140. package/src/i18n/locales/en.ts +19 -0
  141. package/src/i18n/locales/es.ts +86 -2
  142. package/src/i18n/locales/fr.ts +86 -2
  143. package/src/i18n/locales/hu.ts +84 -2
  144. package/src/i18n/locales/it.ts +87 -2
  145. package/src/i18n/locales/zh.ts +79 -2
  146. package/src/i18n/review.ts +19 -0
  147. package/src/index.ts +30 -2
  148. package/src/settings/settings-layout.tsx +58 -34
  149. package/src/shell/account-menu-language.tsx +116 -0
  150. package/src/shell/admin-menu.tsx +117 -0
  151. package/src/shell/app-shell.tsx +44 -3
  152. package/src/shell/dev-switchers.tsx +171 -0
  153. package/src/shell/sync-status-indicator.tsx +301 -0
  154. package/src/shell/top-bar-actions.tsx +79 -0
  155. package/src/shell/top-bar-parts-labels.ts +133 -0
  156. package/src/shell/topbar-action-menu.tsx +15 -2
  157. package/src/shell/topbar-controls.tsx +20 -3
  158. package/src/shell/tour-menu.tsx +144 -0
  159. 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={() => picked && onChoose({ plan: picked.code, interval, currency })}
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
- /** Which of the two is under way: its spinner, no second press. */
39
- pending?: "manage" | "cancel" | null;
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 cancelCall = onCancel ?? (onPortal ? () => onPortal("cancel") : undefined);
82
- const manage =
83
- manageHref !== undefined ? (
84
- <Button href={manageHref} variant="secondary">
85
- {labels.manage}
86
- </Button>
87
- ) : manageCall !== undefined ? (
88
- <Button type="button" variant="secondary" onClick={manageCall} pending={pending === "manage"}>
89
- {labels.manage}
90
- </Button>
91
- ) : null;
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
- cancelHref !== undefined ? (
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} with `wrap`
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