@eifi1/ui-kit 0.38.1 → 0.39.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 (57) hide show
  1. package/README.md +6 -5
  2. package/dist/auth/auth-errors.d.ts +8 -3
  3. package/dist/auth/auth-errors.js +3 -1
  4. package/dist/auth/auth-errors.js.map +1 -1
  5. package/dist/billing/billing-labels.d.ts +25 -2
  6. package/dist/billing/billing-labels.js +11 -2
  7. package/dist/billing/billing-labels.js.map +1 -1
  8. package/dist/billing/billing-overview.d.ts +50 -1
  9. package/dist/billing/checkout-processing.d.ts +15 -3
  10. package/dist/billing/checkout-processing.js +3 -1
  11. package/dist/billing/checkout-processing.js.map +1 -1
  12. package/dist/billing/pay-page.d.ts +55 -18
  13. package/dist/billing/pay-page.js +44 -10
  14. package/dist/billing/pay-page.js.map +1 -1
  15. package/dist/billing/plan-switch.d.ts +79 -0
  16. package/dist/billing/plan-switch.js +93 -0
  17. package/dist/billing/plan-switch.js.map +1 -0
  18. package/dist/bin/eifi1-pay-page.mjs +109 -21
  19. package/dist/eslint/index.js +1 -1
  20. package/dist/i18n/german.js +13 -2
  21. package/dist/i18n/german.js.map +1 -1
  22. package/dist/i18n/locales/en.js +14 -3
  23. package/dist/i18n/locales/en.js.map +2 -2
  24. package/dist/i18n/locales/es.js +13 -2
  25. package/dist/i18n/locales/es.js.map +1 -1
  26. package/dist/i18n/locales/fr.js +13 -2
  27. package/dist/i18n/locales/fr.js.map +1 -1
  28. package/dist/i18n/locales/hu.js +13 -2
  29. package/dist/i18n/locales/hu.js.map +1 -1
  30. package/dist/i18n/locales/it.js +13 -2
  31. package/dist/i18n/locales/it.js.map +1 -1
  32. package/dist/i18n/locales/zh.js +13 -2
  33. package/dist/i18n/locales/zh.js.map +1 -1
  34. package/dist/i18n/review.js +3 -0
  35. package/dist/i18n/review.js.map +1 -1
  36. package/dist/index.d.ts +6 -1
  37. package/dist/index.js +4 -0
  38. package/dist/index.js.map +1 -1
  39. package/dist/lib/translation-review.d.ts +11 -0
  40. package/dist/lib/translation-review.js.map +1 -1
  41. package/dist/pay/pay.js +102 -40
  42. package/package.json +1 -1
  43. package/src/auth/auth-errors.ts +12 -3
  44. package/src/billing/billing-labels.ts +38 -3
  45. package/src/billing/billing-overview.ts +53 -1
  46. package/src/billing/checkout-processing.ts +17 -3
  47. package/src/billing/pay-page.ts +111 -27
  48. package/src/billing/plan-switch.tsx +201 -0
  49. package/src/i18n/german.ts +13 -1
  50. package/src/i18n/locales/es.ts +12 -1
  51. package/src/i18n/locales/fr.ts +12 -2
  52. package/src/i18n/locales/hu.ts +12 -1
  53. package/src/i18n/locales/it.ts +12 -1
  54. package/src/i18n/locales/zh.ts +12 -1
  55. package/src/i18n/review.ts +3 -0
  56. package/src/index.ts +8 -0
  57. package/src/lib/translation-review.ts +12 -0
@@ -3,6 +3,7 @@ import { useCallback, useEffect, useEffectEvent, useLayoutEffect, useRef, useSyn
3
3
  import { toDate } from "../lib/format";
4
4
  import type { DateInput } from "../lib/format";
5
5
  import { readStored, removeStored, writeStored } from "../lib/safe-storage";
6
+ import type { PaidPrice } from "./billing-overview";
6
7
  import type { BillingStanding } from "./billing-standing";
7
8
 
8
9
  /**
@@ -43,6 +44,9 @@ export type CheckoutOverview = Pick<BillingStanding, "status" | "in_good_standin
43
44
  plan: string;
44
45
  source: string;
45
46
  current_period_end?: DateInput;
47
+ /** 0.39 (§15.2.10): what the payer pays — a plan switch moves only this under a grant,
48
+ * and keeps `active` without one. Left out or null: nothing paid. */
49
+ paid?: Pick<PaidPrice, "plan" | "interval"> | null;
46
50
  };
47
51
 
48
52
  /** How long a return keeps the page in "processing" before it trusts the overview. */
@@ -53,9 +57,12 @@ const POLL_EVERY = 4_000;
53
57
  const STORAGE_KEY = "eifi1-billing-checkout";
54
58
 
55
59
  /**
56
- * What of the overview a completed checkout changes, as one string: the plan (read
57
- * without case, §12.16), the status, the source, the standing and the period's end (read
58
- * as an instant, so `…Z` and `…+00:00` agree).
60
+ * What of the overview a completed checkout — or plan switch — changes, as one string:
61
+ * the plan (read without case, §12.16), the status, the source, the standing, the
62
+ * period's end (read as an instant, so `…Z` and `…+00:00` agree) and, since 0.39, the
63
+ * paid plan and period (`paid`, §15.2.10). A switch keeps `active`, and under a grant
64
+ * the plan too, so only `paid` shows it landed: one storage key serves the checkout and
65
+ * the switch.
59
66
  */
