@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.
@@ -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 — verify with bridge-api (server calls Stripe directly),
71
- // then refresh tokens so the new JWT has shouldSelectPlan: false before redirect.
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
- const res = await (kitFetch ?? fetch)(`${ctx.apiBaseUrl}/v1/account/stripe/confirm-checkout`, {
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. Reads shouldSelectPlan
121
- // and paymentsAutoRedirect from getSubscriptionStatus(). Only redirects when:
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.isAuthenticated()) {
131
- const status = await bridge.getSubscriptionStatus();
132
- if (status?.shouldSelectPlan === true && status?.paymentsAutoRedirect !== false) {
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 — read from existing auth context. The exact field
56
- // name varies by SDK; we look up `billing` privilege on the current user.
57
- // Defaults to non-admin if unavailable.
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
- // Best-effort role read. Existing auth-core role probing varies by app;
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 existing billing surface. The exact destination
206
- // depends on app configuration — for now, navigate to /billing on the
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
- window.location.href = '/billing';
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: any authenticated user is
68
- // treated as a billing admin for US-11 until the role/privilege API is
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
- const auth = getBridgeAuth();
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
- window.location.href = '/billing';
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: `'month'`.
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 = 'month',
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 — change plan
136
- await getBridgeAuth().changePlan(plan.key, price);
137
- await loadSubscription();
138
- onSelect?.({ plan, price });
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 plan.description}
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: `'month'`.
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,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
+ });
@@ -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
- vi.mock('./bridge-instance.js', () => ({
38
- getBridgeAuth: () => ({
39
- getPlans: async () => [
40
- { key: 'free', name: 'Free' },
41
- { key: 'pro', name: 'Pro' },
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
- vi.mock('../core/bridge-instance.js', () => {
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
- get tokenStore() {
43
- return _tokenStore;
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 = writable(null);
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.7",
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
- "@nebulr-group/bridge-auth-core": "0.4.0-beta.8"
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": "^5.0.0",
77
+ "typescript": "^6.0.0",
81
78
  "vite": "^6.2.6",
82
79
  "vitest": "^4.1.4"
83
80
  },