@eifi1/ui-kit 0.41.0 → 0.43.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 (190) hide show
  1. package/README.md +26 -7
  2. package/dist/admin/admin-area-labels.d.ts +59 -0
  3. package/dist/admin/admin-area-labels.js +35 -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 +110 -0
  9. package/dist/admin/admin-overview.js +92 -0
  10. package/dist/admin/admin-overview.js.map +1 -0
  11. package/dist/admin/admin-sections.d.ts +94 -0
  12. package/dist/admin/admin-sections.js +81 -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 +33 -2
  18. package/dist/billing/billing-banner.js +29 -3
  19. package/dist/billing/billing-banner.js.map +1 -1
  20. package/dist/billing/billing-labels.d.ts +36 -0
  21. package/dist/billing/billing-labels.js +10 -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-request.d.ts +53 -0
  29. package/dist/billing/cancel-request.js +21 -0
  30. package/dist/billing/cancel-request.js.map +1 -0
  31. package/dist/billing/cancel-subscription.d.ts +46 -0
  32. package/dist/billing/cancel-subscription.js +57 -0
  33. package/dist/billing/cancel-subscription.js.map +1 -0
  34. package/dist/billing/plan-card.d.ts +45 -1
  35. package/dist/billing/plan-card.js +53 -2
  36. package/dist/billing/plan-card.js.map +1 -1
  37. package/dist/billing/plan-switch.d.ts +10 -1
  38. package/dist/billing/plan-switch.js +22 -13
  39. package/dist/billing/plan-switch.js.map +1 -1
  40. package/dist/billing/provider-page.d.ts +40 -0
  41. package/dist/billing/provider-page.js +33 -0
  42. package/dist/billing/provider-page.js.map +1 -0
  43. package/dist/billing/subscription-actions.d.ts +68 -4
  44. package/dist/billing/subscription-actions.js +65 -5
  45. package/dist/billing/subscription-actions.js.map +1 -1
  46. package/dist/components/account-chips.d.ts +50 -2
  47. package/dist/components/account-chips.js +33 -0
  48. package/dist/components/account-chips.js.map +1 -1
  49. package/dist/components/empty-state.d.ts +2 -1
  50. package/dist/components/empty-state.js.map +1 -1
  51. package/dist/eslint/index.js +1 -1
  52. package/dist/feedback/feedback-menu.d.ts +7 -4
  53. package/dist/feedback/feedback-menu.js +4 -2
  54. package/dist/feedback/feedback-menu.js.map +1 -1
  55. package/dist/hooks/use-search-param-state.js +4 -1
  56. package/dist/hooks/use-search-param-state.js.map +1 -1
  57. package/dist/i18n/defaults.js +7 -1
  58. package/dist/i18n/defaults.js.map +1 -1
  59. package/dist/i18n/german.js +113 -0
  60. package/dist/i18n/german.js.map +1 -1
  61. package/dist/i18n/kit-labels.d.ts +12 -0
  62. package/dist/i18n/kit-labels.js.map +1 -1
  63. package/dist/i18n/locales/en.js +142 -1
  64. package/dist/i18n/locales/en.js.map +3 -3
  65. package/dist/i18n/locales/es.js +110 -0
  66. package/dist/i18n/locales/es.js.map +1 -1
  67. package/dist/i18n/locales/fr.js +109 -0
  68. package/dist/i18n/locales/fr.js.map +1 -1
  69. package/dist/i18n/locales/hu.js +110 -0
  70. package/dist/i18n/locales/hu.js.map +1 -1
  71. package/dist/i18n/locales/it.js +110 -0
  72. package/dist/i18n/locales/it.js.map +1 -1
  73. package/dist/i18n/locales/zh.js +109 -0
  74. package/dist/i18n/locales/zh.js.map +1 -1
  75. package/dist/i18n/review.js +34 -1
  76. package/dist/i18n/review.js.map +1 -1
  77. package/dist/index.d.ts +24 -2
  78. package/dist/index.js +29 -1
  79. package/dist/index.js.map +1 -1
  80. package/dist/search/global-search.d.ts +3 -0
  81. package/dist/search/global-search.js +3 -1
  82. package/dist/search/global-search.js.map +1 -1
  83. package/dist/settings/settings-catalogue.d.ts +9 -1
  84. package/dist/settings/settings-catalogue.js +1 -1
  85. package/dist/settings/settings-catalogue.js.map +1 -1
  86. package/dist/settings/settings-layout.d.ts +7 -1
  87. package/dist/settings/settings-layout.js +35 -27
  88. package/dist/settings/settings-layout.js.map +1 -1
  89. package/dist/shell/account-menu-language.d.ts +46 -0
  90. package/dist/shell/account-menu-language.js +64 -0
  91. package/dist/shell/account-menu-language.js.map +1 -0
  92. package/dist/shell/admin-menu.d.ts +69 -0
  93. package/dist/shell/admin-menu.js +64 -0
  94. package/dist/shell/admin-menu.js.map +1 -0
  95. package/dist/shell/app-shell.d.ts +10 -0
  96. package/dist/shell/app-shell.js +22 -4
  97. package/dist/shell/app-shell.js.map +1 -1
  98. package/dist/shell/dev-switchers.d.ts +53 -0
  99. package/dist/shell/dev-switchers.js +107 -0
  100. package/dist/shell/dev-switchers.js.map +1 -0
  101. package/dist/shell/sync-status-indicator.d.ts +86 -0
  102. package/dist/shell/sync-status-indicator.js +170 -0
  103. package/dist/shell/sync-status-indicator.js.map +1 -0
  104. package/dist/shell/top-bar-actions.d.ts +49 -0
  105. package/dist/shell/top-bar-actions.js +31 -0
  106. package/dist/shell/top-bar-actions.js.map +1 -0
  107. package/dist/shell/top-bar-parts-labels.d.ts +90 -0
  108. package/dist/shell/top-bar-parts-labels.js +51 -0
  109. package/dist/shell/top-bar-parts-labels.js.map +1 -0
  110. package/dist/shell/topbar-action-menu.d.ts +13 -1
  111. package/dist/shell/topbar-action-menu.js +5 -2
  112. package/dist/shell/topbar-action-menu.js.map +1 -1
  113. package/dist/shell/topbar-controls.d.ts +16 -3
  114. package/dist/shell/topbar-controls.js +5 -1
  115. package/dist/shell/topbar-controls.js.map +1 -1
  116. package/dist/shell/tour-menu.d.ts +67 -0
  117. package/dist/shell/tour-menu.js +87 -0
  118. package/dist/shell/tour-menu.js.map +1 -0
  119. package/dist/shell.d.ts +16 -0
  120. package/dist/shell.js +21 -0
  121. package/dist/shell.js.map +1 -1
  122. package/dist/tour/tour-catalogue-labels.d.ts +44 -0
  123. package/dist/tour/tour-catalogue-labels.js +32 -0
  124. package/dist/tour/tour-catalogue-labels.js.map +1 -0
  125. package/dist/tour/tour-catalogue.d.ts +68 -0
  126. package/dist/tour/tour-catalogue.js +189 -0
  127. package/dist/tour/tour-catalogue.js.map +1 -0
  128. package/dist/tour/tour-info.d.ts +112 -0
  129. package/dist/tour/tour-info.js +36 -0
  130. package/dist/tour/tour-info.js.map +1 -0
  131. package/dist/tour/use-tour-progress.d.ts +61 -0
  132. package/dist/tour/use-tour-progress.js +116 -0
  133. package/dist/tour/use-tour-progress.js.map +1 -0
  134. package/dist/tour/use-tour-url-sync.d.ts +58 -0
  135. package/dist/tour/use-tour-url-sync.js +110 -0
  136. package/dist/tour/use-tour-url-sync.js.map +1 -0
  137. package/dist/tour.d.ts +8 -0
  138. package/dist/tour.js +18 -0
  139. package/dist/tour.js.map +1 -1
  140. package/package.json +1 -1
  141. package/src/admin/admin-area-labels.ts +91 -0
  142. package/src/admin/admin-forbidden.tsx +75 -0
  143. package/src/admin/admin-overview.tsx +217 -0
  144. package/src/admin/admin-sections.tsx +198 -0
  145. package/src/admin/admin-tool-links.tsx +84 -0
  146. package/src/billing/billing-banner.tsx +57 -6
  147. package/src/billing/billing-labels.ts +59 -0
  148. package/src/billing/billing-overview.ts +37 -0
  149. package/src/billing/billing-standing.ts +8 -0
  150. package/src/billing/cancel-request.ts +75 -0
  151. package/src/billing/cancel-subscription.tsx +102 -0
  152. package/src/billing/plan-card.tsx +97 -1
  153. package/src/billing/plan-switch.tsx +16 -0
  154. package/src/billing/provider-page.ts +75 -0
  155. package/src/billing/subscription-actions.tsx +155 -20
  156. package/src/components/account-chips.tsx +89 -2
  157. package/src/components/empty-state.tsx +2 -1
  158. package/src/feedback/feedback-menu.tsx +8 -3
  159. package/src/hooks/use-search-param-state.ts +6 -1
  160. package/src/i18n/defaults.ts +6 -0
  161. package/src/i18n/german.ts +127 -0
  162. package/src/i18n/kit-labels.tsx +12 -0
  163. package/src/i18n/locales/en.ts +32 -0
  164. package/src/i18n/locales/es.ts +121 -0
  165. package/src/i18n/locales/fr.ts +121 -0
  166. package/src/i18n/locales/hu.ts +119 -0
  167. package/src/i18n/locales/it.ts +122 -0
  168. package/src/i18n/locales/zh.ts +114 -0
  169. package/src/i18n/review.ts +33 -0
  170. package/src/index.ts +38 -2
  171. package/src/search/global-search.tsx +5 -0
  172. package/src/settings/settings-catalogue.ts +10 -2
  173. package/src/settings/settings-layout.tsx +58 -34
  174. package/src/shell/account-menu-language.tsx +116 -0
  175. package/src/shell/admin-menu.tsx +146 -0
  176. package/src/shell/app-shell.tsx +44 -3
  177. package/src/shell/dev-switchers.tsx +179 -0
  178. package/src/shell/sync-status-indicator.tsx +301 -0
  179. package/src/shell/top-bar-actions.tsx +79 -0
  180. package/src/shell/top-bar-parts-labels.ts +142 -0
  181. package/src/shell/topbar-action-menu.tsx +15 -2
  182. package/src/shell/topbar-controls.tsx +20 -3
  183. package/src/shell/tour-menu.tsx +179 -0
  184. package/src/shell.ts +27 -0
  185. package/src/tour/tour-catalogue-labels.ts +75 -0
  186. package/src/tour/tour-catalogue.tsx +316 -0
  187. package/src/tour/tour-info.ts +144 -0
  188. package/src/tour/use-tour-progress.ts +198 -0
  189. package/src/tour/use-tour-url-sync.ts +221 -0
  190. package/src/tour.ts +11 -0