60
67
  export function checkoutFingerprint(o: CheckoutOverview): string {
61
68
  const end = toDate(o.current_period_end ?? null);
@@ -65,6 +72,8 @@ export function checkoutFingerprint(o: CheckoutOverview): string {
65
72
  o.source,
66
73
  String(o.in_good_standing),
67
74
  end ? end.toISOString() : "",
75
+ String(o.paid?.plan ?? "").toLowerCase(),
76
+ o.paid?.interval ?? "",
68
77
  ].join("|");
69
78
  }
70
79
 
@@ -247,6 +256,11 @@ function claimReturn(store: CheckoutStore): boolean {
247
256
  * `payer` is the user's or the company's id: a mark never speaks for another payer.
248
257
  * `overview` is what the page shows now; left out, the return waits for `active`. Pass
249
258
  * the same `storageKey` as the hook's.
259
+ *
260
+ * A plan switch (0.39, §15.2.10) is waited for the same way, under the same key: note
261
+ * the departure before `POST /billing/plan`, and once it answers put the return marker
262
+ * on the page's URL yourself (`checkoutReturnUrl`'s `?checkout=done`) — the row follows
263
+ * by webhook, and the fingerprint's `paid` shows it landed.
250
264
  */
251
265
  export function noteCheckoutStarted(
252
266
  payer: string | number,
@@ -1,21 +1,25 @@
1
1
  import type { UiKitLabels } from "../i18n/kit-labels";
2
- import { checkoutReturnUrl } from "./checkout-return";
3
2
 
4
3
  /**
5
- * The kit's pay page (docs/billing-harmonization.md §14.4, decision 19 as amended): a
6
- * standalone static page that ui-kit ships as `dist/pay/`, and that each app serves on
7
- * its own pay host, `pay.<app domain>` — a separate origin, approved in Paddle, where
8
- * Paddle.js shares the origin with nothing: no sign-in, no app code, no storage.
4
+ * The kit's pay page (docs/billing-harmonization.md §14.4, decision 19 as amended by
5
+ * §15.2): a standalone static page that ui-kit ships as `dist/pay/`, and that each app
6
+ * serves on its own pay host, `pay.<app domain>` — a separate origin, approved in Paddle,
7
+ * where Paddle.js shares the origin with nothing: no sign-in, no app code, no storage.
8
+ * Since 0.39 every pay page serves ANY app's transaction: it names the app from the
9
+ * transaction's custom data, from the kit's map {@link PAY_PAGE_APPS}, not from its
10
+ * domain — Paddle's single default payment link reaches one host for all three apps.
9
11
  *
10
12
  * Paddle's create-transaction answers `checkout.url`: the pay page's URL plus
11
13
  * `?_ptxn=<txn>`, and Paddle.js on that page opens the transaction by itself. The app
12
14
  * sends the buyer there with {@link payPageUrl}, which adds the buyer's language; the
13
15
  * page maps it to Paddle's checkout locale ({@link paddleLocale}). Everything else the
14
- * page needs — the client-side token, sandbox or live, the way back — is the app's
15
- * `pay-config.json`, written at its web build by the `eifi1-pay-page` script.
16
+ * page needs — the client-side token, sandbox or live, the host's app and the map of
17
+ * apps with their ways back — is the host's `pay-config.json`, written at its web build
18
+ * by the `eifi1-pay-page` script.
16
19
  *
17
20
  * Pure, and React-free: the page's own script (`pay-page/pay.ts`) and that build script
18
- * bundle this module. The exports below the barrel's four are theirs.
21
+ * bundle this module. The barrel exports the link's parts and the app map ({@link PAY_PAGE_APPS},
22
+ * {@link PayPageApp}); the exports marked @internal are theirs.
19
23
  */
20
24
 
21
25
  /** Paddle's two environments: the sandbox's client-side tokens start `test_`, live
@@ -138,6 +142,9 @@ export interface PayPageStrings {
138
142
  failed: string;
139
143
  /** `billing.payBack`: the buyer closed the checkout. */
140
144
  closed: string;
145
+ /** `billing.payDone` (0.39): paid, for a transaction that names no app the page
146
+ * knows — there is no way back to offer. */
147
+ done: string;
141
148
  /** `common.back`: the link to the app's subscription page. */
142
149
  back: string;
143
150
  /** `legal.links.terms` and `legal.links.privacy`: the footer's links. */
@@ -154,6 +161,7 @@ export function payPageStrings(labels: UiKitLabels): PayPageStrings {
154
161
  nothing: labels.billing.payNothing,
155
162
  failed: labels.billing.payFailed,
156
163
  closed: labels.billing.payBack,
164
+ done: labels.billing.payDone,
157
165
  back: labels.common.back,
158
166
  terms: labels.legal.links.terms,
159
167
  privacy: labels.legal.links.privacy,
@@ -167,7 +175,45 @@ export function payPageStrings(labels: UiKitLabels): PayPageStrings {
167
175
  export const PAY_CONFIG_FILE = "pay-config.json";
168
176
 
169
177
  /**
170
- * `pay-config.json`: what the page needs that differs per app and deployment. Read by
178
+ * One app a pay page serves (docs/billing-harmonization.md §15.2.6): its name, the way
179
+ * back and its Terms and Privacy. Every pay page knows every app, because Paddle's links
180
+ * — its single default payment link, a payment-method update from its e-mails — reach
181
+ * whichever page the account names, whatever app the transaction is for.
182
+ */
183
+ export interface PayPageApp {
184
+ /** The app's display name: the footer's line and the page's title. */
185
+ name: string;
186
+ /** The app's subscription page. Once paid, the page goes to
187
+ * `checkoutReturnUrl(returnUrl)`; "Back" goes here. */
188
+ returnUrl: string;
189
+ termsUrl: string;
190
+ privacyUrl: string;
191
+ }
192
+
193
+ /** An app's tag: server-kit's `billing_app` pattern, the key of {@link PAY_PAGE_APPS}
194
+ * and the `app` of a transaction's custom data (§14.3). */
195
+ const APP_TAG = /^[a-z][a-z0-9_-]{0,63}$/;
196
+
197
+ /** An app on its site: the name, the Terms and Privacy at `/terms` and `/privacy`, and
198
+ * its subscription page. */
199
+ function appAt(name: string, site: string, returnPath: string): PayPageApp {
200
+ return { name, returnUrl: `${site}${returnPath}`, termsUrl: `${site}/terms`, privacyUrl: `${site}/privacy` };
201
+ }
202
+
203
+ /**
204
+ * The apps the kit's pay pages serve, by their `billing_app` tag (§15.2.6) — one source,
205
+ * so the three pay hosts can't drift. A new app, or a page that moves, is a kit release.
206
+ * The bin writes it into `pay-config.json`; `--app-url` moves the host's own app to
207
+ * another site (a local stack, a dev deploy) and `--apps <file>` merges a map over it.
208
+ */
209
+ export const PAY_PAGE_APPS: Readonly<Record<string, Readonly<PayPageApp>>> = Object.freeze({
210
+ keksdose: Object.freeze(appAt("Keksdose", "https://keksdose.app", "/settings/subscription")),
211
+ kastlan: Object.freeze(appAt("kastlan", "https://kastlan.app", "/admin/billing")),
212
+ kurvenschmiede: Object.freeze(appAt("Kurvenschmiede", "https://kurvenschmiede.app", "/settings/subscription")),
213
+ });
214
+
215
+ /**
216
+ * `pay-config.json`: what the page needs that differs per deployment (§15.2.6). Read by
171
217
  * the page from its own origin — never from the query, where Paddle's own links (it
172
218
  * puts only `_ptxn` there) would lack it and a return URL would be an open redirect on
173
219
  * an approved payment domain. @internal
@@ -177,19 +223,19 @@ export interface PayPageConfig {
177
223
  * while billing is off, and the page says `payNothing`. */
178
224
  token: string | null;
179
225
  environment: PaddleEnvironment | null;
180
- /** The app's subscription page. Paddle.js's `successUrl` is
181
- * `checkoutReturnUrl(returnUrl)`; "Back" goes here. */
182
- returnUrl: string;
183
- appName: string;
184
- termsUrl: string;
185
- privacyUrl: string;
226
+ /** The host's own app, a key of `apps`: the page shows it until the checkout loads,
227
+ * and its Terms and Privacy stay for a transaction that names no app. */
228
+ app: string;
229
+ /** Every app the page serves, by tag. The transaction's `custom_data.app` picks one;
230
+ * a return URL only ever comes from here. */
231
+ apps: Record<string, PayPageApp>;
186
232
  }
187
233
 
188
234
  /** Paddle's client-side token prefixes, per environment. */
189
235
  const TOKEN_PREFIX: Readonly<Record<PaddleEnvironment, string>> = { sandbox: "test_", live: "live_" };
190
236
 
191
- /** `https://`, or `http://` on this machine only (`localhost`, `127.0.0.1`, `[::1]`). */
192
- function isPageUrl(value: unknown): boolean {
237
+ /** `https://`, or `http://` on this machine only (`localhost`, `127.0.0.1`, `[::1]`). @internal */
238
+ export function isPayPageUrl(value: unknown): boolean {
193
239
  if (typeof value !== "string") return false;
194
240
  let url: URL;
195
241
  try {
@@ -201,6 +247,37 @@ function isPageUrl(value: unknown): boolean {
201
247
  return url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
202
248
  }
203
249
 
250
+ /** Whether `tag` is a tag of `apps`, its own key — never `__proto__` or `constructor`. */
251
+ function hasApp(apps: unknown, tag: unknown): tag is string {
252
+ return (
253
+ typeof tag === "string" &&
254
+ APP_TAG.test(tag) &&
255
+ typeof apps === "object" &&
256
+ apps !== null &&
257
+ Object.prototype.hasOwnProperty.call(apps, tag)
258
+ );
259
+ }
260
+
261
+ /**
262
+ * The app a transaction's custom data names (`custom_data.app`, §14.3), from the page's
263
+ * map — or undefined for none, or a tag the map doesn't know. @internal
264
+ */
265
+ export function payPageAppFor(config: Pick<PayPageConfig, "apps">, tag: unknown): PayPageApp | undefined {
266
+ return hasApp(config.apps, tag) ? config.apps[tag] : undefined;
267
+ }
268
+
269
+ /** The problems of one app of the map. */
270
+ function appProblems(tag: string, app: unknown): string[] {
271
+ if (typeof app !== "object" || app === null) return [`apps.${tag} is not an object`];
272
+ const a = app as Record<string, unknown>;
273
+ const problems: string[] = [];
274
+ if (typeof a.name !== "string" || a.name.trim() === "") problems.push(`apps.${tag}.name is missing`);
275
+ for (const key of ["returnUrl", "termsUrl", "privacyUrl"] as const) {
276
+ if (!isPayPageUrl(a[key])) problems.push(`apps.${tag}.${key} must be an https:// URL (http:// for localhost only)`);
277
+ }
278
+ return problems;
279
+ }
280
+
204
281
  /**
205
282
  * What is wrong with a `pay-config.json`, one line each — `[]` when nothing is. The
206
283
  * build script refuses to write a file with any (so a wrong pairing fails the build,
@@ -208,8 +285,10 @@ function isPageUrl(value: unknown): boolean {
208
285
  *
209
286
  * - a token whose prefix disagrees with the environment (`test_` ↔ sandbox, `live_` ↔
210
287
  * live), or a token without an environment;
211
- * - a return, terms or privacy URL that isn't `https://` (`http://` for localhost only);
212
- * - no app name. @internal
288
+ * - an app tag that isn't a `billing_app` tag (`^[a-z][a-z0-9_-]{0,63}$`), an app
289
+ * without a name, or a return, terms or privacy URL that isn't `https://` (`http://`
290
+ * for localhost only);
291
+ * - no `app`, or one that isn't in `apps`. @internal
213
292
  */
214
293
  export function payPageConfigProblems(config: unknown): string[] {
215
294
  if (typeof config !== "object" || config === null) return ["the configuration is not an object"];
@@ -233,14 +312,19 @@ export function payPageConfigProblems(config: unknown): string[] {
233
312
  );
234
313
  }
235
314
  }
236
- for (const key of ["returnUrl", "termsUrl", "privacyUrl"] as const) {
237
- if (!isPageUrl(c[key])) problems.push(`${key} must be an https:// URL (http:// for localhost only)`);
315
+ const { apps, app } = c;
316
+ if (typeof apps !== "object" || apps === null || Array.isArray(apps) || Object.keys(apps).length === 0) {
317
+ problems.push("apps must map each app's tag to its name and URLs");
318
+ } else {
319
+ for (const [tag, entry] of Object.entries(apps)) {
320
+ if (!APP_TAG.test(tag)) problems.push(`apps: ${JSON.stringify(tag)} is not an app tag (a-z, 0-9, _ and -)`);
321
+ else problems.push(...appProblems(tag, entry));
322
+ }
323
+ }
324
+ if (typeof app !== "string" || app === "") problems.push("app is missing: the host's own app tag");
325
+ else if (!hasApp(apps, app)) {
326
+ const known = typeof apps === "object" && apps !== null ? Object.keys(apps).join(", ") : "";
327
+ problems.push(`app ${JSON.stringify(app)} is not in apps${known ? ` (${known})` : ""}`);
238
328
  }
239
- if (typeof c.appName !== "string" || c.appName.trim() === "") problems.push("appName is missing");
240
329
  return problems;
241
330
  }
242
-
243
- /** Paddle.js's `successUrl` for a configuration: the way back, `?checkout=done`. @internal */
244
- export function paySuccessUrl(config: Pick<PayPageConfig, "returnUrl">): string {
245
- return checkoutReturnUrl(config.returnUrl);
246
- }
@@ -0,0 +1,201 @@
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 { pluralForm } from "../i18n/plurals";
7
+ import type { PluralForms } from "../i18n/plurals";
8
+ import { formatDate, formatMoney, formatNumber } from "../lib/format";
9
+ import { Button } from "../components/button";
10
+ import { DialogFrame } from "../components/dialog-frame";
11
+ import { Spinner } from "../components/spinner";
12
+ import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
13
+ import { DEFAULT_PLAN_CHANGE_LABELS } from "../admin/plan-change-labels";
14
+ import { useBillingLabels } from "./billing-labels";
15
+ import type { BillingLabels } from "./billing-labels";
16
+ import type { PlanSwitchPreview } from "./billing-overview";
17
+ import { minorToMajor } from "./plan-price";
18
+ import type { BillingInterval } from "./plan-price";
19
+
20
+ /**
21
+ * The payer's plan switch (docs/billing-harmonization.md §15.2.10, ui-kit 0.39): built
22
+ * once in the kits and used by all three apps, instead of Paddle's portal. While a
23
+ * provider subscription runs, the app sends a `PlanPicker` pick to
24
+ * `POST /billing/plan/preview` and opens this dialog with the answer; Confirm is the
25
+ * app's `POST /billing/plan`. Otherwise the pick goes to the checkout, as before.
26
+ *
27
+ * The switch is immediate and prorated: an upgrade charges the difference now, a
28
+ * downgrade credits the unused part to the customer's balance, which Paddle takes off
29
+ * the next charges. So the payer agrees to a figure — Paddle's preview — and sees the
30
+ * dimensions over the new plan's limit before agreeing (§3.4: a limit gates creation
31
+ * only, so the switch is allowed).
32
+ */
33
+
34
+ /** One side of a switch: a plan code and its billing period. */
35
+ export interface PlanSwitchSide {
36
+ plan: string;
37
+ interval: BillingInterval;
38
+ }
39
+
40
+ export interface PlanSwitchConfirmProps {
41
+ open: boolean;
42
+ /** `false` when the payer dismisses it — Cancel, Escape, the backdrop. Confirm leaves
43
+ * it open: close it once the switch answers. */
44
+ onOpenChange: (open: boolean) => void;
45
+ /** What the payer pays now: the overview's `paid`. */
46
+ from: PlanSwitchSide;
47
+ /** The pick. */
48
+ to: PlanSwitchSide;
49
+ /** `POST /billing/plan/preview`'s answer. Undefined while it loads: a spinner, and
50
+ * Confirm is held. */
51
+ preview?: PlanSwitchPreview;
52
+ /** `POST /billing/plan` is under way: Confirm's spinner, no second press, and Escape
53
+ * doesn't close. */
54
+ pending?: boolean;
55
+ /** The plan's display name, the app's (§3.1). Default: the code. */
56
+ planName?: (code: string) => string;
57
+ /**
58
+ * The app's word per dimension, for the over-limit lines. A heading's word ("Budgets")
59
+ * reads "Budgets: 7 of 5" (`billing.limitUsageLine`); its plural forms
60
+ * (`{ one: "budget", other: "budgets" }`) read "7 of 5 budgets" (`planChange.usageOf`,
61
+ * the form the limit takes). A dimension without one is named by its key.
62
+ */
63
+ dimensionLabels?: Readonly<Record<string, string | PluralForms>>;
64
+ /** How a usage or limit figure is written. Default: a number in the locale. */
65
+ formatLimit?: (dimension: string, value: number) => string;
66
+ /** Confirm: the app's `POST /billing/plan` with `{plan, interval}` of `to`. */
67
+ onConfirm: () => void;
68
+ /** The figures' and the date's locale. Default: the provider's. */
69
+ locale?: string;
70
+ /** Prop > `<UiKitProvider labels={{ billing }}>` > English. */
71
+ labels?: LabelOverride<BillingLabels>;
72
+ }
73
+
74
+ /**
75
+ * The confirm for a plan switch (§15.2.10): from → to, each with its billing period;
76
+ * Paddle's preview — "Charged now" (an upgrade) or "Credited towards your next charges"
77
+ * (a downgrade), and the next charge with its date — in the subscription's currency;
78
+ * the dimensions over the new plan's limit; and Confirm.
79
+ *
80
+ * const preview = useMutation({ mutationFn: api.previewPlanSwitch });
81
+ * <PlanSwitchConfirm
82
+ * open={pick !== null}
83
+ * onOpenChange={(open) => !open && setPick(null)}
84
+ * from={overview.paid}
85
+ * to={pick}
86
+ * preview={preview.data}
87
+ * pending={switching.isPending}
88
+ * planName={planName}
89
+ * onConfirm={() => switching.mutate(pick)}
90
+ * />
91
+ *
92
+ * It takes no billing lock (`COMMIT_EXCEPT_BILLING` built in): a read-only payer may
93
+ * change plan, as they may pay. A demo lock still holds it, as the server's demo guard
94
+ * does. The kit sends no request.
95
+ */
96
+ export function PlanSwitchConfirm({
97
+ open,
98
+ onOpenChange,
99
+ from,
100
+ to,
101
+ preview,
102
+ pending = false,
103
+ planName = (code) => code,
104
+ dimensionLabels,
105
+ formatLimit,
106
+ onConfirm,
107
+ locale: localeProp,
108
+ labels: labelsProp,
109
+ }: PlanSwitchConfirmProps) {
110
+ const labels = useBillingLabels(labelsProp);
111
+ const planChange = useKitLabels("planChange", DEFAULT_PLAN_CHANGE_LABELS);
112
+ const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
113
+ const locale = useKitLocale(localeProp);
114
+
115
+ const period = (interval: BillingInterval) => (interval === "year" ? labels.yearly : labels.monthly);
116
+ const side = (s: PlanSwitchSide) => `${planName(s.plan.toLowerCase())} (${period(s.interval)})`;
117
+ const money = (minor: number, currency: string) =>
118
+ formatMoney(minorToMajor(minor, currency), currency, { locale });
119
+ const figure = (dimension: string, value: number) =>
120
+ formatLimit ? formatLimit(dimension, value) : formatNumber(value, { locale });
121
+
122
+ /** One line per dimension over the new limit, in the word's own sentence. */
123
+ const overLimit = (p: PlanSwitchPreview): string[] =>
124
+ (p.over_limit ?? []).flatMap((dimension) => {
125
+ const limit = p.limits?.[dimension];
126
+ const used = p.usage?.[dimension];
127
+ if (limit === null || limit === undefined || used === undefined) return [];
128
+ const word = dimensionLabels?.[dimension] ?? dimension;
129
+ return [
130
+ typeof word === "string"
131
+ ? labels.limitUsageLine(word, figure(dimension, used), figure(dimension, limit))
132
+ : planChange.usageOf(figure(dimension, used), figure(dimension, limit), pluralForm(limit, word, locale)),
133
+ ];
134
+ });
135
+
136
+ const credit = preview !== undefined && preview.now_minor < 0;
137
+ const lines = preview ? overLimit(preview) : [];
138
+
139
+ return (
140
+ <DialogFrame
141
+ open={open}
142
+ title={labels.switchTitle}
143
+ description={labels.switchFromTo(side(from), side(to))}
144
+ onClose={() => onOpenChange(false)}
145
+ onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
146
+ if (pending && e.key === "Escape") e.preventDefault();
147
+ }}
148
+ aria-busy={preview === undefined || pending || undefined}
149
+ actions={(close) => (
150
+ <>
151
+ <Button variant="secondary" onClick={close} disabled={pending}>
152
+ {common.cancel}
153
+ </Button>
154
+ <Button
155
+ commit={COMMIT_EXCEPT_BILLING}
156
+ pending={pending}
157
+ disabled={preview === undefined}
158
+ onClick={() => {
159
+ if (preview !== undefined && !pending) onConfirm();
160
+ }}
161
+ >
162
+ {labels.switchConfirm}
163
+ </Button>
164
+ </>
165
+ )}
166
+ >
167
+ {preview === undefined ? (
168
+ <div className="flex justify-center py-4">
169
+ <Spinner showLabel />
170
+ </div>
171
+ ) : (
172
+ <>
173
+ <dl className="grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 text-sm" data-plan-switch-preview>
174
+ <dt className="text-secondary">{credit ? labels.switchCredited : labels.switchChargedNow}</dt>
175
+ <dd className="text-right font-medium tabular-nums" data-now={credit ? "credit" : "charge"}>
176
+ {money(Math.abs(preview.now_minor), preview.currency)}
177
+ </dd>
178
+ {preview.next_billed_at && preview.next_minor != null && (
179
+ <>
180
+ <dt className="text-secondary">
181
+ {labels.switchNextCharge(formatDate(preview.next_billed_at, "medium", { locale }))}
182
+ </dt>
183
+ <dd className="text-right tabular-nums">{money(preview.next_minor, preview.currency)}</dd>
184
+ </>
185
+ )}
186
+ </dl>
187
+ {lines.length > 0 && (
188
+ <div className="space-y-1 text-sm" data-plan-switch-over-limit>
189
+ <p>{planChange.overLimit}</p>
190
+ <ul className="list-disc space-y-0.5 ps-5 text-secondary">
191
+ {lines.map((line) => (
192
+ <li key={line}>{line}</li>
193
+ ))}
194
+ </ul>
195
+ </div>
196
+ )}
197
+ </>
198
+ )}
199
+ </DialogFrame>
200
+ );
201
+ }
@@ -1749,6 +1749,14 @@ export function germanLabels(numberLocale: string): UiKitLabels {
1749
1749
  "Der Bezahlvorgang konnte nicht geöffnet werden. Bitte kehren Sie zurück und versuchen Sie es gleich noch einmal.",
1750
1750
  payBack:
1751
1751
  "Der Bezahlvorgang wurde geschlossen. Sie können zurückkehren oder diese Seite neu laden, um ihn wieder zu öffnen.",
1752
+ payDone: "Zahlung abgeschlossen. Sie können diese Seite schließen.",
1753
+ // 0.39 (§15.2.10): PlanSwitchConfirm.
1754
+ switchTitle: "Tarif ändern",
1755
+ switchFromTo: (from, to) => `Von ${from} zu ${to}`,
1756
+ switchChargedNow: "Jetzt belastet",
1757
+ switchCredited: "Gutschrift auf die nächsten Belastungen",
1758
+ switchNextCharge: (date) => `Nächste Belastung am ${date}`,
1759
+ switchConfirm: "Tarif ändern",
1752
1760
  waitingChanges: (count) =>
1753
1761
  count === 1
1754
1762
  ? `${n(count)} Änderung wartet auf einen Tarif`
@@ -1760,9 +1768,13 @@ export function germanLabels(numberLocale: string): UiKitLabels {
1760
1768
  // §14.2: server-kit 0.7's refusals. "Zahlung und Rechnungen", as `manage`.
1761
1769
  providerUnavailable:
1762
1770
  "Der Zahlungsanbieter ist gerade nicht erreichbar. Bitte versuchen Sie es gleich noch einmal.",
1763
- alreadySubscribed: "Sie haben bereits ein Abonnement. Ihren Tarif ändern Sie unter „Zahlung und Rechnungen“.",
1771
+ alreadySubscribed: "Sie haben bereits ein Abonnement. Ändern Sie Ihren Tarif oben.",
1764
1772
  planNotSold:
1765
1773
  "Dieser Tarif ist für diesen Abrechnungszeitraum oder diese Währung nicht erhältlich. Bitte wählen Sie einen anderen.",
1774
+ // 0.39 (§15.2.10): the plan switch's refusals.
1775
+ notSubscribed:
1776
+ "Es läuft kein Abonnement, das sich ändern ließe. Wählen Sie einen Tarif, um eines abzuschließen.",
1777
+ planUnchanged: "Das ist bereits Ihr Tarif. Wählen Sie einen anderen Tarif oder Abrechnungszeitraum.",
1766
1778
  },
1767
1779
  // 0.33.0 — docs/billing-harmonization.md §14.12: the operator's plan parts, "Tarif" as
1768
1780
  // in `billing`; keksdose's reviewed admin wording where it had one.
@@ -1699,6 +1699,14 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
1699
1699
  payNothing: "Aquí no hay nada que pagar. Para elegir un plan, vuelva a la aplicación.",
1700
1700
  payFailed: "No se ha podido abrir el pago. Vuelva atrás e inténtelo de nuevo en un momento.",
1701
1701
  payBack: "El pago se ha cerrado. Puede volver atrás o recargar esta página para abrirlo de nuevo.",
1702
+ payDone: "Pago completado. Puede cerrar esta página.",
1703
+ // 0.39 (§15.2.10): PlanSwitchConfirm.
1704
+ switchTitle: "Cambiar de plan",
1705
+ switchFromTo: (from, to) => `De ${from} a ${to}`,
1706
+ switchChargedNow: "Cargo ahora",
1707
+ switchCredited: "Abonado a sus próximos cargos",
1708
+ switchNextCharge: (date) => `Próximo cargo el ${date}`,
1709
+ switchConfirm: "Cambiar de plan",
1702
1710
  waitingChanges: (count) => `${n(count)} ${plural(count, "cambio", "cambios")} a la espera de un plan`,
1703
1711
  // "Descartar", as `wizard.cancelConfirmLabel`.
1704
1712
  discardWaiting: "Descartar los cambios en espera",
@@ -1706,8 +1714,11 @@ export function uiKitLabelsEs(numberLocale = "es-ES"): UiKitLabels {
1706
1714
  disabled: "Las suscripciones no están disponibles aquí.",
1707
1715
  // §14.2: server-kit 0.7's refusals. "Pagos y facturas", as `manage`. "Usted".
1708
1716
  providerUnavailable: "El proveedor de pagos no está disponible en este momento. Inténtelo de nuevo en un momento.",
1709
- alreadySubscribed: "Ya tiene una suscripción. Para cambiar de plan, vaya a «Pagos y facturas».",
1717
+ alreadySubscribed: "Ya tiene una suscripción. Cambie de plan más arriba.",
1710
1718
  planNotSold: "Este plan no está disponible para ese periodo de facturación o esa moneda. Elija otro.",
1719
+ // 0.39 (§15.2.10): the plan switch's refusals.
1720
+ notSubscribed: "No hay ninguna suscripción activa que cambiar. Elija un plan para suscribirse.",
1721
+ planUnchanged: "Ese ya es su plan. Elija otro plan u otro periodo de facturación.",
1711
1722
  },
1712
1723
  // 0.33.0 — docs/billing-harmonization.md §14.12: the operator's plan parts, "plan" as
1713
1724
  // in `billing`. "Usted".
@@ -1733,6 +1733,14 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
1733
1733
  payNothing: "Il n’y a rien à payer ici. Pour choisir une formule, revenez à l’application.",
1734
1734
  payFailed: "Le paiement n’a pas pu s’ouvrir. Veuillez revenir en arrière et réessayer dans un instant.",
1735
1735
  payBack: "Le paiement a été fermé. Vous pouvez revenir en arrière, ou recharger cette page pour le rouvrir.",
1736
+ payDone: "Paiement effectué. Vous pouvez fermer cette page.",
1737
+ // 0.39 (§15.2.10): PlanSwitchConfirm.
1738
+ switchTitle: "Changer de formule",
1739
+ switchFromTo: (from, to) => `De ${from} à ${to}`,
1740
+ switchChargedNow: "Débité maintenant",
1741
+ switchCredited: "Crédité sur vos prochains paiements",
1742
+ switchNextCharge: (date) => `Prochain prélèvement le ${date}`,
1743
+ switchConfirm: "Changer de formule",
1736
1744
  waitingChanges: (count) =>
1737
1745
  `${n(count)} ${plural(count, "modification en attente", "modifications en attente")} d’une formule`,
1738
1746
  // "Abandonner", as `wizard.cancelConfirmLabel`.
@@ -1742,10 +1750,12 @@ export function uiKitLabelsFr(numberLocale = "fr-FR"): UiKitLabels {
1742
1750
  // §14.2: server-kit 0.7's refusals. "Paiement et factures", as `manage`.
1743
1751
  providerUnavailable:
1744
1752
  "Le prestataire de paiement est injoignable pour le moment. Veuillez réessayer dans un instant.",
1745
- alreadySubscribed:
1746
- "Vous avez déjà un abonnement. Pour changer de formule, allez dans «\u202fPaiement et factures\u202f».",
1753
+ alreadySubscribed: "Vous avez déjà un abonnement. Changez de formule ci-dessus.",
1747
1754
  planNotSold:
1748
1755
  "Cette formule n’est pas proposée pour cette période de facturation ou cette devise. Veuillez en choisir une autre.",
1756
+ // 0.39 (§15.2.10): the plan switch's refusals.
1757
+ notSubscribed: "Aucun abonnement en cours à modifier. Choisissez une formule pour vous abonner.",
1758
+ planUnchanged: "C’est déjà votre formule. Choisissez une autre formule ou une autre période de facturation.",
1749
1759
  },
1750
1760
  // 0.33.0 — docs/billing-harmonization.md §14.12: the operator's plan parts, "formule"
1751
1761
  // as in `billing`. "Vous".
@@ -1702,6 +1702,14 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
1702
1702
  payNothing: "Itt nincs mit kifizetni. Csomag választásához térjen vissza az alkalmazásba.",
1703
1703
  payFailed: "A fizetést nem sikerült megnyitni. Kérjük, lépjen vissza, és próbálja újra egy kis idő múlva.",
1704
1704
  payBack: "A fizetés bezárult. Visszaléphet, vagy a megnyitásához töltse újra ezt az oldalt.",
1705
+ payDone: "A fizetés sikerült. Bezárhatja ezt az oldalt.",
1706
+ // 0.39 (§15.2.10): PlanSwitchConfirm.
1707
+ switchTitle: "Csomag módosítása",
1708
+ switchFromTo: (from, to) => `${from} helyett ${to}`,
1709
+ switchChargedNow: "Most terhelt összeg",
1710
+ switchCredited: "A következő terhelésekből levonva",
1711
+ switchNextCharge: (date) => `Következő terhelés: ${date}`,
1712
+ switchConfirm: "Csomag módosítása",
1705
1713
  // No plural after a numeral (convention 2).
1706
1714
  waitingChanges: (count) => `${n(count)} módosítás csomagra vár`,
1707
1715
  // "Elvetés", as `wizard.cancelConfirmLabel`.
@@ -1710,9 +1718,12 @@ export function uiKitLabelsHu(numberLocale = "hu-HU"): UiKitLabels {
1710
1718
  disabled: "Az előfizetés itt nem érhető el.",
1711
1719
  // §14.2: server-kit 0.7's refusals. "Fizetés és számlák", as `manage`.
1712
1720
  providerUnavailable: "A fizetési szolgáltató jelenleg nem érhető el. Kérjük, próbálja újra egy kis idő múlva.",
1713
- alreadySubscribed: "Már van előfizetése. A csomag módosításához nyissa meg a „Fizetés és számlák” részt.",
1721
+ alreadySubscribed: "Már van előfizetése. A csomagot fent módosíthatja.",
1714
1722
  planNotSold:
1715
1723
  "Ez a csomag ehhez a számlázási időszakhoz vagy pénznemhez nem érhető el. Kérjük, válasszon másikat.",
1724
+ // 0.39 (§15.2.10): the plan switch's refusals.
1725
+ notSubscribed: "Nincs futó előfizetés, amelyet módosítani lehetne. Előfizetéshez válasszon egy csomagot.",
1726
+ planUnchanged: "Ez már az Ön csomagja. Válasszon másik csomagot vagy számlázási időszakot.",
1716
1727
  },
1717
1728
  // 0.33.0 — docs/billing-harmonization.md §14.12: the operator's plan parts, "csomag"
1718
1729
  // as in `billing`. A plan's name takes no case ending: it follows a colon or an arrow.
@@ -1715,6 +1715,14 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
1715
1715
  payNothing: "Qui non c’è nulla da pagare. Per scegliere un piano, torni all’app.",
1716
1716
  payFailed: "Non è stato possibile aprire il pagamento. Torni indietro e riprovi tra un momento.",
1717
1717
  payBack: "Il pagamento è stato chiuso. Può tornare indietro, oppure ricaricare questa pagina per riaprirlo.",
1718
+ payDone: "Pagamento completato. Può chiudere questa pagina.",
1719
+ // 0.39 (§15.2.10): PlanSwitchConfirm.
1720
+ switchTitle: "Cambia piano",
1721
+ switchFromTo: (from, to) => `Da ${from} a ${to}`,
1722
+ switchChargedNow: "Addebitato ora",
1723
+ switchCredited: "Accreditato sui prossimi addebiti",
1724
+ switchNextCharge: (date) => `Prossimo addebito il ${date}`,
1725
+ switchConfirm: "Cambia piano",
1718
1726
  waitingChanges: (count) =>
1719
1727
  `${n(count)} ${plural(count, "modifica in attesa", "modifiche in attesa")} di un piano`,
1720
1728
  // "Scarta", as `wizard.cancelConfirmLabel`.
@@ -1723,9 +1731,12 @@ export function uiKitLabelsIt(numberLocale = "it-IT"): UiKitLabels {
1723
1731
  disabled: "Gli abbonamenti non sono disponibili qui.",
1724
1732
  // §14.2: server-kit 0.7's refusals. "Pagamento e fatture", as `manage`. "Lei".
1725
1733
  providerUnavailable: "Il fornitore dei pagamenti non è raggiungibile al momento. Riprovi tra un momento.",
1726
- alreadySubscribed: "Ha già un abbonamento. Per cambiare piano, vada su «Pagamento e fatture».",
1734
+ alreadySubscribed: "Ha già un abbonamento. Cambi piano qui sopra.",
1727
1735
  planNotSold:
1728
1736
  "Questo piano non è disponibile per questo periodo di fatturazione o questa valuta. Ne scelga un altro.",
1737
+ // 0.39 (§15.2.10): the plan switch's refusals.
1738
+ notSubscribed: "Non c’è nessun abbonamento attivo da modificare. Scelga un piano per abbonarsi.",
1739
+ planUnchanged: "Questo è già il Suo piano. Scelga un altro piano o un altro periodo di fatturazione.",
1729
1740
  },
1730
1741
  // 0.33.0 — docs/billing-harmonization.md §14.12: the operator's plan parts, "piano" as
1731
1742
  // in `billing`. "Lei" in sentences, the imperative on the title and the button.
@@ -1629,6 +1629,14 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
1629
1629
  payNothing: "这里没有需要支付的款项。如需选择套餐,请返回应用。",
1630
1630
  payFailed: "无法打开支付页面。请返回后稍候再试。",
1631
1631
  payBack: "支付页面已关闭。您可以返回,或重新加载此页面以再次打开。",
1632
+ payDone: "付款已完成。您可以关闭此页面。",
1633
+ // 0.39 (§15.2.10): PlanSwitchConfirm.
1634
+ switchTitle: "更改套餐",
1635
+ switchFromTo: (from, to) => `从 ${from} 更改为 ${to}`,
1636
+ switchChargedNow: "立即扣款",
1637
+ switchCredited: "抵扣后续扣款",
1638
+ switchNextCharge: (date) => `下次扣款日期:${date}`,
1639
+ switchConfirm: "更改套餐",
1632
1640
  waitingChanges: (count) => `${n(count)} 项修改正在等待套餐`,
1633
1641
  // "放弃", as `wizard.cancelConfirmLabel`.
1634
1642
  discardWaiting: "放弃等待中的修改",
@@ -1636,8 +1644,11 @@ export function uiKitLabelsZh(numberLocale = "zh-CN"): UiKitLabels {
1636
1644
  disabled: "此处不提供订阅。",
1637
1645
  // §14.2: server-kit 0.7's refusals. "付款与发票", as `manage`.
1638
1646
  providerUnavailable: "暂时无法连接支付服务商,请稍后再试。",
1639
- alreadySubscribed: "您已有订阅。如需更改套餐,请前往“付款与发票”。",
1647
+ alreadySubscribed: "您已有订阅。请在上方更改套餐。",
1640
1648
  planNotSold: "该套餐不支持此计费周期或货币,请选择其他套餐。",
1649
+ // 0.39 (§15.2.10): the plan switch's refusals.
1650
+ notSubscribed: "没有可更改的有效订阅。请选择一个套餐进行订阅。",
1651
+ planUnchanged: "这已经是您当前的套餐。请选择其他套餐或计费周期。",
1641
1652
  },
1642
1653
  // 0.33.0 — docs/billing-harmonization.md §14.12: the operator's plan parts, "套餐" as
1643
1654
  // in `billing`. "测试版", as `landing.beta`.
@@ -390,6 +390,9 @@ export const KIT_LABEL_SAMPLES: Readonly<Record<string, readonly (readonly unkno
390
390
  "billing.waitingChanges": one,
391
391
  // 0.33.0: the limit's figure line (§14.11) and the operator's plan parts (§14.12).
392
392
  "billing.limitUsageLine": [["{{dimension}}", "{{used}}", "{{limit}}"]],
393
+ // 0.39: PlanSwitchConfirm's two lines with figures (§15.2.10).
394
+ "billing.switchFromTo": [["{{from}}", "{{to}}"]],
395
+ "billing.switchNextCharge": [["{{date}}"]],
393
396
  "planChange.current": [["{{plan}}"]],
394
397
  "planChange.changed": [["{{from}}", "{{to}}"]],
395
398
  "planChange.set": [["{{to}}"]],