@eifi1/ui-kit 0.38.0 → 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 (94) 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/feedback/feedback-comment-thread.d.ts +13 -6
  21. package/dist/feedback/feedback-comment-thread.js +10 -2
  22. package/dist/feedback/feedback-comment-thread.js.map +1 -1
  23. package/dist/feedback/feedback-labels.d.ts +11 -0
  24. package/dist/feedback/feedback-labels.js +4 -1
  25. package/dist/feedback/feedback-labels.js.map +1 -1
  26. package/dist/feedback/feedback-public-labels.d.ts +3 -0
  27. package/dist/feedback/feedback-public-labels.js +2 -1
  28. package/dist/feedback/feedback-public-labels.js.map +1 -1
  29. package/dist/feedback/feedback-public-list.d.ts +25 -4
  30. package/dist/feedback/feedback-public-list.js +54 -3
  31. package/dist/feedback/feedback-public-list.js.map +1 -1
  32. package/dist/feedback/feedback-public-switch.js +3 -3
  33. package/dist/feedback/feedback-public-switch.js.map +1 -1
  34. package/dist/feedback/feedback-record.d.ts +7 -7
  35. package/dist/feedback/feedback-record.js.map +1 -1
  36. package/dist/feedback/feedback-replies-context.d.ts +7 -0
  37. package/dist/feedback/feedback-replies-context.js +7 -0
  38. package/dist/feedback/feedback-replies-context.js.map +1 -0
  39. package/dist/feedback/feedback-row-detail.d.ts +12 -3
  40. package/dist/feedback/feedback-row-detail.js +2 -1
  41. package/dist/feedback/feedback-row-detail.js.map +1 -1
  42. package/dist/feedback/feedback-table.js +2 -2
  43. package/dist/feedback/feedback-table.js.map +1 -1
  44. package/dist/feedback/feedback-vote-button.d.ts +2 -1
  45. package/dist/feedback/feedback-vote-button.js.map +1 -1
  46. package/dist/i18n/german.js +19 -4
  47. package/dist/i18n/german.js.map +1 -1
  48. package/dist/i18n/locales/en.js +22 -6
  49. package/dist/i18n/locales/en.js.map +2 -2
  50. package/dist/i18n/locales/es.js +19 -4
  51. package/dist/i18n/locales/es.js.map +1 -1
  52. package/dist/i18n/locales/fr.js +19 -4
  53. package/dist/i18n/locales/fr.js.map +1 -1
  54. package/dist/i18n/locales/hu.js +19 -4
  55. package/dist/i18n/locales/hu.js.map +1 -1
  56. package/dist/i18n/locales/it.js +19 -4
  57. package/dist/i18n/locales/it.js.map +1 -1
  58. package/dist/i18n/locales/zh.js +19 -4
  59. package/dist/i18n/locales/zh.js.map +1 -1
  60. package/dist/i18n/review.js +4 -0
  61. package/dist/i18n/review.js.map +1 -1
  62. package/dist/index.d.ts +6 -1
  63. package/dist/index.js +4 -0
  64. package/dist/index.js.map +1 -1
  65. package/dist/lib/translation-review.d.ts +11 -0
  66. package/dist/lib/translation-review.js.map +1 -1
  67. package/dist/pay/pay.js +102 -40
  68. package/package.json +1 -1
  69. package/src/auth/auth-errors.ts +12 -3
  70. package/src/billing/billing-labels.ts +38 -3
  71. package/src/billing/billing-overview.ts +53 -1
  72. package/src/billing/checkout-processing.ts +17 -3
  73. package/src/billing/pay-page.ts +111 -27
  74. package/src/billing/plan-switch.tsx +201 -0
  75. package/src/feedback/feedback-comment-thread.tsx +29 -7
  76. package/src/feedback/feedback-labels.ts +14 -0
  77. package/src/feedback/feedback-public-labels.ts +4 -0
  78. package/src/feedback/feedback-public-list.tsx +88 -5
  79. package/src/feedback/feedback-public-switch.tsx +3 -3
  80. package/src/feedback/feedback-record.ts +7 -7
  81. package/src/feedback/feedback-replies-context.ts +9 -0
  82. package/src/feedback/feedback-row-detail.tsx +16 -4
  83. package/src/feedback/feedback-table.tsx +2 -2
  84. package/src/feedback/feedback-vote-button.tsx +2 -1
  85. package/src/i18n/german.ts +17 -1
  86. package/src/i18n/locales/en.ts +1 -0
  87. package/src/i18n/locales/es.ts +16 -1
  88. package/src/i18n/locales/fr.ts +16 -2
  89. package/src/i18n/locales/hu.ts +16 -1
  90. package/src/i18n/locales/it.ts +16 -1
  91. package/src/i18n/locales/zh.ts +16 -1
  92. package/src/i18n/review.ts +4 -0
  93. package/src/index.ts +8 -0
  94. 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
