@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.
- package/dist/client/BridgeBootstrap.js +13 -0
- package/dist/client/BridgeBootstrap.svelte +9 -0
- package/dist/client/billing-role.d.ts +3 -2
- package/dist/client/billing-role.js +4 -1
- package/dist/client/components/subscription/BridgeBillingNotice.svelte +4 -7
- package/dist/client/components/subscription/BridgeBillingRoutes.svelte +11 -11
- package/dist/client/components/subscription/BridgePaywall.svelte +3 -4
- package/dist/client/components/subscription/BridgePaywallPage.svelte +3 -6
- package/dist/client/components/subscription/BridgeQuotaBanner.svelte +11 -111
- package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte +37 -20
- package/dist/client/components/subscription/BridgeSubscriptionStatus.svelte.d.ts +2 -0
- package/dist/client/components/subscription/Entitled.svelte +11 -2
- package/dist/client/components/subscription/PlanSelector.svelte +47 -35
- package/dist/client/components/subscription/plan-pick.d.ts +37 -0
- package/dist/client/components/subscription/plan-pick.js +47 -0
- package/dist/client/components/subscription/subscription-badge.d.ts +14 -0
- package/dist/client/components/subscription/subscription-badge.js +17 -0
- package/dist/client/components/team/TeamAddUserDialog.svelte +14 -0
- package/dist/client/components/team/TeamAddUserDialog.svelte.d.ts +6 -0
- package/dist/client/components/team/TeamManagementPanel.svelte +8 -1
- package/dist/client/components/team/TeamManagementPanel.svelte.d.ts +6 -0
- package/dist/client/components/team/TeamUserList.svelte +47 -6
- package/dist/client/components/team/TeamUserList.svelte.d.ts +6 -0
- package/dist/client/components/team/seats.d.ts +8 -0
- package/dist/client/components/team/seats.js +32 -0
- package/dist/client/quota-banner-copy.d.ts +12 -0
- package/dist/client/quota-banner-copy.js +86 -0
- package/dist/core/billing-store.d.ts +58 -0
- package/dist/core/billing-store.js +298 -0
- package/dist/core/bridge-instance.d.ts +15 -0
- package/dist/core/bridge-instance.js +133 -12
- package/dist/core/bridge-runtime.js +27 -11
- package/dist/core/direct-plan-check-note.d.ts +4 -0
- package/dist/core/direct-plan-check-note.js +35 -0
- package/dist/core/entitlements.d.ts +5 -0
- package/dist/core/entitlements.js +11 -1
- package/dist/core/use-quota.d.ts +1 -1
- package/dist/core/use-quota.js +1 -1
- package/dist/flags/bootstrap.js +3 -0
- package/dist/shared/logger.d.ts +2 -0
- package/dist/shared/logger.js +4 -0
- 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
|
-
|
|
81
|
-
|
|
82
|
-
|
|
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 {
|
|
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
|
|
68
|
-
//
|
|
69
|
-
//
|
|
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
|
-
|
|
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,
|
|
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
|
-
|
|
33
|
-
|
|
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 {
|
|
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
|
-
|
|
60
|
-
|
|
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
|
-
|
|
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
|
|
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
|
-
|
|
96
|
-
//
|
|
97
|
-
//
|
|
98
|
-
|
|
99
|
-
const
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 {
|
|
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
|
-
|
|
36
|
-
|
|
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
|
|
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
|
|
66
|
+
{:else if view.kind === 'error'}
|
|
54
67
|
<span class="bss-error">Subscription unavailable</span>
|
|
55
|
-
|
|
56
|
-
|
|
57
|
-
|
|
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')`.
|
|
17
|
-
|
|
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
|
-
//
|
|
161
|
-
|
|
162
|
-
|
|
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
|
|
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
|
-
|
|
217
|
-
|
|
218
|
-
|
|
219
|
-
|
|
220
|
-
|
|
221
|
-
|
|
222
|
-
|
|
223
|
-
|
|
224
|
-
|
|
225
|
-
|
|
226
|
-
|
|
227
|
-
|
|
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
|
-
<
|
|
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}
|