@nebulr-group/bridge-svelte 0.9.1-beta.2 → 0.9.2-beta.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/dist/client/BridgeBootstrap.js +4 -0
- package/dist/client/billing-routes.d.ts +10 -1
- package/dist/client/billing-routes.js +2 -1
- package/dist/client/checkout-result.d.ts +16 -0
- package/dist/client/checkout-result.js +31 -0
- package/dist/client/components/subscription/BridgeBillingRoutes.svelte +73 -10
- package/dist/client/components/subscription/BridgeBillingRoutes.svelte.d.ts +3 -0
- package/dist/client/components/subscription/BridgeCheckoutNotice.svelte +101 -0
- package/dist/client/components/subscription/BridgeCheckoutNotice.svelte.d.ts +15 -0
- package/dist/client/components/subscription/BridgePaywallPage.svelte +3 -3
- package/dist/client/components/subscription/BridgePaywallPage.svelte.d.ts +1 -1
- package/dist/client/components/subscription/PlanSelector.svelte +41 -22
- package/dist/client/components/subscription/PlanSelector.svelte.d.ts +2 -0
- package/dist/client/components/subscription/plan-format.d.ts +17 -0
- package/dist/client/components/subscription/plan-format.js +81 -0
- package/dist/client/components/subscription/plan-pick.d.ts +2 -0
- package/dist/client/components/subscription/plan-pick.js +3 -1
- package/dist/index.d.ts +3 -0
- package/dist/index.js +4 -0
- package/dist/shared/types/config.d.ts +9 -2
- package/dist/styles.css +164 -7
- package/package.json +1 -1
|
@@ -7,6 +7,7 @@ import { dropFlagCache, guardCacheGeneration } from '../auth/guard-cache.js';
|
|
|
7
7
|
import { getBridgeAuth, bridgeReadyStore, markReady, waitForBridge as _waitForBridge, } from '../core/bridge-instance.js';
|
|
8
8
|
import { installBridgeAuthFetch } from '../core/bridge-runtime.js';
|
|
9
9
|
import { refreshBilling, tokenStaleHandlerOf } from '../core/billing-store.js';
|
|
10
|
+
import { markCheckoutComplete } from './checkout-result.js';
|
|
10
11
|
import { prefetchFlagDefinitions } from '../core/flag-definitions.js';
|
|
11
12
|
import { useBridge, sanitizeReturnTo, stashReturnTo, takeReturnTo, withReturnTo, } from '@nebulr-group/bridge-auth-core';
|
|
12
13
|
import { logger } from '../shared/logger.js';
|
|
@@ -418,6 +419,9 @@ async function handleCallbackRoute(url, kitFetch) {
|
|
|
418
419
|
await refreshBilling();
|
|
419
420
|
}
|
|
420
421
|
catch { /* the landing page re-reads */ }
|
|
422
|
+
// TBP-813 — the landing page (the subscription page by default)
|
|
423
|
+
// says the checkout went through; no separate confirmation page.
|
|
424
|
+
markCheckoutComplete({ kind: 'checkout' });
|
|
421
425
|
}
|
|
422
426
|
redirect(303, redirectTo);
|
|
423
427
|
}
|
|
@@ -36,8 +36,17 @@ export interface BridgeBillingRoutes {
|
|
|
36
36
|
paywallIsDefault: boolean;
|
|
37
37
|
/** Where a failed checkout confirmation lands. */
|
|
38
38
|
paymentErrorRoute: string;
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Where a completed checkout (or a Free pick) lands: `billing.successRoute`,
|
|
41
|
+
* else the subscription page, which shows the confirmation banner (TBP-813).
|
|
42
|
+
*/
|
|
40
43
|
successRoute: string;
|
|
44
|
+
/**
|
|
45
|
+
* True when `successRoute` is the default rather than the app's own choice.
|
|
46
|
+
* `<BridgeBillingRoutes>` then lands on its own subscription page, wherever
|
|
47
|
+
* its catch-all lives, instead of on `manageRoute`.
|
|
48
|
+
*/
|
|
49
|
+
successIsDefault: boolean;
|
|
41
50
|
}
|
|
42
51
|
/**
|
|
43
52
|
* Resolve the billing destinations from a `billing` config block. An unset
|
|
@@ -54,7 +54,8 @@ export function resolveBillingRoutes(billing) {
|
|
|
54
54
|
paywallRoute: paywall === false ? null : paywall || BRIDGE_BILLING_DEFAULTS.paywallRoute,
|
|
55
55
|
paywallIsDefault: paywall !== false && !paywall,
|
|
56
56
|
paymentErrorRoute: billing?.paymentErrorRoute || BRIDGE_BILLING_DEFAULTS.paymentErrorRoute,
|
|
57
|
-
successRoute:
|
|
57
|
+
successRoute: billing?.successRoute || manageRoute,
|
|
58
|
+
successIsDefault: !billing?.successRoute,
|
|
58
59
|
};
|
|
59
60
|
}
|
|
60
61
|
/**
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import { type Readable } from 'svelte/store';
|
|
2
|
+
/** What just completed. */
|
|
3
|
+
export interface BridgeCheckoutResult {
|
|
4
|
+
/** `checkout`: a paid Stripe checkout returned. `plan`: a plan was set without one (a Free pick). */
|
|
5
|
+
kind: 'checkout' | 'plan';
|
|
6
|
+
/** The plan's name, when the page that set it knew it. Otherwise read the current plan. */
|
|
7
|
+
planName?: string;
|
|
8
|
+
}
|
|
9
|
+
/** The checkout that just completed, or `null`. Cleared once something shows it. */
|
|
10
|
+
export declare const checkoutResult: Readable<BridgeCheckoutResult | null>;
|
|
11
|
+
/** Record a completed checkout or plan pick. Called by the plugin, not by apps. */
|
|
12
|
+
export declare function markCheckoutComplete(value: BridgeCheckoutResult): void;
|
|
13
|
+
/** Read the result and clear it, so it is shown once. */
|
|
14
|
+
export declare function takeCheckoutResult(): BridgeCheckoutResult | null;
|
|
15
|
+
/** Forget the result without showing it. */
|
|
16
|
+
export declare function clearCheckoutResult(): void;
|
|
@@ -0,0 +1,31 @@
|
|
|
1
|
+
// TBP-813 — "a checkout just finished", for whichever page it lands on.
|
|
2
|
+
//
|
|
3
|
+
// A completed checkout used to stop on a confirmation page (`/subscription/success`)
|
|
4
|
+
// before going anywhere. It now lands on `billing.successRoute` (the subscription
|
|
5
|
+
// page by default), and this one-shot signal is how that page knows to say so:
|
|
6
|
+
// `<BridgeCheckoutNotice>` shows it as a banner, and an app that wants its own
|
|
7
|
+
// message reads `checkoutResult`.
|
|
8
|
+
//
|
|
9
|
+
// In memory only, never in the URL or storage: the checkout return redirects
|
|
10
|
+
// inside the running app, so the signal is still here when the landing page
|
|
11
|
+
// renders, and a reload (or a later visit) does not show it again. Whoever shows
|
|
12
|
+
// it clears it.
|
|
13
|
+
import { get, writable } from 'svelte/store';
|
|
14
|
+
const result = writable(null);
|
|
15
|
+
/** The checkout that just completed, or `null`. Cleared once something shows it. */
|
|
16
|
+
export const checkoutResult = { subscribe: result.subscribe };
|
|
17
|
+
/** Record a completed checkout or plan pick. Called by the plugin, not by apps. */
|
|
18
|
+
export function markCheckoutComplete(value) {
|
|
19
|
+
result.set(value);
|
|
20
|
+
}
|
|
21
|
+
/** Read the result and clear it, so it is shown once. */
|
|
22
|
+
export function takeCheckoutResult() {
|
|
23
|
+
const value = get(result);
|
|
24
|
+
if (value)
|
|
25
|
+
result.set(null);
|
|
26
|
+
return value;
|
|
27
|
+
}
|
|
28
|
+
/** Forget the result without showing it. */
|
|
29
|
+
export function clearCheckoutResult() {
|
|
30
|
+
result.set(null);
|
|
31
|
+
}
|
|
@@ -6,9 +6,10 @@
|
|
|
6
6
|
<BridgeBillingRoutes />
|
|
7
7
|
|
|
8
8
|
Serves, relative to wherever the catch-all lives:
|
|
9
|
-
/subscription the current plan, the plan picker and "Manage billing"
|
|
9
|
+
/subscription the current plan, the plan picker and "Manage billing";
|
|
10
|
+
a completed checkout lands here, with a banner
|
|
10
11
|
/subscription/plan the paywall: where a plan-less workspace is sent
|
|
11
|
-
/subscription/success
|
|
12
|
+
/subscription/success the old confirmation page, for `billing.successRoute`
|
|
12
13
|
/subscription/error where a failed checkout confirmation lands
|
|
13
14
|
An unknown segment gets the app's own 404 (bridgeBootstrap's load throws it —
|
|
14
15
|
a component cannot).
|
|
@@ -28,7 +29,9 @@
|
|
|
28
29
|
4. Headless: `PlanSelector`, `BridgeSubscriptionStatus`, `BillingPortalButton`
|
|
29
30
|
and `subscriptionStore` are the pieces these pages are built from.
|
|
30
31
|
|
|
31
|
-
|
|
32
|
+
TBP-813 — a paid checkout and a Free pick land on `billing.successRoute`, by
|
|
33
|
+
default this catch-all's own subscription page, which shows
|
|
34
|
+
`<BridgeCheckoutNotice>`. Set `billing.successRoute` to land anywhere else.
|
|
32
35
|
-->
|
|
33
36
|
<script lang="ts">
|
|
34
37
|
import type { Snippet } from 'svelte';
|
|
@@ -36,10 +39,15 @@
|
|
|
36
39
|
import { page } from '$app/stores';
|
|
37
40
|
import { ensureSubscription, loadSubscription } from '../../../core/bridge-instance.js';
|
|
38
41
|
import { BRIDGE_AUTH_ROUTE_PARAM, bridgeAuthBase } from '../../auth-routes.js';
|
|
39
|
-
import {
|
|
42
|
+
import { onDestroy, onMount } from 'svelte';
|
|
43
|
+
import { useBridge, type BillingSubscriptionSnapshot } from '@nebulr-group/bridge-auth-core';
|
|
44
|
+
import { billingRoutes, parseBridgeBillingRoute, type BridgeBillingPage } from '../../billing-routes.js';
|
|
45
|
+
import { clearCheckoutResult } from '../../checkout-result.js';
|
|
40
46
|
import PlanSelector from './PlanSelector.svelte';
|
|
41
47
|
import BridgeSubscriptionStatus from './BridgeSubscriptionStatus.svelte';
|
|
42
48
|
import BillingPortalButton from './BillingPortalButton.svelte';
|
|
49
|
+
import BridgeCheckoutNotice from './BridgeCheckoutNotice.svelte';
|
|
50
|
+
import { subscriptionMeta } from './plan-format.js';
|
|
43
51
|
|
|
44
52
|
interface Props {
|
|
45
53
|
/** Everything around the content, on every page. Receives the page name and
|
|
@@ -49,15 +57,37 @@
|
|
|
49
57
|
heading?: Snippet<[BridgeBillingPage]>;
|
|
50
58
|
/** Where "Continue" on the success page goes. @default '/' */
|
|
51
59
|
redirectTo?: string;
|
|
60
|
+
/** The "you're on Pro now" banner on the subscription page after a
|
|
61
|
+
* checkout. `false` hides it. @default true */
|
|
62
|
+
checkoutNotice?: boolean;
|
|
52
63
|
}
|
|
53
64
|
|
|
54
|
-
let { frame, heading, redirectTo = '/' }: Props = $props();
|
|
65
|
+
let { frame, heading, redirectTo = '/', checkoutNotice = true }: Props = $props();
|
|
55
66
|
|
|
56
67
|
const rest = $derived($page.params[BRIDGE_AUTH_ROUTE_PARAM]);
|
|
57
68
|
const route = $derived(parseBridgeBillingRoute(rest));
|
|
58
69
|
// Links are relative to where the catch-all lives, not hard-coded to /subscription.
|
|
59
70
|
const base = $derived(bridgeAuthBase($page.url.pathname, rest) || '/');
|
|
60
71
|
const at = (sub: string) => (base === '/' ? `/${sub}` : `${base}/${sub}`);
|
|
72
|
+
// TBP-813 — a checkout lands on `billing.successRoute`; by default on this
|
|
73
|
+
// catch-all's own subscription page, wherever it lives.
|
|
74
|
+
const routes = billingRoutes();
|
|
75
|
+
const successTarget = $derived(routes.successIsDefault ? base : routes.successRoute);
|
|
76
|
+
|
|
77
|
+
// The current plan's renewal date and card, for the line under its name.
|
|
78
|
+
let snapshot = $state<BillingSubscriptionSnapshot | null>(null);
|
|
79
|
+
let unsubscribe: (() => void) | undefined;
|
|
80
|
+
onMount(() => {
|
|
81
|
+
try {
|
|
82
|
+
const sub = useBridge().subscription;
|
|
83
|
+
snapshot = sub.snapshot();
|
|
84
|
+
unsubscribe = sub.subscribe((s) => (snapshot = s));
|
|
85
|
+
} catch {
|
|
86
|
+
/* Bridge not initialised: no meta line */
|
|
87
|
+
}
|
|
88
|
+
});
|
|
89
|
+
onDestroy(() => unsubscribe?.());
|
|
90
|
+
const meta = $derived(subscriptionMeta(snapshot?.state));
|
|
61
91
|
|
|
62
92
|
const DEFAULT_HEADINGS: Record<BridgeBillingPage, string> = {
|
|
63
93
|
manage: 'Subscription',
|
|
@@ -77,11 +107,16 @@
|
|
|
77
107
|
if (!current) return;
|
|
78
108
|
untrack(() => {
|
|
79
109
|
void (current === 'success' ? loadSubscription() : ensureSubscription());
|
|
110
|
+
// The old confirmation page says it all; no banner on the next page too.
|
|
111
|
+
if (current === 'success') clearCheckoutResult();
|
|
80
112
|
});
|
|
81
113
|
});
|
|
82
114
|
</script>
|
|
83
115
|
|
|
84
116
|
{#snippet body(p: BridgeBillingPage)}
|
|
117
|
+
{#if p === 'manage' && checkoutNotice}
|
|
118
|
+
<BridgeCheckoutNotice />
|
|
119
|
+
{/if}
|
|
85
120
|
{#if heading}
|
|
86
121
|
{@render heading(p)}
|
|
87
122
|
{:else}
|
|
@@ -89,15 +124,21 @@
|
|
|
89
124
|
{/if}
|
|
90
125
|
|
|
91
126
|
{#if p === 'manage'}
|
|
92
|
-
<
|
|
93
|
-
|
|
94
|
-
<
|
|
127
|
+
<p class="bridge-billing-text">Your plan, what it includes, and billing.</p>
|
|
128
|
+
<div class="bridge-billing-summary" data-bridge-billing-summary>
|
|
129
|
+
<div class="bridge-billing-summary-plan">
|
|
130
|
+
<span class="bridge-billing-label">Current plan</span>
|
|
131
|
+
<BridgeSubscriptionStatus class="bridge-billing-summary-status" />
|
|
132
|
+
{#if meta}
|
|
133
|
+
<span class="bridge-billing-summary-meta" data-bridge-billing-meta>{meta}</span>
|
|
134
|
+
{/if}
|
|
135
|
+
</div>
|
|
95
136
|
<BillingPortalButton />
|
|
96
137
|
</div>
|
|
97
|
-
<PlanSelector successRedirect={
|
|
138
|
+
<PlanSelector heading="Change plan" successRedirect={successTarget} cancelRedirect={base} />
|
|
98
139
|
{:else if p === 'plan'}
|
|
99
140
|
<p class="bridge-billing-text">Pick a plan to start using the app.</p>
|
|
100
|
-
<PlanSelector successRedirect={
|
|
141
|
+
<PlanSelector successRedirect={successTarget} cancelRedirect={at('plan')} />
|
|
101
142
|
{:else if p === 'success'}
|
|
102
143
|
<p class="bridge-billing-text">Your plan is active.</p>
|
|
103
144
|
<div class="bridge-billing-current">
|
|
@@ -152,6 +193,11 @@
|
|
|
152
193
|
opacity: 0.8;
|
|
153
194
|
}
|
|
154
195
|
|
|
196
|
+
/* The subtitle sits with its heading, closer than the page's gap. */
|
|
197
|
+
.bridge-billing-heading + .bridge-billing-text {
|
|
198
|
+
margin-top: -0.75rem;
|
|
199
|
+
}
|
|
200
|
+
|
|
155
201
|
.bridge-billing-current {
|
|
156
202
|
display: flex;
|
|
157
203
|
flex-wrap: wrap;
|
|
@@ -159,6 +205,23 @@
|
|
|
159
205
|
gap: 0.75rem;
|
|
160
206
|
}
|
|
161
207
|
|
|
208
|
+
/* The current-plan panel's layout; its surface is in styles.css. */
|
|
209
|
+
.bridge-billing-summary {
|
|
210
|
+
display: flex;
|
|
211
|
+
flex-wrap: wrap;
|
|
212
|
+
align-items: center;
|
|
213
|
+
justify-content: space-between;
|
|
214
|
+
gap: 1rem;
|
|
215
|
+
}
|
|
216
|
+
|
|
217
|
+
.bridge-billing-summary-plan {
|
|
218
|
+
display: flex;
|
|
219
|
+
flex-direction: column;
|
|
220
|
+
align-items: flex-start;
|
|
221
|
+
gap: 0.25rem;
|
|
222
|
+
min-width: 0;
|
|
223
|
+
}
|
|
224
|
+
|
|
162
225
|
.bridge-billing-label {
|
|
163
226
|
font-size: 0.75rem;
|
|
164
227
|
font-weight: 600;
|
|
@@ -8,6 +8,9 @@ interface Props {
|
|
|
8
8
|
heading?: Snippet<[BridgeBillingPage]>;
|
|
9
9
|
/** Where "Continue" on the success page goes. @default '/' */
|
|
10
10
|
redirectTo?: string;
|
|
11
|
+
/** The "you're on Pro now" banner on the subscription page after a
|
|
12
|
+
* checkout. `false` hides it. @default true */
|
|
13
|
+
checkoutNotice?: boolean;
|
|
11
14
|
}
|
|
12
15
|
declare const BridgeBillingRoutes: import("svelte").Component<Props, {}, "">;
|
|
13
16
|
type BridgeBillingRoutes = ReturnType<typeof BridgeBillingRoutes>;
|
|
@@ -0,0 +1,101 @@
|
|
|
1
|
+
<!--
|
|
2
|
+
TBP-813 — the "you're on Pro now" banner after a checkout.
|
|
3
|
+
|
|
4
|
+
A completed checkout (or a Free pick) lands on `billing.successRoute`, the
|
|
5
|
+
subscription page by default, which shows this banner itself. To land
|
|
6
|
+
somewhere else and still say so, put it on that page or in the layout:
|
|
7
|
+
|
|
8
|
+
<BridgeCheckoutNotice />
|
|
9
|
+
|
|
10
|
+
It renders nothing until a checkout has just completed, shows once, and is
|
|
11
|
+
gone after a reload. `children` replaces the text and receives the result;
|
|
12
|
+
for a message of your own anywhere else, read `checkoutResult`.
|
|
13
|
+
-->
|
|
14
|
+
<script lang="ts">
|
|
15
|
+
import type { Snippet } from 'svelte';
|
|
16
|
+
import { onDestroy, onMount } from 'svelte';
|
|
17
|
+
import { useBridge, type BillingSubscriptionSnapshot, type Plan } from '@nebulr-group/bridge-auth-core';
|
|
18
|
+
import { subscriptionStore } from '../../../core/bridge-instance.js';
|
|
19
|
+
import { checkoutResult, clearCheckoutResult, type BridgeCheckoutResult } from '../../checkout-result.js';
|
|
20
|
+
|
|
21
|
+
interface Props {
|
|
22
|
+
/** Optional class on the banner. */
|
|
23
|
+
class?: string;
|
|
24
|
+
/** Show a close button. @default true */
|
|
25
|
+
dismissible?: boolean;
|
|
26
|
+
/** Replaces the banner's text. Receives the result, with the plan name when known. */
|
|
27
|
+
children?: Snippet<[Omit<BridgeCheckoutResult, 'planName'> & { planName: string | null }]>;
|
|
28
|
+
}
|
|
29
|
+
|
|
30
|
+
let { class: className = '', dismissible = true, children }: Props = $props();
|
|
31
|
+
|
|
32
|
+
let shown = $state<BridgeCheckoutResult | null>(null);
|
|
33
|
+
let snapshot = $state<BillingSubscriptionSnapshot | null>(null);
|
|
34
|
+
const unsubs: Array<() => void> = [];
|
|
35
|
+
|
|
36
|
+
onMount(() => {
|
|
37
|
+
// Take the result as soon as it is set, so it shows once — including on a
|
|
38
|
+
// page that is already open when a Free pick lands back on it.
|
|
39
|
+
unsubs.push(
|
|
40
|
+
checkoutResult.subscribe((value) => {
|
|
41
|
+
if (!value) return;
|
|
42
|
+
shown = value;
|
|
43
|
+
queueMicrotask(clearCheckoutResult);
|
|
44
|
+
}),
|
|
45
|
+
);
|
|
46
|
+
try {
|
|
47
|
+
const sub = useBridge().subscription;
|
|
48
|
+
snapshot = sub.snapshot();
|
|
49
|
+
unsubs.push(sub.subscribe((s) => (snapshot = s)));
|
|
50
|
+
} catch {
|
|
51
|
+
/* Bridge not initialised: the banner goes without the plan name */
|
|
52
|
+
}
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
onDestroy(() => unsubs.forEach((u) => u()));
|
|
56
|
+
|
|
57
|
+
const planName = $derived.by<string | null>(() => {
|
|
58
|
+
if (shown?.planName) return shown.planName;
|
|
59
|
+
if (snapshot?.state?.plan.name) return snapshot.state.plan.name;
|
|
60
|
+
const plan = $subscriptionStore.status?.plan;
|
|
61
|
+
return plan && typeof plan === 'object' ? ((plan as Plan).name ?? null) : null;
|
|
62
|
+
});
|
|
63
|
+
</script>
|
|
64
|
+
|
|
65
|
+
{#if shown}
|
|
66
|
+
<div
|
|
67
|
+
class={`bridge-checkout-notice ${className}`}
|
|
68
|
+
data-bridge-checkout-notice
|
|
69
|
+
data-kind={shown.kind}
|
|
70
|
+
role="status"
|
|
71
|
+
>
|
|
72
|
+
<svg class="bridge-checkout-notice-icon" width="18" height="18" viewBox="0 0 20 20" fill="none" aria-hidden="true">
|
|
73
|
+
<circle cx="10" cy="10" r="9" stroke="currentColor" stroke-width="1.6" />
|
|
74
|
+
<path d="M6 10.2l2.6 2.6L14 7.4" stroke="currentColor" stroke-width="1.8" stroke-linecap="round" stroke-linejoin="round" />
|
|
75
|
+
</svg>
|
|
76
|
+
<div class="bridge-checkout-notice-body">
|
|
77
|
+
{#if children}
|
|
78
|
+
{@render children({ ...shown, planName })}
|
|
79
|
+
{:else}
|
|
80
|
+
<strong class="bridge-checkout-notice-title">
|
|
81
|
+
{planName ? `You're on ${planName} now` : 'Your new plan is active'}
|
|
82
|
+
</strong>
|
|
83
|
+
<span class="bridge-checkout-notice-text">
|
|
84
|
+
{shown.kind === 'checkout'
|
|
85
|
+
? 'Your payment went through and the new plan is active.'
|
|
86
|
+
: 'The change takes effect right away.'}
|
|
87
|
+
</span>
|
|
88
|
+
{/if}
|
|
89
|
+
</div>
|
|
90
|
+
{#if dismissible}
|
|
91
|
+
<button
|
|
92
|
+
type="button"
|
|
93
|
+
class="bridge-checkout-notice-close"
|
|
94
|
+
aria-label="Dismiss"
|
|
95
|
+
onclick={() => (shown = null)}
|
|
96
|
+
>
|
|
97
|
+
×
|
|
98
|
+
</button>
|
|
99
|
+
{/if}
|
|
100
|
+
</div>
|
|
101
|
+
{/if}
|
|
@@ -0,0 +1,15 @@
|
|
|
1
|
+
import type { Snippet } from 'svelte';
|
|
2
|
+
import { type BridgeCheckoutResult } from '../../checkout-result.js';
|
|
3
|
+
interface Props {
|
|
4
|
+
/** Optional class on the banner. */
|
|
5
|
+
class?: string;
|
|
6
|
+
/** Show a close button. @default true */
|
|
7
|
+
dismissible?: boolean;
|
|
8
|
+
/** Replaces the banner's text. Receives the result, with the plan name when known. */
|
|
9
|
+
children?: Snippet<[Omit<BridgeCheckoutResult, 'planName'> & {
|
|
10
|
+
planName: string | null;
|
|
11
|
+
}]>;
|
|
12
|
+
}
|
|
13
|
+
declare const BridgeCheckoutNotice: import("svelte").Component<Props, {}, "">;
|
|
14
|
+
type BridgeCheckoutNotice = ReturnType<typeof BridgeCheckoutNotice>;
|
|
15
|
+
export default BridgeCheckoutNotice;
|
|
@@ -13,8 +13,8 @@
|
|
|
13
13
|
page has ever been visited — that is what the config line is for. Mounted
|
|
14
14
|
anywhere else, this page says so in the dev console.
|
|
15
15
|
|
|
16
|
-
A completed checkout lands on the subscription
|
|
17
|
-
|
|
16
|
+
A completed checkout lands on `billing.successRoute` (the subscription page,
|
|
17
|
+
with a confirmation banner, by default); a cancelled one comes back here.
|
|
18
18
|
-->
|
|
19
19
|
<script lang="ts">
|
|
20
20
|
import type { Snippet } from 'svelte';
|
|
@@ -31,7 +31,7 @@
|
|
|
31
31
|
heading?: string;
|
|
32
32
|
/** Content between the heading and the plans — a welcome line, a checklist. */
|
|
33
33
|
children?: Snippet;
|
|
34
|
-
/** Where a completed checkout lands. @default
|
|
34
|
+
/** Where a completed checkout lands. @default `billing.successRoute`, else the subscription page */
|
|
35
35
|
successRedirect?: string;
|
|
36
36
|
/** Where a cancelled checkout lands. @default this page */
|
|
37
37
|
cancelRedirect?: string;
|
|
@@ -5,7 +5,7 @@ interface Props {
|
|
|
5
5
|
heading?: string;
|
|
6
6
|
/** Content between the heading and the plans — a welcome line, a checklist. */
|
|
7
7
|
children?: Snippet;
|
|
8
|
-
/** Where a completed checkout lands. @default
|
|
8
|
+
/** Where a completed checkout lands. @default `billing.successRoute`, else the subscription page */
|
|
9
9
|
successRedirect?: string;
|
|
10
10
|
/** Where a cancelled checkout lands. @default this page */
|
|
11
11
|
cancelRedirect?: string;
|
|
@@ -7,6 +7,8 @@
|
|
|
7
7
|
import { ensureSubscription, getBridgeAuth, loadSubscription, subscriptionStore } from '../../../core/bridge-instance.js';
|
|
8
8
|
import { refreshBilling } from '../../../core/billing-store.js';
|
|
9
9
|
import { pickPlan } from './plan-pick.js';
|
|
10
|
+
import { formatAmount, intervalSuffix, minAmount, pickKind, pickLabel } from './plan-format.js';
|
|
11
|
+
import { markCheckoutComplete } from '../../checkout-result.js';
|
|
10
12
|
import { getConfig } from '../../stores/config.store.js';
|
|
11
13
|
import Alert from '../sdk-auth/shared/Alert.svelte';
|
|
12
14
|
import Spinner from '../sdk-auth/shared/Spinner.svelte';
|
|
@@ -14,6 +16,8 @@
|
|
|
14
16
|
type BillingInterval = PriceOfferSdk['recurrenceInterval'];
|
|
15
17
|
|
|
16
18
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
19
|
+
/** A heading above the plans, beside the Monthly/Yearly switch (TBP-813). */
|
|
20
|
+
heading?: string;
|
|
17
21
|
successRedirect?: string;
|
|
18
22
|
cancelRedirect?: string;
|
|
19
23
|
/**
|
|
@@ -36,6 +40,7 @@
|
|
|
36
40
|
}
|
|
37
41
|
|
|
38
42
|
let {
|
|
43
|
+
heading,
|
|
39
44
|
successRedirect = '/subscription',
|
|
40
45
|
cancelRedirect = '/subscription',
|
|
41
46
|
defaultInterval = 'year',
|
|
@@ -135,11 +140,6 @@
|
|
|
135
140
|
return (plan as Plan & { features?: ReadonlyArray<{ key: string; name: string }> }).features ?? [];
|
|
136
141
|
}
|
|
137
142
|
|
|
138
|
-
function minAmount(plan: Plan): number {
|
|
139
|
-
const amounts = (plan.prices ?? []).map((p) => p.amount);
|
|
140
|
-
return amounts.length > 0 ? Math.min(...amounts) : Number.POSITIVE_INFINITY;
|
|
141
|
-
}
|
|
142
|
-
|
|
143
143
|
type UiState = 'idle' | 'payment-failed' | 'setup-payments' | 'select-plan' | 'active' | 'trial';
|
|
144
144
|
|
|
145
145
|
const uiState = $derived<UiState>((() => {
|
|
@@ -187,15 +187,13 @@
|
|
|
187
187
|
let successNotice = $state<string | null>(null);
|
|
188
188
|
let successTimer: ReturnType<typeof setTimeout> | undefined;
|
|
189
189
|
|
|
190
|
-
const
|
|
191
|
-
|
|
192
|
-
return found?.name ?? currentPlanKey ?? 'your current plan';
|
|
193
|
-
});
|
|
190
|
+
const currentPlan = $derived((plans ?? []).find((p) => p.key === currentPlanKey) ?? null);
|
|
191
|
+
const currentPlanName = $derived(currentPlan?.name ?? currentPlanKey ?? 'your current plan');
|
|
194
192
|
|
|
195
193
|
function formatPrice(price: PriceOfferSdk): string {
|
|
196
194
|
return price.amount === 0
|
|
197
195
|
? 'Free'
|
|
198
|
-
: `${price.amount
|
|
196
|
+
: `${formatAmount(price.amount, price.currency)} ${intervalSuffix(price)}`;
|
|
199
197
|
}
|
|
200
198
|
|
|
201
199
|
function showSuccess(message: string): void {
|
|
@@ -244,6 +242,7 @@
|
|
|
244
242
|
leave: (url) => {
|
|
245
243
|
window.location.href = url;
|
|
246
244
|
},
|
|
245
|
+
complete: (picked) => markCheckoutComplete({ kind: 'plan', planName: picked.name }),
|
|
247
246
|
},
|
|
248
247
|
);
|
|
249
248
|
if (outcome === 'confirm') {
|
|
@@ -320,7 +319,7 @@
|
|
|
320
319
|
</div>
|
|
321
320
|
{/if}
|
|
322
321
|
{:else if plans}
|
|
323
|
-
{#
|
|
322
|
+
{#snippet intervalTabs()}
|
|
324
323
|
<div
|
|
325
324
|
class="bridge-plan-interval-tabs"
|
|
326
325
|
data-bridge-plan-interval-tabs
|
|
@@ -339,6 +338,15 @@
|
|
|
339
338
|
</button>
|
|
340
339
|
{/each}
|
|
341
340
|
</div>
|
|
341
|
+
{/snippet}
|
|
342
|
+
|
|
343
|
+
{#if heading}
|
|
344
|
+
<div class="bridge-plan-head" data-bridge-plan-head>
|
|
345
|
+
<h2 class="bridge-plan-heading">{heading}</h2>
|
|
346
|
+
{#if showIntervalTabs}{@render intervalTabs()}{/if}
|
|
347
|
+
</div>
|
|
348
|
+
{:else if showIntervalTabs}
|
|
349
|
+
{@render intervalTabs()}
|
|
342
350
|
{/if}
|
|
343
351
|
|
|
344
352
|
<div class="bridge-plan-cards" data-bridge-plan-cards>
|
|
@@ -346,6 +354,7 @@
|
|
|
346
354
|
{@const isCurrent = plan.key === currentPlanKey}
|
|
347
355
|
{@const onPick = (price: PriceOfferSdk) => handlePick(plan, price)}
|
|
348
356
|
{@const visiblePrices = pricesForInterval(plan)}
|
|
357
|
+
{@const kind = pickKind(plan, currentPlan)}
|
|
349
358
|
|
|
350
359
|
{#if planCard}
|
|
351
360
|
{@render planCard({ plan, prices: plan.prices, isCurrent, interval: selectedInterval, onPick })}
|
|
@@ -354,8 +363,12 @@
|
|
|
354
363
|
data-bridge-plan-card
|
|
355
364
|
data-current={isCurrent}
|
|
356
365
|
data-trial={plan.trial}
|
|
366
|
+
data-pick={kind}
|
|
357
367
|
class="bridge-plan-card"
|
|
358
368
|
>
|
|
369
|
+
{#if isCurrent}
|
|
370
|
+
<span class="bridge-plan-current-flag" data-bridge-plan-current-flag>Your plan</span>
|
|
371
|
+
{/if}
|
|
359
372
|
<div class="bridge-plan-card-header">
|
|
360
373
|
<h3 class="bridge-plan-name">{plan.name}</h3>
|
|
361
374
|
{#if plan.trial && (plan.trialDays ?? 0) > 0}
|
|
@@ -363,6 +376,13 @@
|
|
|
363
376
|
{/if}
|
|
364
377
|
</div>
|
|
365
378
|
|
|
379
|
+
{#if visiblePrices[0]}
|
|
380
|
+
<div class="bridge-plan-price" data-bridge-plan-price>
|
|
381
|
+
<span class="bridge-plan-price-amount">{formatAmount(visiblePrices[0].amount, visiblePrices[0].currency)}</span>
|
|
382
|
+
<span class="bridge-plan-price-interval">{intervalSuffix(visiblePrices[0])}</span>
|
|
383
|
+
</div>
|
|
384
|
+
{/if}
|
|
385
|
+
|
|
366
386
|
{#if planDescription}
|
|
367
387
|
{@render planDescription({ plan, isCurrent })}
|
|
368
388
|
{:else if plan.description}
|
|
@@ -372,7 +392,12 @@
|
|
|
372
392
|
{#if planFeatures(plan).length > 0}
|
|
373
393
|
<ul class="bridge-plan-features" data-bridge-plan-features aria-label={`Included in ${plan.name}`}>
|
|
374
394
|
{#each planFeatures(plan) as feature (feature.key)}
|
|
375
|
-
<li class="bridge-plan-feature" data-feature={feature.key}>
|
|
395
|
+
<li class="bridge-plan-feature" data-feature={feature.key}>
|
|
396
|
+
<svg class="bridge-plan-feature-check" width="14" height="14" viewBox="0 0 16 16" aria-hidden="true">
|
|
397
|
+
<path d="M3 8.5l3.2 3L13 4.5" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round" stroke-linejoin="round" />
|
|
398
|
+
</svg>
|
|
399
|
+
<span>{feature.name}</span>
|
|
400
|
+
</li>
|
|
376
401
|
{/each}
|
|
377
402
|
</ul>
|
|
378
403
|
{/if}
|
|
@@ -380,27 +405,21 @@
|
|
|
380
405
|
<div class="bridge-plan-prices">
|
|
381
406
|
{#each visiblePrices as price (price.recurrenceInterval + price.currency)}
|
|
382
407
|
<button
|
|
383
|
-
class=
|
|
408
|
+
class={`${kind === 'current' || kind === 'switch' ? 'bridge-btn-secondary' : 'bridge-btn-primary'} bridge-plan-select-btn`}
|
|
384
409
|
disabled={isCurrent || picking}
|
|
385
410
|
onclick={() => onPick(price)}
|
|
386
411
|
>
|
|
387
|
-
{#if isCurrent}
|
|
388
|
-
Current plan
|
|
389
|
-
{:else if price.amount === 0}
|
|
390
|
-
Select free plan
|
|
391
|
-
{:else}
|
|
392
|
-
{price.amount} {price.currency.toUpperCase()} / {price.recurrenceInterval}
|
|
393
|
-
{/if}
|
|
412
|
+
{pickLabel(kind, plan)}{#if visiblePrices.length > 1 && !isCurrent}{` · ${formatAmount(price.amount, price.currency)}`}{/if}
|
|
394
413
|
</button>
|
|
395
414
|
{/each}
|
|
396
415
|
|
|
397
416
|
{#if plan.prices.length === 0}
|
|
398
417
|
<button
|
|
399
|
-
class=
|
|
418
|
+
class={`${kind === 'current' || kind === 'switch' ? 'bridge-btn-secondary' : 'bridge-btn-primary'} bridge-plan-select-btn`}
|
|
400
419
|
disabled={isCurrent || picking}
|
|
401
420
|
onclick={() => handlePick(plan, { id: '', amount: 0, currency: 'usd', recurrenceInterval: 'month' })}
|
|
402
421
|
>
|
|
403
|
-
{
|
|
422
|
+
{pickLabel(kind, plan)}
|
|
404
423
|
</button>
|
|
405
424
|
{:else if visiblePrices.length === 0}
|
|
406
425
|
<p class="bridge-plan-unavailable" data-bridge-plan-unavailable>
|
|
@@ -3,6 +3,8 @@ import type { HTMLAttributes } from 'svelte/elements';
|
|
|
3
3
|
import type { Plan, PriceOfferSdk } from '@nebulr-group/bridge-auth-core';
|
|
4
4
|
type BillingInterval = PriceOfferSdk['recurrenceInterval'];
|
|
5
5
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
+
/** A heading above the plans, beside the Monthly/Yearly switch (TBP-813). */
|
|
7
|
+
heading?: string;
|
|
6
8
|
successRedirect?: string;
|
|
7
9
|
cancelRedirect?: string;
|
|
8
10
|
/**
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
import type { BillingSubscriptionState, Plan, PriceOfferSdk } from '@nebulr-group/bridge-auth-core';
|
|
2
|
+
/** `29` + `usd` → `$29`; `9.5` → `$9.50`. An unknown currency code falls back to `29 XYZ`. */
|
|
3
|
+
export declare function formatAmount(amount: number, currency: string, locale?: string): string;
|
|
4
|
+
/** The cheapest price a plan has in any interval; plans with none sort last. */
|
|
5
|
+
export declare function minAmount(plan: Plan): number;
|
|
6
|
+
/** What picking a plan does, relative to the current one. */
|
|
7
|
+
export type PickKind = 'current' | 'choose' | 'upgrade' | 'switch';
|
|
8
|
+
export declare function pickKind(plan: Plan, current: Plan | null | undefined): PickKind;
|
|
9
|
+
/** The button text for a pick. */
|
|
10
|
+
export declare function pickLabel(kind: PickKind, plan: Plan): string;
|
|
11
|
+
/** `/ month`, after a price. */
|
|
12
|
+
export declare function intervalSuffix(price: PriceOfferSdk): string;
|
|
13
|
+
/**
|
|
14
|
+
* The line under the current plan's name: renewal or end date, and the card on
|
|
15
|
+
* file. Only what the billing state actually carries; empty when it has none.
|
|
16
|
+
*/
|
|
17
|
+
export declare function subscriptionMeta(state: BillingSubscriptionState | null | undefined, locale?: string): string;
|
|
@@ -0,0 +1,81 @@
|
|
|
1
|
+
// TBP-813 — how the plan cards and the current-plan panel word things, as pure
|
|
2
|
+
// functions. Prices are whole currency units (29 = 29 USD), as Bridge stores them.
|
|
3
|
+
/** `29` + `usd` → `$29`; `9.5` → `$9.50`. An unknown currency code falls back to `29 XYZ`. */
|
|
4
|
+
export function formatAmount(amount, currency, locale) {
|
|
5
|
+
const code = currency.toUpperCase();
|
|
6
|
+
try {
|
|
7
|
+
return new Intl.NumberFormat(locale, {
|
|
8
|
+
style: 'currency',
|
|
9
|
+
currency: code,
|
|
10
|
+
minimumFractionDigits: Number.isInteger(amount) ? 0 : 2,
|
|
11
|
+
maximumFractionDigits: 2,
|
|
12
|
+
}).format(amount);
|
|
13
|
+
}
|
|
14
|
+
catch {
|
|
15
|
+
return `${amount} ${code}`;
|
|
16
|
+
}
|
|
17
|
+
}
|
|
18
|
+
/** The cheapest price a plan has in any interval; plans with none sort last. */
|
|
19
|
+
export function minAmount(plan) {
|
|
20
|
+
const amounts = (plan.prices ?? []).map((p) => p.amount);
|
|
21
|
+
return amounts.length > 0 ? Math.min(...amounts) : Number.POSITIVE_INFINITY;
|
|
22
|
+
}
|
|
23
|
+
export function pickKind(plan, current) {
|
|
24
|
+
if (current && current.key === plan.key)
|
|
25
|
+
return 'current';
|
|
26
|
+
if (!current)
|
|
27
|
+
return 'choose';
|
|
28
|
+
return minAmount(plan) > minAmount(current) ? 'upgrade' : 'switch';
|
|
29
|
+
}
|
|
30
|
+
/** The button text for a pick. */
|
|
31
|
+
export function pickLabel(kind, plan) {
|
|
32
|
+
switch (kind) {
|
|
33
|
+
case 'current':
|
|
34
|
+
return 'Current plan';
|
|
35
|
+
case 'upgrade':
|
|
36
|
+
return `Upgrade to ${plan.name}`;
|
|
37
|
+
case 'switch':
|
|
38
|
+
return `Switch to ${plan.name}`;
|
|
39
|
+
default:
|
|
40
|
+
return `Choose ${plan.name}`;
|
|
41
|
+
}
|
|
42
|
+
}
|
|
43
|
+
/** `/ month`, after a price. */
|
|
44
|
+
export function intervalSuffix(price) {
|
|
45
|
+
return `/ ${price.recurrenceInterval}`;
|
|
46
|
+
}
|
|
47
|
+
function formatDate(iso, locale) {
|
|
48
|
+
if (!iso)
|
|
49
|
+
return null;
|
|
50
|
+
const d = new Date(iso);
|
|
51
|
+
if (Number.isNaN(d.getTime()))
|
|
52
|
+
return null;
|
|
53
|
+
return d.toLocaleDateString(locale, { year: 'numeric', month: 'short', day: 'numeric' });
|
|
54
|
+
}
|
|
55
|
+
/**
|
|
56
|
+
* The line under the current plan's name: renewal or end date, and the card on
|
|
57
|
+
* file. Only what the billing state actually carries; empty when it has none.
|
|
58
|
+
*/
|
|
59
|
+
export function subscriptionMeta(state, locale) {
|
|
60
|
+
if (!state)
|
|
61
|
+
return '';
|
|
62
|
+
const parts = [];
|
|
63
|
+
const renews = formatDate(state.renewsAt, locale);
|
|
64
|
+
const ends = formatDate(state.endsAt, locale);
|
|
65
|
+
if (state.status === 'trial') {
|
|
66
|
+
if (ends)
|
|
67
|
+
parts.push(`Trial ends ${ends}`);
|
|
68
|
+
else if (typeof state.daysLeft === 'number')
|
|
69
|
+
parts.push(`${state.daysLeft} days left in trial`);
|
|
70
|
+
}
|
|
71
|
+
else if (state.status === 'cancel_at_period_end' || state.status === 'canceled') {
|
|
72
|
+
if (ends)
|
|
73
|
+
parts.push(`Ends ${ends}`);
|
|
74
|
+
}
|
|
75
|
+
else if (renews) {
|
|
76
|
+
parts.push(`Renews ${renews}`);
|
|
77
|
+
}
|
|
78
|
+
if (state.cardLast4)
|
|
79
|
+
parts.push(`Card ending ${state.cardLast4}`);
|
|
80
|
+
return parts.join(' · ');
|
|
81
|
+
}
|
|
@@ -29,6 +29,8 @@ export interface PlanPickDeps {
|
|
|
29
29
|
navigate: (url: string) => unknown;
|
|
30
30
|
/** Leave the app (Stripe checkout). */
|
|
31
31
|
leave: (url: string) => void;
|
|
32
|
+
/** TBP-813 — record that the plan changed, for the landing page's banner. */
|
|
33
|
+
complete?: (plan: Plan) => void;
|
|
32
34
|
}
|
|
33
35
|
/** `selected`: the plan changed here. `confirm`: ask first. `checkout`: off to Stripe. */
|
|
34
36
|
export type PlanPickOutcome = 'selected' | 'confirm' | 'checkout';
|
|
@@ -5,7 +5,8 @@
|
|
|
5
5
|
// the page passed `onSelect`: the person was left on the plan picker, looking
|
|
6
6
|
// as if nothing had happened. It now goes on exactly like a paid pick does —
|
|
7
7
|
// to `successRedirect`, where a Stripe checkout returns — unless the page took
|
|
8
|
-
// over with `onSelect`.
|
|
8
|
+
// over with `onSelect`. TBP-813: it also records the pick, so the landing page
|
|
9
|
+
// shows the same confirmation banner a checkout gets. Every pick that changed the plan re-reads the billing
|
|
9
10
|
// store (plan list, current plan, billing state) first, so the next page shows
|
|
10
11
|
// the new plan.
|
|
11
12
|
/** A $0 price on a plan with no metered cost is picked without checkout. */
|
|
@@ -43,5 +44,6 @@ async function settle(plan, price, opts, deps) {
|
|
|
43
44
|
opts.onSelect({ plan, price });
|
|
44
45
|
return;
|
|
45
46
|
}
|
|
47
|
+
deps.complete?.(plan);
|
|
46
48
|
await deps.navigate(opts.successRedirect);
|
|
47
49
|
}
|
package/dist/index.d.ts
CHANGED
|
@@ -53,6 +53,9 @@ export { default as PlanSelector } from './client/components/subscription/PlanSe
|
|
|
53
53
|
export { default as BridgeBillingRoutes } from './client/components/subscription/BridgeBillingRoutes.svelte';
|
|
54
54
|
export { default as BridgePaywallPage } from './client/components/subscription/BridgePaywallPage.svelte';
|
|
55
55
|
export { default as BillingPortalButton } from './client/components/subscription/BillingPortalButton.svelte';
|
|
56
|
+
export { default as BridgeCheckoutNotice } from './client/components/subscription/BridgeCheckoutNotice.svelte';
|
|
57
|
+
export { checkoutResult, takeCheckoutResult, clearCheckoutResult } from './client/checkout-result.js';
|
|
58
|
+
export type { BridgeCheckoutResult } from './client/checkout-result.js';
|
|
56
59
|
export { BRIDGE_BILLING_PAGES, BRIDGE_BILLING_DEFAULTS, parseBridgeBillingRoute } from './client/billing-routes.js';
|
|
57
60
|
export type { BridgeBillingPage, BridgeBillingRoute } from './client/billing-routes.js';
|
|
58
61
|
export { default as BridgeSubscriptionStatus } from './client/components/subscription/BridgeSubscriptionStatus.svelte';
|
package/dist/index.js
CHANGED
|
@@ -78,6 +78,10 @@ export { default as PlanSelector } from './client/components/subscription/PlanSe
|
|
|
78
78
|
export { default as BridgeBillingRoutes } from './client/components/subscription/BridgeBillingRoutes.svelte';
|
|
79
79
|
export { default as BridgePaywallPage } from './client/components/subscription/BridgePaywallPage.svelte';
|
|
80
80
|
export { default as BillingPortalButton } from './client/components/subscription/BillingPortalButton.svelte';
|
|
81
|
+
// TBP-813 — a completed checkout lands on `billing.successRoute` (the
|
|
82
|
+
// subscription page by default); this banner, or `checkoutResult`, says so.
|
|
83
|
+
export { default as BridgeCheckoutNotice } from './client/components/subscription/BridgeCheckoutNotice.svelte';
|
|
84
|
+
export { checkoutResult, takeCheckoutResult, clearCheckoutResult } from './client/checkout-result.js';
|
|
81
85
|
export { BRIDGE_BILLING_PAGES, BRIDGE_BILLING_DEFAULTS, parseBridgeBillingRoute } from './client/billing-routes.js';
|
|
82
86
|
// Billing 2.0 (Phase A / US-2) — canonical-model drop-in.
|
|
83
87
|
// Parallel to PlanSelector (which consumes the Stripe-direct path); these
|
|
@@ -38,10 +38,17 @@ export interface BridgeConfig extends BridgeAuthConfig {
|
|
|
38
38
|
* @default '/subscription/error' */
|
|
39
39
|
paymentErrorRoute?: string;
|
|
40
40
|
/** The subscription page — the default destination of the Upgrade/Manage
|
|
41
|
-
* CTA in <BridgeQuotaBanner> and <BridgeBillingNotice
|
|
42
|
-
* checkout lands
|
|
41
|
+
* CTA in <BridgeQuotaBanner> and <BridgeBillingNotice>, and where a
|
|
42
|
+
* completed checkout lands unless `successRoute` says otherwise.
|
|
43
43
|
* @default '/subscription' */
|
|
44
44
|
manageRoute?: string;
|
|
45
|
+
/** Where a completed checkout, or a Free plan pick, lands: any page of your
|
|
46
|
+
* app, e.g. `'/dashboard'`. The subscription page shows a confirmation
|
|
47
|
+
* banner; anywhere else, render `<BridgeCheckoutNotice />` for the same
|
|
48
|
+
* banner, or read `checkoutResult` for your own message. The old
|
|
49
|
+
* confirmation page is `'/subscription/success'`.
|
|
50
|
+
* @default the subscription page (`manageRoute`) */
|
|
51
|
+
successRoute?: string;
|
|
45
52
|
/** The dialog `<BridgeBootstrap />` opens when your backend refuses a
|
|
46
53
|
* request because a plan limit is reached — a `402` whose JSON body has
|
|
47
54
|
* `code: 'QUOTA_EXCEEDED'`, which bridge-nestjs's `@RequireQuota` sends
|
package/dist/styles.css
CHANGED
|
@@ -30,6 +30,8 @@
|
|
|
30
30
|
* --bridge-muted-bg subtle fills: tab tracks, hovers, code boxes
|
|
31
31
|
* --bridge-border input, table, card and secondary button borders
|
|
32
32
|
* --bridge-border-radius corners of inputs, buttons, alerts, cards, dialogs
|
|
33
|
+
* --bridge-card-bg plan cards and the current-plan panel (defaults to --bridge-bg)
|
|
34
|
+
* --bridge-card-shadow their shadow; `none` for a flat look (dark apps often want it)
|
|
33
35
|
* --bridge-overlay backdrop behind dialogs
|
|
34
36
|
* --bridge-alert-error-{bg,fg,border} errors, critical billing notices
|
|
35
37
|
* --bridge-alert-success-{bg,fg,border} confirmations, active plan badge
|
|
@@ -1044,6 +1046,26 @@
|
|
|
1044
1046
|
margin-top: 0.75rem;
|
|
1045
1047
|
}
|
|
1046
1048
|
|
|
1049
|
+
/* TBP-813 — a heading beside the interval switch (`heading` prop). */
|
|
1050
|
+
.bridge-plan-head {
|
|
1051
|
+
display: flex;
|
|
1052
|
+
flex-wrap: wrap;
|
|
1053
|
+
align-items: center;
|
|
1054
|
+
justify-content: space-between;
|
|
1055
|
+
gap: 0.75rem;
|
|
1056
|
+
margin-bottom: 1.25rem;
|
|
1057
|
+
}
|
|
1058
|
+
|
|
1059
|
+
.bridge-plan-heading {
|
|
1060
|
+
margin: 0;
|
|
1061
|
+
font-size: 1.05rem;
|
|
1062
|
+
font-weight: 600;
|
|
1063
|
+
}
|
|
1064
|
+
|
|
1065
|
+
.bridge-plan-head .bridge-plan-interval-tabs {
|
|
1066
|
+
margin: 0;
|
|
1067
|
+
}
|
|
1068
|
+
|
|
1047
1069
|
.bridge-plan-interval-tabs {
|
|
1048
1070
|
/* Centered above the plan cards; flex + fit-content (not inline-flex) so
|
|
1049
1071
|
auto margins can center it regardless of the host page's text alignment. */
|
|
@@ -1055,7 +1077,7 @@
|
|
|
1055
1077
|
padding: 0.25rem;
|
|
1056
1078
|
border: 1px solid var(--bridge-border);
|
|
1057
1079
|
border-radius: var(--bridge-border-radius);
|
|
1058
|
-
background: var(--bridge-
|
|
1080
|
+
background: var(--bridge-card-bg, var(--bridge-bg));
|
|
1059
1081
|
}
|
|
1060
1082
|
|
|
1061
1083
|
.bridge-plan-interval-tab {
|
|
@@ -1137,19 +1159,61 @@
|
|
|
1137
1159
|
|
|
1138
1160
|
.bridge-plan-cards {
|
|
1139
1161
|
display: grid;
|
|
1140
|
-
grid-template-columns: repeat(auto-
|
|
1162
|
+
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
|
1141
1163
|
gap: 1rem;
|
|
1164
|
+
align-items: stretch;
|
|
1165
|
+
}
|
|
1166
|
+
|
|
1167
|
+
/* TBP-813 — cards, and the current-plan panel, are surfaces of their own, so
|
|
1168
|
+
they read on any app background and take the app's tokens. */
|
|
1169
|
+
[data-bridge-plan-card],
|
|
1170
|
+
.bridge-billing-summary {
|
|
1171
|
+
background: var(--bridge-card-bg, var(--bridge-bg));
|
|
1172
|
+
color: var(--bridge-foreground);
|
|
1173
|
+
border: 1px solid var(--bridge-border);
|
|
1174
|
+
border-radius: calc(var(--bridge-border-radius) * 1.5);
|
|
1175
|
+
box-shadow: var(--bridge-card-shadow, 0 1px 2px rgb(16 24 40 / 0.06), 0 1px 3px rgb(16 24 40 / 0.04));
|
|
1176
|
+
}
|
|
1177
|
+
|
|
1178
|
+
.bridge-billing-summary {
|
|
1179
|
+
padding: 1.1rem 1.25rem;
|
|
1180
|
+
background: color-mix(in srgb, var(--bridge-primary) 6%, var(--bridge-card-bg, var(--bridge-bg)));
|
|
1181
|
+
}
|
|
1182
|
+
|
|
1183
|
+
.bridge-billing-summary .bss-plan {
|
|
1184
|
+
font-size: 1.2rem;
|
|
1185
|
+
font-weight: 700;
|
|
1186
|
+
}
|
|
1187
|
+
|
|
1188
|
+
.bridge-billing-summary-meta {
|
|
1189
|
+
font-size: 0.85rem;
|
|
1190
|
+
color: var(--bridge-muted);
|
|
1191
|
+
font-variant-numeric: tabular-nums;
|
|
1142
1192
|
}
|
|
1143
1193
|
|
|
1144
1194
|
[data-bridge-plan-card] {
|
|
1145
|
-
|
|
1195
|
+
position: relative;
|
|
1196
|
+
padding: 1.35rem 1.25rem 1.25rem;
|
|
1146
1197
|
display: flex;
|
|
1147
1198
|
flex-direction: column;
|
|
1148
|
-
gap: 0.
|
|
1199
|
+
gap: 0.9rem;
|
|
1149
1200
|
}
|
|
1150
1201
|
|
|
1151
1202
|
[data-bridge-plan-card][data-current="true"] {
|
|
1152
|
-
|
|
1203
|
+
border: 2px solid var(--bridge-primary);
|
|
1204
|
+
padding: calc(1.35rem - 1px) calc(1.25rem - 1px) calc(1.25rem - 1px);
|
|
1205
|
+
}
|
|
1206
|
+
|
|
1207
|
+
.bridge-plan-current-flag {
|
|
1208
|
+
position: absolute;
|
|
1209
|
+
top: -0.7rem;
|
|
1210
|
+
left: 1.1rem;
|
|
1211
|
+
padding: 0.15rem 0.55rem;
|
|
1212
|
+
border-radius: 9999px;
|
|
1213
|
+
font-size: 0.7rem;
|
|
1214
|
+
font-weight: 600;
|
|
1215
|
+
background: var(--bridge-primary);
|
|
1216
|
+
color: var(--bridge-primary-fg, var(--bridge-primary-foreground, #ffffff));
|
|
1153
1217
|
}
|
|
1154
1218
|
|
|
1155
1219
|
.bridge-plan-card-header {
|
|
@@ -1169,22 +1233,62 @@
|
|
|
1169
1233
|
display: inline-block;
|
|
1170
1234
|
padding: 0.125rem 0.5rem;
|
|
1171
1235
|
font-size: 0.7rem;
|
|
1236
|
+
font-weight: 600;
|
|
1172
1237
|
white-space: nowrap;
|
|
1238
|
+
border-radius: 9999px;
|
|
1239
|
+
border: 1px solid var(--bridge-alert-info-border);
|
|
1240
|
+
background: var(--bridge-alert-info-bg);
|
|
1241
|
+
color: var(--bridge-alert-info-fg);
|
|
1242
|
+
}
|
|
1243
|
+
|
|
1244
|
+
.bridge-plan-price {
|
|
1245
|
+
display: flex;
|
|
1246
|
+
align-items: baseline;
|
|
1247
|
+
flex-wrap: wrap;
|
|
1248
|
+
gap: 0.3rem;
|
|
1249
|
+
font-variant-numeric: tabular-nums;
|
|
1250
|
+
}
|
|
1251
|
+
|
|
1252
|
+
.bridge-plan-price-amount {
|
|
1253
|
+
font-size: 1.9rem;
|
|
1254
|
+
font-weight: 700;
|
|
1255
|
+
letter-spacing: -0.02em;
|
|
1256
|
+
line-height: 1.1;
|
|
1257
|
+
}
|
|
1258
|
+
|
|
1259
|
+
.bridge-plan-price-interval {
|
|
1260
|
+
font-size: 0.85rem;
|
|
1261
|
+
color: var(--bridge-muted);
|
|
1173
1262
|
}
|
|
1174
1263
|
|
|
1175
1264
|
.bridge-plan-description {
|
|
1176
1265
|
margin: 0;
|
|
1177
1266
|
font-size: 0.875rem;
|
|
1267
|
+
color: var(--bridge-muted);
|
|
1178
1268
|
}
|
|
1179
1269
|
|
|
1180
1270
|
/* TBP-755 — the features a plan includes. */
|
|
1181
1271
|
.bridge-plan-features {
|
|
1182
1272
|
margin: 0;
|
|
1183
|
-
padding
|
|
1273
|
+
padding: 0.9rem 0 0;
|
|
1274
|
+
border-top: 1px solid var(--bridge-border);
|
|
1275
|
+
list-style: none;
|
|
1184
1276
|
font-size: 0.875rem;
|
|
1185
1277
|
display: flex;
|
|
1186
1278
|
flex-direction: column;
|
|
1187
|
-
gap: 0.
|
|
1279
|
+
gap: 0.45rem;
|
|
1280
|
+
}
|
|
1281
|
+
|
|
1282
|
+
.bridge-plan-feature {
|
|
1283
|
+
display: flex;
|
|
1284
|
+
align-items: flex-start;
|
|
1285
|
+
gap: 0.5rem;
|
|
1286
|
+
}
|
|
1287
|
+
|
|
1288
|
+
.bridge-plan-feature-check {
|
|
1289
|
+
flex: none;
|
|
1290
|
+
margin-top: 0.2rem;
|
|
1291
|
+
color: var(--bridge-primary);
|
|
1188
1292
|
}
|
|
1189
1293
|
|
|
1190
1294
|
.bridge-plan-prices {
|
|
@@ -1199,6 +1303,59 @@
|
|
|
1199
1303
|
text-align: center;
|
|
1200
1304
|
}
|
|
1201
1305
|
|
|
1306
|
+
[data-bridge-plan-card][data-current="true"] .bridge-plan-select-btn:disabled {
|
|
1307
|
+
background: transparent;
|
|
1308
|
+
color: var(--bridge-muted);
|
|
1309
|
+
opacity: 1;
|
|
1310
|
+
}
|
|
1311
|
+
|
|
1312
|
+
/* ─── BridgeCheckoutNotice (TBP-813) ─────────────────────────────────────── */
|
|
1313
|
+
|
|
1314
|
+
.bridge-checkout-notice {
|
|
1315
|
+
display: flex;
|
|
1316
|
+
align-items: flex-start;
|
|
1317
|
+
gap: 0.75rem;
|
|
1318
|
+
padding: 0.85rem 1rem;
|
|
1319
|
+
border: 1px solid var(--bridge-alert-success-border);
|
|
1320
|
+
border-radius: var(--bridge-border-radius);
|
|
1321
|
+
background: var(--bridge-alert-success-bg);
|
|
1322
|
+
color: var(--bridge-alert-success-fg);
|
|
1323
|
+
font-size: 0.875rem;
|
|
1324
|
+
}
|
|
1325
|
+
|
|
1326
|
+
.bridge-checkout-notice-icon {
|
|
1327
|
+
flex: none;
|
|
1328
|
+
margin-top: 0.1rem;
|
|
1329
|
+
}
|
|
1330
|
+
|
|
1331
|
+
.bridge-checkout-notice-body {
|
|
1332
|
+
flex: 1;
|
|
1333
|
+
min-width: 0;
|
|
1334
|
+
}
|
|
1335
|
+
|
|
1336
|
+
.bridge-checkout-notice-title {
|
|
1337
|
+
display: block;
|
|
1338
|
+
font-weight: 600;
|
|
1339
|
+
}
|
|
1340
|
+
|
|
1341
|
+
.bridge-checkout-notice-close {
|
|
1342
|
+
appearance: none;
|
|
1343
|
+
border: 0;
|
|
1344
|
+
background: none;
|
|
1345
|
+
color: inherit;
|
|
1346
|
+
cursor: pointer;
|
|
1347
|
+
font: inherit;
|
|
1348
|
+
font-size: 1.15rem;
|
|
1349
|
+
line-height: 1;
|
|
1350
|
+
padding: 0 0.2rem;
|
|
1351
|
+
opacity: 0.7;
|
|
1352
|
+
}
|
|
1353
|
+
|
|
1354
|
+
.bridge-checkout-notice-close:hover,
|
|
1355
|
+
.bridge-checkout-notice-close:focus-visible {
|
|
1356
|
+
opacity: 1;
|
|
1357
|
+
}
|
|
1358
|
+
|
|
1202
1359
|
/* ─── WorkspaceSelector ─────────────────────────────────────────────── */
|
|
1203
1360
|
|
|
1204
1361
|
[data-bridge-workspace-selector] {
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebulr-group/bridge-svelte",
|
|
3
|
-
"version": "0.9.
|
|
3
|
+
"version": "0.9.2-beta.0",
|
|
4
4
|
"description": "The Bridge for SvelteKit: sign-in, workspaces and roles, feature flags, Stripe subscriptions and plan limits in one .env line and three files.",
|
|
5
5
|
"author": "Iman Pouya",
|
|
6
6
|
"license": "MIT",
|