@nebulr-group/bridge-svelte 0.9.0-beta.7 → 0.9.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 (42) hide show
  1. package/dist/client/BridgeBootstrap.js +13 -0
  2. package/dist/client/BridgeBootstrap.svelte +9 -0
  3. package/dist/client/billing-role.d.ts +3 -2
  4. package/dist/client/billing-role.js +4 -1
  5. package/dist/client/components/subscription/BridgeBillingNotice.svelte +4 -7
  6. package/dist/client/components/subscription/BridgeBillingRoutes.svelte +11 -11
  7. package/dist/client/components/subscription/BridgePaywall.svelte +3 -4
  8. package/dist/client/components/subscription/BridgePaywallPage.svelte +3 -6
  9. package/dist/client/components/subscription/BridgeQuotaBanner.svelte +11 -111
  10. package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte +37 -20
  11. package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte.d.ts +2 -0
  12. package/dist/client/components/subscription/Entitled.svelte +11 -2
  13. package/dist/client/components/subscription/PlanSelector.svelte +47 -35
  14. package/dist/client/components/subscription/plan-pick.d.ts +37 -0
  15. package/dist/client/components/subscription/plan-pick.js +47 -0
  16. package/dist/client/components/subscription/subscription-badge.d.ts +14 -0
  17. package/dist/client/components/subscription/subscription-badge.js +17 -0
  18. package/dist/client/components/team/TeamAddUserDialog.svelte +14 -0
  19. package/dist/client/components/team/TeamAddUserDialog.svelte.d.ts +6 -0
  20. package/dist/client/components/team/TeamManagementPanel.svelte +8 -1
  21. package/dist/client/components/team/TeamManagementPanel.svelte.d.ts +6 -0
  22. package/dist/client/components/team/TeamUserList.svelte +47 -6
  23. package/dist/client/components/team/TeamUserList.svelte.d.ts +6 -0
  24. package/dist/client/components/team/seats.d.ts +8 -0
  25. package/dist/client/components/team/seats.js +32 -0
  26. package/dist/client/quota-banner-copy.d.ts +12 -0
  27. package/dist/client/quota-banner-copy.js +86 -0
  28. package/dist/core/billing-store.d.ts +58 -0
  29. package/dist/core/billing-store.js +298 -0
  30. package/dist/core/bridge-instance.d.ts +15 -0
  31. package/dist/core/bridge-instance.js +133 -12
  32. package/dist/core/bridge-runtime.js +27 -11
  33. package/dist/core/direct-plan-check-note.d.ts +4 -0
  34. package/dist/core/direct-plan-check-note.js +35 -0
  35. package/dist/core/entitlements.d.ts +5 -0
  36. package/dist/core/entitlements.js +11 -1
  37. package/dist/core/use-quota.d.ts +1 -1
  38. package/dist/core/use-quota.js +1 -1
  39. package/dist/flags/bootstrap.js +3 -0
  40. package/dist/shared/logger.d.ts +2 -0
  41. package/dist/shared/logger.js +4 -0
  42. package/package.json +4 -5
@@ -6,6 +6,7 @@ import { createRouteGuard } from '../auth/route-guard.js';
6
6
  import { dropFlagCache, guardCacheGeneration } from '../auth/guard-cache.js';
7
7
  import { getBridgeAuth, bridgeReadyStore, markReady, waitForBridge as _waitForBridge, } from '../core/bridge-instance.js';
8
8
  import { installBridgeAuthFetch } from '../core/bridge-runtime.js';
9
+ import { refreshBilling, tokenStaleHandlerOf } from '../core/billing-store.js';
9
10
  import { useBridge, sanitizeReturnTo, stashReturnTo, takeReturnTo, withReturnTo, } from '@nebulr-group/bridge-auth-core';
10
11
  import { logger } from '../shared/logger.js';
11
12
  import { bridgeConfig, getConfig, getRouteGuardConfig } from './stores/config.store.js';
@@ -226,6 +227,8 @@ function ensureInitialised() {
226
227
  apiBaseUrl: ctx.apiBaseUrl,
227
228
  accessToken: ctx.accessToken,
228
229
  appId: ctx.appId,
230
+ // TBP-762 — renew an out-of-date sign-in and retry instead of failing.
231
+ onTokenStale: tokenStaleHandlerOf(bridge),
229
232
  });
230
233
  }
231
234
  }
