@nebulr-group/bridge-svelte 0.4.0-beta.4 → 0.4.0-beta.5

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.
@@ -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={isSetMode ? 'Set new password' : 'Reset your password'}
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="Reset your password">
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="Sign in with email link">
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="Sign in with email link" class={className} {style} {...rest}>
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?: string;
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
- <AuthFormWrapper {heading} class={className} {style} {...rest}>
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?: string;
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?: string;
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?: string;
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
- planCard?: Snippet<[{ plan: Plan; prices: PriceOfferSdk[]; isCurrent: boolean; onPick: (price: PriceOfferSdk) => void }]>;
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 plan.prices as price (price.recurrenceInterval + price.currency)}
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;
@@ -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
- /** Canonical subscription (plan + status + endsAt). Populated by session.snapshot. */
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
- /** Authenticated user (id/email/role/tenantId). Populated by session.snapshot. */
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`
@@ -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: tenantSubscriptionStore,
148
+ subscription: _subscriptionSurface,
66
149
  entitlements: {
67
150
  snapshot: tenantEntitlementsStore,
68
151
  can: entitlementsCan,
69
152
  },
70
153
  },
71
- user: userSnapshotStore,
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.4",
3
+ "version": "0.4.0-beta.5",
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.5"
70
+ "@nebulr-group/bridge-auth-core": "0.4.0-beta.7"
71
71
  },
72
72
  "devDependencies": {
73
73
  "@sveltejs/kit": "^2.16.0",