@eifi1/ui-kit 0.41.0 → 0.43.0
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/README.md +26 -7
- package/dist/admin/admin-area-labels.d.ts +59 -0
- package/dist/admin/admin-area-labels.js +35 -0
- package/dist/admin/admin-area-labels.js.map +1 -0
- package/dist/admin/admin-forbidden.d.ts +35 -0
- package/dist/admin/admin-forbidden.js +36 -0
- package/dist/admin/admin-forbidden.js.map +1 -0
- package/dist/admin/admin-overview.d.ts +110 -0
- package/dist/admin/admin-overview.js +92 -0
- package/dist/admin/admin-overview.js.map +1 -0
- package/dist/admin/admin-sections.d.ts +94 -0
- package/dist/admin/admin-sections.js +81 -0
- package/dist/admin/admin-sections.js.map +1 -0
- package/dist/admin/admin-tool-links.d.ts +48 -0
- package/dist/admin/admin-tool-links.js +31 -0
- package/dist/admin/admin-tool-links.js.map +1 -0
- package/dist/billing/billing-banner.d.ts +33 -2
- package/dist/billing/billing-banner.js +29 -3
- package/dist/billing/billing-banner.js.map +1 -1
- package/dist/billing/billing-labels.d.ts +36 -0
- package/dist/billing/billing-labels.js +10 -0
- package/dist/billing/billing-labels.js.map +1 -1
- package/dist/billing/billing-overview.d.ts +33 -0
- package/dist/billing/billing-overview.js +6 -0
- package/dist/billing/billing-overview.js.map +1 -1
- package/dist/billing/billing-standing.d.ts +8 -0
- package/dist/billing/billing-standing.js.map +1 -1
- package/dist/billing/cancel-request.d.ts +53 -0
- package/dist/billing/cancel-request.js +21 -0
- package/dist/billing/cancel-request.js.map +1 -0
- package/dist/billing/cancel-subscription.d.ts +46 -0
- package/dist/billing/cancel-subscription.js +57 -0
- package/dist/billing/cancel-subscription.js.map +1 -0
- package/dist/billing/plan-card.d.ts +45 -1
- package/dist/billing/plan-card.js +53 -2
- package/dist/billing/plan-card.js.map +1 -1
- package/dist/billing/plan-switch.d.ts +10 -1
- package/dist/billing/plan-switch.js +22 -13
- package/dist/billing/plan-switch.js.map +1 -1
- package/dist/billing/provider-page.d.ts +40 -0
- package/dist/billing/provider-page.js +33 -0
- package/dist/billing/provider-page.js.map +1 -0
- package/dist/billing/subscription-actions.d.ts +68 -4
- package/dist/billing/subscription-actions.js +65 -5
- package/dist/billing/subscription-actions.js.map +1 -1
- package/dist/components/account-chips.d.ts +50 -2
- package/dist/components/account-chips.js +33 -0
- package/dist/components/account-chips.js.map +1 -1
- package/dist/components/empty-state.d.ts +2 -1
- package/dist/components/empty-state.js.map +1 -1
- package/dist/eslint/index.js +1 -1
- package/dist/feedback/feedback-menu.d.ts +7 -4
- package/dist/feedback/feedback-menu.js +4 -2
- package/dist/feedback/feedback-menu.js.map +1 -1
- package/dist/hooks/use-search-param-state.js +4 -1
- package/dist/hooks/use-search-param-state.js.map +1 -1
- package/dist/i18n/defaults.js +7 -1
- package/dist/i18n/defaults.js.map +1 -1
- package/dist/i18n/german.js +113 -0
- package/dist/i18n/german.js.map +1 -1
- package/dist/i18n/kit-labels.d.ts +12 -0
- package/dist/i18n/kit-labels.js.map +1 -1
- package/dist/i18n/locales/en.js +142 -1
- package/dist/i18n/locales/en.js.map +3 -3
- package/dist/i18n/locales/es.js +110 -0
- package/dist/i18n/locales/es.js.map +1 -1
- package/dist/i18n/locales/fr.js +109 -0
- package/dist/i18n/locales/fr.js.map +1 -1
- package/dist/i18n/locales/hu.js +110 -0
- package/dist/i18n/locales/hu.js.map +1 -1
- package/dist/i18n/locales/it.js +110 -0
- package/dist/i18n/locales/it.js.map +1 -1
- package/dist/i18n/locales/zh.js +109 -0
- package/dist/i18n/locales/zh.js.map +1 -1
- package/dist/i18n/review.js +34 -1
- package/dist/i18n/review.js.map +1 -1
- package/dist/index.d.ts +24 -2
- package/dist/index.js +29 -1
- package/dist/index.js.map +1 -1
- package/dist/search/global-search.d.ts +3 -0
- package/dist/search/global-search.js +3 -1
- package/dist/search/global-search.js.map +1 -1
- package/dist/settings/settings-catalogue.d.ts +9 -1
- package/dist/settings/settings-catalogue.js +1 -1
- package/dist/settings/settings-catalogue.js.map +1 -1
- package/dist/settings/settings-layout.d.ts +7 -1
- package/dist/settings/settings-layout.js +35 -27
- package/dist/settings/settings-layout.js.map +1 -1
- package/dist/shell/account-menu-language.d.ts +46 -0
- package/dist/shell/account-menu-language.js +64 -0
- package/dist/shell/account-menu-language.js.map +1 -0
- package/dist/shell/admin-menu.d.ts +69 -0
- package/dist/shell/admin-menu.js +64 -0
- package/dist/shell/admin-menu.js.map +1 -0
- package/dist/shell/app-shell.d.ts +10 -0
- package/dist/shell/app-shell.js +22 -4
- package/dist/shell/app-shell.js.map +1 -1
- package/dist/shell/dev-switchers.d.ts +53 -0
- package/dist/shell/dev-switchers.js +107 -0
- package/dist/shell/dev-switchers.js.map +1 -0
- package/dist/shell/sync-status-indicator.d.ts +86 -0
- package/dist/shell/sync-status-indicator.js +170 -0
- package/dist/shell/sync-status-indicator.js.map +1 -0
- package/dist/shell/top-bar-actions.d.ts +49 -0
- package/dist/shell/top-bar-actions.js +31 -0
- package/dist/shell/top-bar-actions.js.map +1 -0
- package/dist/shell/top-bar-parts-labels.d.ts +90 -0
- package/dist/shell/top-bar-parts-labels.js +51 -0
- package/dist/shell/top-bar-parts-labels.js.map +1 -0
- package/dist/shell/topbar-action-menu.d.ts +13 -1
- package/dist/shell/topbar-action-menu.js +5 -2
- package/dist/shell/topbar-action-menu.js.map +1 -1
- package/dist/shell/topbar-controls.d.ts +16 -3
- package/dist/shell/topbar-controls.js +5 -1
- package/dist/shell/topbar-controls.js.map +1 -1
- package/dist/shell/tour-menu.d.ts +67 -0
- package/dist/shell/tour-menu.js +87 -0
- package/dist/shell/tour-menu.js.map +1 -0
- package/dist/shell.d.ts +16 -0
- package/dist/shell.js +21 -0
- package/dist/shell.js.map +1 -1
- package/dist/tour/tour-catalogue-labels.d.ts +44 -0
- package/dist/tour/tour-catalogue-labels.js +32 -0
- package/dist/tour/tour-catalogue-labels.js.map +1 -0
- package/dist/tour/tour-catalogue.d.ts +68 -0
- package/dist/tour/tour-catalogue.js +189 -0
- package/dist/tour/tour-catalogue.js.map +1 -0
- package/dist/tour/tour-info.d.ts +112 -0
- package/dist/tour/tour-info.js +36 -0
- package/dist/tour/tour-info.js.map +1 -0
- package/dist/tour/use-tour-progress.d.ts +61 -0
- package/dist/tour/use-tour-progress.js +116 -0
- package/dist/tour/use-tour-progress.js.map +1 -0
- package/dist/tour/use-tour-url-sync.d.ts +58 -0
- package/dist/tour/use-tour-url-sync.js +110 -0
- package/dist/tour/use-tour-url-sync.js.map +1 -0
- package/dist/tour.d.ts +8 -0
- package/dist/tour.js +18 -0
- package/dist/tour.js.map +1 -1
- package/package.json +1 -1
- package/src/admin/admin-area-labels.ts +91 -0
- package/src/admin/admin-forbidden.tsx +75 -0
- package/src/admin/admin-overview.tsx +217 -0
- package/src/admin/admin-sections.tsx +198 -0
- package/src/admin/admin-tool-links.tsx +84 -0
- package/src/billing/billing-banner.tsx +57 -6
- package/src/billing/billing-labels.ts +59 -0
- package/src/billing/billing-overview.ts +37 -0
- package/src/billing/billing-standing.ts +8 -0
- package/src/billing/cancel-request.ts +75 -0
- package/src/billing/cancel-subscription.tsx +102 -0
- package/src/billing/plan-card.tsx +97 -1
- package/src/billing/plan-switch.tsx +16 -0
- package/src/billing/provider-page.ts +75 -0
- package/src/billing/subscription-actions.tsx +155 -20
- package/src/components/account-chips.tsx +89 -2
- package/src/components/empty-state.tsx +2 -1
- package/src/feedback/feedback-menu.tsx +8 -3
- package/src/hooks/use-search-param-state.ts +6 -1
- package/src/i18n/defaults.ts +6 -0
- package/src/i18n/german.ts +127 -0
- package/src/i18n/kit-labels.tsx +12 -0
- package/src/i18n/locales/en.ts +32 -0
- package/src/i18n/locales/es.ts +121 -0
- package/src/i18n/locales/fr.ts +121 -0
- package/src/i18n/locales/hu.ts +119 -0
- package/src/i18n/locales/it.ts +122 -0
- package/src/i18n/locales/zh.ts +114 -0
- package/src/i18n/review.ts +33 -0
- package/src/index.ts +38 -2
- package/src/search/global-search.tsx +5 -0
- package/src/settings/settings-catalogue.ts +10 -2
- package/src/settings/settings-layout.tsx +58 -34
- package/src/shell/account-menu-language.tsx +116 -0
- package/src/shell/admin-menu.tsx +146 -0
- package/src/shell/app-shell.tsx +44 -3
- package/src/shell/dev-switchers.tsx +179 -0
- package/src/shell/sync-status-indicator.tsx +301 -0
- package/src/shell/top-bar-actions.tsx +79 -0
- package/src/shell/top-bar-parts-labels.ts +142 -0
- package/src/shell/topbar-action-menu.tsx +15 -2
- package/src/shell/topbar-controls.tsx +20 -3
- package/src/shell/tour-menu.tsx +179 -0
- package/src/shell.ts +27 -0
- package/src/tour/tour-catalogue-labels.ts +75 -0
- package/src/tour/tour-catalogue.tsx +316 -0
- package/src/tour/tour-info.ts +144 -0
- package/src/tour/use-tour-progress.ts +198 -0
- package/src/tour/use-tour-url-sync.ts +221 -0
- package/src/tour.ts +11 -0
|
@@ -108,6 +108,13 @@ export interface BillingLabels {
|
|
|
108
108
|
choosePlan: string;
|
|
109
109
|
/** The payment-failed banner's action, to the provider's portal: "Update payment method". */
|
|
110
110
|
updatePayment: string;
|
|
111
|
+
/**
|
|
112
|
+
* 0.42 (§16.7 A, §16.8): the payment-failed banner for an admin who isn't the buyer
|
|
113
|
+
* (`portal: "buyer_only"`), with no button — only the buyer can change the card.
|
|
114
|
+
* `buyer` is their display name, or `undefined` when they are gone ("the admin who
|
|
115
|
+
* bought the subscription").
|
|
116
|
+
*/
|
|
117
|
+
paymentFailedAskBuyer: (buyer?: string) => string;
|
|
111
118
|
/** The processing banner's action, when the app gives one: "Check again". */
|
|
112
119
|
checkAgain: string;
|
|
113
120
|
|
|
@@ -153,6 +160,30 @@ export interface BillingLabels {
|
|
|
153
160
|
*/
|
|
154
161
|
notAtProvider: string;
|
|
155
162
|
|
|
163
|
+
/* ── The portal for the buyer only, the in-app cancel (0.42, §16) ── */
|
|
164
|
+
/**
|
|
165
|
+
* In place of "Payment and invoices" for an admin who isn't the buyer (`portal:
|
|
166
|
+
* "buyer_only"`, §16.2 decision 3): the provider's portal opens only for the buyer.
|
|
167
|
+
* `buyer` is their display name — or `undefined` when they are gone or have left the
|
|
168
|
+
* company, and the words then say "the admin who bought the subscription".
|
|
169
|
+
*/
|
|
170
|
+
buyerHolds: (buyer?: string) => string;
|
|
171
|
+
/** `CancelSubscriptionConfirm`'s title: "Cancel subscription". */
|
|
172
|
+
cancelConfirmTitle: string;
|
|
173
|
+
/** Its body; `date` is the period's end, formatted: "Your subscription ends on
|
|
174
|
+
* 9 Nov 2026. Until then everything works as before." */
|
|
175
|
+
cancelConfirmBody: (date: string) => string;
|
|
176
|
+
/** Its confirm button (§16.4): "Cancel subscription now". German: "Jetzt kündigen",
|
|
177
|
+
* the confirmation button's words in §312k BGB. */
|
|
178
|
+
cancelConfirmAction: string;
|
|
179
|
+
/** In place of "Cancel subscription" while a cancel is scheduled (§16.7 C, `POST
|
|
180
|
+
* /billing/resume`): "Keep subscription". */
|
|
181
|
+
keepSubscription: string;
|
|
182
|
+
/** 0.43 (§17.3): the muted note under the actions while the portal opens in a new tab —
|
|
183
|
+
* `provider` the provider's name ("Paddle"), `app` the app's; without the app, "this
|
|
184
|
+
* page". "Payment and invoices open at Paddle in a new tab; Kastlan stays open here." */
|
|
185
|
+
portalOpensNewTab: (provider: string, app?: string) => string;
|
|
186
|
+
|
|
156
187
|
/* ── The pay page (§14.4) ── */
|
|
157
188
|
/* Built into the static page `dist/pay/` at the kit's build: it has no React and no
|
|
158
189
|
provider, so an app's override never reaches it. */
|
|
@@ -184,6 +215,15 @@ export interface BillingLabels {
|
|
|
184
215
|
/** The confirm button: "Change plan". */
|
|
185
216
|
switchConfirm: string;
|
|
186
217
|
|
|
218
|
+
/* ── The permanent-grant warning (0.42, §16.2 decision 5, §16.8 B) ── */
|
|
219
|
+
/** The warning's title, before a checkout (`PlanPicker`) or a switch
|
|
220
|
+
* (`PlanSwitchConfirm`) under a grant without an end: "Your free access continues". */
|
|
221
|
+
grantWarningTitle: string;
|
|
222
|
+
/** What the purchase does under it: nothing to the plan while the grant runs. */
|
|
223
|
+
grantWarningBody: string;
|
|
224
|
+
/** `PlanPicker`'s button that goes on to the checkout: "Buy anyway". */
|
|
225
|
+
grantWarningAction: string;
|
|
226
|
+
|
|
187
227
|
/* ── Offline (§12.5) ── */
|
|
188
228
|
/** Changes refused as `billing_read_only` inside a sync reply, kept queued and sent
|
|
189
229
|
* after payment: "3 changes waiting for a plan". */
|
|
@@ -271,6 +311,10 @@ export const DEFAULT_BILLING_LABELS: BillingLabels = {
|
|
|
271
311
|
processing: "Your payment is being processed. Your plan starts as soon as it’s confirmed; this page updates by itself.",
|
|
272
312
|
choosePlan: "Choose a plan",
|
|
273
313
|
updatePayment: "Update payment method",
|
|
314
|
+
paymentFailedAskBuyer: (buyer) =>
|
|
315
|
+
buyer
|
|
316
|
+
? `The payment failed. ${buyer} can update the payment method.`
|
|
317
|
+
: "The payment failed. The admin who bought the subscription can update the payment method.",
|
|
274
318
|
checkAgain: "Check again",
|
|
275
319
|
|
|
276
320
|
lockReason: "Your plan has ended. Choose a plan to make changes again.",
|
|
@@ -286,6 +330,16 @@ export const DEFAULT_BILLING_LABELS: BillingLabels = {
|
|
|
286
330
|
manage: "Payment and invoices",
|
|
287
331
|
cancel: "Cancel subscription",
|
|
288
332
|
notAtProvider: "Nothing to manage yet. Payments and invoices appear here once you have a paid plan.",
|
|
333
|
+
buyerHolds: (buyer) =>
|
|
334
|
+
buyer
|
|
335
|
+
? `The payment method and the invoices are with ${buyer}, who bought the subscription.`
|
|
336
|
+
: "The payment method and the invoices are with the admin who bought the subscription.",
|
|
337
|
+
cancelConfirmTitle: "Cancel subscription",
|
|
338
|
+
cancelConfirmBody: (date) => `Your subscription ends on ${date}. Until then everything works as before.`,
|
|
339
|
+
cancelConfirmAction: "Cancel subscription now",
|
|
340
|
+
keepSubscription: "Keep subscription",
|
|
341
|
+
portalOpensNewTab: (provider, app) =>
|
|
342
|
+
`Payment and invoices open at ${provider} in a new tab; ${app ? `${app} stays open here` : "this page stays open"}.`,
|
|
289
343
|
|
|
290
344
|
payOpening: "Opening the secure checkout…",
|
|
291
345
|
payNothing: "There’s no payment to make here. To choose a plan, go back to the app.",
|
|
@@ -300,6 +354,11 @@ export const DEFAULT_BILLING_LABELS: BillingLabels = {
|
|
|
300
354
|
switchNextCharge: (date) => `Next charge on ${date}`,
|
|
301
355
|
switchConfirm: "Change plan",
|
|
302
356
|
|
|
357
|
+
grantWarningTitle: "Your free access continues",
|
|
358
|
+
grantWarningBody:
|
|
359
|
+
"You have free access with no end date. A purchase won’t change your plan while the free access runs: you would pay for a plan that doesn’t apply.",
|
|
360
|
+
grantWarningAction: "Buy anyway",
|
|
361
|
+
|
|
303
362
|
waitingChanges: (count) =>
|
|
304
363
|
count === 1 ? "1 change waiting for a plan" : `${count} changes waiting for a plan`,
|
|
305
364
|
discardWaiting: "Discard waiting changes",
|
|
@@ -54,6 +54,43 @@ export interface BillingOverview {
|
|
|
54
54
|
* server-kit before 0.13 still type-checks; absent, read it as false.
|
|
55
55
|
*/
|
|
56
56
|
switching?: boolean;
|
|
57
|
+
/**
|
|
58
|
+
* 0.42 (server-kit 0.14, §16.3): who may open the provider's portal. `"buyer_only"`:
|
|
59
|
+
* the row has a buyer (`provider_payer_user_id`) and the viewer isn't them — every
|
|
60
|
+
* portal target would answer `403 billing_portal_buyer_only`, so "Payment and
|
|
61
|
+
* invoices" is a caption naming the buyer and "Cancel subscription" is the in-app
|
|
62
|
+
* cancel (`POST /billing/cancel`). `"open"`, or absent (server-kit before 0.14): as
|
|
63
|
+
* before. Pass it to `SubscriptionActions`' `portal`.
|
|
64
|
+
*/
|
|
65
|
+
portal?: BillingPortalAccess;
|
|
66
|
+
/**
|
|
67
|
+
* 0.42 (§16.3, §16.7 G): the buyer's display name, for the caption under
|
|
68
|
+
* `"buyer_only"`; `null` when the buyer is gone or no longer a member of the company,
|
|
69
|
+
* and the words then say "the admin who bought the subscription".
|
|
70
|
+
*/
|
|
71
|
+
buyer_name?: string | null;
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
/** 0.42 (§16.3): `BillingOverview.portal` and `BillingStanding.portal` — server-kit's
|
|
75
|
+
* `Literal["open", "buyer_only"]`. */
|
|
76
|
+
export type BillingPortalAccess = "open" | "buyer_only";
|
|
77
|
+
|
|
78
|
+
/**
|
|
79
|
+
* 0.42 (§16.2 decision 5, §16.7 E): whether the payer's free grant runs WITHOUT AN END —
|
|
80
|
+
* an operator's grant (`source: "manual"`) with `comped_until` empty. A purchase under
|
|
81
|
+
* it leaves the row `comped` on the grant's plan (§12.11): only the paid price moves,
|
|
82
|
+
* so the payer would pay for a plan that never applies. Pass it to `PlanPicker`'s and
|
|
83
|
+
* `PlanSwitchConfirm`'s `permanentGrant`, which warn before the checkout or the switch.
|
|
84
|
+
*
|
|
85
|
+
* `source` is checked explicitly, not implied by the missing date: Kurvenschmiede's
|
|
86
|
+
* pre-launch beta rows are `comped` with `comped_until` null too (their launch-plus-12-
|
|
87
|
+
* months end isn't written yet, §3.2), and they are not permanent. A grant with an end
|
|
88
|
+
* gets no warning either: the bought plan applies from that end on.
|
|
89
|
+
*/
|
|
90
|
+
export function isPermanentGrant(
|
|
91
|
+
overview: Pick<BillingOverview, "status" | "source"> & { comped_until?: BillingOverview["comped_until"] },
|
|
92
|
+
): boolean {
|
|
93
|
+
return overview.status === "comped" && overview.source === "manual" && overview.comped_until == null;
|
|
57
94
|
}
|
|
58
95
|
|
|
59
96
|
/** 0.39: what the payer pays at the provider (server-kit's `PaidPrice`, §15.2.10),
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
import { toDate } from "../lib/format";
|
|
2
2
|
import type { DateInput } from "../lib/format";
|
|
3
3
|
import type { SubscriptionStatus } from "./billing-labels";
|
|
4
|
+
import type { BillingPortalAccess } from "./billing-overview";
|
|
4
5
|
|
|
5
6
|
/**
|
|
6
7
|
* What a device knows of the payer's standing — the fields of `GET /billing/overview`
|
|
@@ -16,6 +17,13 @@ export interface BillingStanding {
|
|
|
16
17
|
/** When a free grant ends: a beta user's 12 months, an operator's grant. Empty for a
|
|
17
18
|
* grant without an end (§12.8). */
|
|
18
19
|
comped_until?: DateInput;
|
|
20
|
+
/** 0.42 (server-kit 0.14, §16.7 A): who may open the provider's portal, as the
|
|
21
|
+
* overview's `portal` — `GET /billing/status` says it too, for the payment-failed
|
|
22
|
+
* banner (`BillingBanner`'s `portal`). Absent: `"open"`. */
|
|
23
|
+
portal?: BillingPortalAccess;
|
|
24
|
+
/** 0.42 (§16.7 A): the buyer's display name, as the overview's `buyer_name`; `null`
|
|
25
|
+
* when the buyer is gone or has left the company. */
|
|
26
|
+
buyer_name?: string | null;
|
|
19
27
|
}
|
|
20
28
|
|
|
21
29
|
/**
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
import { useState } from "react";
|
|
2
|
+
|
|
3
|
+
/**
|
|
4
|
+
* What the payer asked for in the app (0.43, kastlan `18bf4c0`): `"cancel"` after
|
|
5
|
+
* `POST /billing/cancel` answered 204, `"resume"` after `POST /billing/resume` — while the
|
|
6
|
+
* overview still shows the old `cancel_at_period_end`, until the provider's
|
|
7
|
+
* `subscription.updated` has landed (§16.3). `null`: nothing asked, or the row has moved.
|
|
8
|
+
*/
|
|
9
|
+
export type CancelRequested = "cancel" | "resume" | null;
|
|
10
|
+
|
|
11
|
+
/** What the page shows for the row as read now — `CancelRequest.view(rowScheduled)`. */
|
|
12
|
+
export interface CancelRequestView {
|
|
13
|
+
/** For `SubscriptionActions`' `cancelRequested`. */
|
|
14
|
+
requested: CancelRequested;
|
|
15
|
+
/** What the plan line says — the asked-for state until the row moves, then the row's:
|
|
16
|
+
* "Cancels on …" at once after a cancel, "Renews on …" after "Keep subscription". */
|
|
17
|
+
cancelScheduled: boolean;
|
|
18
|
+
/** Call on the 204 of a cancel or of a resume. A cancel and its undo both asked before
|
|
19
|
+
* the row moved cancel out. */
|
|
20
|
+
noteAsked: () => void;
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface CancelRequest {
|
|
24
|
+
/** Read the overview again every few seconds while this is true — the overview query's
|
|
25
|
+
* `waiting` (kastlan's `useBillingOverview({ waiting })`, a `refetchInterval` inside) —
|
|
26
|
+
* so the page follows the provider. Needs no overview data: pass it to the query. */
|
|
27
|
+
waiting: boolean;
|
|
28
|
+
/**
|
|
29
|
+
* The row as read now (the overview's `cancel_at_period_end`; `undefined` while it
|
|
30
|
+
* loads) → what the page shows. Call it during render, after the query: when the row
|
|
31
|
+
* has moved past what was asked, it drops the request (React's "adjust state while
|
|
32
|
+
* rendering" pattern), so `waiting` turns false and the query stops polling.
|
|
33
|
+
*/
|
|
34
|
+
view: (rowScheduled: boolean | null | undefined) => CancelRequestView;
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
/**
|
|
38
|
+
* The in-app cancel's follow-up (0.43, kastlan's derivation in `18bf4c0`): after "Cancel
|
|
39
|
+
* subscription now" or "Keep subscription" the server answers 204 at once, but the row's
|
|
40
|
+
* `cancel_at_period_end` only changes when the provider's webhook lands. Until then the
|
|
41
|
+
* page shows what was asked for, and a second press would ask the provider again.
|
|
42
|
+
*
|
|
43
|
+
* The state lives in the hook and needs no overview at construction, so it goes BEFORE
|
|
44
|
+
* the overview query, which reads `waiting`, and the view comes after it — the overview
|
|
45
|
+
* read once (build review 0.43, kastlan B2; kastlan `subscription-section.tsx:74-92`):
|
|
46
|
+
*
|
|
47
|
+
* const request = useCancelRequest();
|
|
48
|
+
* const { data } = useBillingOverview({ waiting: request.waiting });
|
|
49
|
+
* const view = request.view(data?.cancel_at_period_end);
|
|
50
|
+
* <p>{standingLine({ ...data, cancel_at_period_end: view.cancelScheduled })}</p>
|
|
51
|
+
* <SubscriptionActions
|
|
52
|
+
* cancelAtPeriodEnd={data.cancel_at_period_end}
|
|
53
|
+
* cancelRequested={view.requested}
|
|
54
|
+
* onResume={() => resume.mutate(undefined, { onSuccess: view.noteAsked })}
|
|
55
|
+
* …
|
|
56
|
+
* />
|
|
57
|
+
*/
|
|
58
|
+
export function useCancelRequest(): CancelRequest {
|
|
59
|
+
// What was asked, kept with the row's state it was asked against.
|
|
60
|
+
const [asked, setAsked] = useState<{ scheduled: boolean } | null>(null);
|
|
61
|
+
const view = (row: boolean | null | undefined): CancelRequestView => {
|
|
62
|
+
// Not read yet: nothing to show against, and nothing to drop.
|
|
63
|
+
if (row === undefined || row === null) return { requested: null, cancelScheduled: false, noteAsked: () => {} };
|
|
64
|
+
// The row has moved: it says the rest. Adjusted during render, React's pattern for
|
|
65
|
+
// state that follows a prop.
|
|
66
|
+
if (asked !== null && asked.scheduled !== row) setAsked(null);
|
|
67
|
+
const open = asked !== null && asked.scheduled === row;
|
|
68
|
+
return {
|
|
69
|
+
requested: open ? (row ? "resume" : "cancel") : null,
|
|
70
|
+
cancelScheduled: open ? !row : row,
|
|
71
|
+
noteAsked: () => setAsked((a) => (a !== null && a.scheduled === row ? null : { scheduled: row })),
|
|
72
|
+
};
|
|
73
|
+
};
|
|
74
|
+
return { waiting: asked !== null, view };
|
|
75
|
+
}
|
|
@@ -0,0 +1,102 @@
|
|
|
1
|
+
import type { KeyboardEvent } from "react";
|
|
2
|
+
|
|
3
|
+
import { useKitLabels, useKitLocale } from "../i18n/kit-labels";
|
|
4
|
+
import type { LabelOverride } from "../i18n/kit-labels";
|
|
5
|
+
import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
|
|
6
|
+
import { formatDate } from "../lib/format";
|
|
7
|
+
import type { DateInput } from "../lib/format";
|
|
8
|
+
import { Button } from "../components/button";
|
|
9
|
+
import { DialogFrame } from "../components/dialog-frame";
|
|
10
|
+
import { COMMIT_EXCEPT_BILLING } from "../components/write-lock";
|
|
11
|
+
import { useBillingLabels } from "./billing-labels";
|
|
12
|
+
import type { BillingLabels } from "./billing-labels";
|
|
13
|
+
|
|
14
|
+
export interface CancelSubscriptionConfirmProps {
|
|
15
|
+
open: boolean;
|
|
16
|
+
/** `false` when the admin dismisses it — Back, Escape, the backdrop. Confirm leaves it
|
|
17
|
+
* open: close it once `POST /billing/cancel` answers. */
|
|
18
|
+
onOpenChange: (open: boolean) => void;
|
|
19
|
+
/** The overview's `current_period_end`: the day the subscription ends. Formatted with
|
|
20
|
+
* the kit's date formatter (`formatDate`, `"medium"`). */
|
|
21
|
+
periodEnd: DateInput;
|
|
22
|
+
/** Confirm: the app's `POST /billing/cancel` (server-kit 0.14, §16.3). */
|
|
23
|
+
onConfirm: () => void;
|
|
24
|
+
/** The cancel is under way: Confirm's spinner, no second press, and Escape and Back
|
|
25
|
+
* don't close. */
|
|
26
|
+
pending?: boolean;
|
|
27
|
+
/** The date's locale. Default: the provider's. */
|
|
28
|
+
locale?: string;
|
|
29
|
+
/** Prop > `<UiKitProvider labels={{ billing }}>` > English. */
|
|
30
|
+
labels?: LabelOverride<BillingLabels>;
|
|
31
|
+
}
|
|
32
|
+
|
|
33
|
+
/**
|
|
34
|
+
* The in-app cancel's confirm (docs/billing-harmonization.md §16.2 decision 2, §16.4,
|
|
35
|
+
* ui-kit 0.42): "Cancel subscription" for an admin the provider's portal doesn't open for
|
|
36
|
+
* (`SubscriptionActions`' `portal="buyer_only"` → `onCancelInApp`). It names the day the
|
|
37
|
+
* subscription ends — "Your subscription ends on 9 Nov 2026. Until then everything works
|
|
38
|
+
* as before." — and its button reads "Cancel subscription now" (German "Jetzt kündigen",
|
|
39
|
+
* the confirmation button of §312k BGB).
|
|
40
|
+
*
|
|
41
|
+
* <CancelSubscriptionConfirm
|
|
42
|
+
* open={asking}
|
|
43
|
+
* onOpenChange={setAsking}
|
|
44
|
+
* periodEnd={overview.current_period_end}
|
|
45
|
+
* pending={cancel.isPending}
|
|
46
|
+
* onConfirm={() => cancel.mutate()}
|
|
47
|
+
* />
|
|
48
|
+
*
|
|
49
|
+
* The cancel is at the period's end (Paddle's `cancel`, not immediately); the row follows
|
|
50
|
+
* by webhook (`cancel_at_period_end`), as after a cancel in the portal, and
|
|
51
|
+
* `SubscriptionActions` then offers "Keep subscription" (`onResume`).
|
|
52
|
+
*
|
|
53
|
+
* It takes no billing lock (`COMMIT_EXCEPT_BILLING` built in, §3.3): a read-only payer
|
|
54
|
+
* may always cancel. A demo lock still holds it, as the server's demo guard does. The kit
|
|
55
|
+
* sends no request.
|
|
56
|
+
*/
|
|
57
|
+
export function CancelSubscriptionConfirm({
|
|
58
|
+
open,
|
|
59
|
+
onOpenChange,
|
|
60
|
+
periodEnd,
|
|
61
|
+
onConfirm,
|
|
62
|
+
pending = false,
|
|
63
|
+
locale: localeProp,
|
|
64
|
+
labels: labelsProp,
|
|
65
|
+
}: CancelSubscriptionConfirmProps) {
|
|
66
|
+
const labels = useBillingLabels(labelsProp);
|
|
67
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
68
|
+
const locale = useKitLocale(localeProp);
|
|
69
|
+
|
|
70
|
+
return (
|
|
71
|
+
<DialogFrame
|
|
72
|
+
open={open}
|
|
73
|
+
title={labels.cancelConfirmTitle}
|
|
74
|
+
description={labels.cancelConfirmBody(formatDate(periodEnd, "medium", { locale }))}
|
|
75
|
+
onClose={() => {
|
|
76
|
+
if (!pending) onOpenChange(false);
|
|
77
|
+
}}
|
|
78
|
+
onKeyDown={(e: KeyboardEvent<HTMLDivElement>) => {
|
|
79
|
+
if (pending && e.key === "Escape") e.preventDefault();
|
|
80
|
+
}}
|
|
81
|
+
aria-busy={pending || undefined}
|
|
82
|
+
data-cancel-subscription-confirm=""
|
|
83
|
+
actions={(close) => (
|
|
84
|
+
<>
|
|
85
|
+
<Button variant="secondary" onClick={close} disabled={pending}>
|
|
86
|
+
{common.back}
|
|
87
|
+
</Button>
|
|
88
|
+
<Button
|
|
89
|
+
variant="danger"
|
|
90
|
+
commit={COMMIT_EXCEPT_BILLING}
|
|
91
|
+
pending={pending}
|
|
92
|
+
onClick={() => {
|
|
93
|
+
if (!pending) onConfirm();
|
|
94
|
+
}}
|
|
95
|
+
>
|
|
96
|
+
{labels.cancelConfirmAction}
|
|
97
|
+
</Button>
|
|
98
|
+
</>
|
|
99
|
+
)}
|
|
100
|
+
/>
|
|
101
|
+
);
|
|
102
|
+
}
|
|
@@ -11,6 +11,7 @@ import type { ChoiceCardProps } from "../components/choice-card";
|
|
|
11
11
|
import { Chip } from "../components/chip";
|
|
12
12
|
import { ToggleGroup } from "../components/toggle-group";
|
|
13
13
|
import { Button } from "../components/button";
|
|
14
|
+
import { DialogFrame } from "../components/dialog-frame";
|
|
14
15
|
import { useBillingLabels } from "./billing-labels";
|
|
15
16
|
import type { BillingLabels } from "./billing-labels";
|
|
16
17
|
import { BILLING_INTERVALS, billingCurrencyFor, formatPlanPrice, planPrice } from "./plan-price";
|
|
@@ -263,6 +264,24 @@ export interface PlanPickerProps extends Omit<PlanDisplayProps, "currency" | "in
|
|
|
263
264
|
onChoose: (choice: PlanChoice) => void;
|
|
264
265
|
/** The checkout is being opened: the button's spinner, no second press. */
|
|
265
266
|
pending?: boolean;
|
|
267
|
+
/**
|
|
268
|
+
* 0.42 (§16.2 decision 5, §16.8 B): the payer's free grant runs without an end —
|
|
269
|
+
* `isPermanentGrant(overview)`. A purchase under it changes nothing the payer gets
|
|
270
|
+
* while the grant runs (§12.11), so the button first opens a confirm —
|
|
271
|
+
* `grantWarningTitle`, `grantWarningBody`, "Buy anyway" (`grantWarningAction`) and
|
|
272
|
+
* "Back" — and {@link onChoose} runs only on "Buy anyway". Warned, not refused.
|
|
273
|
+
*
|
|
274
|
+
* Not while {@link switching}: then the pick opens `PlanSwitchConfirm`, which takes
|
|
275
|
+
* the same `permanentGrant` and shows the warning above its figures — one warning,
|
|
276
|
+
* in the dialog where the payer agrees, rather than a confirm before a confirm.
|
|
277
|
+
*/
|
|
278
|
+
permanentGrant?: boolean;
|
|
279
|
+
/**
|
|
280
|
+
* 0.42: the overview's `switching` — the app sends this pick to `PlanSwitchConfirm`
|
|
281
|
+
* (§15.2.10), not the checkout. Only decides where {@link permanentGrant}'s warning
|
|
282
|
+
* shows; the button and {@link onChoose} are the same either way.
|
|
283
|
+
*/
|
|
284
|
+
switching?: boolean;
|
|
266
285
|
/** The provider's localised price for a plan, or `undefined` for the catalogue's —
|
|
267
286
|
* see {@link PlanCardProps.pricePreview}. */
|
|
268
287
|
pricePreview?: (plan: BillingPlan, choice: Omit<PlanChoice, "plan">) => ReactNode | undefined;
|
|
@@ -308,6 +327,11 @@ function pricedIntervals(plans: readonly BillingPlan[]): BillingInterval[] {
|
|
|
308
327
|
* The button never takes the write lock: billing itself always works, a read-only payer
|
|
309
328
|
* must be able to pay (§3.3). It is off, with the reason on hover and focus, while
|
|
310
329
|
* nothing is picked or the current plan is.
|
|
330
|
+
*
|
|
331
|
+
* 0.42 (§16.8 B): under a grant without an end (`permanentGrant`) the payer is warned
|
|
332
|
+
* before paying for a plan that won't apply — here, in a confirm before `onChoose`, on
|
|
333
|
+
* the way to the checkout; on the way to a switch (`switching`) in `PlanSwitchConfirm`
|
|
334
|
+
* instead, so the warning shows once on either path.
|
|
311
335
|
*/
|
|
312
336
|
export function PlanPicker({
|
|
313
337
|
plans,
|
|
@@ -324,6 +348,8 @@ export function PlanPicker({
|
|
|
324
348
|
currencies: currenciesProp,
|
|
325
349
|
onChoose,
|
|
326
350
|
pending,
|
|
351
|
+
permanentGrant = false,
|
|
352
|
+
switching = false,
|
|
327
353
|
pricePreview,
|
|
328
354
|
legend,
|
|
329
355
|
name,
|
|
@@ -338,6 +364,8 @@ export function PlanPicker({
|
|
|
338
364
|
const labels = useBillingLabels(labelsProp);
|
|
339
365
|
const locale = useKitLocale(localeProp);
|
|
340
366
|
const generated = useId();
|
|
367
|
+
/** The choice the grant warning holds back until "Buy anyway". */
|
|
368
|
+
const [warned, setWarned] = useState<PlanChoice | null>(null);
|
|
341
369
|
const groupName = name ?? generated;
|
|
342
370
|
const legendId = `${generated}-legend`;
|
|
343
371
|
|
|
@@ -447,11 +475,79 @@ export function PlanPicker({
|
|
|
447
475
|
className="max-sm:w-full"
|
|
448
476
|
disabledReason={blocked}
|
|
449
477
|
pending={pending}
|
|
450
|
-
onClick={() =>
|
|
478
|
+
onClick={() => {
|
|
479
|
+
if (!picked) return;
|
|
480
|
+
const choice = { plan: picked.code, interval, currency };
|
|
481
|
+
if (permanentGrant && !switching) setWarned(choice);
|
|
482
|
+
else onChoose(choice);
|
|
483
|
+
}}
|
|
451
484
|
>
|
|
452
485
|
{buttonText}
|
|
453
486
|
</Button>
|
|
454
487
|
</div>
|
|
488
|
+
{permanentGrant && !switching && (
|
|
489
|
+
<GrantWarningConfirm
|
|
490
|
+
open={warned !== null}
|
|
491
|
+
onOpenChange={(open) => {
|
|
492
|
+
if (!open) setWarned(null);
|
|
493
|
+
}}
|
|
494
|
+
onConfirm={() => {
|
|
495
|
+
if (warned) onChoose(warned);
|
|
496
|
+
}}
|
|
497
|
+
labels={labelsProp}
|
|
498
|
+
/>
|
|
499
|
+
)}
|
|
455
500
|
</div>
|
|
456
501
|
);
|
|
457
502
|
}
|
|
503
|
+
|
|
504
|
+
export interface GrantWarningConfirmProps {
|
|
505
|
+
/** Shown, controlled. */
|
|
506
|
+
open: boolean;
|
|
507
|
+
/** `false` on "Back", Escape, the backdrop — and after "Buy anyway". */
|
|
508
|
+
onOpenChange: (open: boolean) => void;
|
|
509
|
+
/** "Buy anyway": the app opens its checkout. The dialog closes itself after it. */
|
|
510
|
+
onConfirm: () => void;
|
|
511
|
+
/** Prop > `<UiKitProvider labels={{ billing }}>` > English: `grantWarningTitle`,
|
|
512
|
+
* `grantWarningBody`, `grantWarningAction`. "Back" is `common.back`. */
|
|
513
|
+
labels?: LabelOverride<BillingLabels>;
|
|
514
|
+
}
|
|
515
|
+
|
|
516
|
+
/**
|
|
517
|
+
* 0.42 (§16.8 B): the permanent-grant warning before a checkout — `grantWarningTitle`,
|
|
518
|
+
* `grantWarningBody`, "Back" and "Buy anyway" (`grantWarningAction`). {@link PlanPicker}
|
|
519
|
+
* shows it itself under `permanentGrant` when not `switching`. Exported for the path the
|
|
520
|
+
* picker cannot see: an app that meant to switch, learned there is no subscription to
|
|
521
|
+
* switch (keksdose: `/billing/plan/preview` → 409 `billing_not_subscribed`) and falls back
|
|
522
|
+
* to a checkout — show this before that checkout, so a permanent-grant holder is still
|
|
523
|
+
* warned. Carries `data-grant-warning`.
|
|
524
|
+
*/
|
|
525
|
+
export function GrantWarningConfirm({ open, onOpenChange, onConfirm, labels: labelsProp }: GrantWarningConfirmProps) {
|
|
526
|
+
const labels = useBillingLabels(labelsProp);
|
|
527
|
+
const common = useKitLabels("common", DEFAULT_COMMON_LABELS);
|
|
528
|
+
return (
|
|
529
|
+
<DialogFrame
|
|
530
|
+
open={open}
|
|
531
|
+
title={labels.grantWarningTitle}
|
|
532
|
+
description={labels.grantWarningBody}
|
|
533
|
+
onClose={() => onOpenChange(false)}
|
|
534
|
+
data-grant-warning=""
|
|
535
|
+
actions={(close) => (
|
|
536
|
+
<>
|
|
537
|
+
<Button variant="secondary" onClick={close}>
|
|
538
|
+
{common.back}
|
|
539
|
+
</Button>
|
|
540
|
+
<Button
|
|
541
|
+
variant="primary"
|
|
542
|
+
onClick={() => {
|
|
543
|
+
onConfirm();
|
|
544
|
+
onOpenChange(false);
|
|
545
|
+
}}
|
|
546
|
+
>
|
|
547
|
+
{labels.grantWarningAction}
|
|
548
|
+
</Button>
|
|
549
|
+
</>
|
|
550
|
+
)}
|
|
551
|
+
/>
|
|
552
|
+
);
|
|
553
|
+
}
|
|
@@ -6,6 +6,7 @@ import { DEFAULT_COMMON_LABELS } from "../i18n/shared-labels";
|
|
|
6
6
|
import { pluralForm } from "../i18n/plurals";
|
|
7
7
|
import type { PluralForms } from "../i18n/plurals";
|
|
8
8
|
import { formatDate, formatMoney, formatNumber } from "../lib/format";
|
|
9
|
+
import { AlertBanner } from "../components/alert-banner";
|
|
9
10
|
import { Button } from "../components/button";
|
|
10
11
|
import { DialogFrame } from "../components/dialog-frame";
|
|
11
12
|
import { Spinner } from "../components/spinner";
|
|
@@ -67,6 +68,15 @@ export interface PlanSwitchConfirmProps {
|
|
|
67
68
|
onConfirm: () => void;
|
|
68
69
|
/** The figures' and the date's locale. Default: the provider's. */
|
|
69
70
|
locale?: string;
|
|
71
|
+
/**
|
|
72
|
+
* 0.42 (§16.2 decision 5, §16.8 B): the payer's free grant runs without an end —
|
|
73
|
+
* `isPermanentGrant(overview)`. The switch changes the PAID plan, not the applied one
|
|
74
|
+
* (§12.11), so the dialog says so above its figures (`grantWarningTitle`,
|
|
75
|
+
* `grantWarningBody`) before the payer agrees. Confirm stays Confirm: the warning is
|
|
76
|
+
* this dialog's own, and `PlanPicker` — told `switching` — shows none of its own on
|
|
77
|
+
* the way here, so the payer reads it once.
|
|
78
|
+
*/
|
|
79
|
+
permanentGrant?: boolean;
|
|
70
80
|
/** Prop > `<UiKitProvider labels={{ billing }}>` > English. */
|
|
71
81
|
labels?: LabelOverride<BillingLabels>;
|
|
72
82
|
}
|
|
@@ -105,6 +115,7 @@ export function PlanSwitchConfirm({
|
|
|
105
115
|
formatLimit,
|
|
106
116
|
onConfirm,
|
|
107
117
|
locale: localeProp,
|
|
118
|
+
permanentGrant = false,
|
|
108
119
|
labels: labelsProp,
|
|
109
120
|
}: PlanSwitchConfirmProps) {
|
|
110
121
|
const labels = useBillingLabels(labelsProp);
|
|
@@ -164,6 +175,11 @@ export function PlanSwitchConfirm({
|
|
|
164
175
|
</>
|
|
165
176
|
)}
|
|
166
177
|
>
|
|
178
|
+
{permanentGrant && (
|
|
179
|
+
<AlertBanner tone="warning" size="sm" data-grant-warning="">
|
|
180
|
+
<strong className="block font-medium">{labels.grantWarningTitle}</strong> {labels.grantWarningBody}
|
|
181
|
+
</AlertBanner>
|
|
182
|
+
)}
|
|
167
183
|
{preview === undefined ? (
|
|
168
184
|
<div className="flex justify-center py-4">
|
|
169
185
|
<Spinner showLabel />
|
|
@@ -0,0 +1,75 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The provider's hosted portal in a NEW TAB (0.43, docs/billing-harmonization.md §17):
|
|
3
|
+
* "Payment and invoices", the portal's "Cancel subscription" and the payment-failed
|
|
4
|
+
* banner's "Update payment method" open at the provider while the app stays open where
|
|
5
|
+
* it was. kastlan's `usePortal` (feat/kit-0.42, `72c3177`) is the reference.
|
|
6
|
+
*
|
|
7
|
+
* The checkout is NOT one of them (§17.4): it stays in the same tab, since the page waits
|
|
8
|
+
* for the payer's return (`useCheckoutProcessing`).
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
/** How the page went: in a tab of its own, or — the browser refused one — in this one. */
|
|
12
|
+
export type ProviderPageWay = "new-tab" | "same-tab";
|
|
13
|
+
|
|
14
|
+
export interface OpenProviderPageOptions {
|
|
15
|
+
/** The window to open from and to fall back to. Default the global `window` (a test's
|
|
16
|
+
* or an iframe's own). */
|
|
17
|
+
window?: Window;
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/**
|
|
21
|
+
* Opens the provider's page in a new tab, from INSIDE the click: call it in the click
|
|
22
|
+
* handler itself, before any `await` — a `window.open` after one is blocked as a pop-up.
|
|
23
|
+
*
|
|
24
|
+
* 1. opens `about:blank` synchronously and sets its `opener` to `null`, so the provider's
|
|
25
|
+
* page cannot reach back into the app;
|
|
26
|
+
* 2. awaits `fetchUrl` — the app's `POST /billing/portal` with its target, answering the
|
|
27
|
+
* `url` (the kit sends no request);
|
|
28
|
+
* 3. points the tab at the URL;
|
|
29
|
+
* 4. on a refusal closes the tab again and rethrows, so the app shows its error;
|
|
30
|
+
* 5. where the browser refused the tab (a pop-up blocker), or it was closed while the
|
|
31
|
+
* URL was on its way, the app's own page goes there instead.
|
|
32
|
+
*
|
|
33
|
+
* <SubscriptionActions
|
|
34
|
+
* onPortal={(target) => {
|
|
35
|
+
* openProviderPage(() => api.billingPortal({ target }).then((r) => r.url)).catch(showError);
|
|
36
|
+
* }}
|
|
37
|
+
* providerName="Paddle"
|
|
38
|
+
* appName="Kastlan"
|
|
39
|
+
* />
|
|
40
|
+
*
|
|
41
|
+
* Resolves with the way it went.
|
|
42
|
+
*/
|
|
43
|
+
export async function openProviderPage(
|
|
44
|
+
fetchUrl: () => Promise<string>,
|
|
45
|
+
options: OpenProviderPageOptions = {},
|
|
46
|
+
): Promise<ProviderPageWay> {
|
|
47
|
+
const win = options.window ?? window;
|
|
48
|
+
let tab: Window | null;
|
|
49
|
+
try {
|
|
50
|
+
tab = win.open("about:blank", "_blank");
|
|
51
|
+
} catch {
|
|
52
|
+
tab = null;
|
|
53
|
+
}
|
|
54
|
+
if (tab) {
|
|
55
|
+
try {
|
|
56
|
+
// The provider's page must not reach back into the app.
|
|
57
|
+
tab.opener = null;
|
|
58
|
+
} catch {
|
|
59
|
+
/* a browser that guards `opener`: the tab is still ours to point */
|
|
60
|
+
}
|
|
61
|
+
}
|
|
62
|
+
let url: string;
|
|
63
|
+
try {
|
|
64
|
+
url = await fetchUrl();
|
|
65
|
+
} catch (error) {
|
|
66
|
+
tab?.close();
|
|
67
|
+
throw error;
|
|
68
|
+
}
|
|
69
|
+
if (tab && !tab.closed) {
|
|
70
|
+
tab.location.href = url;
|
|
71
|
+
return "new-tab";
|
|
72
|
+
}
|
|
73
|
+
win.location.assign(url);
|
|
74
|
+
return "same-tab";
|
|
75
|
+
}
|