@nebulr-group/bridge-svelte 0.4.0-beta.9 → 0.4.1

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.
@@ -91,12 +91,44 @@
91
91
  : (availableIntervals[0] ?? defaultInterval);
92
92
  });
93
93
 
94
- // Prices to show for a plan under the active tab: the matching interval, plus
95
- // any free (amount-0) prices which apply regardless of interval.
94
+ // Prices to show for a plan under the active tab at most ONE, because each
95
+ // price renders its own select button.
96
+ //
97
+ // The previous filter was `p.amount === 0 || p.recurrenceInterval === selected`,
98
+ // where the zero-amount test short-circuited the interval test. A free plan
99
+ // defining both `0/month` and `0/year` therefore matched twice and rendered two
100
+ // identical "Select free plan" buttons — visible on The Bridge's own /plan page,
101
+ // whose free plan ("Startup") carries a zero price per interval.
102
+ //
103
+ // The intent behind that clause was that a free plan stays selectable under
104
+ // every tab, which is preserved below: prefer the active interval's price, and
105
+ // fall back to a free price from any interval so a free-on-yearly-only plan
106
+ // doesn't render as "Not available monthly".
96
107
  function pricesForInterval(plan: Plan): PriceOfferSdk[] {
97
- return plan.prices.filter(
98
- (p) => p.amount === 0 || p.recurrenceInterval === selectedInterval,
99
- );
108
+ const exact = plan.prices.filter((p) => p.recurrenceInterval === selectedInterval);
109
+ if (exact.length > 0) return exact;
110
+
111
+ const free = plan.prices.find((p) => p.amount === 0);
112
+ return free ? [free] : [];
113
+ }
114
+
115
+ // Cheapest first. The API returns plans in catalog insertion order, which on
116
+ // prod is growth → starter → free — putting the free plan last, below the most
117
+ // expensive one.
118
+ //
119
+ // The sort key is each plan's cheapest price across ALL intervals, not the
120
+ // active interval's, so the order stays put when the user toggles
121
+ // Monthly/Yearly. A plan's tier is a property of the plan; cards reshuffling
122
+ // under the cursor on a tab switch is worse than the edge case it would fix
123
+ // (a catalog whose yearly ranking inverts its monthly one). Plans with no
124
+ // price sort last rather than being treated as free.
125
+ const sortedPlans = $derived(
126
+ [...(plans ?? [])].sort((a, b) => minAmount(a) - minAmount(b)),
127
+ );
128
+
129
+ function minAmount(plan: Plan): number {
130
+ const amounts = (plan.prices ?? []).map((p) => p.amount);
131
+ return amounts.length > 0 ? Math.min(...amounts) : Number.POSITIVE_INFINITY;
100
132
  }
101
133
 
102
134
  type UiState = 'idle' | 'payment-failed' | 'setup-payments' | 'select-plan' | 'active' | 'trial';
@@ -292,7 +324,7 @@
292
324
  {/if}
293
325
 
294
326
  <div class="bridge-plan-cards" data-bridge-plan-cards>
