@nebulr-group/bridge-svelte 0.3.2 → 0.4.0-beta.0

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (85) hide show
  1. package/dist/client/BridgeBootstrap.d.ts +1 -1
  2. package/dist/client/BridgeBootstrap.js +104 -27
  3. package/dist/client/BridgeBootstrap.svelte +65 -4
  4. package/dist/client/BridgeBootstrap.svelte.d.ts +2 -0
  5. package/dist/client/BridgeProvider.svelte +31 -0
  6. package/dist/client/BridgeProvider.svelte.d.ts +8 -0
  7. package/dist/client/components/developer/ApiTokenManagement.svelte +1 -1
  8. package/dist/client/components/sdk-auth/LoginForm.svelte +10 -7
  9. package/dist/client/components/sdk-auth/LoginForm.svelte.d.ts +1 -1
  10. package/dist/client/components/sdk-auth/PasskeyLogin.svelte +1 -1
  11. package/dist/client/components/sdk-auth/PasskeySetup.svelte +1 -1
  12. package/dist/client/components/sdk-auth/SsoButton.svelte +1 -1
  13. package/dist/client/components/sdk-auth/SsoButton.svelte.d.ts +1 -1
  14. package/dist/client/components/sdk-auth/TenantSelector.svelte +1 -1
  15. package/dist/client/components/sdk-auth/TenantSelector.svelte.d.ts +1 -1
  16. package/dist/client/components/sdk-auth/WorkspaceSelector.svelte +3 -3
  17. package/dist/client/components/sdk-auth/WorkspaceSelector.svelte.d.ts +3 -3
  18. package/dist/client/components/subscription/BridgeBillingNotice.svelte +389 -0
  19. package/dist/client/components/subscription/BridgeBillingNotice.svelte.d.ts +19 -0
  20. package/dist/client/components/subscription/BridgePaywall.svelte +94 -0
  21. package/dist/client/components/subscription/BridgePaywall.svelte.d.ts +19 -0
  22. package/dist/client/components/subscription/BridgeQuotaBanner.svelte +275 -0
  23. package/dist/client/components/subscription/BridgeQuotaBanner.svelte.d.ts +24 -0
  24. package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte +116 -0
  25. package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte.d.ts +7 -0
  26. package/dist/client/components/subscription/PlanSelector.svelte +22 -14
  27. package/dist/client/components/subscription/PlanSelector.svelte.d.ts +3 -3
  28. package/dist/client/components/subscription/billing-notice-gate.test.d.ts +1 -0
  29. package/dist/client/components/subscription/billing-notice-gate.test.js +83 -0
  30. package/dist/client/components/team/TeamAddUserDialog.svelte +1 -1
  31. package/dist/client/components/team/TeamAddUserDialog.svelte.d.ts +1 -1
  32. package/dist/client/components/team/TeamEditUserDialog.svelte +1 -1
  33. package/dist/client/components/team/TeamEditUserDialog.svelte.d.ts +1 -1
  34. package/dist/client/components/team/TeamProfileForm.svelte +1 -1
  35. package/dist/client/components/team/TeamUserList.svelte +1 -1
  36. package/dist/client/components/team/TeamWorkspaceForm.svelte +1 -1
  37. package/dist/client/tracking/pii-hashing.spec.js +4 -14
  38. package/dist/core/bridge-instance.d.ts +1 -2
  39. package/dist/core/bridge-instance.js +0 -4
  40. package/dist/core/bridge-runtime.d.ts +101 -0
  41. package/dist/core/bridge-runtime.js +290 -0
  42. package/dist/core/bridge-runtime.test.d.ts +1 -0
  43. package/dist/core/bridge-runtime.test.js +203 -0
  44. package/dist/core/bridge.d.ts +93 -0
  45. package/dist/core/bridge.js +108 -0
  46. package/dist/core/bridge.test.d.ts +1 -0
  47. package/dist/core/bridge.test.js +224 -0
  48. package/dist/core/events.d.ts +74 -0
  49. package/dist/core/events.js +64 -0
  50. package/dist/core/events.test.d.ts +1 -0
  51. package/dist/core/events.test.js +116 -0
  52. package/dist/core/lazy-slice.d.ts +74 -0
  53. package/dist/core/lazy-slice.js +124 -0
  54. package/dist/core/lazy-slice.test.d.ts +1 -0
  55. package/dist/core/lazy-slice.test.js +144 -0
  56. package/dist/core/realtime-status.d.ts +6 -0
  57. package/dist/core/realtime-status.js +15 -0
  58. package/dist/core/snapshot-stores.d.ts +69 -0
  59. package/dist/core/snapshot-stores.js +64 -0
  60. package/dist/core/use-bridge.d.ts +13 -0
  61. package/dist/core/use-bridge.js +46 -0
  62. package/dist/core/use-bridge.test.d.ts +1 -0
  63. package/dist/core/use-bridge.test.js +24 -0
  64. package/dist/flags/FeatureFlag.svelte +57 -0
  65. package/dist/flags/FeatureFlag.svelte.d.ts +37 -0
  66. package/dist/flags/bootstrap.d.ts +72 -0
  67. package/dist/flags/bootstrap.js +323 -0
  68. package/dist/flags/bootstrap.test.d.ts +1 -0
  69. package/dist/flags/bootstrap.test.js +195 -0
  70. package/dist/flags/flag.svelte.d.ts +31 -0
  71. package/dist/flags/flag.svelte.js +99 -0
  72. package/dist/flags/index.d.ts +8 -0
  73. package/dist/flags/index.js +20 -0
  74. package/dist/flags/index.test.d.ts +1 -0
  75. package/dist/flags/index.test.js +81 -0
  76. package/dist/flags/realtime-status.d.ts +1 -0
  77. package/dist/flags/realtime-status.js +4 -0
  78. package/dist/flags/registry.d.ts +27 -0
  79. package/dist/flags/registry.js +63 -0
  80. package/dist/flags/us13-integration.test.d.ts +1 -0
  81. package/dist/flags/us13-integration.test.js +168 -0
  82. package/dist/index.d.ts +14 -1
  83. package/dist/index.js +31 -2
  84. package/dist/styles.d.ts +2 -0
  85. package/package.json +92 -86
