@eifi1/ui-kit 0.38.1 → 0.39.1
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/i18n/german.js +13 -2
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/locales/en.js +14 -3
- package/dist/i18n/locales/en.js.map +2 -2
- package/dist/i18n/locales/es.js +13 -2
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +13 -2
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +13 -2
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +13 -2
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +13 -2
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +3 -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/i18n/german.ts +13 -1
- package/src/i18n/locales/es.ts +12 -1
- package/src/i18n/locales/fr.ts +12 -2
- package/src/i18n/locales/hu.ts +12 -1
- package/src/i18n/locales/it.ts +12 -1
- package/src/i18n/locales/zh.ts +12 -1
- package/src/i18n/review.ts +3 -0
- package/src/index.ts +8 -0
- package/src/lib/translation-review.ts +12 -0
|
@@ -1,19 +1,24 @@
|
|
|
1
1
|
import type { UiKitLabels } from "../i18n/kit-labels.js";
|
|
2
2
|
/**
|
|
3
|
-
* The kit's pay page (docs/billing-harmonization.md §14.4, decision 19 as amended
|
|
4
|
-
* standalone static page that ui-kit ships as `dist/pay/`, and that each app
|
|
5
|
-
* its own pay host, `pay.<app domain>` — a separate origin, approved in Paddle,
|
|
6
|
-
* Paddle.js shares the origin with nothing: no sign-in, no app code, no storage.
|
|
3
|
+
* The kit's pay page (docs/billing-harmonization.md §14.4, decision 19 as amended by
|
|
4
|
+
* §15.2): a standalone static page that ui-kit ships as `dist/pay/`, and that each app
|
|
5
|
+
* serves on its own pay host, `pay.<app domain>` — a separate origin, approved in Paddle,
|
|
6
|
+
* where Paddle.js shares the origin with nothing: no sign-in, no app code, no storage.
|
|
7
|
+
* Since 0.39 every pay page serves ANY app's transaction: it names the app from the
|
|
8
|
+
* transaction's custom data, from the kit's map {@link PAY_PAGE_APPS}, not from its
|
|
9
|
+
* domain — Paddle's single default payment link reaches one host for all three apps.
|
|
7
10
|
*
|
|
8
11
|
* Paddle's create-transaction answers `checkout.url`: the pay page's URL plus
|
|
9
12
|
* `?_ptxn=<txn>`, and Paddle.js on that page opens the transaction by itself. The app
|
|
10
13
|
* sends the buyer there with {@link payPageUrl}, which adds the buyer's language; the
|
|
11
14
|
* page maps it to Paddle's checkout locale ({@link paddleLocale}). Everything else the
|
|
12
|
-
* page needs — the client-side token, sandbox or live, the
|
|
13
|
-
* `pay-config.json`, written at its web build
|
|
15
|
+
* page needs — the client-side token, sandbox or live, the host's app and the map of
|
|
16
|
+
* apps with their ways back — is the host's `pay-config.json`, written at its web build
|
|
17
|
+
* by the `eifi1-pay-page` script.
|
|
14
18
|
*
|
|
15
19
|
* Pure, and React-free: the page's own script (`pay-page/pay.ts`) and that build script
|
|
16
|
-
* bundle this module. The exports
|
|
20
|
+
* bundle this module. The barrel exports the link's parts and the app map ({@link PAY_PAGE_APPS},
|
|
21
|
+
* {@link PayPageApp}); the exports marked @internal are theirs.
|
|
17
22
|
*/
|
|
18
23
|
/** Paddle's two environments: the sandbox's client-side tokens start `test_`, live
|
|
19
24
|
* ones `live_`. */
|
|
@@ -67,6 +72,9 @@ export interface PayPageStrings {
|
|
|
67
72
|
failed: string;
|
|
68
73
|
/** `billing.payBack`: the buyer closed the checkout. */
|
|
69
74
|
closed: string;
|
|
75
|
+
/** `billing.payDone` (0.39): paid, for a transaction that names no app the page
|
|
76
|
+
* knows — there is no way back to offer. */
|
|
77
|
+
done: string;
|
|
70
78
|
/** `common.back`: the link to the app's subscription page. */
|
|
71
79
|
back: string;
|
|
72
80
|
/** `legal.links.terms` and `legal.links.privacy`: the footer's links. */
|
|
@@ -80,7 +88,29 @@ export declare function payPageStrings(labels: UiKitLabels): PayPageStrings;
|
|
|
80
88
|
/** The configuration file beside the page, written at each app's web build. @internal */
|
|
81
89
|
export declare const PAY_CONFIG_FILE = "pay-config.json";
|
|
82
90
|
/**
|
|
83
|
-
*
|
|
91
|
+
* One app a pay page serves (docs/billing-harmonization.md §15.2.6): its name, the way
|
|
92
|
+
* back and its Terms and Privacy. Every pay page knows every app, because Paddle's links
|
|
93
|
+
* — its single default payment link, a payment-method update from its e-mails — reach
|
|
94
|
+
* whichever page the account names, whatever app the transaction is for.
|
|
95
|
+
*/
|
|
96
|
+
export interface PayPageApp {
|
|
97
|
+
/** The app's display name: the footer's line and the page's title. */
|
|
98
|
+
name: string;
|
|
99
|
+
/** The app's subscription page. Once paid, the page goes to
|
|
100
|
+
* `checkoutReturnUrl(returnUrl)`; "Back" goes here. */
|
|
101
|
+
returnUrl: string;
|
|
102
|
+
termsUrl: string;
|
|
103
|
+
privacyUrl: string;
|
|
104
|
+
}
|
|
105
|
+
/**
|
|
106
|
+
* The apps the kit's pay pages serve, by their `billing_app` tag (§15.2.6) — one source,
|
|
107
|
+
* so the three pay hosts can't drift. A new app, or a page that moves, is a kit release.
|
|
108
|
+
* The bin writes it into `pay-config.json`; `--app-url` moves the host's own app to
|
|
109
|
+
* another site (a local stack, a dev deploy) and `--apps <file>` merges a map over it.
|
|
110
|
+
*/
|
|
111
|
+
export declare const PAY_PAGE_APPS: Readonly<Record<string, Readonly<PayPageApp>>>;
|
|
112
|
+
/**
|
|
113
|
+
* `pay-config.json`: what the page needs that differs per deployment (§15.2.6). Read by
|
|
84
114
|
* the page from its own origin — never from the query, where Paddle's own links (it
|
|
85
115
|
* puts only `_ptxn` there) would lack it and a return URL would be an open redirect on
|
|
86
116
|
* an approved payment domain. @internal
|
|
@@ -90,13 +120,20 @@ export interface PayPageConfig {
|
|
|
90
120
|
* while billing is off, and the page says `payNothing`. */
|
|
91
121
|
token: string | null;
|
|
92
122
|
environment: PaddleEnvironment | null;
|
|
93
|
-
/** The
|
|
94
|
-
*
|
|
95
|
-
|
|
96
|
-
|
|
97
|
-
|
|
98
|
-
|
|
123
|
+
/** The host's own app, a key of `apps`: the page shows it until the checkout loads,
|
|
124
|
+
* and its Terms and Privacy stay for a transaction that names no app. */
|
|
125
|
+
app: string;
|
|
126
|
+
/** Every app the page serves, by tag. The transaction's `custom_data.app` picks one;
|
|
127
|
+
* a return URL only ever comes from here. */
|
|
128
|
+
apps: Record<string, PayPageApp>;
|
|
99
129
|
}
|
|
130
|
+
/** `https://`, or `http://` on this machine only (`localhost`, `127.0.0.1`, `[::1]`). @internal */
|
|
131
|
+
export declare function isPayPageUrl(value: unknown): boolean;
|
|
132
|
+
/**
|
|
133
|
+
* The app a transaction's custom data names (`custom_data.app`, §14.3), from the page's
|
|
134
|
+
* map — or undefined for none, or a tag the map doesn't know. @internal
|
|
135
|
+
*/
|
|
136
|
+
export declare function payPageAppFor(config: Pick<PayPageConfig, "apps">, tag: unknown): PayPageApp | undefined;
|
|
100
137
|
/**
|
|
101
138
|
* What is wrong with a `pay-config.json`, one line each — `[]` when nothing is. The
|
|
102
139
|
* build script refuses to write a file with any (so a wrong pairing fails the build,
|
|
@@ -104,10 +141,10 @@ export interface PayPageConfig {
|
|
|
104
141
|
*
|
|
105
142
|
* - a token whose prefix disagrees with the environment (`test_` ↔ sandbox, `live_` ↔
|
|
106
143
|
* live), or a token without an environment;
|
|
107
|
-
* -
|
|
108
|
-
*
|
|
144
|
+
* - an app tag that isn't a `billing_app` tag (`^[a-z][a-z0-9_-]{0,63}$`), an app
|
|
145
|
+
* without a name, or a return, terms or privacy URL that isn't `https://` (`http://`
|
|
146
|
+
* for localhost only);
|
|
147
|
+
* - no `app`, or one that isn't in `apps`. @internal
|
|
109
148
|
*/
|
|
110
149
|
export declare function payPageConfigProblems(config: unknown): string[];
|
|
111
|
-
/** Paddle.js's `successUrl` for a configuration: the way back, `?checkout=done`. @internal */
|
|
112
|
-
export declare function paySuccessUrl(config: Pick<PayPageConfig, "returnUrl">): string;
|
|
113
150
|
export {};
|
package/dist/billing/pay-page.js
CHANGED
|
@@ -1,5 +1,4 @@
|
|
|
1
1
|
"use client";
|
|
2
|
-
import { checkoutReturnUrl } from "./checkout-return.js";
|
|
3
2
|
const PAY_PAGE_LANG_PARAM = "lang";
|
|
4
3
|
const PAY_PAGE_LANGUAGES = ["de-CH", "en", "es", "fr", "it", "hu", "zh"];
|
|
5
4
|
const PADDLE_LOCALES = {
|
|
@@ -51,6 +50,7 @@ function payPageStrings(labels) {
|
|
|
51
50
|
nothing: labels.billing.payNothing,
|
|
52
51
|
failed: labels.billing.payFailed,
|
|
53
52
|
closed: labels.billing.payBack,
|
|
53
|
+
done: labels.billing.payDone,
|
|
54
54
|
back: labels.common.back,
|
|
55
55
|
terms: labels.legal.links.terms,
|
|
56
56
|
privacy: labels.legal.links.privacy,
|
|
@@ -58,8 +58,17 @@ function payPageStrings(labels) {
|
|
|
58
58
|
};
|
|
59
59
|
}
|
|
60
60
|
const PAY_CONFIG_FILE = "pay-config.json";
|
|
61
|
+
const APP_TAG = /^[a-z][a-z0-9_-]{0,63}$/;
|
|
62
|
+
function appAt(name, site, returnPath) {
|
|
63
|
+
return { name, returnUrl: `${site}${returnPath}`, termsUrl: `${site}/terms`, privacyUrl: `${site}/privacy` };
|
|
64
|
+
}
|
|
65
|
+
const PAY_PAGE_APPS = Object.freeze({
|
|
66
|
+
keksdose: Object.freeze(appAt("Keksdose", "https://keksdose.app", "/settings/subscription")),
|
|
67
|
+
kastlan: Object.freeze(appAt("Kastlan", "https://kastlan.app", "/admin/billing")),
|
|
68
|
+
kurvenschmiede: Object.freeze(appAt("Kurvenschmiede", "https://kurvenschmiede.app", "/settings/subscription"))
|
|
69
|
+
});
|
|
61
70
|
const TOKEN_PREFIX = { sandbox: "test_", live: "live_" };
|
|
62
|
-
function
|
|
71
|
+
function isPayPageUrl(value) {
|
|
63
72
|
if (typeof value !== "string") return false;
|
|
64
73
|
let url;
|
|
65
74
|
try {
|
|
@@ -70,6 +79,22 @@ function isPageUrl(value) {
|
|
|
70
79
|
if (url.protocol === "https:") return true;
|
|
71
80
|
return url.protocol === "http:" && ["localhost", "127.0.0.1", "[::1]"].includes(url.hostname);
|
|
72
81
|
}
|
|
82
|
+
function hasApp(apps, tag) {
|
|
83
|
+
return typeof tag === "string" && APP_TAG.test(tag) && typeof apps === "object" && apps !== null && Object.prototype.hasOwnProperty.call(apps, tag);
|
|
84
|
+
}
|
|
85
|
+
function payPageAppFor(config, tag) {
|
|
86
|
+
return hasApp(config.apps, tag) ? config.apps[tag] : void 0;
|
|
87
|
+
}
|
|
88
|
+
function appProblems(tag, app) {
|
|
89
|
+
if (typeof app !== "object" || app === null) return [`apps.${tag} is not an object`];
|
|
90
|
+
const a = app;
|
|
91
|
+
const problems = [];
|
|
92
|
+
if (typeof a.name !== "string" || a.name.trim() === "") problems.push(`apps.${tag}.name is missing`);
|
|
93
|
+
for (const key of ["returnUrl", "termsUrl", "privacyUrl"]) {
|
|
94
|
+
if (!isPayPageUrl(a[key])) problems.push(`apps.${tag}.${key} must be an https:// URL (http:// for localhost only)`);
|
|
95
|
+
}
|
|
96
|
+
return problems;
|
|
97
|
+
}
|
|
73
98
|
function payPageConfigProblems(config) {
|
|
74
99
|
if (typeof config !== "object" || config === null) return ["the configuration is not an object"];
|
|
75
100
|
const c = config;
|
|
@@ -90,24 +115,33 @@ function payPageConfigProblems(config) {
|
|
|
90
115
|
);
|
|
91
116
|
}
|
|
92
117
|
}
|
|
93
|
-
|
|
94
|
-
|
|
118
|
+
const { apps, app } = c;
|
|
119
|
+
if (typeof apps !== "object" || apps === null || Array.isArray(apps) || Object.keys(apps).length === 0) {
|
|
120
|
+
problems.push("apps must map each app's tag to its name and URLs");
|
|
121
|
+
} else {
|
|
122
|
+
for (const [tag, entry] of Object.entries(apps)) {
|
|
123
|
+
if (!APP_TAG.test(tag)) problems.push(`apps: ${JSON.stringify(tag)} is not an app tag (a-z, 0-9, _ and -)`);
|
|
124
|
+
else problems.push(...appProblems(tag, entry));
|
|
125
|
+
}
|
|
126
|
+
}
|
|
127
|
+
if (typeof app !== "string" || app === "") problems.push("app is missing: the host's own app tag");
|
|
128
|
+
else if (!hasApp(apps, app)) {
|
|
129
|
+
const known = typeof apps === "object" && apps !== null ? Object.keys(apps).join(", ") : "";
|
|
130
|
+
problems.push(`app ${JSON.stringify(app)} is not in apps${known ? ` (${known})` : ""}`);
|
|
95
131
|
}
|
|
96
|
-
if (typeof c.appName !== "string" || c.appName.trim() === "") problems.push("appName is missing");
|
|
97
132
|
return problems;
|
|
98
133
|
}
|
|
99
|
-
function paySuccessUrl(config) {
|
|
100
|
-
return checkoutReturnUrl(config.returnUrl);
|
|
101
|
-
}
|
|
102
134
|
export {
|
|
103
135
|
PAY_CONFIG_FILE,
|
|
136
|
+
PAY_PAGE_APPS,
|
|
104
137
|
PAY_PAGE_LANG_PARAM,
|
|
105
138
|
PAY_PAGE_TRANSACTION_PARAM,
|
|
139
|
+
isPayPageUrl,
|
|
106
140
|
paddleLocale,
|
|
141
|
+
payPageAppFor,
|
|
107
142
|
payPageConfigProblems,
|
|
108
143
|
payPageLanguage,
|
|
109
144
|
payPageStrings,
|
|
110
|
-
payPageUrl
|
|
111
|
-
paySuccessUrl
|
|
145
|
+
payPageUrl
|
|
112
146
|
};
|
|
113
147
|
//# sourceMappingURL=pay-page.js.map
|
|
@@ -1 +1 @@
|
|
|
1
|
-
{"version":3,"sources":["../../src/billing/pay-page.ts"],"sourcesContent":["import type { UiKitLabels } from \"../i18n/kit-labels\";\nimport { checkoutReturnUrl } from \"./checkout-return\";\n\n/**\n * The kit's pay page (docs/billing-harmonization.md §14.4, decision 19 as amended): a\n * standalone static page that ui-kit ships as `dist/pay/`, and that each app serves on\n * its own pay host, `pay.<app domain>` — a separate origin, approved in Paddle, where\n * Paddle.js shares the origin with nothing: no sign-in, no app code, no storage.\n *\n * Paddle's create-transaction answers `checkout.url`: the pay page's URL plus\n * `?_ptxn=<txn>`, and Paddle.js on that page opens the transaction by itself. The app\n * sends the buyer there with {@link payPageUrl}, which adds the buyer's language; the\n * page maps it to Paddle's checkout locale ({@link paddleLocale}). Everything else the\n * page needs — the client-side token, sandbox or live, the way back — is the app's\n * `pay-config.json`, written at its web build by the `eifi1-pay-page` script.\n *\n * Pure, and React-free: the page's own script (`pay-page/pay.ts`) and that build script\n * bundle this module. The exports below the barrel's four are theirs.\n */\n\n/** Paddle's two environments: the sandbox's client-side tokens start `test_`, live\n * ones `live_`. */\nexport type PaddleEnvironment = \"sandbox\" | \"live\";\n\n/** The pay page's language parameter: `?lang=<kit locale>`. */\nexport const PAY_PAGE_LANG_PARAM = \"lang\";\n\n/** The kit's seven languages (`KitLanguageCode`), as the page reads them. */\nconst PAY_PAGE_LANGUAGES = [\"de-CH\", \"en\", \"es\", \"fr\", \"it\", \"hu\", \"zh\"] as const;\n\n/** One of the kit's languages, as the page speaks it. */\nexport type PayPageLanguage = (typeof PAY_PAGE_LANGUAGES)[number];\n\n/**\n * The kit's language → Paddle's checkout locale (`Paddle.Checkout.open`,\n * `settings.locale`, read 2026-10-09: ar, zh-Hans, zh-TW, da, nl, en, fr, de, it, ja, ko,\n * no, pl, pt, pt-BR, tr, ru, es, sv). Paddle has no Hungarian: a Hungarian buyer gets\n * English always, not Paddle's guess from the browser. The page's own lines stay in the\n * kit's language, Hungarian included.\n */\nconst PADDLE_LOCALES: Readonly<Record<PayPageLanguage, string>> = {\n en: \"en\",\n \"de-CH\": \"de\",\n fr: \"fr\",\n it: \"it\",\n es: \"es\",\n zh: \"zh-Hans\",\n hu: \"en\",\n};\n\n/** Paddle's transaction parameter on a pay page link. @internal */\nexport const PAY_PAGE_TRANSACTION_PARAM = \"_ptxn\";\n\n/**\n * The kit language one tag names, or undefined — `resolveLanguage`'s rule\n * (src/i18n/languages.ts), restated here because that module loads the seven catalogues\n * and the page must stay a few kilobytes: exact first, ignoring case and reading `_` as\n * `-`; then by language, so every German is `de-CH`, every Chinese `zh` (Simplified),\n * and any regional form its base (`fr-CA` → `fr`).\n */\nfunction kitLanguageOf(tag: unknown): PayPageLanguage | undefined {\n if (typeof tag !== \"string\") return undefined;\n const clean = tag.trim().replace(/_/g, \"-\");\n if (!clean) return undefined;\n const lower = clean.toLowerCase();\n const exact = PAY_PAGE_LANGUAGES.find((code) => code.toLowerCase() === lower);\n if (exact) return exact;\n const language = lower.split(\"-\")[0];\n return PAY_PAGE_LANGUAGES.find((code) => code.split(\"-\")[0].toLowerCase() === language);\n}\n\n/**\n * The kit's locale → Paddle's checkout locale: `de-CH` → `de`, `zh` → `zh-Hans`, `hu` →\n * `en` (Paddle has no Hungarian), the others as they are. Any tag is read as the kit\n * reads it (`de-AT` is the one German, `zh-TW` the one Chinese); one the kit doesn't\n * ship is English, and so is `undefined` (0.33.1) — `useKitLocale()` with no provider\n * locale.\n */\nexport function paddleLocale(kitLocale: string | undefined): string {\n return PADDLE_LOCALES[kitLanguageOf(kitLocale) ?? \"en\"];\n}\n\n/**\n * The checkout's `{url}` with the buyer's language: `lang=<kit locale>` on a pay page\n * link (one that carries `_ptxn`); any other URL — a hosted checkout's, which takes its\n * own `locale` — unchanged, as is a locale the kit doesn't ship.\n *\n * const { url } = await api.checkout({ plan, interval, currency });\n * noteCheckoutStarted(user.id, overview);\n * window.location.assign(payPageUrl(url, locale)); // locale = useKitLocale()\n *\n * `locale` may be `undefined` (0.33.1, keksdose): `useKitLocale()` answers that with no\n * provider locale, the runtime's language. The link then goes without `lang`, and the\n * page speaks the browser's language, else English — what a missing or wrong `lang`\n * already did: it costs nothing but the language.\n */\nexport function payPageUrl(checkoutUrl: string, locale: string | undefined): string {\n const language = kitLanguageOf(locale);\n if (!language) return checkoutUrl;\n let url: URL;\n try {\n url = new URL(checkoutUrl);\n } catch {\n return checkoutUrl;\n }\n if (!url.searchParams.has(PAY_PAGE_TRANSACTION_PARAM)) return checkoutUrl;\n url.searchParams.set(PAY_PAGE_LANG_PARAM, language);\n return url.toString();\n}\n\n/**\n * The language the page speaks: its `lang` parameter, else the browser's languages in\n * their order, each matched to the kit's; else English. @internal\n */\nexport function payPageLanguage(\n lang: string | null | undefined,\n browserLanguages: readonly string[] = [],\n): PayPageLanguage {\n for (const candidate of [lang, ...browserLanguages]) {\n const found = kitLanguageOf(candidate);\n if (found) return found;\n }\n return \"en\";\n}\n\n/* ── The page's words ──────────────────────────────────────────────────────── */\n\n/**\n * The page's words in one language — built into `pay.js` at the kit's build from the\n * seven catalogues, since the page has no React and no provider. @internal\n */\nexport interface PayPageStrings {\n /** `billing.payOpening`, while the checkout opens. */\n opening: string;\n /** `billing.payNothing`: no `_ptxn`, or no token (billing off). */\n nothing: string;\n /** `billing.payFailed`: Paddle.js didn't load, or the configuration is wrong. */\n failed: string;\n /** `billing.payBack`: the buyer closed the checkout. */\n closed: string;\n /** `common.back`: the link to the app's subscription page. */\n back: string;\n /** `legal.links.terms` and `legal.links.privacy`: the footer's links. */\n terms: string;\n privacy: string;\n /** `legal.navLabel`: the footer's `<nav>`. */\n legal: string;\n}\n\n/** The page's words, read from one of the kit's catalogues. @internal */\nexport function payPageStrings(labels: UiKitLabels): PayPageStrings {\n return {\n opening: labels.billing.payOpening,\n nothing: labels.billing.payNothing,\n failed: labels.billing.payFailed,\n closed: labels.billing.payBack,\n back: labels.common.back,\n terms: labels.legal.links.terms,\n privacy: labels.legal.links.privacy,\n legal: labels.legal.navLabel,\n };\n}\n\n/* ── pay-config.json ───────────────────────────────────────────────────────── */\n\n/** The configuration file beside the page, written at each app's web build. @internal */\nexport const PAY_CONFIG_FILE = \"pay-config.json\";\n\n/**\n * `pay-config.json`: what the page needs that differs per app and deployment. Read by\n * the page from its own origin — never from the query, where Paddle's own links (it\n * puts only `_ptxn` there) would lack it and a return URL would be an open redirect on\n * an approved payment domain. @internal\n */\nexport interface PayPageConfig {\n /** Paddle's client-side token (`test_…` for the sandbox, `live_…` for live); null\n * while billing is off, and the page says `payNothing`. */\n token: string | null;\n environment: PaddleEnvironment | null;\n /** The app's subscription page. Paddle.js's `successUrl` is\n * `checkoutReturnUrl(returnUrl)`; \"Back\" goes here. */\n returnUrl: string;\n appName: string;\n termsUrl: string;\n privacyUrl: string;\n}\n\n/** Paddle's client-side token prefixes, per environment. */\nconst TOKEN_PREFIX: Readonly<Record<PaddleEnvironment, string>> = { sandbox: \"test_\", live: \"live_\" };\n\n/** `https://`, or `http://` on this machine only (`localhost`, `127.0.0.1`, `[::1]`). */\nfunction isPageUrl(value: unknown): boolean {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol === \"https:\") return true;\n return url.protocol === \"http:\" && [\"localhost\", \"127.0.0.1\", \"[::1]\"].includes(url.hostname);\n}\n\n/**\n * What is wrong with a `pay-config.json`, one line each — `[]` when nothing is. The\n * build script refuses to write a file with any (so a wrong pairing fails the build,\n * not a buyer), and the page says `payFailed` for one it reads:\n *\n * - a token whose prefix disagrees with the environment (`test_` ↔ sandbox, `live_` ↔\n * live), or a token without an environment;\n * - a return, terms or privacy URL that isn't `https://` (`http://` for localhost only);\n * - no app name. @internal\n */\nexport function payPageConfigProblems(config: unknown): string[] {\n if (typeof config !== \"object\" || config === null) return [\"the configuration is not an object\"];\n const c = config as Record<string, unknown>;\n const problems: string[] = [];\n const { token, environment } = c;\n if (environment !== null && environment !== undefined && environment !== \"sandbox\" && environment !== \"live\") {\n problems.push(`environment must be \"sandbox\" or \"live\", not ${JSON.stringify(environment)}`);\n }\n if (token !== null && token !== undefined) {\n if (typeof token !== \"string\" || token === \"\") {\n problems.push(\"token must be Paddle's client-side token, or null while billing is off\");\n } else if (environment !== \"sandbox\" && environment !== \"live\") {\n problems.push(\"a token needs its environment: sandbox or live\");\n } else if (!token.startsWith(TOKEN_PREFIX[environment]) || token.length === TOKEN_PREFIX[environment].length) {\n const other = environment === \"sandbox\" ? \"live\" : \"sandbox\";\n problems.push(\n token.startsWith(TOKEN_PREFIX[other])\n ? `the token is a ${other} token (${TOKEN_PREFIX[other]}…), but the environment is ${environment}`\n : `a ${environment} token starts with ${TOKEN_PREFIX[environment]}`,\n );\n }\n }\n for (const key of [\"returnUrl\", \"termsUrl\", \"privacyUrl\"] as const) {\n if (!isPageUrl(c[key])) problems.push(`${key} must be an https:// URL (http:// for localhost only)`);\n }\n if (typeof c.appName !== \"string\" || c.appName.trim() === \"\") problems.push(\"appName is missing\");\n return problems;\n}\n\n/** Paddle.js's `successUrl` for a configuration: the way back, `?checkout=done`. @internal */\nexport function paySuccessUrl(config: Pick<PayPageConfig, \"returnUrl\">): string {\n return checkoutReturnUrl(config.returnUrl);\n}\n"],"mappings":"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","names":[]}
|
|
1
|
+
{"version":3,"sources":["../../src/billing/pay-page.ts"],"sourcesContent":["import type { UiKitLabels } from \"../i18n/kit-labels\";\n\n/**\n * The kit's pay page (docs/billing-harmonization.md §14.4, decision 19 as amended by\n * §15.2): a standalone static page that ui-kit ships as `dist/pay/`, and that each app\n * serves on its own pay host, `pay.<app domain>` — a separate origin, approved in Paddle,\n * where Paddle.js shares the origin with nothing: no sign-in, no app code, no storage.\n * Since 0.39 every pay page serves ANY app's transaction: it names the app from the\n * transaction's custom data, from the kit's map {@link PAY_PAGE_APPS}, not from its\n * domain — Paddle's single default payment link reaches one host for all three apps.\n *\n * Paddle's create-transaction answers `checkout.url`: the pay page's URL plus\n * `?_ptxn=<txn>`, and Paddle.js on that page opens the transaction by itself. The app\n * sends the buyer there with {@link payPageUrl}, which adds the buyer's language; the\n * page maps it to Paddle's checkout locale ({@link paddleLocale}). Everything else the\n * page needs — the client-side token, sandbox or live, the host's app and the map of\n * apps with their ways back — is the host's `pay-config.json`, written at its web build\n * by the `eifi1-pay-page` script.\n *\n * Pure, and React-free: the page's own script (`pay-page/pay.ts`) and that build script\n * bundle this module. The barrel exports the link's parts and the app map ({@link PAY_PAGE_APPS},\n * {@link PayPageApp}); the exports marked @internal are theirs.\n */\n\n/** Paddle's two environments: the sandbox's client-side tokens start `test_`, live\n * ones `live_`. */\nexport type PaddleEnvironment = \"sandbox\" | \"live\";\n\n/** The pay page's language parameter: `?lang=<kit locale>`. */\nexport const PAY_PAGE_LANG_PARAM = \"lang\";\n\n/** The kit's seven languages (`KitLanguageCode`), as the page reads them. */\nconst PAY_PAGE_LANGUAGES = [\"de-CH\", \"en\", \"es\", \"fr\", \"it\", \"hu\", \"zh\"] as const;\n\n/** One of the kit's languages, as the page speaks it. */\nexport type PayPageLanguage = (typeof PAY_PAGE_LANGUAGES)[number];\n\n/**\n * The kit's language → Paddle's checkout locale (`Paddle.Checkout.open`,\n * `settings.locale`, read 2026-10-09: ar, zh-Hans, zh-TW, da, nl, en, fr, de, it, ja, ko,\n * no, pl, pt, pt-BR, tr, ru, es, sv). Paddle has no Hungarian: a Hungarian buyer gets\n * English always, not Paddle's guess from the browser. The page's own lines stay in the\n * kit's language, Hungarian included.\n */\nconst PADDLE_LOCALES: Readonly<Record<PayPageLanguage, string>> = {\n en: \"en\",\n \"de-CH\": \"de\",\n fr: \"fr\",\n it: \"it\",\n es: \"es\",\n zh: \"zh-Hans\",\n hu: \"en\",\n};\n\n/** Paddle's transaction parameter on a pay page link. @internal */\nexport const PAY_PAGE_TRANSACTION_PARAM = \"_ptxn\";\n\n/**\n * The kit language one tag names, or undefined — `resolveLanguage`'s rule\n * (src/i18n/languages.ts), restated here because that module loads the seven catalogues\n * and the page must stay a few kilobytes: exact first, ignoring case and reading `_` as\n * `-`; then by language, so every German is `de-CH`, every Chinese `zh` (Simplified),\n * and any regional form its base (`fr-CA` → `fr`).\n */\nfunction kitLanguageOf(tag: unknown): PayPageLanguage | undefined {\n if (typeof tag !== \"string\") return undefined;\n const clean = tag.trim().replace(/_/g, \"-\");\n if (!clean) return undefined;\n const lower = clean.toLowerCase();\n const exact = PAY_PAGE_LANGUAGES.find((code) => code.toLowerCase() === lower);\n if (exact) return exact;\n const language = lower.split(\"-\")[0];\n return PAY_PAGE_LANGUAGES.find((code) => code.split(\"-\")[0].toLowerCase() === language);\n}\n\n/**\n * The kit's locale → Paddle's checkout locale: `de-CH` → `de`, `zh` → `zh-Hans`, `hu` →\n * `en` (Paddle has no Hungarian), the others as they are. Any tag is read as the kit\n * reads it (`de-AT` is the one German, `zh-TW` the one Chinese); one the kit doesn't\n * ship is English, and so is `undefined` (0.33.1) — `useKitLocale()` with no provider\n * locale.\n */\nexport function paddleLocale(kitLocale: string | undefined): string {\n return PADDLE_LOCALES[kitLanguageOf(kitLocale) ?? \"en\"];\n}\n\n/**\n * The checkout's `{url}` with the buyer's language: `lang=<kit locale>` on a pay page\n * link (one that carries `_ptxn`); any other URL — a hosted checkout's, which takes its\n * own `locale` — unchanged, as is a locale the kit doesn't ship.\n *\n * const { url } = await api.checkout({ plan, interval, currency });\n * noteCheckoutStarted(user.id, overview);\n * window.location.assign(payPageUrl(url, locale)); // locale = useKitLocale()\n *\n * `locale` may be `undefined` (0.33.1, keksdose): `useKitLocale()` answers that with no\n * provider locale, the runtime's language. The link then goes without `lang`, and the\n * page speaks the browser's language, else English — what a missing or wrong `lang`\n * already did: it costs nothing but the language.\n */\nexport function payPageUrl(checkoutUrl: string, locale: string | undefined): string {\n const language = kitLanguageOf(locale);\n if (!language) return checkoutUrl;\n let url: URL;\n try {\n url = new URL(checkoutUrl);\n } catch {\n return checkoutUrl;\n }\n if (!url.searchParams.has(PAY_PAGE_TRANSACTION_PARAM)) return checkoutUrl;\n url.searchParams.set(PAY_PAGE_LANG_PARAM, language);\n return url.toString();\n}\n\n/**\n * The language the page speaks: its `lang` parameter, else the browser's languages in\n * their order, each matched to the kit's; else English. @internal\n */\nexport function payPageLanguage(\n lang: string | null | undefined,\n browserLanguages: readonly string[] = [],\n): PayPageLanguage {\n for (const candidate of [lang, ...browserLanguages]) {\n const found = kitLanguageOf(candidate);\n if (found) return found;\n }\n return \"en\";\n}\n\n/* ── The page's words ──────────────────────────────────────────────────────── */\n\n/**\n * The page's words in one language — built into `pay.js` at the kit's build from the\n * seven catalogues, since the page has no React and no provider. @internal\n */\nexport interface PayPageStrings {\n /** `billing.payOpening`, while the checkout opens. */\n opening: string;\n /** `billing.payNothing`: no `_ptxn`, or no token (billing off). */\n nothing: string;\n /** `billing.payFailed`: Paddle.js didn't load, or the configuration is wrong. */\n failed: string;\n /** `billing.payBack`: the buyer closed the checkout. */\n closed: string;\n /** `billing.payDone` (0.39): paid, for a transaction that names no app the page\n * knows — there is no way back to offer. */\n done: string;\n /** `common.back`: the link to the app's subscription page. */\n back: string;\n /** `legal.links.terms` and `legal.links.privacy`: the footer's links. */\n terms: string;\n privacy: string;\n /** `legal.navLabel`: the footer's `<nav>`. */\n legal: string;\n}\n\n/** The page's words, read from one of the kit's catalogues. @internal */\nexport function payPageStrings(labels: UiKitLabels): PayPageStrings {\n return {\n opening: labels.billing.payOpening,\n nothing: labels.billing.payNothing,\n failed: labels.billing.payFailed,\n closed: labels.billing.payBack,\n done: labels.billing.payDone,\n back: labels.common.back,\n terms: labels.legal.links.terms,\n privacy: labels.legal.links.privacy,\n legal: labels.legal.navLabel,\n };\n}\n\n/* ── pay-config.json ───────────────────────────────────────────────────────── */\n\n/** The configuration file beside the page, written at each app's web build. @internal */\nexport const PAY_CONFIG_FILE = \"pay-config.json\";\n\n/**\n * One app a pay page serves (docs/billing-harmonization.md §15.2.6): its name, the way\n * back and its Terms and Privacy. Every pay page knows every app, because Paddle's links\n * — its single default payment link, a payment-method update from its e-mails — reach\n * whichever page the account names, whatever app the transaction is for.\n */\nexport interface PayPageApp {\n /** The app's display name: the footer's line and the page's title. */\n name: string;\n /** The app's subscription page. Once paid, the page goes to\n * `checkoutReturnUrl(returnUrl)`; \"Back\" goes here. */\n returnUrl: string;\n termsUrl: string;\n privacyUrl: string;\n}\n\n/** An app's tag: server-kit's `billing_app` pattern, the key of {@link PAY_PAGE_APPS}\n * and the `app` of a transaction's custom data (§14.3). */\nconst APP_TAG = /^[a-z][a-z0-9_-]{0,63}$/;\n\n/** An app on its site: the name, the Terms and Privacy at `/terms` and `/privacy`, and\n * its subscription page. */\nfunction appAt(name: string, site: string, returnPath: string): PayPageApp {\n return { name, returnUrl: `${site}${returnPath}`, termsUrl: `${site}/terms`, privacyUrl: `${site}/privacy` };\n}\n\n/**\n * The apps the kit's pay pages serve, by their `billing_app` tag (§15.2.6) — one source,\n * so the three pay hosts can't drift. A new app, or a page that moves, is a kit release.\n * The bin writes it into `pay-config.json`; `--app-url` moves the host's own app to\n * another site (a local stack, a dev deploy) and `--apps <file>` merges a map over it.\n */\nexport const PAY_PAGE_APPS: Readonly<Record<string, Readonly<PayPageApp>>> = Object.freeze({\n keksdose: Object.freeze(appAt(\"Keksdose\", \"https://keksdose.app\", \"/settings/subscription\")),\n kastlan: Object.freeze(appAt(\"Kastlan\", \"https://kastlan.app\", \"/admin/billing\")),\n kurvenschmiede: Object.freeze(appAt(\"Kurvenschmiede\", \"https://kurvenschmiede.app\", \"/settings/subscription\")),\n});\n\n/**\n * `pay-config.json`: what the page needs that differs per deployment (§15.2.6). Read by\n * the page from its own origin — never from the query, where Paddle's own links (it\n * puts only `_ptxn` there) would lack it and a return URL would be an open redirect on\n * an approved payment domain. @internal\n */\nexport interface PayPageConfig {\n /** Paddle's client-side token (`test_…` for the sandbox, `live_…` for live); null\n * while billing is off, and the page says `payNothing`. */\n token: string | null;\n environment: PaddleEnvironment | null;\n /** The host's own app, a key of `apps`: the page shows it until the checkout loads,\n * and its Terms and Privacy stay for a transaction that names no app. */\n app: string;\n /** Every app the page serves, by tag. The transaction's `custom_data.app` picks one;\n * a return URL only ever comes from here. */\n apps: Record<string, PayPageApp>;\n}\n\n/** Paddle's client-side token prefixes, per environment. */\nconst TOKEN_PREFIX: Readonly<Record<PaddleEnvironment, string>> = { sandbox: \"test_\", live: \"live_\" };\n\n/** `https://`, or `http://` on this machine only (`localhost`, `127.0.0.1`, `[::1]`). @internal */\nexport function isPayPageUrl(value: unknown): boolean {\n if (typeof value !== \"string\") return false;\n let url: URL;\n try {\n url = new URL(value);\n } catch {\n return false;\n }\n if (url.protocol === \"https:\") return true;\n return url.protocol === \"http:\" && [\"localhost\", \"127.0.0.1\", \"[::1]\"].includes(url.hostname);\n}\n\n/** Whether `tag` is a tag of `apps`, its own key — never `__proto__` or `constructor`. */\nfunction hasApp(apps: unknown, tag: unknown): tag is string {\n return (\n typeof tag === \"string\" &&\n APP_TAG.test(tag) &&\n typeof apps === \"object\" &&\n apps !== null &&\n Object.prototype.hasOwnProperty.call(apps, tag)\n );\n}\n\n/**\n * The app a transaction's custom data names (`custom_data.app`, §14.3), from the page's\n * map — or undefined for none, or a tag the map doesn't know. @internal\n */\nexport function payPageAppFor(config: Pick<PayPageConfig, \"apps\">, tag: unknown): PayPageApp | undefined {\n return hasApp(config.apps, tag) ? config.apps[tag] : undefined;\n}\n\n/** The problems of one app of the map. */\nfunction appProblems(tag: string, app: unknown): string[] {\n if (typeof app !== \"object\" || app === null) return [`apps.${tag} is not an object`];\n const a = app as Record<string, unknown>;\n const problems: string[] = [];\n if (typeof a.name !== \"string\" || a.name.trim() === \"\") problems.push(`apps.${tag}.name is missing`);\n for (const key of [\"returnUrl\", \"termsUrl\", \"privacyUrl\"] as const) {\n if (!isPayPageUrl(a[key])) problems.push(`apps.${tag}.${key} must be an https:// URL (http:// for localhost only)`);\n }\n return problems;\n}\n\n/**\n * What is wrong with a `pay-config.json`, one line each — `[]` when nothing is. The\n * build script refuses to write a file with any (so a wrong pairing fails the build,\n * not a buyer), and the page says `payFailed` for one it reads:\n *\n * - a token whose prefix disagrees with the environment (`test_` ↔ sandbox, `live_` ↔\n * live), or a token without an environment;\n * - an app tag that isn't a `billing_app` tag (`^[a-z][a-z0-9_-]{0,63}$`), an app\n * without a name, or a return, terms or privacy URL that isn't `https://` (`http://`\n * for localhost only);\n * - no `app`, or one that isn't in `apps`. @internal\n */\nexport function payPageConfigProblems(config: unknown): string[] {\n if (typeof config !== \"object\" || config === null) return [\"the configuration is not an object\"];\n const c = config as Record<string, unknown>;\n const problems: string[] = [];\n const { token, environment } = c;\n if (environment !== null && environment !== undefined && environment !== \"sandbox\" && environment !== \"live\") {\n problems.push(`environment must be \"sandbox\" or \"live\", not ${JSON.stringify(environment)}`);\n }\n if (token !== null && token !== undefined) {\n if (typeof token !== \"string\" || token === \"\") {\n problems.push(\"token must be Paddle's client-side token, or null while billing is off\");\n } else if (environment !== \"sandbox\" && environment !== \"live\") {\n problems.push(\"a token needs its environment: sandbox or live\");\n } else if (!token.startsWith(TOKEN_PREFIX[environment]) || token.length === TOKEN_PREFIX[environment].length) {\n const other = environment === \"sandbox\" ? \"live\" : \"sandbox\";\n problems.push(\n token.startsWith(TOKEN_PREFIX[other])\n ? `the token is a ${other} token (${TOKEN_PREFIX[other]}…), but the environment is ${environment}`\n : `a ${environment} token starts with ${TOKEN_PREFIX[environment]}`,\n );\n }\n }\n const { apps, app } = c;\n if (typeof apps !== \"object\" || apps === null || Array.isArray(apps) || Object.keys(apps).length === 0) {\n problems.push(\"apps must map each app's tag to its name and URLs\");\n } else {\n for (const [tag, entry] of Object.entries(apps)) {\n if (!APP_TAG.test(tag)) problems.push(`apps: ${JSON.stringify(tag)} is not an app tag (a-z, 0-9, _ and -)`);\n else problems.push(...appProblems(tag, entry));\n }\n }\n if (typeof app !== \"string\" || app === \"\") problems.push(\"app is missing: the host's own app tag\");\n else if (!hasApp(apps, app)) {\n const known = typeof apps === \"object\" && apps !== null ? Object.keys(apps).join(\", \") : \"\";\n problems.push(`app ${JSON.stringify(app)} is not in apps${known ? ` (${known})` : \"\"}`);\n }\n return problems;\n}\n"],"mappings":"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","names":[]}
|
|
@@ -0,0 +1,79 @@
|
|
|
1
|
+
import type { LabelOverride } from "../i18n/kit-labels.js";
|
|
2
|
+
import type { PluralForms } from "../i18n/plurals.js";
|
|
3
|
+
import type { BillingLabels } from "./billing-labels.js";
|
|
4
|
+
import type { PlanSwitchPreview } from "./billing-overview.js";
|
|
5
|
+
import type { BillingInterval } from "./plan-price.js";
|
|
6
|
+
/**
|
|
7
|
+
* The payer's plan switch (docs/billing-harmonization.md §15.2.10, ui-kit 0.39): built
|
|
8
|
+
* once in the kits and used by all three apps, instead of Paddle's portal. While a
|
|
9
|
+
* provider subscription runs, the app sends a `PlanPicker` pick to
|
|
10
|
+
* `POST /billing/plan/preview` and opens this dialog with the answer; Confirm is the
|
|
11
|
+
* app's `POST /billing/plan`. Otherwise the pick goes to the checkout, as before.
|
|
12
|
+
*
|
|
13
|
+
* The switch is immediate and prorated: an upgrade charges the difference now, a
|
|
14
|
+
* downgrade credits the unused part to the customer's balance, which Paddle takes off
|
|
15
|
+
* the next charges. So the payer agrees to a figure — Paddle's preview — and sees the
|
|
16
|
+
* dimensions over the new plan's limit before agreeing (§3.4: a limit gates creation
|
|
17
|
+
* only, so the switch is allowed).
|
|
18
|
+
*/
|
|
19
|
+
/** One side of a switch: a plan code and its billing period. */
|
|
20
|
+
export interface PlanSwitchSide {
|
|
21
|
+
plan: string;
|
|
22
|
+
interval: BillingInterval;
|
|
23
|
+
}
|
|
24
|
+
export interface PlanSwitchConfirmProps {
|
|
25
|
+
open: boolean;
|
|
26
|
+
/** `false` when the payer dismisses it — Cancel, Escape, the backdrop. Confirm leaves
|
|
27
|
+
* it open: close it once the switch answers. */
|
|
28
|
+
onOpenChange: (open: boolean) => void;
|
|
29
|
+
/** What the payer pays now: the overview's `paid`. */
|
|
30
|
+
from: PlanSwitchSide;
|
|
31
|
+
/** The pick. */
|
|
32
|
+
to: PlanSwitchSide;
|
|
33
|
+
/** `POST /billing/plan/preview`'s answer. Undefined while it loads: a spinner, and
|
|
34
|
+
* Confirm is held. */
|
|
35
|
+
preview?: PlanSwitchPreview;
|
|
36
|
+
/** `POST /billing/plan` is under way: Confirm's spinner, no second press, and Escape
|
|
37
|
+
* doesn't close. */
|
|
38
|
+
pending?: boolean;
|
|
39
|
+
/** The plan's display name, the app's (§3.1). Default: the code. */
|
|
40
|
+
planName?: (code: string) => string;
|
|
41
|
+
/**
|
|
42
|
+
* The app's word per dimension, for the over-limit lines. A heading's word ("Budgets")
|
|
43
|
+
* reads "Budgets: 7 of 5" (`billing.limitUsageLine`); its plural forms
|
|
44
|
+
* (`{ one: "budget", other: "budgets" }`) read "7 of 5 budgets" (`planChange.usageOf`,
|
|
45
|
+
* the form the limit takes). A dimension without one is named by its key.
|
|
46
|
+
*/
|
|
47
|
+
dimensionLabels?: Readonly<Record<string, string | PluralForms>>;
|
|
48
|
+
/** How a usage or limit figure is written. Default: a number in the locale. */
|
|
49
|
+
formatLimit?: (dimension: string, value: number) => string;
|
|
50
|
+
/** Confirm: the app's `POST /billing/plan` with `{plan, interval}` of `to`. */
|
|
51
|
+
onConfirm: () => void;
|
|
52
|
+
/** The figures' and the date's locale. Default: the provider's. */
|
|
53
|
+
locale?: string;
|
|
54
|
+
/** Prop > `<UiKitProvider labels={{ billing }}>` > English. */
|
|
55
|
+
labels?: LabelOverride<BillingLabels>;
|
|
56
|
+
}
|
|
57
|
+
/**
|
|
58
|
+
* The confirm for a plan switch (§15.2.10): from → to, each with its billing period;
|
|
59
|
+
* Paddle's preview — "Charged now" (an upgrade) or "Credited towards your next charges"
|
|
60
|
+
* (a downgrade), and the next charge with its date — in the subscription's currency;
|
|
61
|
+
* the dimensions over the new plan's limit; and Confirm.
|
|
62
|
+
*
|
|
63
|
+
* const preview = useMutation({ mutationFn: api.previewPlanSwitch });
|
|
64
|
+
* <PlanSwitchConfirm
|
|
65
|
+
* open={pick !== null}
|
|
66
|
+
* onOpenChange={(open) => !open && setPick(null)}
|
|
67
|
+
* from={overview.paid}
|
|
68
|
+
* to={pick}
|
|
69
|
+
* preview={preview.data}
|
|
70
|
+
* pending={switching.isPending}
|
|
71
|
+
* planName={planName}
|
|
72
|
+
* onConfirm={() => switching.mutate(pick)}
|
|
73
|
+
* />
|
|
74
|
+
*
|
|
75
|
+
* It takes no billing lock (`COMMIT_EXCEPT_BILLING` built in): a read-only payer may
|
|
76
|
+
* change plan, as they may pay. A demo lock still holds it, as the server's demo guard
|
|
77
|
+
* does. The kit sends no request.
|
|
78
|
+
*/
|
|
79
|
+
export declare function PlanSwitchConfirm({ open, onOpenChange, from, to, preview, pending, planName, dimensionLabels, formatLimit, onConfirm, locale: localeProp, labels: labelsProp, }: PlanSwitchConfirmProps): import("react").JSX.Element;
|
|
@@ -0,0 +1,93 @@
|
|
|
1
|
+
"use client";
|
|
2
|
+
import { Fragment, jsx, jsxs } from "react/jsx-runtime";
|
|
3
|
+
import { useKitLabels, useKitLocale } from "../i18n/kit-labels.js";
|
|
4
|
+
import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels.js";
|
|
5
|
+
import { pluralForm } from "../i18n/plurals.js";
|
|
6
|
+
import { formatDate, formatMoney, formatNumber } from "../lib/format.js";
|
|
7
|
+
import { Button } from "../components/button.js";
|
|
8
|
+
import { DialogFrame } from "../components/dialog-frame.js";
|
|
9
|
+
import { Spinner } from "../components/spinner.js";
|
|
10
|
+
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock.js";
|
|
11
|
+
import { DEFAULT_PLAN_CHANGE_LABELS } from "../admin/plan-change-labels.js";
|
|
12
|
+
import { useBillingLabels } from "./billing-labels.js";
|
|
13
|
+
import { minorToMajor } from "./plan-price.js";
|
|
14
|
+
function PlanSwitchConfirm({
|
|
15
|
+
open,
|
|
16
|
+
onOpenChange,
|
|
17
|
+
from,
|
|
18
|
+
to,
|
|
19
|
+
preview,
|
|
20
|
+
pending = false,
|
|
21
|
+
planName = (code) => code,
|
|
22
|
+
dimensionLabels,
|
|
23
|
+
formatLimit,
|
|
24
|
+
onConfirm,
|
|
25
|
+
locale: localeProp,
|
|
26
|
+
labels: labelsProp
|
|
27
|
+
}) {
|
|
28
|
+
const labels = useBillingLabels(labelsProp);
|
|
29
|
+
const planChange = useKitLabels("planChange", DEFAULT_PLAN_CHANGE_LABELS);
|
|
30
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
31
|
+
const locale = useKitLocale(localeProp);
|
|
32
|
+
const period = (interval) => interval === "year" ? labels.yearly : labels.monthly;
|
|
33
|
+
const side = (s) => `${planName(s.plan.toLowerCase())} (${period(s.interval)})`;
|
|
34
|
+
const money = (minor, currency) => formatMoney(minorToMajor(minor, currency), currency, { locale });
|
|
35
|
+
const figure = (dimension, value) => formatLimit ? formatLimit(dimension, value) : formatNumber(value, { locale });
|
|
36
|
+
const overLimit = (p) => (p.over_limit ?? []).flatMap((dimension) => {
|
|
37
|
+
const limit = p.limits?.[dimension];
|
|
38
|
+
const used = p.usage?.[dimension];
|
|
39
|
+
if (limit === null || limit === void 0 || used === void 0) return [];
|
|
40
|
+
const word = dimensionLabels?.[dimension] ?? dimension;
|
|
41
|
+
return [
|
|
42
|
+
typeof word === "string" ? labels.limitUsageLine(word, figure(dimension, used), figure(dimension, limit)) : planChange.usageOf(figure(dimension, used), figure(dimension, limit), pluralForm(limit, word, locale))
|
|
43
|
+
];
|
|
44
|
+
});
|
|
45
|
+
const credit = preview !== void 0 && preview.now_minor < 0;
|
|
46
|
+
const lines = preview ? overLimit(preview) : [];
|
|
47
|
+
return /* @__PURE__ */ jsx(
|
|
48
|
+
DialogFrame,
|
|
49
|
+
{
|
|
50
|
+
open,
|
|
51
|
+
title: labels.switchTitle,
|
|
52
|
+
description: labels.switchFromTo(side(from), side(to)),
|
|
53
|
+
onClose: () => onOpenChange(false),
|
|
54
|
+
onKeyDown: (e) => {
|
|
55
|
+
if (pending && e.key === "Escape") e.preventDefault();
|
|
56
|
+
},
|
|
57
|
+
"aria-busy": preview === void 0 || pending || void 0,
|
|
58
|
+
actions: (close) => /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
59
|
+
/* @__PURE__ */ jsx(Button, { variant: "secondary", onClick: close, disabled: pending, children: common.cancel }),
|
|
60
|
+
/* @__PURE__ */ jsx(
|
|
61
|
+
Button,
|
|
62
|
+
{
|
|
63
|
+
commit: COMMIT_EXCEPT_BILLING,
|
|
64
|
+
pending,
|
|
65
|
+
disabled: preview === void 0,
|
|
66
|
+
onClick: () => {
|
|
67
|
+
if (preview !== void 0 && !pending) onConfirm();
|
|
68
|
+
},
|
|
69
|
+
children: labels.switchConfirm
|
|
70
|
+
}
|
|
71
|
+
)
|
|
72
|
+
] }),
|
|
73
|
+
children: preview === void 0 ? /* @__PURE__ */ jsx("div", { className: "flex justify-center py-4", children: /* @__PURE__ */ jsx(Spinner, { showLabel: true }) }) : /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
74
|
+
/* @__PURE__ */ jsxs("dl", { className: "grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 text-sm", "data-plan-switch-preview": true, children: [
|
|
75
|
+
/* @__PURE__ */ jsx("dt", { className: "text-secondary", children: credit ? labels.switchCredited : labels.switchChargedNow }),
|
|
76
|
+
/* @__PURE__ */ jsx("dd", { className: "text-right font-medium tabular-nums", "data-now": credit ? "credit" : "charge", children: money(Math.abs(preview.now_minor), preview.currency) }),
|
|
77
|
+
preview.next_billed_at && preview.next_minor != null && /* @__PURE__ */ jsxs(Fragment, { children: [
|
|
78
|
+
/* @__PURE__ */ jsx("dt", { className: "text-secondary", children: labels.switchNextCharge(formatDate(preview.next_billed_at, "medium", { locale })) }),
|
|
79
|
+
/* @__PURE__ */ jsx("dd", { className: "text-right tabular-nums", children: money(preview.next_minor, preview.currency) })
|
|
80
|
+
] })
|
|
81
|
+
] }),
|
|
82
|
+
lines.length > 0 && /* @__PURE__ */ jsxs("div", { className: "space-y-1 text-sm", "data-plan-switch-over-limit": true, children: [
|
|
83
|
+
/* @__PURE__ */ jsx("p", { children: planChange.overLimit }),
|
|
84
|
+
/* @__PURE__ */ jsx("ul", { className: "list-disc space-y-0.5 ps-5 text-secondary", children: lines.map((line) => /* @__PURE__ */ jsx("li", { children: line }, line)) })
|
|
85
|
+
] })
|
|
86
|
+
] })
|
|
87
|
+
}
|
|
88
|
+
);
|
|
89
|
+
}
|
|
90
|
+
export {
|
|
91
|
+
PlanSwitchConfirm
|
|
92
|
+
};
|
|
93
|
+
//# sourceMappingURL=plan-switch.js.map
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
{"version":3,"sources":["../../src/billing/plan-switch.tsx"],"sourcesContent":["import type { KeyboardEvent } from \"react\";\n\nimport { useKitLabels, useKitLocale } from \"../i18n/kit-labels\";\nimport type { LabelOverride } from \"../i18n/kit-labels\";\nimport { DEFAULT_COMMON_LABELS } from \"../i18n/shared-labels\";\nimport { pluralForm } from \"../i18n/plurals\";\nimport type { PluralForms } from \"../i18n/plurals\";\nimport { formatDate, formatMoney, formatNumber } from \"../lib/format\";\nimport { Button } from \"../components/button\";\nimport { DialogFrame } from \"../components/dialog-frame\";\nimport { Spinner } from \"../components/spinner\";\nimport { COMMIT_EXCEPT_BILLING } from \"../components/write-lock\";\nimport { DEFAULT_PLAN_CHANGE_LABELS } from \"../admin/plan-change-labels\";\nimport { useBillingLabels } from \"./billing-labels\";\nimport type { BillingLabels } from \"./billing-labels\";\nimport type { PlanSwitchPreview } from \"./billing-overview\";\nimport { minorToMajor } from \"./plan-price\";\nimport type { BillingInterval } from \"./plan-price\";\n\n/**\n * The payer's plan switch (docs/billing-harmonization.md §15.2.10, ui-kit 0.39): built\n * once in the kits and used by all three apps, instead of Paddle's portal. While a\n * provider subscription runs, the app sends a `PlanPicker` pick to\n * `POST /billing/plan/preview` and opens this dialog with the answer; Confirm is the\n * app's `POST /billing/plan`. Otherwise the pick goes to the checkout, as before.\n *\n * The switch is immediate and prorated: an upgrade charges the difference now, a\n * downgrade credits the unused part to the customer's balance, which Paddle takes off\n * the next charges. So the payer agrees to a figure — Paddle's preview — and sees the\n * dimensions over the new plan's limit before agreeing (§3.4: a limit gates creation\n * only, so the switch is allowed).\n */\n\n/** One side of a switch: a plan code and its billing period. */\nexport interface PlanSwitchSide {\n plan: string;\n interval: BillingInterval;\n}\n\nexport interface PlanSwitchConfirmProps {\n open: boolean;\n /** `false` when the payer dismisses it — Cancel, Escape, the backdrop. Confirm leaves\n * it open: close it once the switch answers. */\n onOpenChange: (open: boolean) => void;\n /** What the payer pays now: the overview's `paid`. */\n from: PlanSwitchSide;\n /** The pick. */\n to: PlanSwitchSide;\n /** `POST /billing/plan/preview`'s answer. Undefined while it loads: a spinner, and\n * Confirm is held. */\n preview?: PlanSwitchPreview;\n /** `POST /billing/plan` is under way: Confirm's spinner, no second press, and Escape\n * doesn't close. */\n pending?: boolean;\n /** The plan's display name, the app's (§3.1). Default: the code. */\n planName?: (code: string) => string;\n /**\n * The app's word per dimension, for the over-limit lines. A heading's word (\"Budgets\")\n * reads \"Budgets: 7 of 5\" (`billing.limitUsageLine`); its plural forms\n * (`{ one: \"budget\", other: \"budgets\" }`) read \"7 of 5 budgets\" (`planChange.usageOf`,\n * the form the limit takes). A dimension without one is named by its key.\n */\n dimensionLabels?: Readonly<Record<string, string | PluralForms>>;\n /** How a usage or limit figure is written. Default: a number in the locale. */\n formatLimit?: (dimension: string, value: number) => string;\n /** Confirm: the app's `POST /billing/plan` with `{plan, interval}` of `to`. */\n onConfirm: () => void;\n /** The figures' and the date's locale. Default: the provider's. */\n locale?: string;\n /** Prop > `<UiKitProvider labels={{ billing }}>` > English. */\n labels?: LabelOverride<BillingLabels>;\n}\n\n/**\n * The confirm for a plan switch (§15.2.10): from → to, each with its billing period;\n * Paddle's preview — \"Charged now\" (an upgrade) or \"Credited towards your next charges\"\n * (a downgrade), and the next charge with its date — in the subscription's currency;\n * the dimensions over the new plan's limit; and Confirm.\n *\n * const preview = useMutation({ mutationFn: api.previewPlanSwitch });\n * <PlanSwitchConfirm\n * open={pick !== null}\n * onOpenChange={(open) => !open && setPick(null)}\n * from={overview.paid}\n * to={pick}\n * preview={preview.data}\n * pending={switching.isPending}\n * planName={planName}\n * onConfirm={() => switching.mutate(pick)}\n * />\n *\n * It takes no billing lock (`COMMIT_EXCEPT_BILLING` built in): a read-only payer may\n * change plan, as they may pay. A demo lock still holds it, as the server's demo guard\n * does. The kit sends no request.\n */\nexport function PlanSwitchConfirm({\n open,\n onOpenChange,\n from,\n to,\n preview,\n pending = false,\n planName = (code) => code,\n dimensionLabels,\n formatLimit,\n onConfirm,\n locale: localeProp,\n labels: labelsProp,\n}: PlanSwitchConfirmProps) {\n const labels = useBillingLabels(labelsProp);\n const planChange = useKitLabels(\"planChange\", DEFAULT_PLAN_CHANGE_LABELS);\n const common = useKitLabels(\"common\", DEFAULT_COMMON_LABELS);\n const locale = useKitLocale(localeProp);\n\n const period = (interval: BillingInterval) => (interval === \"year\" ? labels.yearly : labels.monthly);\n const side = (s: PlanSwitchSide) => `${planName(s.plan.toLowerCase())} (${period(s.interval)})`;\n const money = (minor: number, currency: string) =>\n formatMoney(minorToMajor(minor, currency), currency, { locale });\n const figure = (dimension: string, value: number) =>\n formatLimit ? formatLimit(dimension, value) : formatNumber(value, { locale });\n\n /** One line per dimension over the new limit, in the word's own sentence. */\n const overLimit = (p: PlanSwitchPreview): string[] =>\n (p.over_limit ?? []).flatMap((dimension) => {\n const limit = p.limits?.[dimension];\n const used = p.usage?.[dimension];\n if (limit === null || limit === undefined || used === undefined) return [];\n const word = dimensionLabels?.[dimension] ?? dimension;\n return [\n typeof word === \"string\"\n ? labels.limitUsageLine(word, figure(dimension, used), figure(dimension, limit))\n : planChange.usageOf(figure(dimension, used), figure(dimension, limit), pluralForm(limit, word, locale)),\n ];\n });\n\n const credit = preview !== undefined && preview.now_minor < 0;\n const lines = preview ? overLimit(preview) : [];\n\n return (\n <DialogFrame\n open={open}\n title={labels.switchTitle}\n description={labels.switchFromTo(side(from), side(to))}\n onClose={() => onOpenChange(false)}\n onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {\n if (pending && e.key === \"Escape\") e.preventDefault();\n }}\n aria-busy={preview === undefined || pending || undefined}\n actions={(close) => (\n <>\n <Button variant=\"secondary\" onClick={close} disabled={pending}>\n {common.cancel}\n </Button>\n <Button\n commit={COMMIT_EXCEPT_BILLING}\n pending={pending}\n disabled={preview === undefined}\n onClick={() => {\n if (preview !== undefined && !pending) onConfirm();\n }}\n >\n {labels.switchConfirm}\n </Button>\n </>\n )}\n >\n {preview === undefined ? (\n <div className=\"flex justify-center py-4\">\n <Spinner showLabel />\n </div>\n ) : (\n <>\n <dl className=\"grid grid-cols-[minmax(0,1fr)_auto] gap-x-4 gap-y-1 text-sm\" data-plan-switch-preview>\n <dt className=\"text-secondary\">{credit ? labels.switchCredited : labels.switchChargedNow}</dt>\n <dd className=\"text-right font-medium tabular-nums\" data-now={credit ? \"credit\" : \"charge\"}>\n {money(Math.abs(preview.now_minor), preview.currency)}\n </dd>\n {preview.next_billed_at && preview.next_minor != null && (\n <>\n <dt className=\"text-secondary\">\n {labels.switchNextCharge(formatDate(preview.next_billed_at, \"medium\", { locale }))}\n </dt>\n <dd className=\"text-right tabular-nums\">{money(preview.next_minor, preview.currency)}</dd>\n </>\n )}\n </dl>\n {lines.length > 0 && (\n <div className=\"space-y-1 text-sm\" data-plan-switch-over-limit>\n <p>{planChange.overLimit}</p>\n <ul className=\"list-disc space-y-0.5 ps-5 text-secondary\">\n {lines.map((line) => (\n <li key={line}>{line}</li>\n ))}\n </ul>\n </div>\n )}\n </>\n )}\n </DialogFrame>\n );\n}\n"],"mappings":"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","names":[]}
|