@@ -108,6 +108,13 @@ export interface BillingLabels {
108
108
  choosePlan: string;
109
109
  /** The payment-failed banner's action, to the provider's portal: "Update payment method". */
110
110
  updatePayment: string;
111
+ /**
112
+ * 0.42 (§16.7 A, §16.8): the payment-failed banner for an admin who isn't the buyer
113
+ * (`portal: "buyer_only"`), with no button — only the buyer can change the card.
114
+ * `buyer` is their display name, or `undefined` when they are gone ("the admin who
115
+ * bought the subscription").
116
+ */
117
+ paymentFailedAskBuyer: (buyer?: string) => string;
111
118
  /** The processing banner's action, when the app gives one: "Check again". */
112
119
  checkAgain: string;
113
120
 
@@ -153,6 +160,30 @@ export interface BillingLabels {
153
160
  */
154
161
  notAtProvider: string;
155
162
 
163
+ /* ── The portal for the buyer only, the in-app cancel (0.42, §16) ── */
164
+ /**
165
+ * In place of "Payment and invoices" for an admin who isn't the buyer (`portal:
166
+ * "buyer_only"`, §16.2 decision 3): the provider's portal opens only for the buyer.
167
+ * `buyer` is their display name — or `undefined` when they are gone or have left the
168
+ * company, and the words then say "the admin who bought the subscription".
169
+ */
170
+ buyerHolds: (buyer?: string) => string;
171
+ /** `CancelSubscriptionConfirm`'s title: "Cancel subscription". */
172
+ cancelConfirmTitle: string;
173
+ /** Its body; `date` is the period's end, formatted: "Your subscription ends on
174
+ * 9 Nov 2026. Until then everything works as before." */
175
+ cancelConfirmBody: (date: string) => string;
176
+ /** Its confirm button (§16.4): "Cancel subscription now". German: "Jetzt kündigen",
177
+ * the confirmation button's words in §312k BGB. */
178
+ cancelConfirmAction: string;
179
+ /** In place of "Cancel subscription" while a cancel is scheduled (§16.7 C, `POST
180
+ * /billing/resume`): "Keep subscription". */
181
+ keepSubscription: string;
182
+ /** 0.43 (§17.3): the muted note under the actions while the portal opens in a new tab —
183
+ * `provider` the provider's name ("Paddle"), `app` the app's; without the app, "this
184
+ * page". "Payment and invoices open at Paddle in a new tab; Kastlan stays open here." */
185
+ portalOpensNewTab: (provider: string, app?: string) => string;
186
+
156
187
  /* ── The pay page (§14.4) ── */
157
188
  /* Built into the static page `dist/pay/` at the kit's build: it has no React and no
158
189
  provider, so an app's override never reaches it. */
@@ -184,6 +215,15 @@ export interface BillingLabels {
184
215
  /** The confirm button: "Change plan". */
185
216
  switchConfirm: string;
186
217
 
218
+ /* ── The permanent-grant warning (0.42, §16.2 decision 5, §16.8 B) ── */
219
+ /** The warning's title, before a checkout (`PlanPicker`) or a switch
220
+ * (`PlanSwitchConfirm`) under a grant without an end: "Your free access continues". */
221
+ grantWarningTitle: string;
222
+ /** What the purchase does under it: nothing to the plan while the grant runs. */
223
+ grantWarningBody: string;
224
+ /** `PlanPicker`'s button that goes on to the checkout: "Buy anyway". */
225
+ grantWarningAction: string;
226
+
187
227
  /* ── Offline (§12.5) ── */
188
228
  /** Changes refused as `billing_read_only` inside a sync reply, kept queued and sent
189
229
  * after payment: "3 changes waiting for a plan". */
@@ -271,6 +311,10 @@ export const DEFAULT_BILLING_LABELS: BillingLabels = {
271
311
  processing: "Your payment is being processed. Your plan starts as soon as it’s confirmed; this page updates by itself.",
272
312
  choosePlan: "Choose a plan",
273
313
  updatePayment: "Update payment method",
314
+ paymentFailedAskBuyer: (buyer) =>
315
+ buyer
316
+ ? `The payment failed. ${buyer} can update the payment method.`
317
+ : "The payment failed. The admin who bought the subscription can update the payment method.",
274
318
  checkAgain: "Check again",
275
319
 
276
320
  lockReason: "Your plan has ended. Choose a plan to make changes again.",
@@ -286,6 +330,16 @@ export const DEFAULT_BILLING_LABELS: BillingLabels = {
286
330
  manage: "Payment and invoices",
287
331
  cancel: "Cancel subscription",
288
332
  notAtProvider: "Nothing to manage yet. Payments and invoices appear here once you have a paid plan.",
333
+ buyerHolds: (buyer) =>
334
+ buyer
335
+ ? `The payment method and the invoices are with ${buyer}, who bought the subscription.`
336
+ : "The payment method and the invoices are with the admin who bought the subscription.",
337
+ cancelConfirmTitle: "Cancel subscription",
338
+ cancelConfirmBody: (date) => `Your subscription ends on ${date}. Until then everything works as before.`,
339
+ cancelConfirmAction: "Cancel subscription now",
340
+ keepSubscription: "Keep subscription",
341
+ portalOpensNewTab: (provider, app) =>
342
+ `Payment and invoices open at ${provider} in a new tab; ${app ? `${app} stays open here` : "this page stays open"}.`,
289
343
 
290
344
  payOpening: "Opening the secure checkout…",
291
345
  payNothing: "There’s no payment to make here. To choose a plan, go back to the app.",
@@ -300,6 +354,11 @@ export const DEFAULT_BILLING_LABELS: BillingLabels = {
300
354
  switchNextCharge: (date) => `Next charge on ${date}`,
301
355
  switchConfirm: "Change plan",
302
356
 
357
+ grantWarningTitle: "Your free access continues",
358
+ grantWarningBody:
359
+ "You have free access with no end date. A purchase won’t change your plan while the free access runs: you would pay for a plan that doesn’t apply.",
360
+ grantWarningAction: "Buy anyway",
361
+
303
362
  waitingChanges: (count) =>
304
363
  count === 1 ? "1 change waiting for a plan" : `${count} changes waiting for a plan`,
305
364
  discardWaiting: "Discard waiting changes",
@@ -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,75 @@
1
+ import { useState } from "react";
2
+
3
+ /**
4
+ * What the payer asked for in the app (0.43, kastlan `18bf4c0`): `"cancel"` after
5
+ * `POST /billing/cancel` answered 204, `"resume"` after `POST /billing/resume` — while the
6
+ * overview still shows the old `cancel_at_period_end`, until the provider's
7
+ * `subscription.updated` has landed (§16.3). `null`: nothing asked, or the row has moved.
8
+ */
9
+ export type CancelRequested = "cancel" | "resume" | null;
10
+
11
+ /** What the page shows for the row as read now — `CancelRequest.view(rowScheduled)`. */
12
+ export interface CancelRequestView {
13
+ /** For `SubscriptionActions`' `cancelRequested`. */
14
+ requested: CancelRequested;
15
+ /** What the plan line says — the asked-for state until the row moves, then the row's:
16
+ * "Cancels on …" at once after a cancel, "Renews on …" after "Keep subscription". */
17
+ cancelScheduled: boolean;
18
+ /** Call on the 204 of a cancel or of a resume. A cancel and its undo both asked before
19
+ * the row moved cancel out. */
20
+ noteAsked: () => void;
21
+ }
22
+
23
+ export interface CancelRequest {
24
+ /** Read the overview again every few seconds while this is true — the overview query's
25
+ * `waiting` (kastlan's `useBillingOverview({ waiting })`, a `refetchInterval` inside) —
26
+ * so the page follows the provider. Needs no overview data: pass it to the query. */
27
+ waiting: boolean;
28
+ /**
29
+ * The row as read now (the overview's `cancel_at_period_end`; `undefined` while it
30
+ * loads) → what the page shows. Call it during render, after the query: when the row
31
+ * has moved past what was asked, it drops the request (React's "adjust state while
32
+ * rendering" pattern), so `waiting` turns false and the query stops polling.
33
+ */
34
+ view: (rowScheduled: boolean | null | undefined) => CancelRequestView;
35
+ }
36
+
37
+ /**
38
+ * The in-app cancel's follow-up (0.43, kastlan's derivation in `18bf4c0`): after "Cancel
39
+ * subscription now" or "Keep subscription" the server answers 204 at once, but the row's
40
+ * `cancel_at_period_end` only changes when the provider's webhook lands. Until then the
41
+ * page shows what was asked for, and a second press would ask the provider again.
42
+ *
43
+ * The state lives in the hook and needs no overview at construction, so it goes BEFORE
44
+ * the overview query, which reads `waiting`, and the view comes after it — the overview
45
+ * read once (build review 0.43, kastlan B2; kastlan `subscription-section.tsx:74-92`):
46
+ *
47
+ * const request = useCancelRequest();
48
+ * const { data } = useBillingOverview({ waiting: request.waiting });
49
+ * const view = request.view(data?.cancel_at_period_end);
50
+ * <p>{standingLine({ ...data, cancel_at_period_end: view.cancelScheduled })}</p>
51
+ * <SubscriptionActions
52
+ * cancelAtPeriodEnd={data.cancel_at_period_end}
53
+ * cancelRequested={view.requested}
54
+ * onResume={() => resume.mutate(undefined, { onSuccess: view.noteAsked })}
55
+ * …
56
+ * />
57
+ */
58
+ export function useCancelRequest(): CancelRequest {
59
+ // What was asked, kept with the row's state it was asked against.
60
+ const [asked, setAsked] = useState<{ scheduled: boolean } | null>(null);
61
+ const view = (row: boolean | null | undefined): CancelRequestView => {
62
+ // Not read yet: nothing to show against, and nothing to drop.
63
+ if (row === undefined || row === null) return { requested: null, cancelScheduled: false, noteAsked: () => {} };
64
+ // The row has moved: it says the rest. Adjusted during render, React's pattern for
65
+ // state that follows a prop.
66
+ if (asked !== null && asked.scheduled !== row) setAsked(null);
67
+ const open = asked !== null && asked.scheduled === row;
68
+ return {
69
+ requested: open ? (row ? "resume" : "cancel") : null,
70
+ cancelScheduled: open ? !row : row,
71
+ noteAsked: () => setAsked((a) => (a !== null && a.scheduled === row ? null : { scheduled: row })),
72
+ };
73
+ };
74
+ return { waiting: asked !== null, view };
75
+ }
@@ -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 />
@@ -0,0 +1,75 @@
1
+ /**
2
+ * The provider's hosted portal in a NEW TAB (0.43, docs/billing-harmonization.md §17):
3
+ * "Payment and invoices", the portal's "Cancel subscription" and the payment-failed
4
+ * banner's "Update payment method" open at the provider while the app stays open where
5
+ * it was. kastlan's `usePortal` (feat/kit-0.42, `72c3177`) is the reference.
6
+ *
7
+ * The checkout is NOT one of them (§17.4): it stays in the same tab, since the page waits
8
+ * for the payer's return (`useCheckoutProcessing`).
9
+ */
10
+
11
+ /** How the page went: in a tab of its own, or — the browser refused one — in this one. */
12
+ export type ProviderPageWay = "new-tab" | "same-tab";
13
+
14
+ export interface OpenProviderPageOptions {
15
+ /** The window to open from and to fall back to. Default the global `window` (a test's
16
+ * or an iframe's own). */
17
+ window?: Window;
18
+ }
19
+
20
+ /**
21
+ * Opens the provider's page in a new tab, from INSIDE the click: call it in the click
22
+ * handler itself, before any `await` — a `window.open` after one is blocked as a pop-up.
23
+ *
24
+ * 1. opens `about:blank` synchronously and sets its `opener` to `null`, so the provider's
25
+ * page cannot reach back into the app;
26
+ * 2. awaits `fetchUrl` — the app's `POST /billing/portal` with its target, answering the
27
+ * `url` (the kit sends no request);
28
+ * 3. points the tab at the URL;
29
+ * 4. on a refusal closes the tab again and rethrows, so the app shows its error;
30
+ * 5. where the browser refused the tab (a pop-up blocker), or it was closed while the
31
+ * URL was on its way, the app's own page goes there instead.
32
+ *
33
+ * <SubscriptionActions
34
+ * onPortal={(target) => {
35
+ * openProviderPage(() => api.billingPortal({ target }).then((r) => r.url)).catch(showError);
36
+ * }}
37
+ * providerName="Paddle"
38
+ * appName="Kastlan"
39
+ * />
40
+ *
41
+ * Resolves with the way it went.
42
+ */
43
+ export async function openProviderPage(
44
+ fetchUrl: () => Promise<string>,
45
+ options: OpenProviderPageOptions = {},
46
+ ): Promise<ProviderPageWay> {
47
+ const win = options.window ?? window;
48
+ let tab: Window | null;
49
+ try {
50
+ tab = win.open("about:blank", "_blank");
51
+ } catch {
52
+ tab = null;
53
+ }
54
+ if (tab) {
55
+ try {
56
+ // The provider's page must not reach back into the app.
57
+ tab.opener = null;
58
+ } catch {
59
+ /* a browser that guards `opener`: the tab is still ours to point */
60
+ }
61
+ }
62
+ let url: string;
63
+ try {
64
+ url = await fetchUrl();
65
+ } catch (error) {
66
+ tab?.close();
67
+ throw error;
68
+ }
69
+ if (tab && !tab.closed) {
70
+ tab.location.href = url;
71
+ return "new-tab";
72
+ }
73
+ win.location.assign(url);
74
+ return "same-tab";
75
+ }