@@ -340,6 +343,16 @@ async function handleCallbackRoute(url, kitFetch) {
340
343
  const bridge = getBridgeAuth();
341
344
  try {
342
345
  await bridge.confirmStripeCheckout(sessionId, kitFetch);
346
+ // TBP-762 — the checkout changed the plan: re-read the billing stores
347
+ // before the landing page renders, so its "Current plan" is the new
348
+ // one on first render. In the browser only — a server-side read
349
+ // would fill a store no page sees. Never throws.
350
+ if (typeof window !== 'undefined') {
351
+ try {
352
+ await refreshBilling();
353
+ }
354
+ catch { /* the landing page re-reads */ }
355
+ }
343
356
  redirect(303, redirectTo);
344
357
  }
345
358
  catch (err) {
@@ -24,6 +24,7 @@
24
24
  type StartBridgeRuntimeOptions,
25
25
  } from '../core/bridge-runtime.js';
26
26
  import RealtimeDevBadge from './components/developer/RealtimeDevBadge.svelte';
27
+ import { startBillingRefresh } from '../core/billing-store.js';
27
28
  import BridgeUpgradeDialog from './components/subscription/BridgeUpgradeDialog.svelte';
28
29
  import { dismissQuotaRefusal, quotaRefusal } from '../core/quota-refusal.js';
29
30
  import { dismissFeatureUpgrade, featureUpgrade, openFeatureUpgrade } from '../core/feature-upgrade.js';
@@ -219,6 +220,7 @@
219
220
  let _recheckTimer: ReturnType<typeof setTimeout> | undefined;
220
221
  let _stopFlagWatch: (() => void) | undefined;
221
222
  let _stopAuthzWatch: (() => void) | undefined;
223
+ let _stopBillingRefresh: (() => void) | undefined;
222
224
 
223
225
  function scheduleRouteRecheck() {
224
226
  if (_recheckTimer) clearTimeout(_recheckTimer);
@@ -255,6 +257,11 @@
255
257
  // protected page and a revoked entitlement ejects the user.
256
258
  _stopAuthzWatch = onBridgeAuthorizationChange(() => scheduleRouteRecheck());
257
259
 
260
+ // TBP-762 — the billing store's refresh rule: re-read the plan list,
261
+ // current plan and billing state on a renewed sign-in and on tab focus
262
+ // after 30 s (live billing events are wired in the runtime).
263
+ _stopBillingRefresh = startBillingRefresh();
264
+
258
265
  // Fetch app config outside load() so we use the correct fetch context.
259
266
  // LoginForm also calls ensureAppConfig() — both share the same in-flight promise.
260
267
  void ensureAppConfig();
@@ -287,6 +294,8 @@
287
294
  });
288
295
 