+ }
@@ -1,3 +1,4 @@
1
+ import { useContext } from "react";
1
2
  import type { ReactNode } from "react";
2
3
  import { EyeOff } from "lucide-react";
3
4
  import { Button } from "../components/button";
@@ -9,6 +10,7 @@ import type { FeedbackComment, FeedbackReply } from "./feedback-public-types";
9
10
  import { DEFAULT_FEEDBACK_PUBLIC_LABELS } from "./feedback-public-labels";
10
11
  import { FEEDBACK_COMMENT_MAX } from "./feedback-record";
11
12
  import { useFeedbackRepliesLabels } from "./feedback-replies";
13
+ import { FeedbackRepliesDrawnAboveContext } from "./feedback-replies-context";
12
14
  import { ChatComposer, FeedbackThread } from "./feedback-thread";
13
15
  import type { FeedbackThreadMessage } from "./feedback-thread";
14
16
  import { DEFAULT_FEEDBACK_COMMENTS_LABELS } from "./feedback-thread-parts-labels";
@@ -69,17 +71,24 @@ export { DEFAULT_FEEDBACK_COMMENTS_LABELS };
69
71
  /* ── The thread ───────────────────────────────────────────────────────────── */
70
72
 
71
73
  export interface FeedbackCommentThreadProps {
72
- /** The report's replies (§9.7), oldest first. `[]` when it has none. */
73
- replies: readonly FeedbackReply[];
74
+ /** The report's replies (§9.7), oldest first. Left out or `[]`: none.
75
+ *
76
+ * Inside `FeedbackRowDetail`'s `comments` they are dropped (0.38.1): the detail's
77
+ * Replies section draws them already, and the thread there is the comments alone —
78
+ * so passing them there cannot draw them twice. */
79
+ replies?: readonly FeedbackReply[];
74
80
  /** `GET /feedback/{id}/comments`, oldest first. */
75
81
  comments: readonly FeedbackComment[];
76
82
  /** The viewer is the app's admin: Hide / Unhide, the hidden text struck through. */
77
83
  isAdmin: boolean;
78
84
  /** `POST /feedback/{id}/comments`. Return the promise: the box clears when it
79
- * resolves. Left out, no composer. */
85
+ * resolves, and keeps the draft when it rejects. A failure is the app's to toast —
86
+ * the server's `detail`, else `feedbackToast.commentFailed` (0.38.1). Left out, no
87
+ * composer. */
80
88
  onComment?: (body: string) => void | Promise<unknown>;
81
- /** `PATCH /feedback/{id}/comments/{comment_id} {hidden}` — the admin's. Left out, no
82
- * Hide / Unhide. */
89
+ /** `PATCH /feedback/{id}/comments/{comment_id} {hidden}` — the admin's. A rejection
90
+ * is caught (0.38.1) and is the app's to toast (`feedbackToast.updateFailed`). Left
91
+ * out, no Hide / Unhide. */
83
92
  onHide?: (comment: FeedbackComment, hidden: boolean) => void | Promise<unknown>;
84
93
  /** `DELETE /feedback/{id}/comments/{comment_id}` — the writer's own, after the
85
94
  * confirm. Left out, no Delete. */
@@ -98,13 +107,22 @@ export interface FeedbackCommentThreadProps {
98
107
 
99
108
  type Entry = { at: number; order: number; message: FeedbackThreadMessage };
100
109
 
110
+ /** The admin's Hide / Unhide as a promise — a sync throw or a `void` return included —
111
+ * so the click can catch it as Delete's does. */
112
+ const hideComment = async (
113
+ onHide: NonNullable<FeedbackCommentThreadProps["onHide"]>,
114
+ comment: FeedbackComment,
115
+ ) => {
116
+ await onHide(comment, !comment.hidden);
117
+ };
118
+
101
119
  const instant = (iso: string) => {
102
120
  const ms = Date.parse(iso);
103
121
  return Number.isNaN(ms) ? 0 : ms;
104
122
  };
105
123
 
106
124
  export function FeedbackCommentThread({
107
- replies,
125
+ replies: repliesProp = [],
108
126
  comments,
109
127
  isAdmin,
110
128
  onComment,
@@ -120,6 +138,8 @@ export function FeedbackCommentThread({
120
138
  const labels = useKitLabels("feedbackComments", DEFAULT_FEEDBACK_COMMENTS_LABELS, labelsProp);
121
139
  const anonymous = useKitLabels("feedbackPublic", DEFAULT_FEEDBACK_PUBLIC_LABELS).anonymous;
122
140
  const replyLabels = useFeedbackRepliesLabels();
141
+ const repliesDrawnAbove = useContext(FeedbackRepliesDrawnAboveContext);
142
+ const replies = repliesDrawnAbove ? [] : repliesProp;
123
143
 
124
144
  const entries: Entry[] = [];
125
145
  replies.forEach((reply, order) => {
@@ -163,7 +183,9 @@ export function FeedbackCommentThread({
163
183
  tone="muted"
164
184
  size="sm"
165
185
  commit={commit}
166
- onClick={() => void onHide(comment, !comment.hidden)}
186
+ // Caught as Delete's is (0.38.1): a rejection is the app's to toast — its
187
+ // mutation's `onError` — and must not surface as an unhandled one.
188
+ onClick={() => void hideComment(onHide, comment).catch(() => {})}
167
189
  >
168
190
  {comment.hidden ? labels.unhide : labels.hide}
169
191
  </Button>,
@@ -92,6 +92,17 @@ export interface FeedbackToastLabels {
92
92
  /** The Undo landed — `status` is the RESTORED status's label.
93
93
  * de-CH: "Zurück auf „{{status}}“: {{title}}" */
94
94
  statusRestored: (status: string, title: string) => string;
95
+ /** 0.38.1: `POST /feedback/{id}/comments` failed with no `detail` of its own — for
96
+ * `FeedbackCommentThread`'s `onComment`. de-CH: "Kommentar konnte nicht gesendet
97
+ * werden." */
98
+ commentFailed: string;
99
+ /** 0.38.1: `PUT` / `DELETE /feedback/{id}/vote` failed with no `detail` of its own —
100
+ * for `FeedbackVoteButton`'s `onVote` (the button rolls back; the app toasts).
101
+ * de-CH: "Stimme konnte nicht gespeichert werden." */
102
+ voteFailed: string;
103
+ /** 0.38.1: `POST /feedback/{id}/replies` failed with no `detail` of its own — for
104
+ * `FeedbackRowDetail`'s `onReply`. de-CH: "Antwort konnte nicht gesendet werden." */
105
+ replyFailed: string;
95
106
  }
96
107
 
97
108
  export const DEFAULT_FEEDBACK_TOAST_LABELS: FeedbackToastLabels = {
@@ -108,6 +119,9 @@ export const DEFAULT_FEEDBACK_TOAST_LABELS: FeedbackToastLabels = {
108
119
  statusChanged: (status, title) => `Set to “${status}”: ${title}`,
109
120
  statusUndo: "Undo",
110
121
  statusRestored: (status, title) => `Back to “${status}”: ${title}`,
122
+ commentFailed: "Could not post the comment.",
123
+ voteFailed: "Could not save your vote.",
124
+ replyFailed: "Could not send the reply.",
111
125
  };
112
126
 
113
127
  /** `feedbackStatus` resolved: English, then `<UiKitProvider labels>`, then `labels`. For
@@ -51,6 +51,9 @@ export interface FeedbackPublicLabels {
51
51
  visibleHint: string;
52
52
  /** Why the author cannot flip it now: the report is past its open window (§9.3). */
53
53
  visibleLocked: string;
54
+ /** 0.38.1: the pager's "where am I" — `page` counts from 1. The server answers no
55
+ * total, so there is no "of N". */
56
+ page: (page: number) => string;
54
57
  }
55
58
 
56
59
  export const DEFAULT_FEEDBACK_PUBLIC_LABELS: FeedbackPublicLabels = {
@@ -73,4 +76,5 @@ export const DEFAULT_FEEDBACK_PUBLIC_LABELS: FeedbackPublicLabels = {
73
76
  visibleToOthers: "Visible to others",
74
77
  visibleHint: "Other users see the title and text, never your screenshots or files.",
75
78
  visibleLocked: "Only while the report is still open.",
79
+ page: (page) => `Page ${page}`,
76
80
  };