@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
|
|
95
|
-
//
|
|
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
|
-
|
|
98
|
-
|
|
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
|
|
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 @@
|
|
|
1
|
+
export {};
|
|
@@ -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
|
-
|
|
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.
|
|
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
|
|
67
|
+
"@nebulr-group/bridge-auth-core": "0.4.0"
|
|
68
68
|
},
|
|
69
69
|
"devDependencies": {
|
|
70
70
|
"@sveltejs/kit": "^2.16.0",
|