289
296
  onDestroy(() => {
297
+ _stopBillingRefresh?.();
298
+ _stopBillingRefresh = undefined;
290
299
  if (_recheckTimer) {
291
300
  clearTimeout(_recheckTimer);
292
301
  _recheckTimer = undefined;
@@ -4,6 +4,7 @@ export declare function isBillingAdmin(): boolean;
4
4
  export declare const CONTACT_WORKSPACE_OWNER = "Contact your workspace owner.";
5
5
  /**
6
6
  * The member-facing sentence for a quota, by how close it is to the cap.
7
- * `over` is also what a refused request (the upgrade dialog) says.
7
+ * `over` is also what a refused request (the upgrade dialog) says. `reached`
8
+ * (TBP-697): exactly at a hard limit — 2 of 2 is not "approaching".
8
9
  */
9
- export declare function quotaMemberBody(label: string, state: 'over' | 'critical' | 'approaching'): string;
10
+ export declare function quotaMemberBody(label: string, state: 'over' | 'reached' | 'critical' | 'approaching'): string;
@@ -22,12 +22,15 @@ export function isBillingAdmin() {
22
22
  export const CONTACT_WORKSPACE_OWNER = 'Contact your workspace owner.';
23
23
  /**
24
24
  * The member-facing sentence for a quota, by how close it is to the cap.
25
- * `over` is also what a refused request (the upgrade dialog) says.
25
+ * `over` is also what a refused request (the upgrade dialog) says. `reached`
26
+ * (TBP-697): exactly at a hard limit — 2 of 2 is not "approaching".
26
27
  */
27
28
  export function quotaMemberBody(label, state) {
28
29
  switch (state) {
29
30
  case 'over':
30
31
  return `Your workspace is over its ${label} cap. ${CONTACT_WORKSPACE_OWNER}`;
32
+ case 'reached':
33
+ return `Your workspace has reached its ${label} limit. ${CONTACT_WORKSPACE_OWNER}`;
31
34
  case 'critical':
32
35
  return `Your workspace is approaching its ${label} cap. ${CONTACT_WORKSPACE_OWNER}`;
33
36
  case 'approaching':
@@ -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 { ensureBillingState } from '../../../core/billing-store.js';
27
28
  import { billingRoutes } from '../../billing-routes.js';
28
29
 
29
30
  type Chassis = 'bar' | 'rail' | 'card';
@@ -77,13 +78,9 @@
77
78
  useBridge().subscription.setError('Not authenticated');
78
79
  return;
79
80
  }
80
- if (!snapshot.state) {
81
- useBridge().subscription.mount({
82
- apiBaseUrl: ctx.apiBaseUrl,
83
- accessToken: ctx.accessToken,
84
- appId: ctx.appId,
85
- });
86
- }
81
+ // TBP-762 — through the billing store: renews an out-of-date sign-in,
82
+ // retries once, and re-reads on the store's refresh rule.
83
+ void ensureBillingState();
87
84
  });
88
85
 
89
86
  onDestroy(() => unsubscribe?.());
@@ -27,12 +27,14 @@
27
27
  pages keep working.
28
28
  4. Headless: `PlanSelector`, `BridgeSubscriptionStatus`, `BillingPortalButton`
29
29
  and `subscriptionStore` are the pieces these pages are built from.
30
+
31
+ A Free pick (like a paid checkout) lands on /subscription/success (TBP-762).
30
32
  -->
31
33
  <script lang="ts">
32
34
  import type { Snippet } from 'svelte';
33
35
  import { untrack } from 'svelte';
34
36
  import { page } from '$app/stores';
35
- import { loadSubscription, subscriptionStore } from '../../../core/bridge-instance.js';
37
+ import { ensureSubscription, loadSubscription } from '../../../core/bridge-instance.js';
36
38
  import { BRIDGE_AUTH_ROUTE_PARAM, bridgeAuthBase } from '../../auth-routes.js';
37
39
  import { parseBridgeBillingRoute, type BridgeBillingPage } from '../../billing-routes.js';
38
40
  import PlanSelector from './PlanSelector.svelte';
@@ -64,19 +66,17 @@
64
66
  error: "We couldn't confirm your payment",
65
67
  };
66
68
 
67
- // Every page reads the subscription. The success page always re-reads it: the
68
- // checkout just changed it, and whatever the store holds predates that. Keyed
69
- // on the page, because moving between these pages reuses this component.
69
+ // Every page reads the subscription through the billing store (TBP-762): a
70
+ // read younger than 30 s is reused, anything older is re-read. The success
71
+ // page always re-reads everything — the checkout just changed it, and
72
+ // whatever the store holds predates that. Keyed on the page, because moving
73
+ // between these pages reuses this component. (The success page's badge is
74
+ // `fresh`: it re-reads the billing state itself.)
70
75
  $effect(() => {
71
76
  const current = route?.page;
72
77
  if (!current) return;
73
78
  untrack(() => {
74
- const { status, loading } = $subscriptionStore;
75
- if (current === 'success' || (!status && !loading)) {
76
- loadSubscription().catch(() => {
77
- /* surfaced via subscriptionStore.error */
78
- });
79
- }
79
+ void (current === 'success' ? loadSubscription() : ensureSubscription());
80
80
  });
81
81
  });
82
82
  </script>
@@ -102,7 +102,7 @@
102
102
  <p class="bridge-billing-text">Your plan is active.</p>
103
103
  <div class="bridge-billing-current">
104
104
  <span class="bridge-billing-label">Current plan</span>
105
- <BridgeSubscriptionStatus />
105
+ <BridgeSubscriptionStatus fresh />
106
106
  </div>
107
107
  <a class="bridge-btn-primary bridge-billing-action" href={redirectTo}>Continue</a>
108
108
  {:else if p === 'error'}
@@ -2,7 +2,7 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import { onMount } from 'svelte';
4
4
  import type { Plan, PriceOfferSdk } from '@nebulr-group/bridge-auth-core';
5
- import { subscriptionStore, loadSubscription } from '../../../core/bridge-instance.js';
5
+ import { subscriptionStore, ensureSubscription } from '../../../core/bridge-instance.js';
6
6
  import PlanSelector from './PlanSelector.svelte';
7
7
 
8
8
  interface Props {
@@ -29,9 +29,8 @@
29
29
  const loading = $derived($subscriptionStore.loading);
30
30
 
31
31
  onMount(() => {
32
- if (!$subscriptionStore.status && !$subscriptionStore.loading) {
33
- loadSubscription();
34
- }
32
+ // TBP-762 — the billing store: reuses a read younger than 30 s.
33
+ void ensureSubscription();
35
34
  });
36
35
 
37
36
  // Show paywall only once the subscription status is known and the tenant
@@ -21,7 +21,7 @@
21
21
  import { onMount } from 'svelte';
22
22
  import { page } from '$app/stores';
23
23
  import type { Plan, PriceOfferSdk } from '@nebulr-group/bridge-auth-core';
24
- import { loadSubscription, subscriptionStore } from '../../../core/bridge-instance.js';
24
+ import { ensureSubscription } from '../../../core/bridge-instance.js';
25
25
  import { logger } from '../../../shared/logger.js';
26
26
  import { billingRoutes } from '../../billing-routes.js';
27
27
  import PlanSelector from './PlanSelector.svelte';
@@ -56,11 +56,8 @@
56
56
  const cancel = $derived(cancelRedirect ?? $page.url.pathname);
57
57
 
58
58
  onMount(() => {
59
- if (!$subscriptionStore.status && !$subscriptionStore.loading) {
60
- loadSubscription().catch(() => {
61
- /* surfaced via subscriptionStore.error */
62
- });
63
- }
59
+ // TBP-762 — the billing store: reuses a read younger than 30 s.
60
+ void ensureSubscription();
64
61
  const here = $page.url.pathname;
65
62
  if (isDevBuild() && routes.paywallRoute !== here) {
66
63
  logger.warn(
@@ -9,9 +9,10 @@
9
9
  - the metric has no quota configured on the plan (server returns null), OR
10
10
  - usage is below 80% of the limit (warningLevel === null).
11
11
 
12
- Three visible states:
12
+ Four visible states (hard quotas):
13
13
  - approaching (80-94% used) → severity 'warn'
14
14
  - critical (95-99% used) → severity 'critical'
15
+ - reached (used == limit) → severity 'critical', "limit reached" (TBP-697)
15
16
  - over-cap (used > limit) → severity 'critical', different copy
16
17
 
17
18
  Two role variants (admin / member): admins get an Upgrade CTA; members get
@@ -26,7 +27,8 @@
26
27
  useBridge,
27
28
  type QuotaSnapshot,
28
29
  } from '@nebulr-group/bridge-auth-core';
29
- import { isBillingAdmin as canManageBilling, quotaMemberBody } from '../../billing-role.js';
30
+ import { isBillingAdmin as canManageBilling } from '../../billing-role.js';
31
+ import { quotaBannerCopy, quotaBannerState } from '../../quota-banner-copy.js';
30
32
  import { billingRoutes } from '../../billing-routes.js';
31
33
 
32
34
  type Chassis = 'rail';
@@ -92,29 +94,14 @@
92
94
  snapshot = useBridge().quota(metric);
93
95
  });
94
96
 
95
- const warningLevel = $derived(snapshot?.warningLevel ?? null);
96
- // TBP-275 — `metered` quotas bill overage instead of blocking. Prefer the
97
- // server-authoritative `overcap` flag (handles limit === 0 pure-per-unit)
98
- // over a UI-derived `used > limit`.
99
- const isMetered = $derived(snapshot?.policy === 'metered');
100
- const overCap = $derived(
101
- snapshot
102
- ? (snapshot.overcap ?? snapshot.used > snapshot.limit)
103
- : false,
104
- );
105
- // Hard caps show only at the warning thresholds. Metered quotas also show
106
- // once billing has engaged (overCap), even with no warningLevel — that's the
107
- // live "you're now being billed for overage" state.
108
- const visible = $derived(
109
- snapshot !== undefined && (warningLevel !== null || (isMetered && overCap)),
110
- );
97
+ // TBP-697 — the state and the copy live in quota-banner-copy.ts: at
98
+ // `used >= limit` on a hard quota the banner says the limit is REACHED (2 of
99
+ // 2 is not "approaching"), whatever warning level the server sent.
100
+ const bannerState = $derived(quotaBannerState(snapshot));
101
+ const visible = $derived(bannerState !== 'hidden');
111
102
  // Metered never blocks, so it never renders as 'critical' — it's informational.
112
103
  const severity = $derived<Severity>(
113
- isMetered
114
- ? 'warn'
115
- : warningLevel === 'critical' || overCap
116
- ? 'critical'
117
- : 'warn',
104
+ bannerState === 'critical' || bannerState === 'reached' || bannerState === 'over' ? 'critical' : 'warn',
118
105
  );
119
106
  const displayLabel = $derived(label ?? snapshot?.label ?? metric);
120
107
  const percent = $derived(
@@ -125,94 +112,7 @@
125
112
  // Meter bar is meaningless for pure per-unit metered (limit 0) — hide it there.
126
113
  const showMeter = $derived(!!snapshot && snapshot.limit > 0);
127
114
 
128
- /** Format an estimated cost like "$1.00" / "1.00 SEK". */
129
- function formatCost(amount: number | undefined, currency: string | undefined): string {
130
- if (amount === undefined) return '';
131
- const cur = (currency ?? '').toUpperCase();
132
- try {
133
- return new Intl.NumberFormat(undefined, {
134
- style: 'currency',
135
- currency: cur || 'USD',
136
- }).format(amount);
137
- } catch {
138
- // Unknown currency code → fall back to "<amount> <CUR>".
139
- return `${amount.toFixed(2)}${cur ? ` ${cur}` : ''}`;
140
- }
141
- }
142
-
143
- function getCopy(
144
- snap: QuotaSnapshot | undefined,
145
- admin: boolean,
146
- ): { title: string; body: string; cta?: string } {
147
- if (!snap) return { title: '', body: '' };
148
-
149
- // TBP-275 — metered: live usage + projected cost, never a blocking message.
150
- if (snap.policy === 'metered') {
151
- const overUnits = snap.limit > 0 ? Math.max(0, snap.used - snap.limit) : snap.used;
152
- const cost = formatCost(snap.overageEstimate, snap.currency);
153
- const costSuffix = cost ? ` · ~${cost} estimated this period` : '';
154
- if (snap.limit > 0) {
155
- // included allotment + overage
156
- if (overUnits > 0) {
157
- return {
158
- title: `${displayLabel} overage`,
159
- body: `${overUnits.toLocaleString()} over your ${snap.limit.toLocaleString()} included${costSuffix}.`,
160
- };
161
- }
162
- // approaching the included allotment (warningLevel drove visibility)
163
- const unit = formatCost(snap.unitAmount, snap.currency);
164
- return {
165
- title: `${displayLabel} approaching included limit`,
166
- body: `You've used ${snap.used.toLocaleString()} of ${snap.limit.toLocaleString()} included${unit ? ` — extra usage is billed at ${unit}/unit` : ''}.`,
167
- };
168
- }
169
- // pure per-unit (limit 0) — billed from unit 1
170
- return {
171
- title: `${displayLabel} usage`,
172
- body: `${snap.used.toLocaleString()} ${displayLabel}${costSuffix}.`,
173
- };
174
- }
175
-
176
- const over = snap.overcap ?? snap.used > snap.limit;
177
- const remaining = Math.max(0, snap.remaining);
178
- if (over) {
179
- return admin
180
- ? {
181
- title: `${displayLabel} over cap`,
182
- body: `You've used ${snap.used.toLocaleString()} of ${snap.limit.toLocaleString()}. Upgrade your plan to add headroom.`,
183
- cta: 'Upgrade',
184
- }
185
- : {
186
- title: `${displayLabel} over cap`,
187
- body: quotaMemberBody(displayLabel, 'over'),
188
- };
189
- }
190
- if (warningLevel === 'critical') {
191
- return admin
192
- ? {
193
- title: `${displayLabel} near cap`,
194
- body: `You've used ${snap.used.toLocaleString()} of ${snap.limit.toLocaleString()} (${remaining.toLocaleString()} left). Upgrade to avoid hitting the cap.`,
195
- cta: 'Upgrade',
196
- }
197
- : {
198
- title: `${displayLabel} near cap`,
199
- body: quotaMemberBody(displayLabel, 'critical'),
200
- };
201
- }
202
- // approaching
203
- return admin
204
- ? {
205
- title: `${displayLabel} approaching cap`,
206
- body: `You've used ${snap.used.toLocaleString()} of ${snap.limit.toLocaleString()} (${remaining.toLocaleString()} left).`,
207
- cta: 'Upgrade',
208
- }
209
- : {
210
- title: `${displayLabel} approaching cap`,
211
- body: quotaMemberBody(displayLabel, 'approaching'),
212
- };
213
- }
214
-
215
- const copy = $derived(getCopy(snapshot, isBillingAdmin));
115
+ const copy = $derived(quotaBannerCopy(snapshot, isBillingAdmin, displayLabel));
216
116
 
217
117
  function handleAction() {
218
118
  if (!snapshot) return;
@@ -7,7 +7,16 @@
7
7
  `BridgeFlags`. Do NOT confuse with the older `<PlanSelector />` which
8
8
  consumes the Stripe-direct path via `subscriptionStore`.
9
9
 
10
- No live push in v1 — that's US-3. Fetches once on mount.
10
+ TBP-762 — reads through the plugin's billing store (core/billing-store.ts):
11
+ it re-reads on a plan change, a renewed sign-in and tab focus, so the badge
12
+ follows the plan without a reload. A read that meets an out-of-date sign-in
13
+ renews it and retries; a failed read is retried once, and "Loading…" stays up
14
+ meanwhile. "Subscription unavailable" only after that; with `debug: true` the
15
+ reason is shown next to it.
16
+
17
+ `fresh` (the checkout success page): show "Loading…" until a read started
18
+ after this badge mounted has answered, so the plan it shows is the one the
19
+ checkout just bought, never the one the store held before.
11
20
  -->
12
21
  <script lang="ts">
13
22
  import { onDestroy, onMount } from 'svelte';
@@ -15,46 +24,53 @@
15
24
  useBridge,
16
25
  type BillingSubscriptionSnapshot,
17
26
  } from '@nebulr-group/bridge-auth-core';
18
- import { getBridgeAuth } from '../../../core/bridge-instance.js';
27
+ import { ensureBillingState, refreshBillingState } from '../../../core/billing-store.js';
28
+ import { isLoggerDebug } from '../../../shared/logger.js';
29
+ import { subscriptionBadgeView } from './subscription-badge.js';
19
30
 
20
31
  interface Props {
21
32
  /** Optional class applied to the root span. */
22
33
  class?: string;
34
+ /** Wait for a read made after mount before showing a plan. */
35
+ fresh?: boolean;
23
36
  }
24
37
 
25
- let { class: className = '' }: Props = $props();
38
+ let { class: className = '', fresh = false }: Props = $props();
26
39
 
27
40
  let snapshot = $state<BillingSubscriptionSnapshot>(useBridge().subscription.snapshot());
41
+ // "Loading…" until this badge's own read has answered when it has nothing to
42
+ // show yet (never a "No subscription" flash), or always on `fresh`.
43
+ // svelte-ignore state_referenced_locally
44
+ let awaiting = $state(fresh || snapshot.state === null);
28
45
  let unsubscribe: (() => void) | undefined;
46
+ const debug = isLoggerDebug();
29
47
 
30
48
  onMount(() => {
31
49
  unsubscribe = useBridge().subscription.subscribe((snap) => {
32
50
  snapshot = snap;
33
51
  });
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,
52
+ const read = fresh ? refreshBillingState() : ensureBillingState();
53
+ void read.finally(() => {
54
+ awaiting = false;
44
55
  });
45
56
  });
46
57
 
47
58
  onDestroy(() => unsubscribe?.());
59
+
60
+ const view = $derived(subscriptionBadgeView(snapshot, awaiting, debug));
48
61
  </script>
49
62
 
50
- <span class={`bridge-subscription-status ${className}`}>
51
- {#if snapshot.loading}
63
+ <span class={`bridge-subscription-status ${className}`} data-state={view.kind}>
64
+ {#if view.kind === 'loading'}
52
65
  <span class="bss-loading">Loading…</span>
53
- {:else if snapshot.error}
66
+ {:else if view.kind === 'error'}
54
67
  <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>
68
+ {#if view.reason}
69
+ <span class="bss-error-reason" data-bridge-error-reason>({view.reason})</span>
70
+ {/if}
71
+ {:else if view.kind === 'plan'}
72
+ <span class="bss-plan">{view.name}</span>
73
+ <span class={`bss-badge bss-badge-${view.status}`}>{view.status}</span>
58
74
  {:else}
59
75
  <span class="bss-empty">No subscription</span>
60
76
  {/if}
@@ -110,7 +126,8 @@
110
126
  font-style: italic;
111
127
  }
112
128
 
113
- .bss-error {
129
+ .bss-error,
130
+ .bss-error-reason {
114
131
  color: var(--bridge-alert-error-fg, #991b1b);
115
132
  }
116
133
  </style>
@@ -1,6 +1,8 @@
1
1
  interface Props {
2
2
  /** Optional class applied to the root span. */
3
3
  class?: string;
4
+ /** Wait for a read made after mount before showing a plan. */
5
+ fresh?: boolean;
4
6
  }
5
7
  declare const BridgeSubscriptionStatus: import("svelte").Component<Props, {}, "">;
6
8
  type BridgeSubscriptionStatus = ReturnType<typeof BridgeSubscriptionStatus>;
@@ -13,12 +13,15 @@
13
13
  optional `loading` snippet — so a cold start never flashes the upgrade prompt
14
14
  at a paying workspace, nor the paid feature at a free one.
15
15
 
16
- The markup form of `$entitlements.can('analytics')`. Decoration only: the
17
- backend's @RequireEntitlement is what refuses the request.
16
+ The markup form of `$entitlements.can('analytics')`. It reads the plan
17
+ directly, which is the exception: the standard gate is `<FeatureFlag key>`
18
+ with a flag ruled `bridge:billing.entitlement.<key> eq true`. In development
19
+ it logs a one-time note saying so (TBP-705).
18
20
  -->
19
21
  <script lang="ts">
20
22
  import type { Snippet } from 'svelte';
21
23
  import { entitlements } from '../../../core/entitlements.js';
24
+ import { noteDirectPlanCheck } from '../../../core/direct-plan-check-note.js';
22
25
 
23
26
  interface Props {
24
27
  /** The entitlement key, e.g. `'analytics'`. */
@@ -32,6 +35,12 @@
32
35
  }
33
36
 
34
37
  let { to, children, fallback, loading }: Props = $props();
38
+
39
+ // Runs before the first render, so this note — not the store's `can()` one —
40
+ // is the one a page using <Entitled> sees. Once per page load, so the first
41
+ // `to` is the one it names.
42
+ const note = () => noteDirectPlanCheck('entitled', to);
43
+ note();
35
44
  </script>
36
45
 
37
46
  {#if !$entitlements.ready}
@@ -2,8 +2,11 @@
2
2
  import type { Snippet } from 'svelte';
3
3
  import type { HTMLAttributes } from 'svelte/elements';
4
4
  import { onMount } from 'svelte';
5
+ import { goto } from '$app/navigation';
5
6
  import type { Plan, PriceOfferSdk } from '@nebulr-group/bridge-auth-core';
6
- import { getBridgeAuth, loadSubscription, subscriptionStore } from '../../../core/bridge-instance.js';
7
+ import { ensureSubscription, getBridgeAuth, loadSubscription, subscriptionStore } from '../../../core/bridge-instance.js';
8
+ import { refreshBilling } from '../../../core/billing-store.js';
9
+ import { pickPlan } from './plan-pick.js';
7
10
  import { getConfig } from '../../stores/config.store.js';
8
11
  import Alert from '../sdk-auth/shared/Alert.svelte';
9
12
  import Spinner from '../sdk-auth/shared/Spinner.svelte';
@@ -157,12 +160,23 @@
157
160
  );
158
161
 
159
162
  onMount(() => {
160
- // Only load if not already loaded
161
- if (!$subscriptionStore.status && !$subscriptionStore.loading) {
162
- loadSubscription();
163
- }
163
+ // TBP-762 — read unless the billing store holds a read younger than 30 s;
164
+ // the store re-reads on plan changes, renewed sign-ins and tab focus.
165
+ void ensureSubscription();
164
166
  });
165
167
 
168
+ // TBP-762 — an empty plan list is not proof the app has none (the store has
169
+ // already asked twice): offer a retry instead of "No plans available".
170
+ let retrying = $state(false);
171
+ async function retryPlans(): Promise<void> {
172
+ retrying = true;
173
+ try {
174
+ await loadSubscription();
175
+ } finally {
176
+ retrying = false;
177
+ }
178
+ }
179
+
166
180
  // ── Plan-change confirmation (TBP-33) ─────────────────────────────────
167
181
  // Switching an existing subscriber's plan is instant (no Stripe checkout
168
182
  // page), so it needs an explicit confirm step. Free selection and the
@@ -197,7 +211,7 @@
197
211
  confirmError = null;
198
212
  try {
199
213
  await getBridgeAuth().changePlan(plan.key, price);
200
- await loadSubscription();
214
+ await refreshBilling();
201
215
  confirmTarget = null;
202
216
  showSuccess(`You're now on ${plan.name} (${formatPrice(price)}).`);
203
217
  onSelect?.({ plan, price });
@@ -213,37 +227,30 @@
213
227
  picking = true;
214
228
  pickError = null;
215
229
  try {
216
- if (price.amount === 0 && !plan.hasCost) {
217
- // Free plan — select directly. TBP-275: guard on `!plan.hasCost` so a
218
- // $0-base plan that carries METERED pricing is NOT treated as free —
219
- // it falls through to checkout/changePlan below, capturing a payment
220
- // method so per-unit overage can be billed (US-C). Without this guard a
221
- // metered $0-base plan would hit selectFreePlan, which the backend now
222
- // rejects ("has a cost — use the checkout endpoint instead").
223
- await getBridgeAuth().selectFreePlan(plan.key);
224
- await loadSubscription();
225
- onSelect?.({ plan, price });
226
- } else if (status?.paymentsEnabled) {
227
- // Already has payment method — instant switch, so require an explicit
230
+ const outcome = await pickPlan(
231
+ plan,
232
+ price,
233
+ {
234
+ paymentsEnabled: !!status?.paymentsEnabled,
235
+ successRedirect,
236
+ cancelRedirect,
237
+ callbackBase: getConfig().callbackUrl ?? `${window.location.origin}/auth/oauth-callback`,
238
+ onSelect,
239
+ },
240
+ {
241
+ auth: getBridgeAuth(),
242
+ refresh: refreshBilling,
243
+ navigate: (url) => goto(url),
244
+ leave: (url) => {
245
+ window.location.href = url;
246
+ },
247
+ },
248
+ );
249
+ if (outcome === 'confirm') {
250
+ // Already has a payment method — instant switch, so require an explicit
228
251
  // confirmation (TBP-33). The actual changePlan runs in confirmPlanChange.
229
252
  confirmTarget = { plan, price };
230
253
  confirmError = null;
231
- } else {
232
- // Needs checkout — redirect to Stripe (or direct plan set when Stripe not configured)
233
- const base = getConfig().callbackUrl ?? `${window.location.origin}/auth/oauth-callback`;
234
- // Use {CHECKOUT_SESSION_ID} placeholder — Stripe substitutes it in-place,
235
- // avoiding the double-? bug that occurs when Stripe appends ?session_id= to a URL that already has query params.
236
- const successUrl = `${base}?stripe_success=1&session_id={CHECKOUT_SESSION_ID}&redirect=${encodeURIComponent(successRedirect)}`;
237
- const cancelUrl = `${base}?stripe_cancel=1&redirect=${encodeURIComponent(cancelRedirect)}`;
238
- const session = await getBridgeAuth().startCheckout(plan.key, price, { successUrl, cancelUrl });
239
- if (session.sessionId === null) {
240
- // Stripe not configured — plan was set directly on the backend
241
- await loadSubscription();
242
- onSelect?.({ plan, price });
243
- } else {
244
- if (!session.checkoutUrl) throw new Error('Checkout session URL missing');
245
- window.location.href = session.checkoutUrl;
246
- }
247
254
  }
248
255
  } catch (err) {
249
256
  pickError = err instanceof Error ? err.message : 'Something went wrong';
@@ -305,7 +312,12 @@
305
312
  {#if emptyState}
306
313
  {@render emptyState()}
307
314
  {:else}
308
- <p class="bridge-plan-empty">No plans available.</p>
315
+ <div class="bridge-plan-empty" data-bridge-plan-empty>
316
+ <p>The plans could not be loaded.</p>
317
+ <button type="button" class="bridge-btn-primary" onclick={retryPlans} disabled={retrying}>
318
+ {retrying ? 'Loading…' : 'Try again'}
319
+ </button>
320
+ </div>
309
321
  {/if}
310
322
  {:else if plans}
311
323
  {#if showIntervalTabs}