@@ -0,0 +1,275 @@
1
+ <!--
2
+ Billing 2.0 / Phase C / US-11 (TBP-263) — live quota counter banner.
3
+
4
+ Drop-in component that renders a live "approaching cap" / "over cap" notice
5
+ for a single metric. Reads `useBridge().quota(metric)` so initial hydration
6
+ + live `quota.updated` pushes flow into the UI without any consumer wiring.
7
+
8
+ Renders NOTHING while:
9
+ - the metric has no quota configured on the plan (server returns null), OR
10
+ - usage is below 80% of the limit (warningLevel === null).
11
+
12
+ Three visible states:
13
+ - approaching (80-94% used) → severity 'warn'
14
+ - critical (95-99% used) → severity 'critical'
15
+ - over-cap (used > limit) → severity 'critical', different copy
16
+
17
+ Two role variants (admin / member): admins get an Upgrade CTA; members get
18
+ an informational variant suggesting they contact their workspace owner.
19
+
20
+ Reuses the BridgeBillingNotice chassis (rail variant, severity tokens,
21
+ aria-live patterns).
22
+ -->
23
+ <script lang="ts">
24
+ import { onDestroy, onMount } from 'svelte';
25
+ import {
26
+ useBridge,
27
+ type QuotaSnapshot,
28
+ } from '@nebulr-group/bridge-auth-core';
29
+ import { getBridgeAuth } from '../../../core/bridge-instance.js';
30
+
31
+ type Chassis = 'rail';
32
+ type Severity = 'warn' | 'critical';
33
+
34
+ interface Props {
35
+ /** Metric key to watch (e.g. 'ai_completions', 'bridge.active_users'). */
36
+ metric: string;
37
+ /**
38
+ * Visual chassis variant. Only 'rail' is implemented in US-11; bar / card
39
+ * variants land alongside the full design pack rollout.
40
+ */
41
+ chassis?: Chassis;
42
+ /** Optional class applied to the root element. */
43
+ class?: string;
44
+ /** Override the default Upgrade CTA click handler. */
45
+ onActionClick?: (snap: QuotaSnapshot) => void;
46
+ /**
47
+ * Optional display label override. Defaults to the snapshot's `.label`
48
+ * (raw metric key for US-11; the framework wrapper will eventually
49
+ * supply a humanized label).
50
+ */
51
+ label?: string;
52
+ }
53
+
54
+ let {
55
+ metric,
56
+ chassis = 'rail',
57
+ class: className = '',
58
+ onActionClick,
59
+ label,
60
+ }: Props = $props();
61
+
62
+ // Trigger lazy hydration immediately so subscribers see the snapshot the
63
+ // moment the server responds (the store dedupes repeat calls).
64
+ let snapshot = $state<QuotaSnapshot | undefined>(useBridge().quota(metric));
65
+ let unsubscribe: (() => void) | undefined;
66
+
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.
70
+ let isBillingAdmin = $state(false);
71
+
72
+ onMount(() => {
73
+ unsubscribe = useBridge().quotas.subscribe((m, snap) => {
74
+ if (m !== metric) return;
75
+ snapshot = snap;
76
+ });
77
+ // Re-trigger hydration in case the prop changed since `$state` init.
78
+ snapshot = useBridge().quota(metric);
79
+
80
+ try {
81
+ const auth = getBridgeAuth();
82
+ const ctx = auth.getApiContext();
83
+ if (ctx.accessToken) {
84
+ isBillingAdmin = true;
85
+ }
86
+ } catch {
87
+ // No BridgeAuth instance — render the member variant.
88
+ }
89
+ });
90
+
91
+ onDestroy(() => unsubscribe?.());
92
+
93
+ // Re-hydrate when the metric prop changes mid-mount.
94
+ $effect(() => {
95
+ snapshot = useBridge().quota(metric);
96
+ });
97
+
98
+ const warningLevel = $derived(snapshot?.warningLevel ?? null);
99
+ const overCap = $derived(
100
+ snapshot ? snapshot.used > snapshot.limit : false,
101
+ );
102
+ const visible = $derived(snapshot !== undefined && warningLevel !== null);
103
+ const severity = $derived<Severity>(
104
+ warningLevel === 'critical' || overCap ? 'critical' : 'warn',
105
+ );
106
+ const displayLabel = $derived(label ?? snapshot?.label ?? metric);
107
+ const percent = $derived(
108
+ snapshot && snapshot.limit > 0
109
+ ? Math.min(100, Math.round((snapshot.used / snapshot.limit) * 100))
110
+ : 0,
111
+ );
112
+
113
+ function getCopy(
114
+ snap: QuotaSnapshot | undefined,
115
+ admin: boolean,
116
+ ): { title: string; body: string; cta?: string } {
117
+ if (!snap) return { title: '', body: '' };
118
+ const over = snap.used > snap.limit;
119
+ const remaining = Math.max(0, snap.remaining);
120
+ if (over) {
121
+ return admin
122
+ ? {
123
+ title: `${displayLabel} over cap`,
124
+ body: `You've used ${snap.used.toLocaleString()} of ${snap.limit.toLocaleString()}. Upgrade your plan to add headroom.`,
125
+ cta: 'Upgrade',
126
+ }
127
+ : {
128
+ title: `${displayLabel} over cap`,
129
+ body: `Your workspace is over its ${displayLabel} cap. Contact your workspace owner.`,
130
+ };
131
+ }
132
+ if (warningLevel === 'critical') {
133
+ return admin
134
+ ? {
135
+ title: `${displayLabel} near cap`,
136
+ body: `You've used ${snap.used.toLocaleString()} of ${snap.limit.toLocaleString()} (${remaining.toLocaleString()} left). Upgrade to avoid hitting the cap.`,
137
+ cta: 'Upgrade',
138
+ }
139
+ : {
140
+ title: `${displayLabel} near cap`,
141
+ body: `Your workspace is approaching its ${displayLabel} cap. Contact your workspace owner.`,
142
+ };
143
+ }
144
+ // approaching
145
+ return admin
146
+ ? {
147
+ title: `${displayLabel} approaching cap`,
148
+ body: `You've used ${snap.used.toLocaleString()} of ${snap.limit.toLocaleString()} (${remaining.toLocaleString()} left).`,
149
+ cta: 'Upgrade',
150
+ }
151
+ : {
152
+ title: `${displayLabel} approaching cap`,
153
+ body: `Your workspace is approaching its ${displayLabel} cap.`,
154
+ };
155
+ }
156
+
157
+ const copy = $derived(getCopy(snapshot, isBillingAdmin));
158
+
159
+ function handleAction() {
160
+ if (!snapshot) return;
161
+ if (onActionClick) {
162
+ onActionClick(snapshot);
163
+ return;
164
+ }
165
+ if (typeof window !== 'undefined') {
166
+ window.location.href = '/billing';
167
+ }
168
+ }
169
+ </script>
170
+
171
+ {#if visible && snapshot}
172
+ <div
173
+ class={`bridge-quota-banner bqb-chassis-${chassis} bqb-severity-${severity} ${className}`}
174
+ role={severity === 'critical' ? 'alert' : 'status'}
175
+ aria-live={severity === 'critical' ? 'assertive' : 'polite'}
176
+ aria-label={`${displayLabel} quota ${percent}% used`}
177
+ >
178
+ <div class="bqb-content">
179
+ <strong class="bqb-title">{copy.title}</strong>
180
+ <span class="bqb-body">{copy.body}</span>
181
+ <div class="bqb-meter" aria-hidden="true">
182
+ <div class="bqb-meter-fill" style={`width: ${Math.min(100, percent)}%`}></div>
183
+ </div>
184
+ </div>
185
+ {#if copy.cta && isBillingAdmin}
186
+ <button type="button" class="bqb-cta" onclick={handleAction}>{copy.cta}</button>
187
+ {/if}
188
+ </div>
189
+ {/if}
190
+
191
+ <style>
192
+ .bridge-quota-banner {
193
+ display: flex;
194
+ align-items: center;
195
+ justify-content: space-between;
196
+ gap: 1rem;
197
+ padding: 0.75rem 1rem;
198
+ border-radius: 0.5rem;
199
+ font: inherit;
200
+ transition: opacity 280ms ease, transform 320ms ease;
201
+ }
202
+
203
+ .bqb-content {
204
+ display: flex;
205
+ flex-direction: column;
206
+ gap: 0.25rem;
207
+ flex: 1 1 auto;
208
+ }
209
+
210
+ .bqb-title {
211
+ font-weight: 600;
212
+ }
213
+
214
+ .bqb-body {
215
+ font-size: 0.875rem;
216
+ opacity: 0.85;
217
+ }
218
+
219
+ .bqb-meter {
220
+ margin-top: 0.25rem;
221
+ width: 100%;
222
+ height: 0.375rem;
223
+ background: rgba(0, 0, 0, 0.08);
224
+ border-radius: 999px;
225
+ overflow: hidden;
226
+ }
227
+
228
+ .bqb-meter-fill {
229
+ height: 100%;
230
+ background: currentColor;
231
+ border-radius: 999px;
232
+ transition: width 320ms ease;
233
+ }
234
+
235
+ .bqb-cta {
236
+ padding: 0.5rem 1rem;
237
+ border-radius: 0.375rem;
238
+ border: 1px solid currentColor;
239
+ background: transparent;
240
+ color: inherit;
241
+ font: inherit;
242
+ font-weight: 500;
243
+ cursor: pointer;
244
+ flex: 0 0 auto;
245
+ }
246
+
247
+ .bqb-cta:hover {
248
+ background: currentColor;
249
+ color: white;
250
+ }
251
+
252
+ /* Chassis — rail only for US-11. */
253
+ .bqb-chassis-rail {
254
+ border-left: 4px solid currentColor;
255
+ border-radius: 0 0.5rem 0.5rem 0;
256
+ }
257
+
258
+ /* Severity tokens — mirror BridgeBillingNotice for visual consistency. */
259
+ .bqb-severity-warn {
260
+ background: #fef3c7;
261
+ color: #92400e;
262
+ }
263
+
264
+ .bqb-severity-critical {
265
+ background: #fee2e2;
266
+ color: #991b1b;
267
+ }
268
+
269
+ @media (prefers-reduced-motion: reduce) {
270
+ .bridge-quota-banner,
271
+ .bqb-meter-fill {
272
+ transition: none;
273
+ }
274
+ }
275
+ </style>
@@ -0,0 +1,24 @@
1
+ import { type QuotaSnapshot } from '@nebulr-group/bridge-auth-core';
2
+ type Chassis = 'rail';
3
+ interface Props {
4
+ /** Metric key to watch (e.g. 'ai_completions', 'bridge.active_users'). */
5
+ metric: string;
6
+ /**
7
+ * Visual chassis variant. Only 'rail' is implemented in US-11; bar / card
8
+ * variants land alongside the full design pack rollout.
9
+ */
10
+ chassis?: Chassis;
11
+ /** Optional class applied to the root element. */
12
+ class?: string;
13
+ /** Override the default Upgrade CTA click handler. */
14
+ onActionClick?: (snap: QuotaSnapshot) => void;
15
+ /**
16
+ * Optional display label override. Defaults to the snapshot's `.label`
17
+ * (raw metric key for US-11; the framework wrapper will eventually
18
+ * supply a humanized label).
19
+ */
20
+ label?: string;
21
+ }
22
+ declare const BridgeQuotaBanner: import("svelte").Component<Props, {}, "">;
23
+ type BridgeQuotaBanner = ReturnType<typeof BridgeQuotaBanner>;
24
+ export default BridgeQuotaBanner;
@@ -0,0 +1,116 @@
1
+ <!--
2
+ Billing 2.0 / Phase A / US-2 (TBP-248).
3
+
4
+ Drop-in component that renders the workspace's current canonical plan name
5
+ + subscription status. Reads `useBridge().subscription` (auth-core) which
6
+ is the new billing-specific reactive surface — parallel to FF 2.0's
7
+ `BridgeFlags`. Do NOT confuse with the older `<PlanSelector />` which
8
+ consumes the Stripe-direct path via `subscriptionStore`.
9
+
10
+ No live push in v1 — that's US-3. Fetches once on mount.
11
+ -->
12
+ <script lang="ts">
13
+ import { onDestroy, onMount } from 'svelte';
14
+ import {
15
+ useBridge,
16
+ type BillingSubscriptionSnapshot,
17
+ } from '@nebulr-group/bridge-auth-core';
18
+ import { getBridgeAuth } from '../../../core/bridge-instance.js';
19
+
20
+ interface Props {
21
+ /** Optional class applied to the root span. */
22
+ class?: string;
23
+ }
24
+
25
+ let { class: className = '' }: Props = $props();
26
+
27
+ let snapshot = $state<BillingSubscriptionSnapshot>(useBridge().subscription.snapshot());
28
+ let unsubscribe: (() => void) | undefined;
29
+
30
+ onMount(() => {
31
+ unsubscribe = useBridge().subscription.subscribe((snap) => {
32
+ snapshot = snap;
33
+ });
34
+
35
+ const ctx = getBridgeAuth().getApiContext();
36
+ if (!ctx.accessToken) {
37
+ useBridge().subscription.setError('Not authenticated');
38
+ return;
39
+ }
40
+ useBridge().subscription.mount({
41
+ apiBaseUrl: ctx.apiBaseUrl,
42
+ accessToken: ctx.accessToken,
43
+ appId: ctx.appId,
44
+ });
45
+ });
46
+
47
+ onDestroy(() => unsubscribe?.());
48
+ </script>
49
+
50
+ <span class={`bridge-subscription-status ${className}`}>
51
+ {#if snapshot.loading}
52
+ <span class="bss-loading">Loading…</span>
53
+ {:else if snapshot.error}
54
+ <span class="bss-error">Subscription unavailable</span>
55
+ {:else if snapshot.state}
56
+ <span class="bss-plan">{snapshot.state.plan.name}</span>
57
+ <span class={`bss-badge bss-badge-${snapshot.state.status}`}>{snapshot.state.status}</span>
58
+ {:else}
59
+ <span class="bss-empty">No subscription</span>
60
+ {/if}
61
+ </span>
62
+
63
+ <style>
64
+ .bridge-subscription-status {
65
+ display: inline-flex;
66
+ align-items: center;
67
+ gap: 0.5rem;
68
+ font: inherit;
69
+ }
70
+
71
+ .bss-plan {
72
+ font-weight: 600;
73
+ }
74
+
75
+ .bss-badge {
76
+ display: inline-block;
77
+ padding: 0.125rem 0.5rem;
78
+ border-radius: 9999px;
79
+ font-size: 0.75rem;
80
+ font-weight: 500;
81
+ text-transform: lowercase;
82
+ background: #e5e7eb;
83
+ color: #374151;
84
+ }
85
+
86
+ .bss-badge-active {
87
+ background: #d1fae5;
88
+ color: #065f46;
89
+ }
90
+
91
+ .bss-badge-trial {
92
+ background: #dbeafe;
93
+ color: #1e40af;
94
+ }
95
+
96
+ .bss-badge-past_due,
97
+ .bss-badge-cancel_at_period_end {
98
+ background: #fef3c7;
99
+ color: #92400e;
100
+ }
101
+
102
+ .bss-badge-canceled {
103
+ background: #fee2e2;
104
+ color: #991b1b;
105
+ }
106
+
107
+ .bss-loading,
108
+ .bss-empty {
109
+ color: #6b7280;
110
+ font-style: italic;
111
+ }
112
+
113
+ .bss-error {
114
+ color: #b91c1c;
115
+ }
116
+ </style>
@@ -0,0 +1,7 @@
1
+ interface Props {
2
+ /** Optional class applied to the root span. */
3
+ class?: string;
4
+ }
5
+ declare const BridgeSubscriptionStatus: import("svelte").Component<Props, {}, "">;
6
+ type BridgeSubscriptionStatus = ReturnType<typeof BridgeSubscriptionStatus>;
7
+ export default BridgeSubscriptionStatus;
@@ -2,14 +2,15 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
4
  import { onMount } from 'svelte';
5
- import type { Plan, PriceOfferSdk } from '@thebridge/auth-core';
5
+ import type { Plan, PriceOfferSdk } from '@nebulr-group/bridge-auth-core';
6
6
  import { getBridgeAuth, loadSubscription, subscriptionStore } from '../../../core/bridge-instance.js';
7
+ import { getConfig } from '../../stores/config.store.js';
7
8
  import Alert from '../sdk-auth/shared/Alert.svelte';
8
9
  import Spinner from '../sdk-auth/shared/Spinner.svelte';
9
10
 
10
11
  interface Props extends HTMLAttributes<HTMLDivElement> {
11
- successUrl: string;
12
- cancelUrl: string;
12
+ successRedirect?: string;
13
+ cancelRedirect?: string;
13
14
  onSelect?: (detail: { plan: Plan; price: PriceOfferSdk }) => void;
14
15
  planCard?: Snippet<[{ plan: Plan; prices: PriceOfferSdk[]; isCurrent: boolean; onPick: (price: PriceOfferSdk) => void }]>;
15
16
  emptyState?: Snippet;
@@ -17,8 +18,8 @@
17
18
  }
18
19
 
19
20
  let {
20
- successUrl,
21
- cancelUrl,
21
+ successRedirect = '/subscription',
22
+ cancelRedirect = '/subscription',
22
23
  onSelect,
23
24
  planCard,
24
25
  emptyState,
@@ -48,7 +49,12 @@
48
49
  return 'select-plan';
49
50
  })());
50
51
 
51
- const currentPlanKey = $derived(status?.plan?.key ?? null);
52
+ // status.plan is a Plan object from the REST endpoint; a string from JWT-derived paths.
53
+ const currentPlanKey = $derived(
54
+ status?.plan
55
+ ? typeof status.plan === 'string' ? status.plan : (status.plan as Plan).key
56
+ : null
57
+ );
52
58
 
53
59
  onMount(() => {
54
60
  // Only load if not already loaded
@@ -73,16 +79,19 @@
73
79
  onSelect?.({ plan, price });
74
80
  } else {
75
81
  // Needs checkout — redirect to Stripe (or direct plan set when Stripe not configured)
82
+ const base = getConfig().callbackUrl ?? `${window.location.origin}/auth/oauth-callback`;
83
+ // Use {CHECKOUT_SESSION_ID} placeholder — Stripe substitutes it in-place,
84
+ // avoiding the double-? bug that occurs when Stripe appends ?session_id= to a URL that already has query params.
85
+ const successUrl = `${base}?stripe_success=1&session_id={CHECKOUT_SESSION_ID}&redirect=${encodeURIComponent(successRedirect)}`;
86
+ const cancelUrl = `${base}?stripe_cancel=1&redirect=${encodeURIComponent(cancelRedirect)}`;
76
87
  const session = await getBridgeAuth().startCheckout(plan.key, price, { successUrl, cancelUrl });
77
88
  if (session.sessionId === null) {
78
89
  // Stripe not configured — plan was set directly on the backend
79
90
  await loadSubscription();
80
91
  onSelect?.({ plan, price });
81
92
  } else {
82
- const { loadStripe } = await import('@stripe/stripe-js');
83
- const stripe = await loadStripe(session.publicKey);
84
- if (!stripe) throw new Error('Failed to load Stripe');
85
- await stripe.redirectToCheckout({ sessionId: session.sessionId });
93
+ if (!session.checkoutUrl) throw new Error('Checkout session URL missing');
94
+ window.location.href = session.checkoutUrl;
86
95
  }
87
96
  }
88
97
  } catch (err) {
@@ -94,8 +103,7 @@
94
103
 
95
104
  async function goToPortal(): Promise<void> {
96
105
  try {
97
- const url = await getBridgeAuth().getPortalUrl();
98
- window.location.href = url;
106
+ await getBridgeAuth().redirectToPlanSelection();
99
107
  } catch (err) {
100
108
  pickError = err instanceof Error ? err.message : 'Failed to open billing portal';
101
109
  }
@@ -159,7 +167,7 @@
159
167
  >
160
168
  <div class="bridge-plan-card-header">
161
169
  <h3 class="bridge-plan-name">{plan.name}</h3>
162
- {#if plan.trial && plan.trialDays > 0}
170
+ {#if plan.trial && (plan.trialDays ?? 0) > 0}
163
171
  <span class="bridge-plan-trial-badge">{plan.trialDays}-day trial</span>
164
172
  {/if}
165
173
  </div>
@@ -189,7 +197,7 @@
189
197
  <button
190
198
  class="bridge-btn-primary bridge-plan-select-btn"
191
199
  disabled={isCurrent || picking}
192
- onclick={() => handlePick(plan, { amount: 0, currency: 'usd', recurrenceInterval: 'month' })}
200
+ onclick={() => handlePick(plan, { id: '', amount: 0, currency: 'usd', recurrenceInterval: 'month' })}
193
201
  >
194
202
  {isCurrent ? 'Current plan' : 'Select plan'}
195
203
  </button>
@@ -1,9 +1,9 @@
1
1
  import type { Snippet } from 'svelte';
2
2
  import type { HTMLAttributes } from 'svelte/elements';
3
- import type { Plan, PriceOfferSdk } from '@thebridge/auth-core';
3
+ import type { Plan, PriceOfferSdk } from '@nebulr-group/bridge-auth-core';
4
4
  interface Props extends HTMLAttributes<HTMLDivElement> {
5
- successUrl: string;
6
- cancelUrl: string;
5
+ successRedirect?: string;
6
+ cancelRedirect?: string;
7
7
  onSelect?: (detail: {
8
8
  plan: Plan;
9
9
  price: PriceOfferSdk;
@@ -0,0 +1,83 @@
1
+ // Billing 2.0 soft gate — <BridgeBillingNotice mode="hard"> rendering decision.
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
+ // happy-dom / @testing-library/svelte are NOT installed anywhere in the
6
+ // workspace). The established UI-rendering pattern for this plugin is Playwright
7
+ // E2E (e2e/playwright/tests/subscription/*.spec.ts), which drives a live demo
8
+ // app against a running bridge-api — incompatible with the "mocked, no
9
+ // bridge-api" requirement for this gate logic.
10
+ //
11
+ // So this test exercises the EXACT rendering decision the component derives,
12
+ // using the same auth-core primitives the component imports, against a real
13
+ // BridgeSubscription store flipped with mocked snapshots / markLocked():
14
+ //
15
+ // visible = snapshot.state !== null && noticeState !== 'active'
16
+ // asLockscreen = mode === 'hard' && severity === 'locked'
17
+ // inline = visible && !asLockscreen (.bridge-billing-notice)
18
+ // lockscreen = visible && asLockscreen (.bridge-billing-lockscreen)
19
+ //
20
+ // The component source these mirror is BridgeBillingNotice.svelte (the
21
+ // `{#if visible}{#if asLockscreen}` template block).
22
+ import { describe, it, expect, beforeEach } from 'vitest';
23
+ import { BridgeSubscription, deriveNoticeState, deriveSeverity, } from '@nebulr-group/bridge-auth-core';
24
+ /**
25
+ * Pure replica of the component's render decision. Given a snapshot + mode,
26
+ * returns which root element (if any) BridgeBillingNotice renders.
27
+ */
28
+ function renderTarget(snapshot, mode) {
29
+ const noticeState = deriveNoticeState(snapshot.state);
30
+ const severity = deriveSeverity(noticeState);
31
+ const visible = snapshot.state !== null && noticeState !== 'active';
32
+ const asLockscreen = mode === 'hard' && severity === 'locked';
33
+ if (!visible)
34
+ return 'none';
35
+ return asLockscreen ? 'lockscreen' : 'notice';
36
+ }
37
+ const LOCKED_PAYLOAD = {
38
+ reason: 'billing_locked',
39
+ billing: { status: 'past_due', gateEngaged: true, recoveryUrl: '/billing' },
40
+ };
41
+ const ACTIVE_STATE = {
42
+ plan: { slug: 'pro', name: 'Pro' },
43
+ status: 'active',
44
+ };
45
+ describe('BridgeBillingNotice gate decision (mode=hard vs soft)', () => {
46
+ let sub;
47
+ beforeEach(() => {
48
+ sub = new BridgeSubscription();
49
+ });
50
+ describe('when the subscription store is locked (gateEngaged, severity locked)', () => {
51
+ beforeEach(() => {
52
+ sub.markLocked(LOCKED_PAYLOAD);
53
+ // Sanity: the store really is in the locked/severity=locked shape.
54
+ const snap = sub.snapshot();
55
+ expect(snap.state?.gateEngaged).toBe(true);
56
+ expect(deriveSeverity(deriveNoticeState(snap.state))).toBe('locked');
57
+ });
58
+ it('mode="hard" renders the full-screen lockscreen (.bridge-billing-lockscreen)', () => {
59
+ expect(renderTarget(sub.snapshot(), 'hard')).toBe('lockscreen');
60
+ });
61
+ it('mode="hard" does NOT render the inline .bridge-billing-notice', () => {
62
+ expect(renderTarget(sub.snapshot(), 'hard')).not.toBe('notice');
63
+ });
64
+ it('mode="soft" (default) renders the inline banner even when locked', () => {
65
+ expect(renderTarget(sub.snapshot(), 'soft')).toBe('notice');
66
+ });
67
+ it('mode="soft" does NOT render the lockscreen when locked', () => {
68
+ expect(renderTarget(sub.snapshot(), 'soft')).not.toBe('lockscreen');
69
+ });
70
+ });
71
+ describe('when not locked', () => {
72
+ it('renders neither lockscreen nor inline notice on a null/unmounted state', () => {
73
+ // Fresh store: state === null → not visible.
74
+ expect(renderTarget(sub.snapshot(), 'hard')).toBe('none');
75
+ expect(renderTarget(sub.snapshot(), 'soft')).toBe('none');
76
+ });
77
+ it('renders neither for an active subscription (noticeState === active)', () => {
78
+ sub.hydrate(ACTIVE_STATE);
79
+ expect(renderTarget(sub.snapshot(), 'hard')).toBe('none');
80
+ expect(renderTarget(sub.snapshot(), 'soft')).toBe('none');
81
+ });
82
+ });
83
+ });
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { TeamUser } from '@thebridge/auth-core';
2
+ import type { TeamUser } from '@nebulr-group/bridge-auth-core';
3
3
  import type { Snippet } from 'svelte';
4
4
  import type { HTMLDialogAttributes } from 'svelte/elements';
5
5
  import { getBridgeAuth } from '../../../core/bridge-instance.js';
@@ -1,4 +1,4 @@
1
- import type { TeamUser } from '@thebridge/auth-core';
1
+ import type { TeamUser } from '@nebulr-group/bridge-auth-core';
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLDialogAttributes } from 'svelte/elements';
4
4
  interface Props extends HTMLDialogAttributes {
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { TeamUser } from '@thebridge/auth-core';
2
+ import type { TeamUser } from '@nebulr-group/bridge-auth-core';
3
3
  import type { Snippet } from 'svelte';
4
4
  import type { HTMLDialogAttributes } from 'svelte/elements';
5
5
  import { getBridgeAuth } from '../../../core/bridge-instance.js';
@@ -1,4 +1,4 @@
1
- import type { TeamUser } from '@thebridge/auth-core';
1
+ import type { TeamUser } from '@nebulr-group/bridge-auth-core';
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLDialogAttributes } from 'svelte/elements';
4
4
  interface Props extends HTMLDialogAttributes {
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { TeamProfile } from '@thebridge/auth-core';
2
+ import type { TeamProfile } from '@nebulr-group/bridge-auth-core';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
4
  import { onMount } from 'svelte';
5
5
  import { getBridgeAuth } from '../../../core/bridge-instance.js';
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { TeamUser } from '@thebridge/auth-core';
2
+ import type { TeamUser } from '@nebulr-group/bridge-auth-core';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
4
  import { onMount } from 'svelte';
5
5
  import { getBridgeAuth } from '../../../core/bridge-instance.js';
@@ -1,5 +1,5 @@
1
1
  <script lang="ts">
2
- import type { TeamWorkspace } from '@thebridge/auth-core';
2
+ import type { TeamWorkspace } from '@nebulr-group/bridge-auth-core';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
4
  import { onMount } from 'svelte';
5
5
  import { getBridgeAuth } from '../../../core/bridge-instance.js';