@nebulr-group/bridge-svelte 0.4.0-beta.8 → 0.4.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 +13 -23
- package/dist/client/components/sdk-auth/SignupForm.svelte +14 -1
- package/dist/client/components/sdk-auth/SignupForm.svelte.d.ts +6 -0
- package/dist/client/components/subscription/BridgeBillingNotice.svelte +14 -4
- package/dist/client/components/subscription/BridgeBillingNotice.svelte.d.ts +3 -0
- package/dist/client/components/subscription/BridgeQuotaBanner.svelte +14 -1
- package/dist/client/components/subscription/BridgeQuotaBanner.svelte.d.ts +3 -0
- package/dist/client/components/subscription/PlanSelector.svelte +111 -7
- package/dist/client/components/subscription/PlanSelector.svelte.d.ts +13 -1
- package/dist/client/components/subscription/billing-manage-route.test.d.ts +1 -0
- package/dist/client/components/subscription/billing-manage-route.test.js +258 -0
- package/dist/client/components/subscription/plan-selector.test.d.ts +1 -0
- package/dist/client/components/subscription/plan-selector.test.js +391 -0
- package/dist/core/bridge.test.js +17 -9
- package/dist/flags/bootstrap.test.js +12 -7
- package/dist/styles.css +59 -1
- package/package.json +3 -6
|
@@ -67,22 +67,15 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
|
|
|
67
67
|
}
|
|
68
68
|
}
|
|
69
69
|
else if (stripeSuccess && sessionId) {
|
|
70
|
-
// Stripe payment success —
|
|
71
|
-
//
|
|
70
|
+
// Stripe payment success — auth-core's confirmStripeCheckout() verifies the session
|
|
71
|
+
// with bridge-api (server calls Stripe directly) and refreshes tokens so the new JWT
|
|
72
|
+
// has shouldSelectPlan: false before we redirect. It throws on a non-OK response or
|
|
73
|
+
// network error → we fall through to the payment-error redirect. (TBP-369: the HTTP +
|
|
74
|
+
// token-refresh logic now lives in auth-core so every plugin port can reuse it.)
|
|
72
75
|
logger.debug('[bridgeBootstrap] Stripe success callback — confirming with bridge-api');
|
|
73
76
|
const bridge = getBridgeAuth();
|
|
74
|
-
const ctx = bridge.getApiContext();
|
|
75
77
|
try {
|
|
76
|
-
|
|
77
|
-
method: 'POST',
|
|
78
|
-
headers: { 'Content-Type': 'application/json' },
|
|
79
|
-
body: JSON.stringify({ sessionId, appId: ctx.appId }),
|
|
80
|
-
});
|
|
81
|
-
if (!res.ok) {
|
|
82
|
-
logger.warn('[bridgeBootstrap] confirm-checkout failed', res.status);
|
|
83
|
-
redirect(303, getConfig().billing?.paymentErrorRoute ?? '/payment-error');
|
|
84
|
-
}
|
|
85
|
-
await bridge.refreshTokens();
|
|
78
|
+
await bridge.confirmStripeCheckout(sessionId, kitFetch);
|
|
86
79
|
redirect(303, redirectTo);
|
|
87
80
|
}
|
|
88
81
|
catch (err) {
|
|
@@ -117,22 +110,19 @@ export async function bridgeBootstrap(url, config, routeConfig = { rules: [], de
|
|
|
117
110
|
catch {
|
|
118
111
|
// Non-fatal — stale tokens will be caught by route guard
|
|
119
112
|
}
|
|
120
|
-
// 2b. Paywall redirect — fires before any page renders.
|
|
121
|
-
//
|
|
113
|
+
// 2b. Paywall redirect — fires before any page renders. The framework-agnostic
|
|
114
|
+
// decision (authenticated + shouldSelectPlan + not opted out via
|
|
115
|
+
// paymentsAutoRedirect) now lives in auth-core's shouldRedirectToPaywall()
|
|
116
|
+
// (TBP-369). We only own the route/config guards here:
|
|
122
117
|
// - billing.paywallRoute is configured
|
|
123
118
|
// - the current path is not already the paywall route (no redirect loop)
|
|
124
|
-
// - the tenant is authenticated but has not selected a plan
|
|
125
|
-
// - the app has not opted out via paymentsAutoRedirect: false
|
|
126
119
|
try {
|
|
127
120
|
const paywallRoute = getConfig().billing?.paywallRoute;
|
|
128
121
|
if (paywallRoute && url.pathname !== paywallRoute) {
|
|
129
122
|
const bridge = getBridgeAuth();
|
|
130
|
-
if (bridge.
|
|
131
|
-
|
|
132
|
-
|
|
133
|
-
logger.debug('[bridgeBootstrap] paywall redirect', paywallRoute);
|
|
134
|
-
redirect(303, paywallRoute);
|
|
135
|
-
}
|
|
123
|
+
if (await bridge.shouldRedirectToPaywall()) {
|
|
124
|
+
logger.debug('[bridgeBootstrap] paywall redirect', paywallRoute);
|
|
125
|
+
redirect(303, paywallRoute);
|
|
136
126
|
}
|
|
137
127
|
}
|
|
138
128
|
}
|
|
@@ -15,6 +15,12 @@
|
|
|
15
15
|
loginHref?: string | undefined;
|
|
16
16
|
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
17
17
|
heading?: string | null;
|
|
18
|
+
/** TBP-36 — preselected plan applied at tenant creation (from ?signupPlan= links). */
|
|
19
|
+
plan?: string | null;
|
|
20
|
+
/** TBP-36 — currency of the preselected plan's price offer. */
|
|
21
|
+
currency?: string | null;
|
|
22
|
+
/** TBP-36 — recurrence interval of the preselected plan's price offer. */
|
|
23
|
+
recurrenceInterval?: string | null;
|
|
18
24
|
footer?: Snippet;
|
|
19
25
|
}
|
|
20
26
|
|
|
@@ -24,6 +30,9 @@
|
|
|
24
30
|
showLoginLink = true,
|
|
25
31
|
loginHref = undefined,
|
|
26
32
|
heading = 'Create your account',
|
|
33
|
+
plan = null,
|
|
34
|
+
currency = null,
|
|
35
|
+
recurrenceInterval = null,
|
|
27
36
|
footer,
|
|
28
37
|
class: className,
|
|
29
38
|
style,
|
|
@@ -44,7 +53,11 @@
|
|
|
44
53
|
error = null;
|
|
45
54
|
loading = true;
|
|
46
55
|
try {
|
|
47
|
-
await getBridgeAuth().signup(email, firstName, lastName
|
|
56
|
+
await getBridgeAuth().signup(email, firstName, lastName, plan ? {
|
|
57
|
+
plan,
|
|
58
|
+
currency: currency ?? undefined,
|
|
59
|
+
recurrenceInterval: recurrenceInterval ?? undefined,
|
|
60
|
+
} : undefined);
|
|
48
61
|
success = true;
|
|
49
62
|
onSignup?.();
|
|
50
63
|
} catch (err: any) {
|
|
@@ -8,6 +8,12 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
|
8
8
|
loginHref?: string | undefined;
|
|
9
9
|
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
10
10
|
heading?: string | null;
|
|
11
|
+
/** TBP-36 — preselected plan applied at tenant creation (from ?signupPlan= links). */
|
|
12
|
+
plan?: string | null;
|
|
13
|
+
/** TBP-36 — currency of the preselected plan's price offer. */
|
|
14
|
+
currency?: string | null;
|
|
15
|
+
/** TBP-36 — recurrence interval of the preselected plan's price offer. */
|
|
16
|
+
recurrenceInterval?: string | null;
|
|
11
17
|
footer?: Snippet;
|
|
12
18
|
}
|
|
13
19
|
declare const SignupForm: import("svelte").Component<Props, {}, "">;
|
|
@@ -24,6 +24,7 @@
|
|
|
24
24
|
type BillingSubscriptionSnapshot,
|
|
25
25
|
} from '@nebulr-group/bridge-auth-core';
|
|
26
26
|
import { getBridgeAuth } from '../../../core/bridge-instance.js';
|
|
27
|
+
import { getConfig } from '../../stores/config.store.js';
|
|
27
28
|
|
|
28
29
|
type Chassis = 'bar' | 'rail' | 'card';
|
|
29
30
|
|
|
@@ -40,6 +41,9 @@
|
|
|
40
41
|
class?: string;
|
|
41
42
|
/** Override the default CTA click handler (links to billing surface). */
|
|
42
43
|
onActionClick?: (state: BillingNoticeState) => void;
|
|
44
|
+
/** CTA destination for this instance. Overrides `billing.manageRoute`
|
|
45
|
+
* config; `onActionClick` takes precedence over both. */
|
|
46
|
+
actionHref?: string;
|
|
43
47
|
}
|
|
44
48
|
|
|
45
49
|
let {
|
|
@@ -47,6 +51,7 @@
|
|
|
47
51
|
mode = 'soft',
|
|
48
52
|
class: className = '',
|
|
49
53
|
onActionClick,
|
|
54
|
+
actionHref,
|
|
50
55
|
}: Props = $props();
|
|
51
56
|
|
|
52
57
|
let snapshot = $state<BillingSubscriptionSnapshot>(useBridge().subscription.snapshot());
|
|
@@ -194,11 +199,16 @@
|
|
|
194
199
|
onActionClick(noticeState);
|
|
195
200
|
return;
|
|
196
201
|
}
|
|
197
|
-
// Default: open the
|
|
198
|
-
//
|
|
199
|
-
// current origin. Apps can override via `onActionClick`.
|
|
202
|
+
// Default: open the app's billing surface. Destination priority:
|
|
203
|
+
// `actionHref` prop → `billing.manageRoute` config → '/billing'.
|
|
200
204
|
if (typeof window !== 'undefined') {
|
|
201
|
-
|
|
205
|
+
let manageRoute: string | undefined;
|
|
206
|
+
try {
|
|
207
|
+
manageRoute = getConfig().billing?.manageRoute;
|
|
208
|
+
} catch {
|
|
209
|
+
// Config not initialized — fall through to the default.
|
|
210
|
+
}
|
|
211
|
+
window.location.href = actionHref ?? manageRoute ?? '/billing';
|
|
202
212
|
}
|
|
203
213
|
}
|
|
204
214
|
</script>
|
|
@@ -13,6 +13,9 @@ interface Props {
|
|
|
13
13
|
class?: string;
|
|
14
14
|
/** Override the default CTA click handler (links to billing surface). */
|
|
15
15
|
onActionClick?: (state: BillingNoticeState) => void;
|
|
16
|
+
/** CTA destination for this instance. Overrides `billing.manageRoute`
|
|
17
|
+
* config; `onActionClick` takes precedence over both. */
|
|
18
|
+
actionHref?: string;
|
|
16
19
|
}
|
|
17
20
|
declare const BridgeBillingNotice: import("svelte").Component<Props, {}, "">;
|
|
18
21
|
type BridgeBillingNotice = ReturnType<typeof BridgeBillingNotice>;
|
|
@@ -27,6 +27,7 @@
|
|
|
27
27
|
type QuotaSnapshot,
|
|
28
28
|
} from '@nebulr-group/bridge-auth-core';
|
|
29
29
|
import { getBridgeAuth } from '../../../core/bridge-instance.js';
|
|
30
|
+
import { getConfig } from '../../stores/config.store.js';
|
|
30
31
|
|
|
31
32
|
type Chassis = 'rail';
|
|
32
33
|
type Severity = 'warn' | 'critical';
|
|
@@ -43,6 +44,9 @@
|
|
|
43
44
|
class?: string;
|
|
44
45
|
/** Override the default Upgrade CTA click handler. */
|
|
45
46
|
onActionClick?: (snap: QuotaSnapshot) => void;
|
|
47
|
+
/** CTA destination for this instance. Overrides `billing.manageRoute`
|
|
48
|
+
* config; `onActionClick` takes precedence over both. */
|
|
49
|
+
actionHref?: string;
|
|
46
50
|
/**
|
|
47
51
|
* Optional display label override. Defaults to the snapshot's `.label`
|
|
48
52
|
* (raw metric key for US-11; the framework wrapper will eventually
|
|
@@ -56,6 +60,7 @@
|
|
|
56
60
|
chassis = 'rail',
|
|
57
61
|
class: className = '',
|
|
58
62
|
onActionClick,
|
|
63
|
+
actionHref,
|
|
59
64
|
label,
|
|
60
65
|
}: Props = $props();
|
|
61
66
|
|
|
@@ -218,8 +223,16 @@
|
|
|
218
223
|
onActionClick(snapshot);
|
|
219
224
|
return;
|
|
220
225
|
}
|
|
226
|
+
// Destination priority: `actionHref` prop → `billing.manageRoute` config
|
|
227
|
+
// → '/billing'.
|
|
221
228
|
if (typeof window !== 'undefined') {
|
|
222
|
-
|
|
229
|
+
let manageRoute: string | undefined;
|
|
230
|
+
try {
|
|
231
|
+
manageRoute = getConfig().billing?.manageRoute;
|
|
232
|
+
} catch {
|
|
233
|
+
// Config not initialized — fall through to the default.
|
|
234
|
+
}
|
|
235
|
+
window.location.href = actionHref ?? manageRoute ?? '/billing';
|
|
223
236
|
}
|
|
224
237
|
}
|
|
225
238
|
</script>
|
|
@@ -12,6 +12,9 @@ interface Props {
|
|
|
12
12
|
class?: string;
|
|
13
13
|
/** Override the default Upgrade CTA click handler. */
|
|
14
14
|
onActionClick?: (snap: QuotaSnapshot) => void;
|
|
15
|
+
/** CTA destination for this instance. Overrides `billing.manageRoute`
|
|
16
|
+
* config; `onActionClick` takes precedence over both. */
|
|
17
|
+
actionHref?: string;
|
|
15
18
|
/**
|
|
16
19
|
* Optional display label override. Defaults to the snapshot's `.label`
|
|
17
20
|
* (raw metric key for US-11; the framework wrapper will eventually
|
|
@@ -16,12 +16,18 @@
|
|
|
16
16
|
/**
|
|
17
17
|
* Which billing interval tab is selected by default (`'month'` | `'year'` |
|
|
18
18
|
* `'week'` | `'day'`). Falls back to the first available interval when the
|
|
19
|
-
* requested one isn't offered by any plan. Default: `'
|
|
19
|
+
* requested one isn't offered by any plan. Default: `'year'` (TBP-34 —
|
|
20
|
+
* annual is the default; apps without yearly prices fall back gracefully).
|
|
20
21
|
*/
|
|
21
22
|
defaultInterval?: BillingInterval;
|
|
22
23
|
onSelect?: (detail: { plan: Plan; price: PriceOfferSdk }) => void;
|
|
23
24
|
/** Custom card renderer. `prices` is the plan's full price list; `interval` is the active tab. */
|
|
24
25
|
planCard?: Snippet<[{ plan: Plan; prices: PriceOfferSdk[]; isCurrent: boolean; interval: BillingInterval; onPick: (price: PriceOfferSdk) => void }]>;
|
|
26
|
+
/** Replaces the built-in description paragraph of the default card —
|
|
27
|
+
* custom copy or markup per plan without reimplementing the whole card. */
|
|
28
|
+
planDescription?: Snippet<[{ plan: Plan; isCurrent: boolean }]>;
|
|
29
|
+
/** Rendered at the bottom of the default card, after the price buttons. */
|
|
30
|
+
planFooter?: Snippet<[{ plan: Plan; isCurrent: boolean }]>;
|
|
25
31
|
emptyState?: Snippet;
|
|
26
32
|
loadingState?: Snippet;
|
|
27
33
|
}
|
|
@@ -29,9 +35,11 @@
|
|
|
29
35
|
let {
|
|
30
36
|
successRedirect = '/subscription',
|
|
31
37
|
cancelRedirect = '/subscription',
|
|
32
|
-
defaultInterval = '
|
|
38
|
+
defaultInterval = 'year',
|
|
33
39
|
onSelect,
|
|
34
40
|
planCard,
|
|
41
|
+
planDescription,
|
|
42
|
+
planFooter,
|
|
35
43
|
emptyState,
|
|
36
44
|
loadingState,
|
|
37
45
|
class: className,
|
|
@@ -117,6 +125,52 @@
|
|
|
117
125
|
}
|
|
118
126
|
});
|
|
119
127
|
|
|
128
|
+
// ── Plan-change confirmation (TBP-33) ─────────────────────────────────
|
|
129
|
+
// Switching an existing subscriber's plan is instant (no Stripe checkout
|
|
130
|
+
// page), so it needs an explicit confirm step. Free selection and the
|
|
131
|
+
// checkout redirect keep their existing single-click behavior.
|
|
132
|
+
let confirmTarget = $state<{ plan: Plan; price: PriceOfferSdk } | null>(null);
|
|
133
|
+
let confirmBusy = $state(false);
|
|
134
|
+
let confirmError = $state<string | null>(null);
|
|
135
|
+
let successNotice = $state<string | null>(null);
|
|
136
|
+
let successTimer: ReturnType<typeof setTimeout> | undefined;
|
|
137
|
+
|
|
138
|
+
const currentPlanName = $derived.by(() => {
|
|
139
|
+
const found = (plans ?? []).find((p) => p.key === currentPlanKey);
|
|
140
|
+
return found?.name ?? currentPlanKey ?? 'your current plan';
|
|
141
|
+
});
|
|
142
|
+
|
|
143
|
+
function formatPrice(price: PriceOfferSdk): string {
|
|
144
|
+
return price.amount === 0
|
|
145
|
+
? 'Free'
|
|
146
|
+
: `${price.amount} ${price.currency.toUpperCase()} / ${price.recurrenceInterval}`;
|
|
147
|
+
}
|
|
148
|
+
|
|
149
|
+
function showSuccess(message: string): void {
|
|
150
|
+
successNotice = message;
|
|
151
|
+
clearTimeout(successTimer);
|
|
152
|
+
successTimer = setTimeout(() => (successNotice = null), 6000);
|
|
153
|
+
}
|
|
154
|
+
|
|
155
|
+
async function confirmPlanChange(): Promise<void> {
|
|
156
|
+
if (!confirmTarget) return;
|
|
157
|
+
const { plan, price } = confirmTarget;
|
|
158
|
+
confirmBusy = true;
|
|
159
|
+
confirmError = null;
|
|
160
|
+
try {
|
|
161
|
+
await getBridgeAuth().changePlan(plan.key, price);
|
|
162
|
+
await loadSubscription();
|
|
163
|
+
confirmTarget = null;
|
|
164
|
+
showSuccess(`You're now on ${plan.name} (${formatPrice(price)}).`);
|
|
165
|
+
onSelect?.({ plan, price });
|
|
166
|
+
} catch (err) {
|
|
167
|
+
// AC: failure surfaces inside the dialog, not just a banner.
|
|
168
|
+
confirmError = err instanceof Error ? err.message : 'Plan change failed';
|
|
169
|
+
} finally {
|
|
170
|
+
confirmBusy = false;
|
|
171
|
+
}
|
|
172
|
+
}
|
|
173
|
+
|
|
120
174
|
async function handlePick(plan: Plan, price: PriceOfferSdk): Promise<void> {
|
|
121
175
|
picking = true;
|
|
122
176
|
pickError = null;
|
|
@@ -132,10 +186,10 @@
|
|
|
132
186
|
await loadSubscription();
|
|
133
187
|
onSelect?.({ plan, price });
|
|
134
188
|
} else if (status?.paymentsEnabled) {
|
|
135
|
-
// Already has payment method —
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
189
|
+
// Already has payment method — instant switch, so require an explicit
|
|
190
|
+
// confirmation (TBP-33). The actual changePlan runs in confirmPlanChange.
|
|
191
|
+
confirmTarget = { plan, price };
|
|
192
|
+
confirmError = null;
|
|
139
193
|
} else {
|
|
140
194
|
// Needs checkout — redirect to Stripe (or direct plan set when Stripe not configured)
|
|
141
195
|
const base = getConfig().callbackUrl ?? `${window.location.origin}/auth/oauth-callback`;
|
|
@@ -192,6 +246,12 @@
|
|
|
192
246
|
<Alert variant="error">{pickError}</Alert>
|
|
193
247
|
{/if}
|
|
194
248
|
|
|
249
|
+
{#if successNotice}
|
|
250
|
+
<div class="bridge-plan-success" data-bridge-plan-success role="status">
|
|
251
|
+
<Alert variant="success">{successNotice}</Alert>
|
|
252
|
+
</div>
|
|
253
|
+
{/if}
|
|
254
|
+
|
|
195
255
|
{#if uiState === 'payment-failed'}
|
|
196
256
|
<div data-bridge-plan-payment-failed class="bridge-plan-payment-failed">
|
|
197
257
|
<Alert variant="error">
|
|
@@ -253,7 +313,9 @@
|
|
|
253
313
|
{/if}
|
|
254
314
|
</div>
|
|
255
315
|
|
|
256
|
-
{#if
|
|
316
|
+
{#if planDescription}
|
|
317
|
+
{@render planDescription({ plan, isCurrent })}
|
|
318
|
+
{:else if plan.description}
|
|
257
319
|
<p class="bridge-plan-description">{plan.description}</p>
|
|
258
320
|
{/if}
|
|
259
321
|
|
|
@@ -288,10 +350,52 @@
|
|
|
288
350
|
</p>
|
|
289
351
|
{/if}
|
|
290
352
|
</div>
|
|
353
|
+
|
|
354
|
+
{#if planFooter}
|
|
355
|
+
{@render planFooter({ plan, isCurrent })}
|
|
356
|
+
{/if}
|
|
291
357
|
</div>
|
|
292
358
|
{/if}
|
|
293
359
|
{/each}
|
|
294
360
|
</div>
|
|
295
361
|
{/if}
|
|
296
362
|
{/if}
|
|
363
|
+
|
|
364
|
+
{#if confirmTarget}
|
|
365
|
+
<div class="bridge-plan-confirm-backdrop" data-bridge-plan-confirm role="dialog" aria-modal="true" aria-labelledby="bridge-plan-confirm-title" tabindex="-1">
|
|
366
|
+
<div class="bridge-plan-confirm">
|
|
367
|
+
<h3 id="bridge-plan-confirm-title" class="bridge-plan-confirm-title">Change plan?</h3>
|
|
368
|
+
<p class="bridge-plan-confirm-body">
|
|
369
|
+
Switch from <strong>{currentPlanName}</strong> to
|
|
370
|
+
<strong>{confirmTarget.plan.name}</strong> ({formatPrice(confirmTarget.price)}).
|
|
371
|
+
</p>
|
|
372
|
+
<p class="bridge-plan-confirm-note">
|
|
373
|
+
The change takes effect immediately — any price difference is prorated
|
|
374
|
+
on your next invoice.
|
|
375
|
+
</p>
|
|
376
|
+
{#if confirmError}
|
|
377
|
+
<Alert variant="error">{confirmError}</Alert>
|
|
378
|
+
{/if}
|
|
379
|
+
<div class="bridge-plan-confirm-actions">
|
|
380
|
+
<button
|
|
381
|
+
type="button"
|
|
382
|
+
class="bridge-btn-secondary"
|
|
383
|
+
disabled={confirmBusy}
|
|
384
|
+
onclick={() => (confirmTarget = null)}
|
|
385
|
+
>
|
|
386
|
+
Cancel
|
|
387
|
+
</button>
|
|
388
|
+
<button
|
|
389
|
+
type="button"
|
|
390
|
+
class="bridge-btn-primary"
|
|
391
|
+
data-bridge-plan-confirm-btn
|
|
392
|
+
disabled={confirmBusy}
|
|
393
|
+
onclick={confirmPlanChange}
|
|
394
|
+
>
|
|
395
|
+
{confirmBusy ? 'Switching…' : 'Confirm change'}
|
|
396
|
+
</button>
|
|
397
|
+
</div>
|
|
398
|
+
</div>
|
|
399
|
+
</div>
|
|
400
|
+
{/if}
|
|
297
401
|
</div>
|
|
@@ -8,7 +8,8 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
|
8
8
|
/**
|
|
9
9
|
* Which billing interval tab is selected by default (`'month'` | `'year'` |
|
|
10
10
|
* `'week'` | `'day'`). Falls back to the first available interval when the
|
|
11
|
-
* requested one isn't offered by any plan. Default: `'
|
|
11
|
+
* requested one isn't offered by any plan. Default: `'year'` (TBP-34 —
|
|
12
|
+
* annual is the default; apps without yearly prices fall back gracefully).
|
|
12
13
|
*/
|
|
13
14
|
defaultInterval?: BillingInterval;
|
|
14
15
|
onSelect?: (detail: {
|
|
@@ -23,6 +24,17 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
|
23
24
|
interval: BillingInterval;
|
|
24
25
|
onPick: (price: PriceOfferSdk) => void;
|
|
25
26
|
}]>;
|
|
27
|
+
/** Replaces the built-in description paragraph of the default card —
|
|
28
|
+
* custom copy or markup per plan without reimplementing the whole card. */
|
|
29
|
+
planDescription?: Snippet<[{
|
|
30
|
+
plan: Plan;
|
|
31
|
+
isCurrent: boolean;
|
|
32
|
+
}]>;
|
|
33
|
+
/** Rendered at the bottom of the default card, after the price buttons. */
|
|
34
|
+
planFooter?: Snippet<[{
|
|
35
|
+
plan: Plan;
|
|
36
|
+
isCurrent: boolean;
|
|
37
|
+
}]>;
|
|
26
38
|
emptyState?: Snippet;
|
|
27
39
|
loadingState?: Snippet;
|
|
28
40
|
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,258 @@
|
|
|
1
|
+
// Billing CTA destination — config-driven manage route (TBP-451 / S1).
|
|
2
|
+
//
|
|
3
|
+
// Under test: the destination precedence shared by <BridgeBillingNotice> and
|
|
4
|
+
// <BridgeQuotaBanner>:
|
|
5
|
+
//
|
|
6
|
+
// onActionClick callback (highest — short-circuits, no navigation at all)
|
|
7
|
+
// → `actionHref` prop
|
|
8
|
+
// → getConfig().billing?.manageRoute
|
|
9
|
+
// → '/billing' (default)
|
|
10
|
+
//
|
|
11
|
+
// HARNESS NOTE: bridge-svelte's vitest config (vitest.config.ts) runs in a
|
|
12
|
+
// `node` environment with no Svelte compiler plugin and no DOM (jsdom /
|
|
13
|
+
// happy-dom / @testing-library/svelte are NOT installed anywhere in the
|
|
14
|
+
// workspace), so neither component can be mounted here. Following the
|
|
15
|
+
// established pattern in billing-notice-gate.test.ts and plan-selector.test.ts,
|
|
16
|
+
// this file exercises an EXACT replica of the components' `handleAction()`
|
|
17
|
+
// script-block logic, with the external singletons (`getConfig` from
|
|
18
|
+
// config.store.js and `window.location`) injected as mocks instead of
|
|
19
|
+
// module-mocking them.
|
|
20
|
+
//
|
|
21
|
+
// The replicas below mirror, line for line:
|
|
22
|
+
// - BridgeBillingNotice.svelte `function handleAction()`
|
|
23
|
+
// - BridgeQuotaBanner.svelte `function handleAction()`
|
|
24
|
+
//
|
|
25
|
+
// The two differ only in (a) the argument handed to `onActionClick`
|
|
26
|
+
// (BillingNoticeState vs QuotaSnapshot) and (b) the quota banner's
|
|
27
|
+
// `if (!snapshot) return;` guard. The destination resolution is byte-identical
|
|
28
|
+
// and MUST stay that way — the "both components agree" block below is the
|
|
29
|
+
// regression guard for that.
|
|
30
|
+
//
|
|
31
|
+
// If either component's handleAction changes, update the replica here to
|
|
32
|
+
// match — a drift between the two is a test bug, not a component bug.
|
|
33
|
+
import { describe, it, expect, vi, beforeEach } from 'vitest';
|
|
34
|
+
const configWith = (manageRoute) => () => manageRoute === undefined ? {} : { billing: { manageRoute } };
|
|
35
|
+
const uninitializedConfig = () => {
|
|
36
|
+
throw new Error('Config has not been initialized. Call initConfig(...) early in app startup.');
|
|
37
|
+
};
|
|
38
|
+
/** Config initialized, but the app never declared a `billing` block. */
|
|
39
|
+
const configWithoutBillingBlock = () => ({});
|
|
40
|
+
function makeNoticeHarness(opts = {}) {
|
|
41
|
+
const { getConfig = uninitializedConfig, actionHref, onActionClick, hasWindow = true, } = opts;
|
|
42
|
+
// Records every write to `window.location.href`.
|
|
43
|
+
const navigations = [];
|
|
44
|
+
const noticeState = 'past_due';
|
|
45
|
+
function handleAction() {
|
|
46
|
+
if (onActionClick) {
|
|
47
|
+
onActionClick(noticeState);
|
|
48
|
+
return;
|
|
49
|
+
}
|
|
50
|
+
// Default: open the app's billing surface. Destination priority:
|
|
51
|
+
// `actionHref` prop → `billing.manageRoute` config → '/billing'.
|
|
52
|
+
if (hasWindow) {
|
|
53
|
+
let manageRoute;
|
|
54
|
+
try {
|
|
55
|
+
manageRoute = getConfig().billing?.manageRoute;
|
|
56
|
+
}
|
|
57
|
+
catch {
|
|
58
|
+
// Config not initialized — fall through to the default.
|
|
59
|
+
}
|
|
60
|
+
navigations.push(actionHref ?? manageRoute ?? '/billing');
|
|
61
|
+
}
|
|
62
|
+
}
|
|
63
|
+
return { handleAction, navigations, noticeState };
|
|
64
|
+
}
|
|
65
|
+
function makeQuotaHarness(opts = {}) {
|
|
66
|
+
const { getConfig = uninitializedConfig, actionHref, onActionClick, hasWindow = true, snapshot = { metric: 'ai_completions' }, } = opts;
|
|
67
|
+
const navigations = [];
|
|
68
|
+
function handleAction() {
|
|
69
|
+
if (!snapshot)
|
|
70
|
+
return;
|
|
71
|
+
if (onActionClick) {
|
|
72
|
+
onActionClick(snapshot);
|
|
73
|
+
return;
|
|
74
|
+
}
|
|
75
|
+
// Destination priority: `actionHref` prop → `billing.manageRoute` config
|
|
76
|
+
// → '/billing'.
|
|
77
|
+
if (hasWindow) {
|
|
78
|
+
let manageRoute;
|
|
79
|
+
try {
|
|
80
|
+
manageRoute = getConfig().billing?.manageRoute;
|
|
81
|
+
}
|
|
82
|
+
catch {
|
|
83
|
+
// Config not initialized — fall through to the default.
|
|
84
|
+
}
|
|
85
|
+
navigations.push(actionHref ?? manageRoute ?? '/billing');
|
|
86
|
+
}
|
|
87
|
+
}
|
|
88
|
+
return { handleAction, navigations, snapshot };
|
|
89
|
+
}
|
|
90
|
+
/** The two harnesses, keyed by component, for the shared-behaviour table. */
|
|
91
|
+
const HARNESSES = {
|
|
92
|
+
BridgeBillingNotice: makeNoticeHarness,
|
|
93
|
+
BridgeQuotaBanner: (opts) => makeQuotaHarness(opts),
|
|
94
|
+
};
|
|
95
|
+
const COMPONENTS = Object.keys(HARNESSES);
|
|
96
|
+
// ── Tests ────────────────────────────────────────────────────────────────────
|
|
97
|
+
describe('Billing CTA manage-route precedence (TBP-451)', () => {
|
|
98
|
+
beforeEach(() => {
|
|
99
|
+
vi.restoreAllMocks();
|
|
100
|
+
});
|
|
101
|
+
describe.each(COMPONENTS)('%s', (component) => {
|
|
102
|
+
const harness = HARNESSES[component];
|
|
103
|
+
describe("default — '/billing'", () => {
|
|
104
|
+
it('navigates to /billing when config is uninitialized and no props are given', () => {
|
|
105
|
+
const h = harness({ getConfig: uninitializedConfig });
|
|
106
|
+
h.handleAction();
|
|
107
|
+
expect(h.navigations).toEqual(['/billing']);
|
|
108
|
+
});
|
|
109
|
+
it('navigates to /billing when config is loaded but has no billing block', () => {
|
|
110
|
+
const h = harness({ getConfig: configWithoutBillingBlock });
|
|
111
|
+
h.handleAction();
|
|
112
|
+
expect(h.navigations).toEqual(['/billing']);
|
|
113
|
+
});
|
|
114
|
+
it('navigates to /billing when billing exists but manageRoute is unset', () => {
|
|
115
|
+
const h = harness({ getConfig: () => ({ billing: {} }) });
|
|
116
|
+
h.handleAction();
|
|
117
|
+
expect(h.navigations).toEqual(['/billing']);
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
describe('config — billing.manageRoute', () => {
|
|
121
|
+
it('honors the configured manageRoute over the built-in default', () => {
|
|
122
|
+
const h = harness({ getConfig: configWith('/settings/billing') });
|
|
123
|
+
h.handleAction();
|
|
124
|
+
expect(h.navigations).toEqual(['/settings/billing']);
|
|
125
|
+
});
|
|
126
|
+
it('honors an absolute manageRoute URL verbatim', () => {
|
|
127
|
+
const h = harness({ getConfig: configWith('https://billing.example.com/portal') });
|
|
128
|
+
h.handleAction();
|
|
129
|
+
expect(h.navigations).toEqual(['https://billing.example.com/portal']);
|
|
130
|
+
});
|
|
131
|
+
it('reads the config on every click, so a later initConfig is picked up', () => {
|
|
132
|
+
let manageRoute;
|
|
133
|
+
const h = harness({ getConfig: () => ({ billing: { manageRoute } }) });
|
|
134
|
+
h.handleAction(); // config not yet carrying a route
|
|
135
|
+
manageRoute = '/settings/billing';
|
|
136
|
+
h.handleAction(); // same component instance, config now set
|
|
137
|
+
expect(h.navigations).toEqual(['/billing', '/settings/billing']);
|
|
138
|
+
});
|
|
139
|
+
});
|
|
140
|
+
describe('actionHref prop', () => {
|
|
141
|
+
it('overrides the configured manageRoute', () => {
|
|
142
|
+
const h = harness({
|
|
143
|
+
getConfig: configWith('/settings/billing'),
|
|
144
|
+
actionHref: '/team/upgrade',
|
|
145
|
+
});
|
|
146
|
+
h.handleAction();
|
|
147
|
+
expect(h.navigations).toEqual(['/team/upgrade']);
|
|
148
|
+
});
|
|
149
|
+
it('overrides the default when no config is available at all', () => {
|
|
150
|
+
const h = harness({ getConfig: uninitializedConfig, actionHref: '/team/upgrade' });
|
|
151
|
+
h.handleAction();
|
|
152
|
+
expect(h.navigations).toEqual(['/team/upgrade']);
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
describe('onActionClick callback (highest precedence)', () => {
|
|
156
|
+
it('wins over both actionHref and config, and performs NO navigation', () => {
|
|
157
|
+
const onActionClick = vi.fn();
|
|
158
|
+
const h = harness({
|
|
159
|
+
getConfig: configWith('/settings/billing'),
|
|
160
|
+
actionHref: '/team/upgrade',
|
|
161
|
+
onActionClick,
|
|
162
|
+
});
|
|
163
|
+
h.handleAction();
|
|
164
|
+
expect(onActionClick).toHaveBeenCalledOnce();
|
|
165
|
+
expect(h.navigations).toEqual([]);
|
|
166
|
+
});
|
|
167
|
+
it('wins even with no other destination configured', () => {
|
|
168
|
+
const onActionClick = vi.fn();
|
|
169
|
+
const h = harness({ getConfig: uninitializedConfig, onActionClick });
|
|
170
|
+
h.handleAction();
|
|
171
|
+
expect(onActionClick).toHaveBeenCalledOnce();
|
|
172
|
+
expect(h.navigations).toEqual([]);
|
|
173
|
+
});
|
|
174
|
+
it('never touches getConfig when the callback short-circuits', () => {
|
|
175
|
+
const getConfig = vi.fn(configWith('/settings/billing'));
|
|
176
|
+
const h = harness({ getConfig, onActionClick: vi.fn() });
|
|
177
|
+
h.handleAction();
|
|
178
|
+
expect(getConfig).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
});
|
|
181
|
+
describe('SSR guard', () => {
|
|
182
|
+
it('does not navigate when there is no window (typeof window === undefined)', () => {
|
|
183
|
+
const h = harness({ getConfig: configWith('/settings/billing'), hasWindow: false });
|
|
184
|
+
h.handleAction();
|
|
185
|
+
expect(h.navigations).toEqual([]);
|
|
186
|
+
});
|
|
187
|
+
});
|
|
188
|
+
});
|
|
189
|
+
// ── Cross-component agreement ──────────────────────────────────────────────
|
|
190
|
+
//
|
|
191
|
+
// The whole point of S1 is that an app configures `billing.manageRoute` ONCE
|
|
192
|
+
// and both CTAs obey it. This block fails the moment the two handlers drift.
|
|
193
|
+
describe('BridgeBillingNotice and BridgeQuotaBanner resolve identically', () => {
|
|
194
|
+
const cases = [
|
|
195
|
+
{
|
|
196
|
+
label: 'no config, no props → /billing',
|
|
197
|
+
opts: { getConfig: uninitializedConfig },
|
|
198
|
+
expected: ['/billing'],
|
|
199
|
+
},
|
|
200
|
+
{
|
|
201
|
+
label: 'config manageRoute only',
|
|
202
|
+
opts: { getConfig: configWith('/settings/billing') },
|
|
203
|
+
expected: ['/settings/billing'],
|
|
204
|
+
},
|
|
205
|
+
{
|
|
206
|
+
label: 'actionHref beats config',
|
|
207
|
+
opts: { getConfig: configWith('/settings/billing'), actionHref: '/team/upgrade' },
|
|
208
|
+
expected: ['/team/upgrade'],
|
|
209
|
+
},
|
|
210
|
+
{
|
|
211
|
+
label: 'actionHref with no config',
|
|
212
|
+
opts: { getConfig: uninitializedConfig, actionHref: '/team/upgrade' },
|
|
213
|
+
expected: ['/team/upgrade'],
|
|
214
|
+
},
|
|
215
|
+
{
|
|
216
|
+
label: 'no window → no navigation',
|
|
217
|
+
opts: { getConfig: configWith('/settings/billing'), hasWindow: false },
|
|
218
|
+
expected: [],
|
|
219
|
+
},
|
|
220
|
+
];
|
|
221
|
+
it.each(cases)('$label', ({ opts, expected }) => {
|
|
222
|
+
const notice = makeNoticeHarness(opts);
|
|
223
|
+
const quota = makeQuotaHarness(opts);
|
|
224
|
+
notice.handleAction();
|
|
225
|
+
quota.handleAction();
|
|
226
|
+
expect(notice.navigations).toEqual(expected);
|
|
227
|
+
expect(quota.navigations).toEqual(expected);
|
|
228
|
+
expect(quota.navigations).toEqual(notice.navigations);
|
|
229
|
+
});
|
|
230
|
+
it('onActionClick suppresses navigation in both components', () => {
|
|
231
|
+
const noticeCb = vi.fn();
|
|
232
|
+
const quotaCb = vi.fn();
|
|
233
|
+
const shared = { getConfig: configWith('/settings/billing'), actionHref: '/team/upgrade' };
|
|
234
|
+
const notice = makeNoticeHarness({ ...shared, onActionClick: noticeCb });
|
|
235
|
+
const quota = makeQuotaHarness({ ...shared, onActionClick: quotaCb });
|
|
236
|
+
notice.handleAction();
|
|
237
|
+
quota.handleAction();
|
|
238
|
+
expect(noticeCb).toHaveBeenCalledOnce();
|
|
239
|
+
expect(quotaCb).toHaveBeenCalledOnce();
|
|
240
|
+
expect(notice.navigations).toEqual([]);
|
|
241
|
+
expect(quota.navigations).toEqual([]);
|
|
242
|
+
});
|
|
243
|
+
});
|
|
244
|
+
// ── Quota-banner-only guard ────────────────────────────────────────────────
|
|
245
|
+
describe('BridgeQuotaBanner snapshot guard', () => {
|
|
246
|
+
it('does nothing at all — no callback, no navigation — without a snapshot', () => {
|
|
247
|
+
const onActionClick = vi.fn();
|
|
248
|
+
const h = makeQuotaHarness({
|
|
249
|
+
getConfig: configWith('/settings/billing'),
|
|
250
|
+
snapshot: null,
|
|
251
|
+
onActionClick,
|
|
252
|
+
});
|
|
253
|
+
h.handleAction();
|
|
254
|
+
expect(onActionClick).not.toHaveBeenCalled();
|
|
255
|
+
expect(h.navigations).toEqual([]);
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
});
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
export {};
|
|
@@ -0,0 +1,391 @@
|
|
|
1
|
+
// PlanSelector — annual-default interval (TBP-34) + plan-change confirmation (TBP-33).
|
|
2
|
+
//
|
|
3
|
+
// HARNESS NOTE: bridge-svelte's vitest config (vitest.config.ts) runs in a
|
|
4
|
+
// `node` environment with no Svelte compiler plugin and no DOM (jsdom /
|
|
5
|
+
// @testing-library/svelte are NOT installed anywhere in the workspace), so the
|
|
6
|
+
// PlanSelector.svelte component cannot be mounted here. Following the
|
|
7
|
+
// established pattern in billing-notice-gate.test.ts, this file exercises an
|
|
8
|
+
// EXACT replica of the component's script-block decisions, with the external
|
|
9
|
+
// singletons (getBridgeAuth / loadSubscription / getConfig / window.location)
|
|
10
|
+
// injected as mocks instead of module-mocking `bridge-instance.js`.
|
|
11
|
+
//
|
|
12
|
+
// The replicated logic mirrors PlanSelector.svelte:
|
|
13
|
+
// - availableIntervals ($derived.by, paid prices only, stable order)
|
|
14
|
+
// - selectedInterval (override → defaultInterval → first available)
|
|
15
|
+
// - showIntervalTabs (≥2 intervals)
|
|
16
|
+
// - pricesForInterval (matching interval + interval-agnostic free prices)
|
|
17
|
+
// - handlePick (free → selectFreePlan; paymentsEnabled+paid →
|
|
18
|
+
// confirm dialog, NO changePlan; else → checkout)
|
|
19
|
+
// - confirmPlanChange (changePlan behind explicit confirm; success notice;
|
|
20
|
+
// failure kept inside the dialog)
|
|
21
|
+
//
|
|
22
|
+
// If PlanSelector.svelte's script logic changes, update the replica here to
|
|
23
|
+
// match — a drift between the two is a test bug, not a component bug.
|
|
24
|
+
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
|
|
25
|
+
// ── Replicas of the interval derivations (TBP-34) ────────────────────────────
|
|
26
|
+
const INTERVAL_LABELS = {
|
|
27
|
+
day: 'Daily',
|
|
28
|
+
week: 'Weekly',
|
|
29
|
+
month: 'Monthly',
|
|
30
|
+
year: 'Yearly',
|
|
31
|
+
};
|
|
32
|
+
/** Mirror of the component's `availableIntervals` $derived.by. */
|
|
33
|
+
function availableIntervals(plans) {
|
|
34
|
+
const order = ['day', 'week', 'month', 'year'];
|
|
35
|
+
return order.filter((i) => (plans ?? []).some((plan) => plan.prices.some((p) => p.amount > 0 && p.recurrenceInterval === i)));
|
|
36
|
+
}
|
|
37
|
+
/** Mirror of the component's `selectedInterval` $derived.by. */
|
|
38
|
+
function selectedInterval(plans, defaultInterval, intervalOverride = null) {
|
|
39
|
+
const available = availableIntervals(plans);
|
|
40
|
+
if (intervalOverride && available.includes(intervalOverride)) {
|
|
41
|
+
return intervalOverride;
|
|
42
|
+
}
|
|
43
|
+
return available.includes(defaultInterval)
|
|
44
|
+
? defaultInterval
|
|
45
|
+
: (available[0] ?? defaultInterval);
|
|
46
|
+
}
|
|
47
|
+
/** Mirror of the component's `showIntervalTabs` $derived. */
|
|
48
|
+
function showIntervalTabs(plans) {
|
|
49
|
+
return availableIntervals(plans).length >= 2;
|
|
50
|
+
}
|
|
51
|
+
/** Mirror of the component's `pricesForInterval(plan)`. */
|
|
52
|
+
function pricesForInterval(plan, selected) {
|
|
53
|
+
return plan.prices.filter((p) => p.amount === 0 || p.recurrenceInterval === selected);
|
|
54
|
+
}
|
|
55
|
+
function makeHarness(opts = {}) {
|
|
56
|
+
const { plans = null, status = null, callbackUrl, onSelect, successRedirect = '/subscription', cancelRedirect = '/subscription', } = opts;
|
|
57
|
+
// Injected singletons (component gets these from bridge-instance.js /
|
|
58
|
+
// config.store.js / window.location).
|
|
59
|
+
const auth = {
|
|
60
|
+
selectFreePlan: vi.fn(async (_planKey) => { }),
|
|
61
|
+
changePlan: vi.fn(async (_planKey, _price) => { }),
|
|
62
|
+
startCheckout: vi.fn(async (_planKey, _price, _urls) => ({ sessionId: null, checkoutUrl: null })),
|
|
63
|
+
};
|
|
64
|
+
const loadSubscription = vi.fn(async () => { });
|
|
65
|
+
const navigate = vi.fn((_url) => { });
|
|
66
|
+
const origin = 'http://localhost:5173';
|
|
67
|
+
// Mirrored component state.
|
|
68
|
+
const state = {
|
|
69
|
+
picking: false,
|
|
70
|
+
pickError: null,
|
|
71
|
+
confirmTarget: null,
|
|
72
|
+
confirmBusy: false,
|
|
73
|
+
confirmError: null,
|
|
74
|
+
successNotice: null,
|
|
75
|
+
};
|
|
76
|
+
let successTimer;
|
|
77
|
+
function formatPrice(price) {
|
|
78
|
+
return price.amount === 0
|
|
79
|
+
? 'Free'
|
|
80
|
+
: `${price.amount} ${price.currency.toUpperCase()} / ${price.recurrenceInterval}`;
|
|
81
|
+
}
|
|
82
|
+
function showSuccess(message) {
|
|
83
|
+
state.successNotice = message;
|
|
84
|
+
clearTimeout(successTimer);
|
|
85
|
+
successTimer = setTimeout(() => (state.successNotice = null), 6000);
|
|
86
|
+
}
|
|
87
|
+
async function confirmPlanChange() {
|
|
88
|
+
if (!state.confirmTarget)
|
|
89
|
+
return;
|
|
90
|
+
const { plan, price } = state.confirmTarget;
|
|
91
|
+
state.confirmBusy = true;
|
|
92
|
+
state.confirmError = null;
|
|
93
|
+
try {
|
|
94
|
+
await auth.changePlan(plan.key, price);
|
|
95
|
+
await loadSubscription();
|
|
96
|
+
state.confirmTarget = null;
|
|
97
|
+
showSuccess(`You're now on ${plan.name} (${formatPrice(price)}).`);
|
|
98
|
+
onSelect?.({ plan, price });
|
|
99
|
+
}
|
|
100
|
+
catch (err) {
|
|
101
|
+
// AC: failure surfaces inside the dialog, not just a banner.
|
|
102
|
+
state.confirmError = err instanceof Error ? err.message : 'Plan change failed';
|
|
103
|
+
}
|
|
104
|
+
finally {
|
|
105
|
+
state.confirmBusy = false;
|
|
106
|
+
}
|
|
107
|
+
}
|
|
108
|
+
async function handlePick(plan, price) {
|
|
109
|
+
state.picking = true;
|
|
110
|
+
state.pickError = null;
|
|
111
|
+
try {
|
|
112
|
+
if (price.amount === 0 && !plan.hasCost) {
|
|
113
|
+
// Free plan — select directly (TBP-275 guards metered $0-base plans).
|
|
114
|
+
await auth.selectFreePlan(plan.key);
|
|
115
|
+
await loadSubscription();
|
|
116
|
+
onSelect?.({ plan, price });
|
|
117
|
+
}
|
|
118
|
+
else if (status?.paymentsEnabled) {
|
|
119
|
+
// Instant switch — requires explicit confirmation (TBP-33).
|
|
120
|
+
state.confirmTarget = { plan, price };
|
|
121
|
+
state.confirmError = null;
|
|
122
|
+
}
|
|
123
|
+
else {
|
|
124
|
+
const base = callbackUrl ?? `${origin}/auth/oauth-callback`;
|
|
125
|
+
const successUrl = `${base}?stripe_success=1&session_id={CHECKOUT_SESSION_ID}&redirect=${encodeURIComponent(successRedirect)}`;
|
|
126
|
+
const cancelUrl = `${base}?stripe_cancel=1&redirect=${encodeURIComponent(cancelRedirect)}`;
|
|
127
|
+
const session = await auth.startCheckout(plan.key, price, { successUrl, cancelUrl });
|
|
128
|
+
if (session.sessionId === null) {
|
|
129
|
+
await loadSubscription();
|
|
130
|
+
onSelect?.({ plan, price });
|
|
131
|
+
}
|
|
132
|
+
else {
|
|
133
|
+
if (!session.checkoutUrl)
|
|
134
|
+
throw new Error('Checkout session URL missing');
|
|
135
|
+
navigate(session.checkoutUrl);
|
|
136
|
+
}
|
|
137
|
+
}
|
|
138
|
+
}
|
|
139
|
+
catch (err) {
|
|
140
|
+
state.pickError = err instanceof Error ? err.message : 'Something went wrong';
|
|
141
|
+
}
|
|
142
|
+
finally {
|
|
143
|
+
state.picking = false;
|
|
144
|
+
}
|
|
145
|
+
}
|
|
146
|
+
function cancelConfirm() {
|
|
147
|
+
// Mirror of the dialog's Cancel button: onclick={() => (confirmTarget = null)}
|
|
148
|
+
state.confirmTarget = null;
|
|
149
|
+
}
|
|
150
|
+
return { state, auth, loadSubscription, navigate, handlePick, confirmPlanChange, cancelConfirm };
|
|
151
|
+
}
|
|
152
|
+
// ── Fixtures ──────────────────────────────────────────────────────────────────
|
|
153
|
+
const price = (amount, recurrenceInterval, currency = 'usd') => ({ id: `${recurrenceInterval}-${amount}`, amount, currency, recurrenceInterval });
|
|
154
|
+
const plan = (key, prices, extra = {}) => ({
|
|
155
|
+
key,
|
|
156
|
+
name: key.charAt(0).toUpperCase() + key.slice(1),
|
|
157
|
+
prices,
|
|
158
|
+
...extra,
|
|
159
|
+
});
|
|
160
|
+
const FREE_PLAN = plan('free', [price(0, 'month')]);
|
|
161
|
+
const PRO_PLAN = plan('pro', [price(29, 'month'), price(290, 'year')]);
|
|
162
|
+
const TEAM_PLAN = plan('team', [price(99, 'month'), price(990, 'year')]);
|
|
163
|
+
const MONTHLY_ONLY_PLAN = plan('starter', [price(9, 'month')]);
|
|
164
|
+
const METERED_ZERO_BASE_PLAN = plan('usage', [price(0, 'month')], { hasCost: true });
|
|
165
|
+
// ── TBP-34: annual default interval + fallback ───────────────────────────────
|
|
166
|
+
describe('PlanSelector interval selection (TBP-34)', () => {
|
|
167
|
+
describe('availableIntervals', () => {
|
|
168
|
+
it('collects only intervals with paid prices, in stable day→week→month→year order', () => {
|
|
169
|
+
const plans = [
|
|
170
|
+
plan('a', [price(290, 'year'), price(29, 'month')]),
|
|
171
|
+
plan('b', [price(2, 'week')]),
|
|
172
|
+
];
|
|
173
|
+
expect(availableIntervals(plans)).toEqual(['week', 'month', 'year']);
|
|
174
|
+
});
|
|
175
|
+
it('ignores free (amount-0) prices — they are interval-agnostic and never add a tab', () => {
|
|
176
|
+
expect(availableIntervals([FREE_PLAN])).toEqual([]);
|
|
177
|
+
expect(availableIntervals([FREE_PLAN, MONTHLY_ONLY_PLAN])).toEqual(['month']);
|
|
178
|
+
});
|
|
179
|
+
it('returns [] for null or empty plan lists', () => {
|
|
180
|
+
expect(availableIntervals(null)).toEqual([]);
|
|
181
|
+
expect(availableIntervals([])).toEqual([]);
|
|
182
|
+
});
|
|
183
|
+
});
|
|
184
|
+
describe('selectedInterval default', () => {
|
|
185
|
+
it("defaults to 'year' when yearly prices exist (annual is the default tab)", () => {
|
|
186
|
+
expect(selectedInterval([PRO_PLAN, TEAM_PLAN], 'year')).toBe('year');
|
|
187
|
+
});
|
|
188
|
+
it('falls back to the first available interval when no plan offers yearly', () => {
|
|
189
|
+
expect(selectedInterval([MONTHLY_ONLY_PLAN], 'year')).toBe('month');
|
|
190
|
+
});
|
|
191
|
+
it('falls back to the requested default itself when no paid intervals exist at all', () => {
|
|
192
|
+
// Free-only app: no tabs are rendered, the value is inert but must not crash.
|
|
193
|
+
expect(selectedInterval([FREE_PLAN], 'year')).toBe('year');
|
|
194
|
+
expect(selectedInterval(null, 'year')).toBe('year');
|
|
195
|
+
});
|
|
196
|
+
it('honors a non-year defaultInterval prop when available', () => {
|
|
197
|
+
expect(selectedInterval([PRO_PLAN], 'month')).toBe('month');
|
|
198
|
+
});
|
|
199
|
+
});
|
|
200
|
+
describe('selectedInterval user override', () => {
|
|
201
|
+
it('uses the user-picked tab when it is still available', () => {
|
|
202
|
+
expect(selectedInterval([PRO_PLAN], 'year', 'month')).toBe('month');
|
|
203
|
+
});
|
|
204
|
+
it('discards an override that no longer matches any paid price (reconciles to default)', () => {
|
|
205
|
+
// e.g. plans reloaded and weekly prices disappeared.
|
|
206
|
+
expect(selectedInterval([PRO_PLAN], 'year', 'week')).toBe('year');
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
describe('showIntervalTabs', () => {
|
|
210
|
+
it('hides the toggle when fewer than two intervals are offered', () => {
|
|
211
|
+
expect(showIntervalTabs([MONTHLY_ONLY_PLAN])).toBe(false);
|
|
212
|
+
expect(showIntervalTabs([FREE_PLAN])).toBe(false);
|
|
213
|
+
expect(showIntervalTabs(null)).toBe(false);
|
|
214
|
+
});
|
|
215
|
+
it('shows the toggle when two or more intervals are offered', () => {
|
|
216
|
+
expect(showIntervalTabs([PRO_PLAN])).toBe(true);
|
|
217
|
+
});
|
|
218
|
+
});
|
|
219
|
+
describe('pricesForInterval', () => {
|
|
220
|
+
it('shows only the active-interval price plus interval-agnostic free prices', () => {
|
|
221
|
+
const mixed = plan('mixed', [price(0, 'month'), price(29, 'month'), price(290, 'year')]);
|
|
222
|
+
expect(pricesForInterval(mixed, 'year').map((p) => p.id)).toEqual([
|
|
223
|
+
'month-0',
|
|
224
|
+
'year-290',
|
|
225
|
+
]);
|
|
226
|
+
expect(pricesForInterval(mixed, 'month').map((p) => p.id)).toEqual([
|
|
227
|
+
'month-0',
|
|
228
|
+
'month-29',
|
|
229
|
+
]);
|
|
230
|
+
});
|
|
231
|
+
it('returns [] for a paid-only plan not offered under the active interval', () => {
|
|
232
|
+
expect(pricesForInterval(MONTHLY_ONLY_PLAN, 'year')).toEqual([]);
|
|
233
|
+
});
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
// ── TBP-33: plan-change confirmation flow ────────────────────────────────────
|
|
237
|
+
describe('PlanSelector pick flow + confirmation dialog (TBP-33)', () => {
|
|
238
|
+
beforeEach(() => {
|
|
239
|
+
vi.useFakeTimers();
|
|
240
|
+
});
|
|
241
|
+
afterEach(() => {
|
|
242
|
+
vi.useRealTimers();
|
|
243
|
+
});
|
|
244
|
+
describe('free plan (single-click, no confirmation)', () => {
|
|
245
|
+
it('calls selectFreePlan immediately and never opens the dialog', async () => {
|
|
246
|
+
const onSelect = vi.fn();
|
|
247
|
+
const h = makeHarness({ status: { paymentsEnabled: true }, onSelect });
|
|
248
|
+
await h.handlePick(FREE_PLAN, FREE_PLAN.prices[0]);
|
|
249
|
+
expect(h.auth.selectFreePlan).toHaveBeenCalledExactlyOnceWith('free');
|
|
250
|
+
expect(h.auth.changePlan).not.toHaveBeenCalled();
|
|
251
|
+
expect(h.auth.startCheckout).not.toHaveBeenCalled();
|
|
252
|
+
expect(h.loadSubscription).toHaveBeenCalledOnce();
|
|
253
|
+
expect(h.state.confirmTarget).toBeNull();
|
|
254
|
+
expect(onSelect).toHaveBeenCalledExactlyOnceWith({
|
|
255
|
+
plan: FREE_PLAN,
|
|
256
|
+
price: FREE_PLAN.prices[0],
|
|
257
|
+
});
|
|
258
|
+
});
|
|
259
|
+
it('TBP-275: a $0-base plan with hasCost is NOT treated as free — it needs confirm/checkout', async () => {
|
|
260
|
+
const h = makeHarness({ status: { paymentsEnabled: true } });
|
|
261
|
+
await h.handlePick(METERED_ZERO_BASE_PLAN, METERED_ZERO_BASE_PLAN.prices[0]);
|
|
262
|
+
expect(h.auth.selectFreePlan).not.toHaveBeenCalled();
|
|
263
|
+
expect(h.state.confirmTarget).toEqual({
|
|
264
|
+
plan: METERED_ZERO_BASE_PLAN,
|
|
265
|
+
price: METERED_ZERO_BASE_PLAN.prices[0],
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
});
|
|
269
|
+
describe('paid pick with paymentsEnabled (confirmation gate)', () => {
|
|
270
|
+
it('opens the dialog and does NOT call changePlan until confirmed', async () => {
|
|
271
|
+
const onSelect = vi.fn();
|
|
272
|
+
const h = makeHarness({ status: { paymentsEnabled: true }, onSelect });
|
|
273
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[1]);
|
|
274
|
+
expect(h.state.confirmTarget).toEqual({ plan: PRO_PLAN, price: PRO_PLAN.prices[1] });
|
|
275
|
+
expect(h.state.confirmError).toBeNull();
|
|
276
|
+
expect(h.auth.changePlan).not.toHaveBeenCalled();
|
|
277
|
+
expect(h.auth.startCheckout).not.toHaveBeenCalled();
|
|
278
|
+
expect(h.loadSubscription).not.toHaveBeenCalled();
|
|
279
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
280
|
+
});
|
|
281
|
+
it('re-opening the dialog clears a stale confirmError from a previous attempt', async () => {
|
|
282
|
+
const h = makeHarness({ status: { paymentsEnabled: true } });
|
|
283
|
+
h.state.confirmError = 'old failure';
|
|
284
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
|
|
285
|
+
expect(h.state.confirmError).toBeNull();
|
|
286
|
+
});
|
|
287
|
+
it('cancel closes the dialog without ever calling changePlan', async () => {
|
|
288
|
+
const h = makeHarness({ status: { paymentsEnabled: true } });
|
|
289
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
|
|
290
|
+
h.cancelConfirm();
|
|
291
|
+
expect(h.state.confirmTarget).toBeNull();
|
|
292
|
+
expect(h.auth.changePlan).not.toHaveBeenCalled();
|
|
293
|
+
// A later confirm click on the closed dialog must be a no-op.
|
|
294
|
+
await h.confirmPlanChange();
|
|
295
|
+
expect(h.auth.changePlan).not.toHaveBeenCalled();
|
|
296
|
+
});
|
|
297
|
+
});
|
|
298
|
+
describe('confirmPlanChange success', () => {
|
|
299
|
+
it('calls changePlan with the picked plan/price, refreshes, closes, notifies', async () => {
|
|
300
|
+
const onSelect = vi.fn();
|
|
301
|
+
const h = makeHarness({ status: { paymentsEnabled: true }, onSelect });
|
|
302
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[1]); // 290 usd / year
|
|
303
|
+
await h.confirmPlanChange();
|
|
304
|
+
expect(h.auth.changePlan).toHaveBeenCalledExactlyOnceWith('pro', PRO_PLAN.prices[1]);
|
|
305
|
+
expect(h.loadSubscription).toHaveBeenCalledOnce();
|
|
306
|
+
expect(h.state.confirmTarget).toBeNull();
|
|
307
|
+
expect(h.state.confirmBusy).toBe(false);
|
|
308
|
+
expect(h.state.confirmError).toBeNull();
|
|
309
|
+
expect(h.state.successNotice).toBe("You're now on Pro (290 USD / year).");
|
|
310
|
+
expect(onSelect).toHaveBeenCalledExactlyOnceWith({
|
|
311
|
+
plan: PRO_PLAN,
|
|
312
|
+
price: PRO_PLAN.prices[1],
|
|
313
|
+
});
|
|
314
|
+
});
|
|
315
|
+
it('auto-dismisses the success notice after 6 seconds', async () => {
|
|
316
|
+
const h = makeHarness({ status: { paymentsEnabled: true } });
|
|
317
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
|
|
318
|
+
await h.confirmPlanChange();
|
|
319
|
+
expect(h.state.successNotice).not.toBeNull();
|
|
320
|
+
vi.advanceTimersByTime(5999);
|
|
321
|
+
expect(h.state.successNotice).not.toBeNull();
|
|
322
|
+
vi.advanceTimersByTime(1);
|
|
323
|
+
expect(h.state.successNotice).toBeNull();
|
|
324
|
+
});
|
|
325
|
+
});
|
|
326
|
+
describe('confirmPlanChange failure', () => {
|
|
327
|
+
it('keeps the dialog open with the error inside it — no crash, no success path', async () => {
|
|
328
|
+
const onSelect = vi.fn();
|
|
329
|
+
const h = makeHarness({ status: { paymentsEnabled: true }, onSelect });
|
|
330
|
+
h.auth.changePlan.mockRejectedValueOnce(new Error('card declined'));
|
|
331
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
|
|
332
|
+
await expect(h.confirmPlanChange()).resolves.toBeUndefined();
|
|
333
|
+
expect(h.state.confirmError).toBe('card declined');
|
|
334
|
+
expect(h.state.confirmTarget).toEqual({ plan: PRO_PLAN, price: PRO_PLAN.prices[0] });
|
|
335
|
+
expect(h.state.confirmBusy).toBe(false);
|
|
336
|
+
expect(h.state.successNotice).toBeNull();
|
|
337
|
+
expect(h.loadSubscription).not.toHaveBeenCalled();
|
|
338
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
339
|
+
});
|
|
340
|
+
it('falls back to a generic message for non-Error rejections', async () => {
|
|
341
|
+
const h = makeHarness({ status: { paymentsEnabled: true } });
|
|
342
|
+
h.auth.changePlan.mockRejectedValueOnce('boom');
|
|
343
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
|
|
344
|
+
await h.confirmPlanChange();
|
|
345
|
+
expect(h.state.confirmError).toBe('Plan change failed');
|
|
346
|
+
});
|
|
347
|
+
it('retry after failure succeeds and closes the dialog', async () => {
|
|
348
|
+
const h = makeHarness({ status: { paymentsEnabled: true } });
|
|
349
|
+
h.auth.changePlan.mockRejectedValueOnce(new Error('transient'));
|
|
350
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
|
|
351
|
+
await h.confirmPlanChange();
|
|
352
|
+
expect(h.state.confirmError).toBe('transient');
|
|
353
|
+
await h.confirmPlanChange();
|
|
354
|
+
expect(h.auth.changePlan).toHaveBeenCalledTimes(2);
|
|
355
|
+
expect(h.state.confirmTarget).toBeNull();
|
|
356
|
+
expect(h.state.confirmError).toBeNull();
|
|
357
|
+
expect(h.state.successNotice).toContain("You're now on Pro");
|
|
358
|
+
});
|
|
359
|
+
});
|
|
360
|
+
describe('paid pick without paymentsEnabled (checkout keeps single-click behavior)', () => {
|
|
361
|
+
it('goes straight to startCheckout — no confirmation dialog', async () => {
|
|
362
|
+
const h = makeHarness({ status: { paymentsEnabled: false } });
|
|
363
|
+
h.auth.startCheckout.mockResolvedValueOnce({
|
|
364
|
+
sessionId: 'cs_123',
|
|
365
|
+
checkoutUrl: 'https://checkout.stripe.test/cs_123',
|
|
366
|
+
});
|
|
367
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
|
|
368
|
+
expect(h.state.confirmTarget).toBeNull();
|
|
369
|
+
expect(h.auth.changePlan).not.toHaveBeenCalled();
|
|
370
|
+
expect(h.auth.startCheckout).toHaveBeenCalledOnce();
|
|
371
|
+
expect(h.navigate).toHaveBeenCalledExactlyOnceWith('https://checkout.stripe.test/cs_123');
|
|
372
|
+
});
|
|
373
|
+
it('sessionId === null (Stripe not configured) refreshes and fires onSelect directly', async () => {
|
|
374
|
+
const onSelect = vi.fn();
|
|
375
|
+
const h = makeHarness({ status: null, onSelect });
|
|
376
|
+
h.auth.startCheckout.mockResolvedValueOnce({ sessionId: null, checkoutUrl: null });
|
|
377
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
|
|
378
|
+
expect(h.loadSubscription).toHaveBeenCalledOnce();
|
|
379
|
+
expect(h.navigate).not.toHaveBeenCalled();
|
|
380
|
+
expect(onSelect).toHaveBeenCalledExactlyOnceWith({ plan: PRO_PLAN, price: PRO_PLAN.prices[0] });
|
|
381
|
+
});
|
|
382
|
+
it('a session without a checkoutUrl surfaces a pickError banner', async () => {
|
|
383
|
+
const h = makeHarness({ status: null });
|
|
384
|
+
h.auth.startCheckout.mockResolvedValueOnce({ sessionId: 'cs_456', checkoutUrl: null });
|
|
385
|
+
await h.handlePick(PRO_PLAN, PRO_PLAN.prices[0]);
|
|
386
|
+
expect(h.state.pickError).toBe('Checkout session URL missing');
|
|
387
|
+
expect(h.navigate).not.toHaveBeenCalled();
|
|
388
|
+
expect(h.state.picking).toBe(false);
|
|
389
|
+
});
|
|
390
|
+
});
|
|
391
|
+
});
|
package/dist/core/bridge.test.js
CHANGED
|
@@ -33,15 +33,23 @@ afterEach(() => {
|
|
|
33
33
|
});
|
|
34
34
|
// Stub the bridge-instance singleton so bridge.app.plans.load() doesn't
|
|
35
35
|
// require a real BridgeAuth in vitest. The actual load impl reads from
|
|
36
|
-
// getBridgeAuth() which throws when uninitialized.
|
|
37
|
-
|
|
38
|
-
|
|
39
|
-
|
|
40
|
-
|
|
41
|
-
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
}
|
|
36
|
+
// getBridgeAuth() which throws when uninitialized. `tokenStore` must be
|
|
37
|
+
// exported too — bridge.ts derives the JWT user fallback from it at
|
|
38
|
+
// module-eval time.
|
|
39
|
+
vi.mock('./bridge-instance.js', async () => {
|
|
40
|
+
const { writable } = await import('svelte/store');
|
|
41
|
+
return {
|
|
42
|
+
tokenStore: writable(null),
|
|
43
|
+
subscriptionStore: writable({ status: null, plans: null, loading: false, error: null }),
|
|
44
|
+
loadSubscription: async () => { },
|
|
45
|
+
getBridgeAuth: () => ({
|
|
46
|
+
getPlans: async () => [
|
|
47
|
+
{ key: 'free', name: 'Free' },
|
|
48
|
+
{ key: 'pro', name: 'Pro' },
|
|
49
|
+
],
|
|
50
|
+
}),
|
|
51
|
+
};
|
|
52
|
+
});
|
|
45
53
|
describe('bridge surface (Phase 4, TBP-319)', () => {
|
|
46
54
|
it('initial slice stores are null until a snapshot lands', () => {
|
|
47
55
|
expect(get(bridge.app.branding)).toBeNull();
|
|
@@ -19,7 +19,6 @@
|
|
|
19
19
|
// the BillingAttributeProvider without spinning up real billing stores.
|
|
20
20
|
import { describe, it, expect, beforeEach, vi } from 'vitest';
|
|
21
21
|
import { writable } from 'svelte/store';
|
|
22
|
-
let _tokenStore;
|
|
23
22
|
function makeJwt(claims) {
|
|
24
23
|
// Browser-compatible base64url (no padding). atob lives in Node 18+ and in the
|
|
25
24
|
// bootstrap module's decodeJwtPayload path.
|
|
@@ -36,12 +35,15 @@ function makeJwt(claims) {
|
|
|
36
35
|
// ── Mocks ───────────────────────────────────────────────────────────────────
|
|
37
36
|
// Mock the bridge-instance module so we don't bring up real BridgeAuth.
|
|
38
37
|
// `tokenStore` is a real svelte writable so we can push JWT-shaped values
|
|
39
|
-
// during the test; the rest is a safe stub.
|
|
40
|
-
|
|
38
|
+
// during the test; the rest is a safe stub. The store must be created INSIDE
|
|
39
|
+
// the hoisted factory: bridge.ts derives from `tokenStore` at module-eval
|
|
40
|
+
// time (during import), before any test-body binding would initialize.
|
|
41
|
+
vi.mock('../core/bridge-instance.js', async () => {
|
|
42
|
+
const { writable } = await import('svelte/store');
|
|
41
43
|
return {
|
|
42
|
-
|
|
43
|
-
|
|
44
|
-
},
|
|
44
|
+
tokenStore: writable(null),
|
|
45
|
+
subscriptionStore: writable({ status: null, plans: null, loading: false, error: null }),
|
|
46
|
+
loadSubscription: async () => { },
|
|
45
47
|
getBridgeAuth: () => ({
|
|
46
48
|
getApiContext: () => ({ appId: 'app-1', accessToken: null }),
|
|
47
49
|
refreshTokens: async () => { },
|
|
@@ -82,7 +84,7 @@ vi.mock('@nebulr-group/bridge-auth-core', async (importOriginal) => {
|
|
|
82
84
|
// bootstrap (which fires `void fetch(...)`) doesn't write to a real network.
|
|
83
85
|
// Bootstrap swallows the resulting non-ok response, so a 500 is fine.
|
|
84
86
|
beforeEach(() => {
|
|
85
|
-
_tokenStore
|
|
87
|
+
_tokenStore.set(null);
|
|
86
88
|
vi.stubGlobal('fetch', vi.fn(async () =>
|
|
87
89
|
// eslint-disable-next-line @typescript-eslint/no-explicit-any
|
|
88
90
|
({ ok: false, status: 500, json: async () => [] })));
|
|
@@ -91,6 +93,9 @@ beforeEach(() => {
|
|
|
91
93
|
// Imported AFTER the mocks above so the bootstrap module picks up the stubs.
|
|
92
94
|
import { createBridgeFlags } from './bootstrap.js';
|
|
93
95
|
import { AuthAttributeProvider, BillingAttributeProvider } from '@nebulr-group/bridge-auth-core';
|
|
96
|
+
import { tokenStore as _mockedTokenStore } from '../core/bridge-instance.js';
|
|
97
|
+
// The mocked writable, typed for test-side .set() pushes.
|
|
98
|
+
const _tokenStore = _mockedTokenStore;
|
|
94
99
|
// After the TBP-Live-Channel-Unification hoist, the realtime client lives
|
|
95
100
|
// in `core/bridge-runtime.ts` and is owned by `<BridgeBootstrap />`. Tests
|
|
96
101
|
// don't start the core runtime so `getBridgeRealtime()` returns undefined
|
package/dist/styles.css
CHANGED
|
@@ -936,7 +936,11 @@
|
|
|
936
936
|
}
|
|
937
937
|
|
|
938
938
|
.bridge-plan-interval-tabs {
|
|
939
|
-
|
|
939
|
+
/* Centered above the plan cards; flex + fit-content (not inline-flex) so
|
|
940
|
+
auto margins can center it regardless of the host page's text alignment. */
|
|
941
|
+
display: flex;
|
|
942
|
+
width: fit-content;
|
|
943
|
+
margin-inline: auto;
|
|
940
944
|
gap: 0.25rem;
|
|
941
945
|
margin-bottom: 1.25rem;
|
|
942
946
|
padding: 0.25rem;
|
|
@@ -968,6 +972,60 @@
|
|
|
968
972
|
color: var(--bridge-muted, #64748b);
|
|
969
973
|
}
|
|
970
974
|
|
|
975
|
+
/* Plan-change confirmation dialog + success notice (TBP-33) */
|
|
976
|
+
.bridge-plan-success {
|
|
977
|
+
margin-bottom: 1rem;
|
|
978
|
+
}
|
|
979
|
+
|
|
980
|
+
.bridge-plan-confirm-backdrop {
|
|
981
|
+
position: fixed;
|
|
982
|
+
inset: 0;
|
|
983
|
+
background: rgba(15, 23, 42, 0.45);
|
|
984
|
+
display: grid;
|
|
985
|
+
place-items: center;
|
|
986
|
+
z-index: 1000;
|
|
987
|
+
padding: 1.25rem;
|
|
988
|
+
}
|
|
989
|
+
|
|
990
|
+
.bridge-plan-confirm {
|
|
991
|
+
background: var(--bridge-bg, #fff);
|
|
992
|
+
border: 1px solid var(--bridge-border, #e2e8f0);
|
|
993
|
+
border-radius: var(--bridge-border-radius, 0.5rem);
|
|
994
|
+
box-shadow: 0 24px 60px -12px rgba(0, 0, 0, 0.35);
|
|
995
|
+
max-width: 26rem;
|
|
996
|
+
width: 100%;
|
|
997
|
+
padding: 1.25rem 1.25rem 1rem;
|
|
998
|
+
display: flex;
|
|
999
|
+
flex-direction: column;
|
|
1000
|
+
gap: 0.75rem;
|
|
1001
|
+
}
|
|
1002
|
+
|
|
1003
|
+
.bridge-plan-confirm-title {
|
|
1004
|
+
margin: 0;
|
|
1005
|
+
font-size: 1rem;
|
|
1006
|
+
font-weight: 600;
|
|
1007
|
+
}
|
|
1008
|
+
|
|
1009
|
+
.bridge-plan-confirm-body {
|
|
1010
|
+
margin: 0;
|
|
1011
|
+
font-size: 0.875rem;
|
|
1012
|
+
line-height: 1.5;
|
|
1013
|
+
}
|
|
1014
|
+
|
|
1015
|
+
.bridge-plan-confirm-note {
|
|
1016
|
+
margin: 0;
|
|
1017
|
+
font-size: 0.8125rem;
|
|
1018
|
+
color: var(--bridge-muted, #64748b);
|
|
1019
|
+
line-height: 1.5;
|
|
1020
|
+
}
|
|
1021
|
+
|
|
1022
|
+
.bridge-plan-confirm-actions {
|
|
1023
|
+
display: flex;
|
|
1024
|
+
justify-content: flex-end;
|
|
1025
|
+
gap: 0.5rem;
|
|
1026
|
+
margin-top: 0.25rem;
|
|
1027
|
+
}
|
|
1028
|
+
|
|
971
1029
|
.bridge-plan-cards {
|
|
972
1030
|
display: grid;
|
|
973
1031
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebulr-group/bridge-svelte",
|
|
3
|
-
"version": "0.4.0
|
|
3
|
+
"version": "0.4.0",
|
|
4
4
|
"description": "Bridge Svelte library, This library helps you to add bridge authentication and feature flags, and payments to your svelte application.",
|
|
5
5
|
"author": "Iman Pouya",
|
|
6
6
|
"license": "MIT",
|
|
@@ -55,19 +55,16 @@
|
|
|
55
55
|
"peerDependencies": {
|
|
56
56
|
"svelte": "^5.0.0",
|
|
57
57
|
"@sveltejs/kit": "^2.0.0",
|
|
58
|
-
"@simplewebauthn/browser": "^13",
|
|
59
58
|
"@stripe/stripe-js": ">=4.0.0"
|
|
60
59
|
},
|
|
61
60
|
"peerDependenciesMeta": {
|
|
62
|
-
"@simplewebauthn/browser": {
|
|
63
|
-
"optional": true
|
|
64
|
-
},
|
|
65
61
|
"@stripe/stripe-js": {
|
|
66
62
|
"optional": true
|
|
67
63
|
}
|
|
68
64
|
},
|
|
69
65
|
"dependencies": {
|
|
70
|
-
"@
|
|
66
|
+
"@simplewebauthn/browser": "^13.0.0",
|
|
67
|
+
"@nebulr-group/bridge-auth-core": "0.4.0"
|
|
71
68
|
},
|
|
72
69
|
"devDependencies": {
|
|
73
70
|
"@sveltejs/kit": "^2.16.0",
|