295
- {#each plans as plan (plan.key)}
327
+ {#each sortedPlans as plan (plan.key)}
296
328
  {@const isCurrent = plan.key === currentPlanKey}
297
329
  {@const onPick = (price: PriceOfferSdk) => handlePick(plan, price)}
298
330
  {@const visiblePrices = pricesForInterval(plan)}
@@ -0,0 +1,258 @@
1
+ // Billing CTA destination — config-driven manage route (TBP-451 / S1).
2
+ //
3
+ // Under test: the destination precedence shared by <BridgeBillingNotice> and
4
+ // <BridgeQuotaBanner>:
5
+ //
6
+ // onActionClick callback (highest — short-circuits, no navigation at all)
7
+ // → `actionHref` prop
8
+ // → getConfig().billing?.manageRoute
9
+ // → '/billing' (default)
10
+ //
11
+ // HARNESS NOTE: bridge-svelte's vitest config (vitest.config.ts) runs in a
12
+ // `node` environment with no Svelte compiler plugin and no DOM (jsdom /
13
+ // happy-dom / @testing-library/svelte are NOT installed anywhere in the
14
+ // workspace), so neither component can be mounted here. Following the
15
+ // established pattern in billing-notice-gate.test.ts and plan-selector.test.ts,
16
+ // this file exercises an EXACT replica of the components' `handleAction()`
17
+ // script-block logic, with the external singletons (`getConfig` from
18
+ // config.store.js and `window.location`) injected as mocks instead of
19
+ // module-mocking them.
20
+ //
21
+ // The replicas below mirror, line for line:
22
+ // - BridgeBillingNotice.svelte `function handleAction()`
23
+ // - BridgeQuotaBanner.svelte `function handleAction()`
24
+ //
25
+ // The two differ only in (a) the argument handed to `onActionClick`
26
+ // (BillingNoticeState vs QuotaSnapshot) and (b) the quota banner's
27
+ // `if (!snapshot) return;` guard. The destination resolution is byte-identical
28
+ // and MUST stay that way — the "both components agree" block below is the
29
+ // regression guard for that.
30
+ //
31
+ // If either component's handleAction changes, update the replica here to
32
+ // match — a drift between the two is a test bug, not a component bug.
33
+ import { describe, it, expect, vi, beforeEach } from 'vitest';
34
+ const configWith = (manageRoute) => () => manageRoute === undefined ? {} : { billing: { manageRoute } };
35
+ const uninitializedConfig = () => {
36
+ throw new Error('Config has not been initialized. Call initConfig(...) early in app startup.');
37
+ };
38
+ /** Config initialized, but the app never declared a `billing` block. */
39
+ const configWithoutBillingBlock = () => ({});
40
+ function makeNoticeHarness(opts = {}) {
41
+ const { getConfig = uninitializedConfig, actionHref, onActionClick, hasWindow = true, } = opts;
42
+ // Records every write to `window.location.href`.
43
+ const navigations = [];
44
+ const noticeState = 'past_due';
45
+ function handleAction() {
46
+ if (onActionClick) {
47
+ onActionClick(noticeState);
48
+ return;
49
+ }
50
+ // Default: open the app's billing surface. Destination priority:
51
+ // `actionHref` prop → `billing.manageRoute` config → '/billing'.
52
+ if (hasWindow) {
53
+ let manageRoute;
54
+ try {
55
+ manageRoute = getConfig().billing?.manageRoute;
56
+ }
57
+ catch {
58
+ // Config not initialized — fall through to the default.
59
+ }
60
+ navigations.push(actionHref ?? manageRoute ?? '/billing');
61
+ }
62
+ }
63
+ return { handleAction, navigations, noticeState };
64
+ }
65
+ function makeQuotaHarness(opts = {}) {
66
+ const { getConfig = uninitializedConfig, actionHref, onActionClick, hasWindow = true, snapshot = { metric: 'ai_completions' }, } = opts;
67
+ const navigations = [];
68
+ function handleAction() {
69
+ if (!snapshot)
70
+ return;
71
+ if (onActionClick) {
72
+ onActionClick(snapshot);
73
+ return;
74
+ }
75
+ // Destination priority: `actionHref` prop → `billing.manageRoute` config
76
+ // → '/billing'.
77
+ if (hasWindow) {
78
+ let manageRoute;
79
+ try {
80
+ manageRoute = getConfig().billing?.manageRoute;
81
+ }
82
+ catch {
83
+ // Config not initialized — fall through to the default.
84
+ }
85
+ navigations.push(actionHref ?? manageRoute ?? '/billing');
86
+ }
87
+ }
88
+ return { handleAction, navigations, snapshot };
89
+ }
90
+ /** The two harnesses, keyed by component, for the shared-behaviour table. */
91
+ const HARNESSES = {
92
+ BridgeBillingNotice: makeNoticeHarness,
93
+ BridgeQuotaBanner: (opts) => makeQuotaHarness(opts),
94
+ };
95
+ const COMPONENTS = Object.keys(HARNESSES);
96
+ // ── Tests ────────────────────────────────────────────────────────────────────
97
+ describe('Billing CTA manage-route precedence (TBP-451)', () => {
98
+ beforeEach(() => {
99
+ vi.restoreAllMocks();
100
+ });
101
+ describe.each(COMPONENTS)('%s', (component) => {
102
+ const harness = HARNESSES[component];
103
+ describe("default — '/billing'", () => {
104
+ it('navigates to /billing when config is uninitialized and no props are given', () => {
105
+ const h = harness({ getConfig: uninitializedConfig });
106
+ h.handleAction();
107
+ expect(h.navigations).toEqual(['/billing']);
108
+ });
109
+ it('navigates to /billing when config is loaded but has no billing block', () => {
110
+ const h = harness({ getConfig: configWithoutBillingBlock });
111
+ h.handleAction();
112
+ expect(h.navigations).toEqual(['/billing']);
113
+ });
114
+ it('navigates to /billing when billing exists but manageRoute is unset', () => {
115
+ const h = harness({ getConfig: () => ({ billing: {} }) });
116
+ h.handleAction();
117
+ expect(h.navigations).toEqual(['/billing']);
118
+ });
119
+ });
120
+ describe('config — billing.manageRoute', () => {
121
+ it('honors the configured manageRoute over the built-in default', () => {
122
+ const h = harness({ getConfig: configWith('/settings/billing') });
123
+ h.handleAction();
124
+ expect(h.navigations).toEqual(['/settings/billing']);
125
+ });
126
+ it('honors an absolute manageRoute URL verbatim', () => {
127
+ const h = harness({ getConfig: configWith('https://billing.example.com/portal') });
128
+ h.handleAction();
129
+ expect(h.navigations).toEqual(['https://billing.example.com/portal']);
130
+ });
131
+ it('reads the config on every click, so a later initConfig is picked up', () => {
132
+ let manageRoute;
133
+ const h = harness({ getConfig: () => ({ billing: { manageRoute } }) });
134
+ h.handleAction(); // config not yet carrying a route
135
+ manageRoute = '/settings/billing';
136
+ h.handleAction(); // same component instance, config now set
137
+ expect(h.navigations).toEqual(['/billing', '/settings/billing']);
138
+ });
139
+ });
140
+ describe('actionHref prop', () => {
141
+ it('overrides the configured manageRoute', () => {
142
+ const h = harness({
143
+ getConfig: configWith('/settings/billing'),
144
+ actionHref: '/team/upgrade',
145
+ });
146
+ h.handleAction();
147
+ expect(h.navigations).toEqual(['/team/upgrade']);
148
+ });
149
+ it('overrides the default when no config is available at all', () => {
150
+ const h = harness({ getConfig: uninitializedConfig, actionHref: '/team/upgrade' });
151
+ h.handleAction();
152
+ expect(h.navigations).toEqual(['/team/upgrade']);
153
+ });
154
+ });
155
+ describe('onActionClick callback (highest precedence)', () => {
156
+ it('wins over both actionHref and config, and performs NO navigation', () => {
157
+ const onActionClick = vi.fn();
158
+ const h = harness({
159
+ getConfig: configWith('/settings/billing'),
160
+ actionHref: '/team/upgrade',
161
+ onActionClick,
162
+ });
163
+ h.handleAction();
164
+ expect(onActionClick).toHaveBeenCalledOnce();
165
+ expect(h.navigations).toEqual([]);
166
+ });
167
+ it('wins even with no other destination configured', () => {
168
+ const onActionClick = vi.fn();
169
+ const h = harness({ getConfig: uninitializedConfig, onActionClick });
170
+ h.handleAction();
171
+ expect(onActionClick).toHaveBeenCalledOnce();
172
+ expect(h.navigations).toEqual([]);
173
+ });
174
+ it('never touches getConfig when the callback short-circuits', () => {
175
+ const getConfig = vi.fn(configWith('/settings/billing'));
176
+ const h = harness({ getConfig, onActionClick: vi.fn() });
177
+ h.handleAction();
178
+ expect(getConfig).not.toHaveBeenCalled();
179
+ });
180
+ });
181
+ describe('SSR guard', () => {
182
+ it('does not navigate when there is no window (typeof window === undefined)', () => {
183
+ const h = harness({ getConfig: configWith('/settings/billing'), hasWindow: false });
184
+ h.handleAction();
185
+ expect(h.navigations).toEqual([]);
186
+ });
187
+ });
188
+ });
189
+ // ── Cross-component agreement ──────────────────────────────────────────────
190
+ //
191
+ // The whole point of S1 is that an app configures `billing.manageRoute` ONCE
192
+ // and both CTAs obey it. This block fails the moment the two handlers drift.
193
+ describe('BridgeBillingNotice and BridgeQuotaBanner resolve identically', () => {
194
+ const cases = [
195
+ {
196
+ label: 'no config, no props → /billing',
197
+ opts: { getConfig: uninitializedConfig },
198
+ expected: ['/billing'],
199
+ },
200
+ {
201
+ label: 'config manageRoute only',
202
+ opts: { getConfig: configWith('/settings/billing') },
203
+ expected: ['/settings/billing'],
204
+ },
205
+ {
206
+ label: 'actionHref beats config',
207
+ opts: { getConfig: configWith('/settings/billing'), actionHref: '/team/upgrade' },
208
+ expected: ['/team/upgrade'],
209
+ },
210
+ {
211
+ label: 'actionHref with no config',
212
+ opts: { getConfig: uninitializedConfig, actionHref: '/team/upgrade' },
213
+ expected: ['/team/upgrade'],
214
+ },
215
+ {
216
+ label: 'no window → no navigation',
217
+ opts: { getConfig: configWith('/settings/billing'), hasWindow: false },
218
+ expected: [],
219
+ },
220
+ ];
221
+ it.each(cases)('$label', ({ opts, expected }) => {
222
+ const notice = makeNoticeHarness(opts);
223
+ const quota = makeQuotaHarness(opts);
224
+ notice.handleAction();
225
+ quota.handleAction();
226
+ expect(notice.navigations).toEqual(expected);
227
+ expect(quota.navigations).toEqual(expected);
228
+ expect(quota.navigations).toEqual(notice.navigations);
229
+ });
230
+ it('onActionClick suppresses navigation in both components', () => {
231
+ const noticeCb = vi.fn();
232
+ const quotaCb = vi.fn();
233
+ const shared = { getConfig: configWith('/settings/billing'), actionHref: '/team/upgrade' };
234
+ const notice = makeNoticeHarness({ ...shared, onActionClick: noticeCb });
235
+ const quota = makeQuotaHarness({ ...shared, onActionClick: quotaCb });
236
+ notice.handleAction();
237
+ quota.handleAction();
238
+ expect(noticeCb).toHaveBeenCalledOnce();
239
+ expect(quotaCb).toHaveBeenCalledOnce();
240
+ expect(notice.navigations).toEqual([]);
241
+ expect(quota.navigations).toEqual([]);
242
+ });
243
+ });
244
+ // ── Quota-banner-only guard ────────────────────────────────────────────────
245
+ describe('BridgeQuotaBanner snapshot guard', () => {
246
+ it('does nothing at all — no callback, no navigation — without a snapshot', () => {
247
+ const onActionClick = vi.fn();
248
+ const h = makeQuotaHarness({
249
+ getConfig: configWith('/settings/billing'),
250
+ snapshot: null,
251
+ onActionClick,
252
+ });
253
+ h.handleAction();
254
+ expect(onActionClick).not.toHaveBeenCalled();
255
+ expect(h.navigations).toEqual([]);
256
+ });
257
+ });
258
+ });
package/dist/styles.css CHANGED
@@ -936,7 +936,11 @@
936
936
  }
937
937
 
938
938
  .bridge-plan-interval-tabs {
939
- display: inline-flex;
939
+ /* Centered above the plan cards; flex + fit-content (not inline-flex) so
940
+ auto margins can center it regardless of the host page's text alignment. */
941
+ display: flex;
942
+ width: fit-content;
943
+ margin-inline: auto;
940
944
  gap: 0.25rem;
941
945
  margin-bottom: 1.25rem;
942
946
  padding: 0.25rem;
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@nebulr-group/bridge-svelte",
3
- "version": "0.4.0-beta.9",
3
+ "version": "0.4.1",
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",
@@ -64,7 +64,7 @@
64
64
  },
65
65
  "dependencies": {
66
66
  "@simplewebauthn/browser": "^13.0.0",
67
- "@nebulr-group/bridge-auth-core": "0.4.0-beta.10"
67
+ "@nebulr-group/bridge-auth-core": "0.4.0"
68
68
  },
69
69
  "devDependencies": {
70
70
  "@sveltejs/kit": "^2.16.0",