@nebulr-group/bridge-svelte 0.4.0-beta.4 → 0.4.0-beta.6
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.svelte +44 -2
- package/dist/client/components/sdk-auth/ForgotPassword.svelte +15 -1
- package/dist/client/components/sdk-auth/ForgotPassword.svelte.d.ts +5 -0
- package/dist/client/components/sdk-auth/LoginForm.svelte +2 -2
- package/dist/client/components/sdk-auth/MagicLink.svelte +4 -1
- package/dist/client/components/sdk-auth/MagicLink.svelte.d.ts +2 -0
- package/dist/client/components/sdk-auth/SignupForm.svelte +5 -2
- package/dist/client/components/sdk-auth/SignupForm.svelte.d.ts +2 -1
- package/dist/client/components/sdk-auth/shared/AuthFormWrapper.svelte +2 -1
- package/dist/client/components/sdk-auth/shared/AuthFormWrapper.svelte.d.ts +2 -1
- package/dist/client/components/subscription/PlanSelector.svelte +83 -3
- package/dist/client/components/subscription/PlanSelector.svelte.d.ts +9 -0
- package/dist/core/bridge.d.ts +10 -2
- package/dist/core/bridge.js +87 -4
- package/dist/styles.css +33 -0
- package/package.json +2 -2
|
@@ -1,8 +1,15 @@
|
|
|
1
1
|
<script lang="ts">
|
|
2
2
|
import { beforeNavigate, goto } from '$app/navigation';
|
|
3
|
+
import { page } from '$app/stores';
|
|
3
4
|
import { onMount, onDestroy } from 'svelte';
|
|
4
5
|
import { createRouteGuard } from '../auth/route-guard.js';
|
|
5
|
-
import {
|
|
6
|
+
import {
|
|
7
|
+
getBridgeAuth,
|
|
8
|
+
isAuthenticated,
|
|
9
|
+
subscriptionStore,
|
|
10
|
+
loadSubscription,
|
|
11
|
+
ensureAppConfig,
|
|
12
|
+
} from '../core/bridge-instance.js';
|
|
6
13
|
import { bridge as bridgeSurface } from '../core/bridge.js';
|
|
7
14
|
import { setBridgeContext } from '../core/use-bridge.js';
|
|
8
15
|
import { getConfig } from './stores/config.store.js';
|
|
@@ -11,7 +18,6 @@
|
|
|
11
18
|
stopBridgeRuntime,
|
|
12
19
|
type StartBridgeRuntimeOptions,
|
|
13
20
|
} from '../core/bridge-runtime.js';
|
|
14
|
-
import { loadSubscription, ensureAppConfig } from '../core/bridge-instance.js';
|
|
15
21
|
|
|
16
22
|
// Props: optional `runtime` overrides for advanced/debug use (websocketFactory,
|
|
17
23
|
// reconnect overrides, etc.); `onBootstrapComplete` callback fires after the
|
|
@@ -30,6 +36,42 @@
|
|
|
30
36
|
|
|
31
37
|
const guard = createRouteGuard();
|
|
32
38
|
|
|
39
|
+
// Reactive paywall enforcement.
|
|
40
|
+
//
|
|
41
|
+
// The one-shot redirect in bridgeBootstrap() (load) only fires on the very
|
|
42
|
+
// first load — it short-circuits on every later navigation (bridgeReadyStore),
|
|
43
|
+
// so it misses the post-login case where auth + subscription state resolve a
|
|
44
|
+
// beat AFTER load() ran. This guard owns correctness: it actively loads the
|
|
45
|
+
// subscription once known-authenticated, then redirects reactively whenever
|
|
46
|
+
// `shouldSelectPlan` resolves true. Same data source as <BridgePaywall>, so the
|
|
47
|
+
// overlay and the redirect agree. The load() redirect remains a no-flash
|
|
48
|
+
// fast-path for direct loads/refreshes only.
|
|
49
|
+
$effect(() => {
|
|
50
|
+
const paywallRoute = getConfig().billing?.paywallRoute;
|
|
51
|
+
if (!paywallRoute || !$isAuthenticated) return;
|
|
52
|
+
|
|
53
|
+
const { status, loading, error } = $subscriptionStore;
|
|
54
|
+
|
|
55
|
+
// Status unknown → trigger a single load. Guarding on `!error` avoids a
|
|
56
|
+
// tight refetch loop on persistent failure (fail-pending, not fail-open);
|
|
57
|
+
// a workspace switch resets the store and re-attempts.
|
|
58
|
+
if (!status && !loading && !error) {
|
|
59
|
+
loadSubscription().catch(() => { /* surfaced via store.error */ });
|
|
60
|
+
return;
|
|
61
|
+
}
|
|
62
|
+
|
|
63
|
+
// Status known → enforce. `$page.url.pathname` makes this re-run on
|
|
64
|
+
// navigation too, so manual nav to a protected page while plan-less is
|
|
65
|
+
// also caught. Path guard prevents a redirect loop on the paywall itself.
|
|
66
|
+
if (
|
|
67
|
+
status?.shouldSelectPlan === true &&
|
|
68
|
+
status?.paymentsAutoRedirect !== false &&
|
|
69
|
+
$page.url.pathname !== paywallRoute
|
|
70
|
+
) {
|
|
71
|
+
goto(paywallRoute);
|
|
72
|
+
}
|
|
73
|
+
});
|
|
74
|
+
|
|
33
75
|
async function handleRoute(pathname: string, cancel?: () => void) {
|
|
34
76
|
const decision = await guard.getNavigationDecision(pathname);
|
|
35
77
|
if (decision.type === 'login') {
|
|
@@ -10,6 +10,11 @@
|
|
|
10
10
|
onComplete?: () => void;
|
|
11
11
|
onError?: (error: Error) => void;
|
|
12
12
|
loginHref?: string;
|
|
13
|
+
/**
|
|
14
|
+
* Override the built-in heading ("Set new password" / "Reset your password").
|
|
15
|
+
* Pass `null`/`''` to render no heading and use your own page title.
|
|
16
|
+
*/
|
|
17
|
+
heading?: string | null;
|
|
13
18
|
}
|
|
14
19
|
|
|
15
20
|
let {
|
|
@@ -17,6 +22,7 @@
|
|
|
17
22
|
onComplete,
|
|
18
23
|
onError,
|
|
19
24
|
loginHref = '/login',
|
|
25
|
+
heading = undefined,
|
|
20
26
|
class: className,
|
|
21
27
|
style,
|
|
22
28
|
...rest
|
|
@@ -33,6 +39,14 @@
|
|
|
33
39
|
|
|
34
40
|
const isSetMode = $derived(!!token);
|
|
35
41
|
|
|
42
|
+
// In a success state ("Password set" / the email-sent alert) the form heading
|
|
43
|
+
// is redundant, so suppress it. Otherwise use the override (if provided) or
|
|
44
|
+
// the built-in, state-appropriate heading.
|
|
45
|
+
const builtInHeading = $derived(isSetMode ? 'Set new password' : 'Reset your password');
|
|
46
|
+
const wrapperHeading = $derived(
|
|
47
|
+
passwordReset || emailSent ? null : heading !== undefined ? heading : builtInHeading,
|
|
48
|
+
);
|
|
49
|
+
|
|
36
50
|
async function handleSendLink() {
|
|
37
51
|
if (loading) return;
|
|
38
52
|
error = null;
|
|
@@ -77,7 +91,7 @@
|
|
|
77
91
|
</script>
|
|
78
92
|
|
|
79
93
|
<AuthFormWrapper
|
|
80
|
-
heading={
|
|
94
|
+
heading={wrapperHeading}
|
|
81
95
|
class={className}
|
|
82
96
|
{style}
|
|
83
97
|
{...rest}
|
|
@@ -4,6 +4,11 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
|
4
4
|
onComplete?: () => void;
|
|
5
5
|
onError?: (error: Error) => void;
|
|
6
6
|
loginHref?: string;
|
|
7
|
+
/**
|
|
8
|
+
* Override the built-in heading ("Set new password" / "Reset your password").
|
|
9
|
+
* Pass `null`/`''` to render no heading and use your own page title.
|
|
10
|
+
*/
|
|
11
|
+
heading?: string | null;
|
|
7
12
|
}
|
|
8
13
|
declare const ForgotPassword: import("svelte").Component<Props, {}, "">;
|
|
9
14
|
type ForgotPassword = ReturnType<typeof ForgotPassword>;
|
|
@@ -219,7 +219,7 @@
|
|
|
219
219
|
|
|
220
220
|
<!-- Inline forgot password -->
|
|
221
221
|
{:else if step === 'forgot-password'}
|
|
222
|
-
<AuthFormWrapper heading=
|
|
222
|
+
<AuthFormWrapper heading={fpEmailSent ? null : 'Reset your password'}>
|
|
223
223
|
{#if error}
|
|
224
224
|
<Alert variant="error">{error}</Alert>
|
|
225
225
|
{/if}
|
|
@@ -257,7 +257,7 @@
|
|
|
257
257
|
|
|
258
258
|
<!-- Inline magic link -->
|
|
259
259
|
{:else if step === 'magic-link'}
|
|
260
|
-
<AuthFormWrapper heading=
|
|
260
|
+
<AuthFormWrapper heading={mlSent ? null : 'Sign in with email link'}>
|
|
261
261
|
{#if error}
|
|
262
262
|
<Alert variant="error">{error}</Alert>
|
|
263
263
|
{/if}
|
|
@@ -9,12 +9,15 @@
|
|
|
9
9
|
onSent?: () => void;
|
|
10
10
|
onError?: (error: Error) => void;
|
|
11
11
|
loginHref?: string;
|
|
12
|
+
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
13
|
+
heading?: string | null;
|
|
12
14
|
}
|
|
13
15
|
|
|
14
16
|
let {
|
|
15
17
|
onSent,
|
|
16
18
|
onError,
|
|
17
19
|
loginHref = '/login',
|
|
20
|
+
heading = 'Sign in with email link',
|
|
18
21
|
class: className,
|
|
19
22
|
style,
|
|
20
23
|
...rest
|
|
@@ -51,7 +54,7 @@
|
|
|
51
54
|
}
|
|
52
55
|
</script>
|
|
53
56
|
|
|
54
|
-
<AuthFormWrapper heading=
|
|
57
|
+
<AuthFormWrapper heading={sent ? null : heading} class={className} {style} {...rest}>
|
|
55
58
|
{#if error}
|
|
56
59
|
<Alert variant="error">{error}</Alert>
|
|
57
60
|
{/if}
|
|
@@ -3,6 +3,8 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
|
3
3
|
onSent?: () => void;
|
|
4
4
|
onError?: (error: Error) => void;
|
|
5
5
|
loginHref?: string;
|
|
6
|
+
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
7
|
+
heading?: string | null;
|
|
6
8
|
}
|
|
7
9
|
declare const MagicLink: import("svelte").Component<Props, {}, "">;
|
|
8
10
|
type MagicLink = ReturnType<typeof MagicLink>;
|
|
@@ -13,7 +13,8 @@
|
|
|
13
13
|
showLoginLink?: boolean;
|
|
14
14
|
/** Override login page URL. Default: derived from BridgeConfig loginRoute. */
|
|
15
15
|
loginHref?: string | undefined;
|
|
16
|
-
heading
|
|
16
|
+
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
17
|
+
heading?: string | null;
|
|
17
18
|
footer?: Snippet;
|
|
18
19
|
}
|
|
19
20
|
|
|
@@ -55,7 +56,9 @@
|
|
|
55
56
|
}
|
|
56
57
|
</script>
|
|
57
58
|
|
|
58
|
-
|
|
59
|
+
<!-- In the success state, the "Check your email" heading below is the title,
|
|
60
|
+
so suppress the form heading to avoid two stacked headings. -->
|
|
61
|
+
<AuthFormWrapper heading={success ? null : heading} class={className} {style} {...rest}>
|
|
59
62
|
{#if success}
|
|
60
63
|
<h2 class="bridge-success-heading">Check your email</h2>
|
|
61
64
|
<p class="bridge-step-desc">We sent a verification link to <strong>{email}</strong>. Check your inbox to activate your account.</p>
|
|
@@ -6,7 +6,8 @@ interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
|
6
6
|
showLoginLink?: boolean;
|
|
7
7
|
/** Override login page URL. Default: derived from BridgeConfig loginRoute. */
|
|
8
8
|
loginHref?: string | undefined;
|
|
9
|
-
heading
|
|
9
|
+
/** Heading text. Pass `null`/`''` to render no heading and use your own page title. */
|
|
10
|
+
heading?: string | null;
|
|
10
11
|
footer?: Snippet;
|
|
11
12
|
}
|
|
12
13
|
declare const SignupForm: import("svelte").Component<Props, {}, "">;
|
|
@@ -3,7 +3,8 @@
|
|
|
3
3
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
4
4
|
|
|
5
5
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
6
|
-
heading
|
|
6
|
+
/** Heading text. Pass `null` or `''` to render no heading. */
|
|
7
|
+
heading?: string | null;
|
|
7
8
|
headingSnippet?: Snippet;
|
|
8
9
|
children?: Snippet;
|
|
9
10
|
}
|
|
@@ -1,7 +1,8 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
3
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
4
|
-
heading
|
|
4
|
+
/** Heading text. Pass `null` or `''` to render no heading. */
|
|
5
|
+
heading?: string | null;
|
|
5
6
|
headingSnippet?: Snippet;
|
|
6
7
|
children?: Snippet;
|
|
7
8
|
}
|
|
@@ -8,11 +8,20 @@
|
|
|
8
8
|
import Alert from '../sdk-auth/shared/Alert.svelte';
|
|
9
9
|
import Spinner from '../sdk-auth/shared/Spinner.svelte';
|
|
10
10
|
|
|
11
|
+
type BillingInterval = PriceOfferSdk['recurrenceInterval'];
|
|
12
|
+
|
|
11
13
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
12
14
|
successRedirect?: string;
|
|
13
15
|
cancelRedirect?: string;
|
|
16
|
+
/**
|
|
17
|
+
* Which billing interval tab is selected by default (`'month'` | `'year'` |
|
|
18
|
+
* `'week'` | `'day'`). Falls back to the first available interval when the
|
|
19
|
+
* requested one isn't offered by any plan. Default: `'month'`.
|
|
20
|
+
*/
|
|
21
|
+
defaultInterval?: BillingInterval;
|
|
14
22
|
onSelect?: (detail: { plan: Plan; price: PriceOfferSdk }) => void;
|
|
15
|
-
|
|
23
|
+
/** Custom card renderer. `prices` is the plan's full price list; `interval` is the active tab. */
|
|
24
|
+
planCard?: Snippet<[{ plan: Plan; prices: PriceOfferSdk[]; isCurrent: boolean; interval: BillingInterval; onPick: (price: PriceOfferSdk) => void }]>;
|
|
16
25
|
emptyState?: Snippet;
|
|
17
26
|
loadingState?: Snippet;
|
|
18
27
|
}
|
|
@@ -20,6 +29,7 @@
|
|
|
20
29
|
let {
|
|
21
30
|
successRedirect = '/subscription',
|
|
22
31
|
cancelRedirect = '/subscription',
|
|
32
|
+
defaultInterval = 'month',
|
|
23
33
|
onSelect,
|
|
24
34
|
planCard,
|
|
25
35
|
emptyState,
|
|
@@ -37,6 +47,50 @@
|
|
|
37
47
|
const loading = $derived($subscriptionStore.loading);
|
|
38
48
|
const storeError = $derived($subscriptionStore.error);
|
|
39
49
|
|
|
50
|
+
const INTERVAL_LABELS: Record<BillingInterval, string> = {
|
|
51
|
+
day: 'Daily',
|
|
52
|
+
week: 'Weekly',
|
|
53
|
+
month: 'Monthly',
|
|
54
|
+
year: 'Yearly',
|
|
55
|
+
};
|
|
56
|
+
|
|
57
|
+
// Distinct billing intervals offered by any *paid* price across all plans,
|
|
58
|
+
// in a stable display order. (Free, amount-0 prices are interval-agnostic
|
|
59
|
+
// and always shown, so they don't contribute a tab.)
|
|
60
|
+
const availableIntervals = $derived.by<BillingInterval[]>(() => {
|
|
61
|
+
const order: BillingInterval[] = ['day', 'week', 'month', 'year'];
|
|
62
|
+
return order.filter((i) =>
|
|
63
|
+
(plans ?? []).some((plan) =>
|
|
64
|
+
plan.prices.some((p) => p.amount > 0 && p.recurrenceInterval === i),
|
|
65
|
+
),
|
|
66
|
+
);
|
|
67
|
+
});
|
|
68
|
+
|
|
69
|
+
// Only render the toggle when there's a genuine choice (≥2 intervals).
|
|
70
|
+
const showIntervalTabs = $derived(availableIntervals.length >= 2);
|
|
71
|
+
|
|
72
|
+
// The user's explicit tab choice (null until they pick one). The effective
|
|
73
|
+
// selection is derived — the override when still valid, else the requested
|
|
74
|
+
// `defaultInterval`, else the first available. Kept as $derived (rather than
|
|
75
|
+
// an $effect that writes state) so it reconciles automatically as plans load.
|
|
76
|
+
let intervalOverride = $state<BillingInterval | null>(null);
|
|
77
|
+
const selectedInterval = $derived.by<BillingInterval>(() => {
|
|
78
|
+
if (intervalOverride && availableIntervals.includes(intervalOverride)) {
|
|
79
|
+
return intervalOverride;
|
|
80
|
+
}
|
|
81
|
+
return availableIntervals.includes(defaultInterval)
|
|
82
|
+
? defaultInterval
|
|
83
|
+
: (availableIntervals[0] ?? defaultInterval);
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
// Prices to show for a plan under the active tab: the matching interval, plus
|
|
87
|
+
// any free (amount-0) prices which apply regardless of interval.
|
|
88
|
+
function pricesForInterval(plan: Plan): PriceOfferSdk[] {
|
|
89
|
+
return plan.prices.filter(
|
|
90
|
+
(p) => p.amount === 0 || p.recurrenceInterval === selectedInterval,
|
|
91
|
+
);
|
|
92
|
+
}
|
|
93
|
+
|
|
40
94
|
type UiState = 'idle' | 'payment-failed' | 'setup-payments' | 'select-plan' | 'active' | 'trial';
|
|
41
95
|
|
|
42
96
|
const uiState = $derived<UiState>((() => {
|
|
@@ -151,13 +205,35 @@
|
|
|
151
205
|
<p class="bridge-plan-empty">No plans available.</p>
|
|
152
206
|
{/if}
|
|
153
207
|
{:else if plans}
|
|
208
|
+
{#if showIntervalTabs}
|
|
209
|
+
<div
|
|
210
|
+
class="bridge-plan-interval-tabs"
|
|
211
|
+
data-bridge-plan-interval-tabs
|
|
212
|
+
role="group"
|
|
213
|
+
aria-label="Billing interval"
|
|
214
|
+
>
|
|
215
|
+
{#each availableIntervals as interval (interval)}
|
|
216
|
+
<button
|
|
217
|
+
type="button"
|
|
218
|
+
class="bridge-plan-interval-tab"
|
|
219
|
+
data-active={interval === selectedInterval}
|
|
220
|
+
aria-pressed={interval === selectedInterval}
|
|
221
|
+
onclick={() => (intervalOverride = interval)}
|
|
222
|
+
>
|
|
223
|
+
{INTERVAL_LABELS[interval]}
|
|
224
|
+
</button>
|
|
225
|
+
{/each}
|
|
226
|
+
</div>
|
|
227
|
+
{/if}
|
|
228
|
+
|
|
154
229
|
<div class="bridge-plan-cards" data-bridge-plan-cards>
|
|
155
230
|
{#each plans as plan (plan.key)}
|
|
156
231
|
{@const isCurrent = plan.key === currentPlanKey}
|
|
157
232
|
{@const onPick = (price: PriceOfferSdk) => handlePick(plan, price)}
|
|
233
|
+
{@const visiblePrices = pricesForInterval(plan)}
|
|
158
234
|
|
|
159
235
|
{#if planCard}
|
|
160
|
-
{@render planCard({ plan, prices: plan.prices, isCurrent, onPick })}
|
|
236
|
+
{@render planCard({ plan, prices: plan.prices, isCurrent, interval: selectedInterval, onPick })}
|
|
161
237
|
{:else}
|
|
162
238
|
<div
|
|
163
239
|
data-bridge-plan-card
|
|
@@ -177,7 +253,7 @@
|
|
|
177
253
|
{/if}
|
|
178
254
|
|
|
179
255
|
<div class="bridge-plan-prices">
|
|
180
|
-
{#each
|
|
256
|
+
{#each visiblePrices as price (price.recurrenceInterval + price.currency)}
|
|
181
257
|
<button
|
|
182
258
|
class="bridge-btn-primary bridge-plan-select-btn"
|
|
183
259
|
disabled={isCurrent || picking}
|
|
@@ -201,6 +277,10 @@
|
|
|
201
277
|
>
|
|
202
278
|
{isCurrent ? 'Current plan' : 'Select plan'}
|
|
203
279
|
</button>
|
|
280
|
+
{:else if visiblePrices.length === 0}
|
|
281
|
+
<p class="bridge-plan-unavailable" data-bridge-plan-unavailable>
|
|
282
|
+
Not available {INTERVAL_LABELS[selectedInterval].toLowerCase()}
|
|
283
|
+
</p>
|
|
204
284
|
{/if}
|
|
205
285
|
</div>
|
|
206
286
|
</div>
|
|
@@ -1,17 +1,26 @@
|
|
|
1
1
|
import type { Snippet } from 'svelte';
|
|
2
2
|
import type { HTMLAttributes } from 'svelte/elements';
|
|
3
3
|
import type { Plan, PriceOfferSdk } from '@nebulr-group/bridge-auth-core';
|
|
4
|
+
type BillingInterval = PriceOfferSdk['recurrenceInterval'];
|
|
4
5
|
interface Props extends HTMLAttributes<HTMLDivElement> {
|
|
5
6
|
successRedirect?: string;
|
|
6
7
|
cancelRedirect?: string;
|
|
8
|
+
/**
|
|
9
|
+
* Which billing interval tab is selected by default (`'month'` | `'year'` |
|
|
10
|
+
* `'week'` | `'day'`). Falls back to the first available interval when the
|
|
11
|
+
* requested one isn't offered by any plan. Default: `'month'`.
|
|
12
|
+
*/
|
|
13
|
+
defaultInterval?: BillingInterval;
|
|
7
14
|
onSelect?: (detail: {
|
|
8
15
|
plan: Plan;
|
|
9
16
|
price: PriceOfferSdk;
|
|
10
17
|
}) => void;
|
|
18
|
+
/** Custom card renderer. `prices` is the plan's full price list; `interval` is the active tab. */
|
|
11
19
|
planCard?: Snippet<[{
|
|
12
20
|
plan: Plan;
|
|
13
21
|
prices: PriceOfferSdk[];
|
|
14
22
|
isCurrent: boolean;
|
|
23
|
+
interval: BillingInterval;
|
|
15
24
|
onPick: (price: PriceOfferSdk) => void;
|
|
16
25
|
}]>;
|
|
17
26
|
emptyState?: Snippet;
|
package/dist/core/bridge.d.ts
CHANGED
|
@@ -36,7 +36,11 @@ export interface BridgeTenantSurface {
|
|
|
36
36
|
id: Readable<string | null>;
|
|
37
37
|
/** Workspace display name. Populated by session.snapshot. */
|
|
38
38
|
name: Readable<string | null>;
|
|
39
|
-
/**
|
|
39
|
+
/**
|
|
40
|
+
* Canonical subscription (plan + status + endsAt). Live via `session.snapshot`
|
|
41
|
+
* when the realtime channel is active; otherwise transparently falls back to
|
|
42
|
+
* the REST subscription endpoint (fetched lazily on first read).
|
|
43
|
+
*/
|
|
40
44
|
subscription: Readable<SubscriptionSnapshot | null>;
|
|
41
45
|
/**
|
|
42
46
|
* Entitlements scope. `snapshot` is the full `{ key: boolean }` map;
|
|
@@ -52,7 +56,11 @@ export interface BridgeTenantSurface {
|
|
|
52
56
|
export interface BridgeSurface {
|
|
53
57
|
app: BridgeAppSurface;
|
|
54
58
|
tenant: BridgeTenantSurface;
|
|
55
|
-
/**
|
|
59
|
+
/**
|
|
60
|
+
* Authenticated user (id/email/role/tenantId). Live via `session.snapshot`
|
|
61
|
+
* when the realtime channel is active; otherwise transparently seeded from the
|
|
62
|
+
* access-token claims (the only client-side source of `role`).
|
|
63
|
+
*/
|
|
56
64
|
user: Readable<UserSnapshot | null>;
|
|
57
65
|
/**
|
|
58
66
|
* Phase 5 (TBP-328) — single attribute write surface. `set/bind/bindMany`
|
package/dist/core/bridge.js
CHANGED
|
@@ -16,11 +16,11 @@
|
|
|
16
16
|
* only exposes the singleton aggregate `bridge`; consumers can import it
|
|
17
17
|
* directly until the context hook ships.
|
|
18
18
|
*/
|
|
19
|
-
import { derived } from 'svelte/store';
|
|
19
|
+
import { derived, get } from 'svelte/store';
|
|
20
20
|
import { appBrandingStore, tenantEntitlementsStore, tenantIdStore, tenantNameStore, tenantSubscriptionStore, userSnapshotStore, } from './snapshot-stores.js';
|
|
21
21
|
import { LazySlice } from './lazy-slice.js';
|
|
22
22
|
import { DevAttributeProvider } from '@nebulr-group/bridge-auth-core';
|
|
23
|
-
import { getBridgeAuth } from './bridge-instance.js';
|
|
23
|
+
import { getBridgeAuth, tokenStore, subscriptionStore, loadSubscription } from './bridge-instance.js';
|
|
24
24
|
import { bridgeEvents } from './events.js';
|
|
25
25
|
let _lastEntitlements = null;
|
|
26
26
|
// Keep `_lastEntitlements` in sync so `can()` can answer synchronously.
|
|
@@ -54,6 +54,89 @@ const _plansSlice = new LazySlice({
|
|
|
54
54
|
// decision #20: providers < setContext < per-call, and dev's provider is
|
|
55
55
|
// effectively the per-call equivalent for set/bind/bindMany).
|
|
56
56
|
const _devAttributes = new DevAttributeProvider();
|
|
57
|
+
// ── Layered sources behind `bridge.user` / `bridge.tenant.subscription` ──────
|
|
58
|
+
//
|
|
59
|
+
// Design contract (locked): the developer always reads the SAME store; whether
|
|
60
|
+
// the value comes from the live realtime channel or a static fallback is
|
|
61
|
+
// invisible to them.
|
|
62
|
+
// A. Default — the `session.snapshot` channel populates and live-updates the
|
|
63
|
+
// snapshot stores; they win whenever present.
|
|
64
|
+
// B. Fallback (channel not active) — `bridge.user` is seeded from the access
|
|
65
|
+
// token claims (instant, full-fidelity for role); `bridge.tenant.subscription`
|
|
66
|
+
// falls back to the REST subscription endpoint (full plan name/status,
|
|
67
|
+
// fetched lazily). These are NOT live-updated — by design, since the app
|
|
68
|
+
// opted out of the live channel.
|
|
69
|
+
/**
|
|
70
|
+
* JWT-derived user, recomputed whenever the token set changes (login, refresh,
|
|
71
|
+
* workspace switch, logout). Reuses auth-core's `getCurrentUser()` — the single
|
|
72
|
+
* home for access-token claim parsing. `null` when unauthenticated.
|
|
73
|
+
*/
|
|
74
|
+
const _jwtUser = derived(tokenStore, ($t) => {
|
|
75
|
+
if (!$t?.accessToken)
|
|
76
|
+
return null;
|
|
77
|
+
let claims = null;
|
|
78
|
+
try {
|
|
79
|
+
claims = getBridgeAuth().getCurrentUser();
|
|
80
|
+
}
|
|
81
|
+
catch {
|
|
82
|
+
return null;
|
|
83
|
+
}
|
|
84
|
+
if (!claims)
|
|
85
|
+
return null;
|
|
86
|
+
return {
|
|
87
|
+
id: claims.id,
|
|
88
|
+
email: claims.email,
|
|
89
|
+
// UserSnapshot requires these; the live snapshot supplies the authoritative
|
|
90
|
+
// values, the JWT fallback fills them when claims carry them.
|
|
91
|
+
role: claims.role ?? '',
|
|
92
|
+
tenantId: claims.tenantId ?? '',
|
|
93
|
+
};
|
|
94
|
+
});
|
|
95
|
+
/** `bridge.user`: live snapshot wins; JWT seed is the static fallback. */
|
|
96
|
+
const _userSurface = derived([userSnapshotStore, _jwtUser], ([snap, jwt]) => snap ?? jwt);
|
|
97
|
+
/** Map the REST `SubscriptionStatus` to the canonical `SubscriptionSnapshot` shape. */
|
|
98
|
+
function mapRestSubscription(status) {
|
|
99
|
+
if (!status)
|
|
100
|
+
return null;
|
|
101
|
+
const plan = status.plan;
|
|
102
|
+
let slug = '';
|
|
103
|
+
let name = '';
|
|
104
|
+
if (typeof plan === 'string') {
|
|
105
|
+
slug = plan;
|
|
106
|
+
name = plan;
|
|
107
|
+
}
|
|
108
|
+
else if (plan) {
|
|
109
|
+
slug = plan.key;
|
|
110
|
+
name = plan.name;
|
|
111
|
+
}
|
|
112
|
+
// REST exposes lifecycle as booleans; derive a best-effort status string to
|
|
113
|
+
// parallel the snapshot's `status` field.
|
|
114
|
+
const statusStr = status.paymentFailed
|
|
115
|
+
? 'past_due'
|
|
116
|
+
: status.trial
|
|
117
|
+
? 'trialing'
|
|
118
|
+
: status.paymentsEnabled || status.plan
|
|
119
|
+
? 'active'
|
|
120
|
+
: 'incomplete';
|
|
121
|
+
return { plan: { slug, name }, status: statusStr };
|
|
122
|
+
}
|
|
123
|
+
/**
|
|
124
|
+
* `bridge.tenant.subscription`: live snapshot wins; otherwise the REST endpoint
|
|
125
|
+
* is fetched lazily on first subscribe (only when no snapshot and not already
|
|
126
|
+
* loading/loaded). In live apps the snapshot is present, so REST is never hit.
|
|
127
|
+
*/
|
|
128
|
+
const _subscriptionMerged = derived([tenantSubscriptionStore, subscriptionStore], ([snap, rest]) => snap ?? mapRestSubscription(rest.status));
|
|
129
|
+
const _subscriptionSurface = {
|
|
130
|
+
subscribe(run, invalidate) {
|
|
131
|
+
const noSnapshot = get(tenantSubscriptionStore) == null;
|
|
132
|
+
const rest = get(subscriptionStore);
|
|
133
|
+
if (noSnapshot && rest.status == null && !rest.loading) {
|
|
134
|
+
// Lazy fallback fetch — throws if unauthenticated; swallow (stays null).
|
|
135
|
+
loadSubscription().catch(() => { });
|
|
136
|
+
}
|
|
137
|
+
return _subscriptionMerged.subscribe(run, invalidate);
|
|
138
|
+
},
|
|
139
|
+
};
|
|
57
140
|
export const bridge = {
|
|
58
141
|
app: {
|
|
59
142
|
branding: appBrandingStore,
|
|
@@ -62,13 +145,13 @@ export const bridge = {
|
|
|
62
145
|
tenant: {
|
|
63
146
|
id: tenantIdStore,
|
|
64
147
|
name: tenantNameStore,
|
|
65
|
-
subscription:
|
|
148
|
+
subscription: _subscriptionSurface,
|
|
66
149
|
entitlements: {
|
|
67
150
|
snapshot: tenantEntitlementsStore,
|
|
68
151
|
can: entitlementsCan,
|
|
69
152
|
},
|
|
70
153
|
},
|
|
71
|
-
user:
|
|
154
|
+
user: _userSurface,
|
|
72
155
|
attributes: _devAttributes,
|
|
73
156
|
events: bridgeEvents,
|
|
74
157
|
};
|
package/dist/styles.css
CHANGED
|
@@ -935,6 +935,39 @@
|
|
|
935
935
|
margin-top: 0.75rem;
|
|
936
936
|
}
|
|
937
937
|
|
|
938
|
+
.bridge-plan-interval-tabs {
|
|
939
|
+
display: inline-flex;
|
|
940
|
+
gap: 0.25rem;
|
|
941
|
+
margin-bottom: 1.25rem;
|
|
942
|
+
padding: 0.25rem;
|
|
943
|
+
border: 1px solid var(--bridge-border, #e2e8f0);
|
|
944
|
+
border-radius: var(--bridge-border-radius, 0.5rem);
|
|
945
|
+
background: var(--bridge-muted-bg, var(--bridge-bg-muted, #f8fafc));
|
|
946
|
+
}
|
|
947
|
+
|
|
948
|
+
.bridge-plan-interval-tab {
|
|
949
|
+
appearance: none;
|
|
950
|
+
border: none;
|
|
951
|
+
background: transparent;
|
|
952
|
+
cursor: pointer;
|
|
953
|
+
font: inherit;
|
|
954
|
+
font-size: 0.8125rem;
|
|
955
|
+
padding: 0.375rem 0.875rem;
|
|
956
|
+
border-radius: calc(var(--bridge-border-radius, 0.5rem) - 0.2rem);
|
|
957
|
+
color: var(--bridge-muted, #64748b);
|
|
958
|
+
}
|
|
959
|
+
|
|
960
|
+
.bridge-plan-interval-tab[data-active="true"] {
|
|
961
|
+
background: var(--bridge-primary, #2563eb);
|
|
962
|
+
color: var(--bridge-primary-fg, var(--bridge-primary-foreground, #fff));
|
|
963
|
+
}
|
|
964
|
+
|
|
965
|
+
.bridge-plan-unavailable {
|
|
966
|
+
margin: 0;
|
|
967
|
+
font-size: 0.8125rem;
|
|
968
|
+
color: var(--bridge-muted, #64748b);
|
|
969
|
+
}
|
|
970
|
+
|
|
938
971
|
.bridge-plan-cards {
|
|
939
972
|
display: grid;
|
|
940
973
|
grid-template-columns: repeat(auto-fill, minmax(220px, 1fr));
|
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@nebulr-group/bridge-svelte",
|
|
3
|
-
"version": "0.4.0-beta.
|
|
3
|
+
"version": "0.4.0-beta.6",
|
|
4
4
|
"description": "Bridge Svelte library, This library helps you to add bridge authentication and feature flags, and payments to your svelte application.",
|
|
5
5
|
"author": "Iman Pouya",
|
|
6
6
|
"license": "MIT",
|
|
@@ -67,7 +67,7 @@
|
|
|
67
67
|
}
|
|
68
68
|
},
|
|
69
69
|
"dependencies": {
|
|
70
|
-
"@nebulr-group/bridge-auth-core": "0.4.0-beta.
|
|
70
|
+
"@nebulr-group/bridge-auth-core": "0.4.0-beta.7"
|
|
71
71
|
},
|
|
72
72
|
"devDependencies": {
|
|
73
73
|
"@sveltejs/kit": "^2.16.0",
|