@nebulr-group/bridge-svelte 0.4.0-beta.7 → 0.4.0-beta.9
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/PasskeyLogin.svelte +1 -1
- package/dist/client/components/sdk-auth/PasskeySetup.svelte +1 -1
- 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 +18 -16
- package/dist/client/components/subscription/BridgeBillingNotice.svelte.d.ts +3 -0
- package/dist/client/components/subscription/BridgeQuotaBanner.svelte +17 -9
- 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/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 +54 -0
- package/package.json +4 -7
|
@@ -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
|
}
|
|
@@ -40,7 +40,7 @@
|
|
|
40
40
|
|
|
41
41
|
let authResponse: any;
|
|
42
42
|
if (typeof window !== 'undefined' && (window as any).__simpleWebAuthn?.startAuthentication) {
|
|
43
|
-
authResponse = await (window as any).__simpleWebAuthn.startAuthentication(options, autofill);
|
|
43
|
+
authResponse = await (window as any).__simpleWebAuthn.startAuthentication({ optionsJSON: options, useBrowserAutofill: autofill });
|
|
44
44
|
} else {
|
|
45
45
|
// @ts-ignore — @simplewebauthn/browser is an optional peer dependency
|
|
46
46
|
const mod = await import('@simplewebauthn/browser');
|
|
@@ -54,7 +54,7 @@
|
|
|
54
54
|
|
|
55
55
|
let regResponse: any;
|
|
56
56
|
if (typeof window !== 'undefined' && (window as any).__simpleWebAuthn?.startRegistration) {
|
|
57
|
-
regResponse = await (window as any).__simpleWebAuthn.startRegistration(options);
|
|
57
|
+
regResponse = await (window as any).__simpleWebAuthn.startRegistration({ optionsJSON: options });
|
|
58
58
|
} else {
|
|
59
59
|
// @ts-ignore — @simplewebauthn/browser is an optional peer dependency
|
|
60
60
|
const mod = await import('@simplewebauthn/browser');
|
|
@@ -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,14 +51,15 @@
|
|
|
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());
|
|
53
58
|
let unsubscribe: (() => void) | undefined;
|
|
54
59
|
|
|
55
|
-
// Admin/member variant
|
|
56
|
-
//
|
|
57
|
-
//
|
|
60
|
+
// Admin/member variant. v1 policy: workspace owner only, via
|
|
61
|
+
// canManageBilling() (immutable OWNER role key). Defaults to non-admin if
|
|
62
|
+
// unavailable.
|
|
58
63
|
let isBillingAdmin = $state(false);
|
|
59
64
|
|
|
60
65
|
onMount(() => {
|
|
@@ -65,15 +70,7 @@
|
|
|
65
70
|
const auth = getBridgeAuth();
|
|
66
71
|
const ctx = auth.getApiContext();
|
|
67
72
|
if (ctx.accessToken) {
|
|
68
|
-
|
|
69
|
-
// defensive cast so the component never crashes on missing fields.
|
|
70
|
-
const tokenManager = (auth as unknown as { tokenManager?: { getTokens?: () => { accessToken?: string } } }).tokenManager;
|
|
71
|
-
void tokenManager; // intentionally unused — placeholder until role API is finalized
|
|
72
|
-
// For US-5 default: assume admin if a JWT is present. Subsequent
|
|
73
|
-
// stories will read the exact privilege claim. Keeping permissive so
|
|
74
|
-
// the CTA shows by default; member variant lands when the role check
|
|
75
|
-
// is wired through.
|
|
76
|
-
isBillingAdmin = true;
|
|
73
|
+
isBillingAdmin = auth.canManageBilling();
|
|
77
74
|
}
|
|
78
75
|
|
|
79
76
|
if (!ctx.accessToken) {
|
|
@@ -202,11 +199,16 @@
|
|
|
202
199
|
onActionClick(noticeState);
|
|
203
200
|
return;
|
|
204
201
|
}
|
|
205
|
-
// Default: open the
|
|
206
|
-
//
|
|
207
|
-
// current origin. Apps can override via `onActionClick`.
|
|
202
|
+
// Default: open the app's billing surface. Destination priority:
|
|
203
|
+
// `actionHref` prop → `billing.manageRoute` config → '/billing'.
|
|
208
204
|
if (typeof window !== 'undefined') {
|
|
209
|
-
|
|
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';
|
|
210
212
|
}
|
|
211
213
|
}
|
|
212
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
|
|
|
@@ -64,9 +69,8 @@
|
|
|
64
69
|
let snapshot = $state<QuotaSnapshot | undefined>(useBridge().quota(metric));
|
|
65
70
|
let unsubscribe: (() => void) | undefined;
|
|
66
71
|
|
|
67
|
-
// Role variant mirrors BridgeBillingNotice:
|
|
68
|
-
//
|
|
69
|
-
// finalized. Member variant lands when the role probe is wired through.
|
|
72
|
+
// Role variant mirrors BridgeBillingNotice: CTA only for the workspace
|
|
73
|
+
// owner (v1 canManageBilling() policy). Member variant renders otherwise.
|
|
70
74
|
let isBillingAdmin = $state(false);
|
|
71
75
|
|
|
72
76
|
onMount(() => {
|
|
@@ -78,11 +82,7 @@
|
|
|
78
82
|
snapshot = useBridge().quota(metric);
|
|
79
83
|
|
|
80
84
|
try {
|
|
81
|
-
|
|
82
|
-
const ctx = auth.getApiContext();
|
|
83
|
-
if (ctx.accessToken) {
|
|
84
|
-
isBillingAdmin = true;
|
|
85
|
-
}
|
|
85
|
+
isBillingAdmin = getBridgeAuth().canManageBilling();
|
|
86
86
|
} catch {
|
|
87
87
|
// No BridgeAuth instance — render the member variant.
|
|
88
88
|
}
|
|
@@ -223,8 +223,16 @@
|
|
|
223
223
|
onActionClick(snapshot);
|
|
224
224
|
return;
|
|
225
225
|
}
|
|
226
|
+
// Destination priority: `actionHref` prop → `billing.manageRoute` config
|
|
227
|
+
// → '/billing'.
|
|
226
228
|
if (typeof window !== 'undefined') {
|
|
227
|
-
|
|
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';
|
|
228
236
|
}
|
|
229
237
|
}
|
|
230
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,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
|
@@ -968,6 +968,60 @@
|
|
|
968
968
|
color: var(--bridge-muted, #64748b);
|
|
969
969
|
}
|
|
970
970
|
|
|
971
|
+
/* Plan-change confirmation dialog + success notice (TBP-33) */
|
|
972
|
+
.bridge-plan-success {
|
|
973
|
+
margin-bottom: 1rem;
|
|
974
|
+
}
|
|
975
|
+
|
|
976
|
+
.bridge-plan-confirm-backdrop {
|
|
977
|
+
position: fixed;
|
|
978
|
+
inset: 0;
|
|
979
|
+
background: rgba(15, 23, 42, 0.45);
|
|
980
|
+
display: grid;
|
|
981
|
+
place-items: center;
|
|
982
|
+
z-index: 1000;
|
|
983
|
+
padding: 1.25rem;
|
|
984
|
+
}
|
|
985
|
+
|
|
986
|
+
.bridge-plan-confirm {
|
|
987
|
+
background: var(--bridge-bg, #fff);
|
|
988
|
+
border: 1px solid var(--bridge-border, #e2e8f0);
|
|
989
|
+
border-radius: var(--bridge-border-radius, 0.5rem);
|
|
990
|
+
box-shadow: 0 24px 60px -12px rgba(0, 0, 0, 0.35);
|
|
991
|
+
max-width: 26rem;
|
|
992
|
+
width: 100%;
|
|
993
|
+
padding: 1.25rem 1.25rem 1rem;
|
|
994
|
+
display: flex;
|
|
995
|
+
flex-direction: column;
|
|
996
|
+
gap: 0.75rem;
|
|
997
|
+
}
|
|
998
|
+
|
|
999
|
+
.bridge-plan-confirm-title {
|
|
1000
|
+
margin: 0;
|
|
1001
|
+
font-size: 1rem;
|
|
1002
|
+
font-weight: 600;
|
|
1003
|
+
}
|
|
1004
|
+
|
|
1005
|
+
.bridge-plan-confirm-body {
|
|
1006
|
+
margin: 0;
|
|
1007
|
+
font-size: 0.875rem;
|
|
1008
|
+
line-height: 1.5;
|
|
1009
|
+
}
|
|
1010
|
+
|
|
1011
|
+
.bridge-plan-confirm-note {
|
|
1012
|
+
margin: 0;
|
|
1013
|
+
font-size: 0.8125rem;
|
|
1014
|
+
color: var(--bridge-muted, #64748b);
|
|
1015
|
+
line-height: 1.5;
|
|
1016
|
+
}
|
|
1017
|
+
|
|
1018
|
+
.bridge-plan-confirm-actions {
|
|
1019
|
+
display: flex;
|
|
1020
|
+
justify-content: flex-end;
|
|
1021
|
+
gap: 0.5rem;
|
|
1022
|
+
margin-top: 0.25rem;
|
|
1023
|
+
}
|
|
1024
|
+
|
|
971
1025
|
.bridge-plan-cards {
|
|
972
1026
|
display: grid;
|
|
973
1027
|
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-beta.
|
|
3
|
+
"version": "0.4.0-beta.9",
|
|
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": ">=10.0.0",
|
|
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-beta.10"
|
|
71
68
|
},
|
|
72
69
|
"devDependencies": {
|
|
73
70
|
"@sveltejs/kit": "^2.16.0",
|
|
@@ -77,7 +74,7 @@
|
|
|
77
74
|
"publint": "^0.3.2",
|
|
78
75
|
"svelte": "^5.0.0",
|
|
79
76
|
"svelte-check": "^4.0.0",
|
|
80
|
-
"typescript": "^
|
|
77
|
+
"typescript": "^6.0.0",
|
|
81
78
|
"vite": "^6.2.6",
|
|
82
79
|
"vitest": "^4.1.4"
|
|
83
80
|
},
|