@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.
- package/README.md +6 -5
- package/dist/auth/auth-errors.d.ts +8 -3
- package/dist/auth/auth-errors.js +3 -1
- package/dist/auth/auth-errors.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +25 -2
- package/dist/billing/billing-labels.js +11 -2
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/billing-overview.d.ts +50 -1
- package/dist/billing/checkout-processing.d.ts +15 -3
- package/dist/billing/checkout-processing.js +3 -1
- package/dist/billing/checkout-processing.js.map +1 -1
- package/dist/billing/pay-page.d.ts +55 -18
- package/dist/billing/pay-page.js +44 -10
- package/dist/billing/pay-page.js.map +1 -1
- package/dist/billing/plan-switch.d.ts +79 -0
- package/dist/billing/plan-switch.js +93 -0
- package/dist/billing/plan-switch.js.map +1 -0
- package/dist/bin/eifi1-pay-page.mjs +109 -21
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-comment-thread.d.ts +13 -6
- package/dist/feedback/feedback-comment-thread.js +10 -2
- package/dist/feedback/feedback-comment-thread.js.map +1 -1
- package/dist/feedback/feedback-labels.d.ts +11 -0
- package/dist/feedback/feedback-labels.js +4 -1
- package/dist/feedback/feedback-labels.js.map +1 -1
- package/dist/feedback/feedback-public-labels.d.ts +3 -0
- package/dist/feedback/feedback-public-labels.js +2 -1
- package/dist/feedback/feedback-public-labels.js.map +1 -1
- package/dist/feedback/feedback-public-list.d.ts +25 -4
- package/dist/feedback/feedback-public-list.js +54 -3
- package/dist/feedback/feedback-public-list.js.map +1 -1
- package/dist/feedback/feedback-public-switch.js +3 -3
- package/dist/feedback/feedback-public-switch.js.map +1 -1
- package/dist/feedback/feedback-record.d.ts +7 -7
- package/dist/feedback/feedback-record.js.map +1 -1
- package/dist/feedback/feedback-replies-context.d.ts +7 -0
- package/dist/feedback/feedback-replies-context.js +7 -0
- package/dist/feedback/feedback-replies-context.js.map +1 -0
- package/dist/feedback/feedback-row-detail.d.ts +12 -3
- package/dist/feedback/feedback-row-detail.js +2 -1
- package/dist/feedback/feedback-row-detail.js.map +1 -1
- package/dist/feedback/feedback-table.js +2 -2
- package/dist/feedback/feedback-table.js.map +1 -1
- package/dist/feedback/feedback-vote-button.d.ts +2 -1
- package/dist/feedback/feedback-vote-button.js.map +1 -1
- package/dist/i18n/german.js +19 -4
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/locales/en.js +22 -6
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +19 -4
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +19 -4
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +19 -4
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +19 -4
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +19 -4
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +4 -0
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +6 -1
- package/dist/index.js +4 -0
- package/dist/index.js.map +1 -1
- package/dist/lib/translation-review.d.ts +11 -0
- package/dist/lib/translation-review.js.map +1 -1
- package/dist/pay/pay.js +102 -40
- package/package.json +1 -1
- package/src/auth/auth-errors.ts +12 -3
- package/src/billing/billing-labels.ts +38 -3
- package/src/billing/billing-overview.ts +53 -1
- package/src/billing/checkout-processing.ts +17 -3
- package/src/billing/pay-page.ts +111 -27
- package/src/billing/plan-switch.tsx +201 -0
- package/src/feedback/feedback-comment-thread.tsx +29 -7
- package/src/feedback/feedback-labels.ts +14 -0
- package/src/feedback/feedback-public-labels.ts +4 -0
- package/src/feedback/feedback-public-list.tsx +88 -5
- package/src/feedback/feedback-public-switch.tsx +3 -3
- package/src/feedback/feedback-record.ts +7 -7
- package/src/feedback/feedback-replies-context.ts +9 -0
- package/src/feedback/feedback-row-detail.tsx +16 -4
- package/src/feedback/feedback-table.tsx +2 -2
- package/src/feedback/feedback-vote-button.tsx +2 -1
- package/src/i18n/german.ts +17 -1
- package/src/i18n/locales/en.ts +1 -0
- package/src/i18n/locales/es.ts +16 -1
- package/src/i18n/locales/fr.ts +16 -2
- package/src/i18n/locales/hu.ts +16 -1
- package/src/i18n/locales/it.ts +16 -1
- package/src/i18n/locales/zh.ts +16 -1
- package/src/i18n/review.ts +4 -0
- package/src/index.ts +8 -0
- 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:
|
|
57
|
-
* without case, §12.16), the status, the source, the standing
|
|
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,
|
package/src/billing/pay-page.ts
CHANGED
|
@@ -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
|
|
6
|
-
* standalone static page that ui-kit ships as `dist/pay/`, and that each app
|
|
7
|
-
* its own pay host, `pay.<app domain>` — a separate origin, approved in Paddle,
|
|
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
|
|
15
|
-
* `pay-config.json`, written at its web build
|
|
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
|
|
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
|
-
*
|
|
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
|
|
181
|
-
*
|
|
182
|
-
|
|
183
|
-
|
|
184
|
-
|
|
185
|
-
|
|
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
|
|
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
|
-
* -
|
|
212
|
-
*
|
|
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
|
-
|
|
237
|
-
|
|
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. `[]
|
|
73
|
-
|
|
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
|
|
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.
|
|
82
|
-
*
|
|
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
|
-
|
|
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
|
};
|