@nebulr-group/bridge-svelte 0.4.0-beta.9 → 0.4.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.
@@ -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.0",
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",