@startsimpli/billing 0.1.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.
- package/README.md +145 -0
- package/package.json +50 -0
- package/src/components/ManageSubscription.test.tsx +140 -0
- package/src/components/ManageSubscription.tsx +53 -0
- package/src/components/PricingDetailPage.integration.test.tsx +244 -0
- package/src/components/PricingDetailPage.test.tsx +404 -0
- package/src/components/PricingDetailPage.tsx +295 -0
- package/src/components/PricingPage.test.tsx +278 -0
- package/src/components/PricingPage.tsx +153 -0
- package/src/components/PricingSection.test.tsx +319 -0
- package/src/components/PricingSection.tsx +154 -0
- package/src/components/SubscriptionManager.test.tsx +498 -0
- package/src/components/SubscriptionManager.tsx +270 -0
- package/src/components/UpgradeModal.test.tsx +152 -0
- package/src/components/UpgradeModal.tsx +195 -0
- package/src/components/index.ts +12 -0
- package/src/hooks/BillingProvider.test.tsx +125 -0
- package/src/hooks/BillingProvider.tsx +52 -0
- package/src/hooks/index.ts +6 -0
- package/src/hooks/useCheckout.test.tsx +232 -0
- package/src/hooks/useCheckout.ts +75 -0
- package/src/hooks/usePortal.test.tsx +189 -0
- package/src/hooks/usePortal.ts +43 -0
- package/src/hooks/useProduct.test.tsx +155 -0
- package/src/hooks/useProduct.ts +43 -0
- package/src/hooks/useSubscription.test.tsx +167 -0
- package/src/hooks/useSubscription.ts +40 -0
- package/src/index.ts +47 -0
- package/src/server/index.ts +2 -0
- package/src/server/proxy.ts +89 -0
- package/src/types/index.ts +78 -0
- package/src/utils/api.test.ts +129 -0
- package/src/utils/api.ts +123 -0
|
@@ -0,0 +1,404 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD tests for PricingDetailPage component.
|
|
3
|
+
*
|
|
4
|
+
* PricingDetailPage is a full dedicated pricing page with detailed plan comparison
|
|
5
|
+
* and feature table. It renders all offer cards plus a comparison table showing
|
|
6
|
+
* features across all plans.
|
|
7
|
+
*
|
|
8
|
+
* Usage: <PricingDetailPage productId="raise-simpli" showComparison={true} />
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import { describe, it, expect, vi } from "vitest";
|
|
12
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
13
|
+
import userEvent from "@testing-library/user-event";
|
|
14
|
+
import React from "react";
|
|
15
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
16
|
+
import { PricingDetailPage } from "./PricingDetailPage";
|
|
17
|
+
import type { BillingProduct } from "../types";
|
|
18
|
+
|
|
19
|
+
const mockProduct: BillingProduct = {
|
|
20
|
+
id: "prod-1",
|
|
21
|
+
slug: "raise-simpli",
|
|
22
|
+
name: "RaiseSimpli",
|
|
23
|
+
description: "VC fundraising platform",
|
|
24
|
+
offers: [
|
|
25
|
+
{
|
|
26
|
+
id: "offer-1",
|
|
27
|
+
name: "Starter",
|
|
28
|
+
slug: "starter-monthly",
|
|
29
|
+
unit_price: "0.00",
|
|
30
|
+
currency: "USD",
|
|
31
|
+
pricing_model: "flat",
|
|
32
|
+
interval_unit: "month",
|
|
33
|
+
interval_count: 1,
|
|
34
|
+
interval_display: "per month",
|
|
35
|
+
is_recurring: true,
|
|
36
|
+
is_free: true,
|
|
37
|
+
features: [
|
|
38
|
+
{ key: "seats", name: "Team seats", value: 1, limit: 1, category: "core" },
|
|
39
|
+
{ key: "projects", name: "Projects", value: 3, limit: 3, category: "core" },
|
|
40
|
+
{ key: "email_support", name: "Email support", value: true, category: "support" },
|
|
41
|
+
{ key: "priority_support", name: "Priority support", value: false, category: "support" },
|
|
42
|
+
],
|
|
43
|
+
is_active: true,
|
|
44
|
+
is_featured: false,
|
|
45
|
+
trial_days: 0,
|
|
46
|
+
sort_order: 0,
|
|
47
|
+
cta_text: "Start Free",
|
|
48
|
+
},
|
|
49
|
+
{
|
|
50
|
+
id: "offer-2",
|
|
51
|
+
name: "Pro",
|
|
52
|
+
slug: "pro-monthly",
|
|
53
|
+
unit_price: "49.00",
|
|
54
|
+
currency: "USD",
|
|
55
|
+
pricing_model: "flat",
|
|
56
|
+
interval_unit: "month",
|
|
57
|
+
interval_count: 1,
|
|
58
|
+
interval_display: "per month",
|
|
59
|
+
is_recurring: true,
|
|
60
|
+
is_free: false,
|
|
61
|
+
features: [
|
|
62
|
+
{ key: "seats", name: "Team seats", value: 5, limit: 5, category: "core" },
|
|
63
|
+
{ key: "projects", name: "Projects", value: "Unlimited", category: "core" },
|
|
64
|
+
{ key: "email_support", name: "Email support", value: true, category: "support" },
|
|
65
|
+
{ key: "priority_support", name: "Priority support", value: true, category: "support" },
|
|
66
|
+
],
|
|
67
|
+
is_active: true,
|
|
68
|
+
is_featured: true,
|
|
69
|
+
trial_days: 14,
|
|
70
|
+
sort_order: 1,
|
|
71
|
+
cta_text: "Start Trial",
|
|
72
|
+
},
|
|
73
|
+
{
|
|
74
|
+
id: "offer-3",
|
|
75
|
+
name: "Enterprise",
|
|
76
|
+
slug: "enterprise-monthly",
|
|
77
|
+
unit_price: "199.00",
|
|
78
|
+
currency: "USD",
|
|
79
|
+
pricing_model: "per_seat",
|
|
80
|
+
interval_unit: "month",
|
|
81
|
+
interval_count: 1,
|
|
82
|
+
interval_display: "per month",
|
|
83
|
+
is_recurring: true,
|
|
84
|
+
is_free: false,
|
|
85
|
+
features: [
|
|
86
|
+
{ key: "seats", name: "Team seats", value: "Unlimited", category: "core" },
|
|
87
|
+
{ key: "projects", name: "Projects", value: "Unlimited", category: "core" },
|
|
88
|
+
{ key: "email_support", name: "Email support", value: true, category: "support" },
|
|
89
|
+
{ key: "priority_support", name: "Priority support", value: true, category: "support" },
|
|
90
|
+
],
|
|
91
|
+
is_active: true,
|
|
92
|
+
is_featured: false,
|
|
93
|
+
trial_days: 0,
|
|
94
|
+
sort_order: 2,
|
|
95
|
+
cta_text: "Contact Sales",
|
|
96
|
+
},
|
|
97
|
+
],
|
|
98
|
+
};
|
|
99
|
+
|
|
100
|
+
function createWrapper(fetchImpl: typeof fetch) {
|
|
101
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
102
|
+
return (
|
|
103
|
+
<BillingProvider
|
|
104
|
+
apiBaseUrl="https://api.test.com/api/v1"
|
|
105
|
+
authToken="tok_123"
|
|
106
|
+
fetcher={fetchImpl}
|
|
107
|
+
>
|
|
108
|
+
{children}
|
|
109
|
+
</BillingProvider>
|
|
110
|
+
);
|
|
111
|
+
};
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
function mockFetchSuccess() {
|
|
115
|
+
return vi.fn().mockResolvedValue({
|
|
116
|
+
ok: true,
|
|
117
|
+
json: () => Promise.resolve(mockProduct),
|
|
118
|
+
}) as unknown as typeof fetch;
|
|
119
|
+
}
|
|
120
|
+
|
|
121
|
+
describe("PricingDetailPage", () => {
|
|
122
|
+
it("renders loading state initially", () => {
|
|
123
|
+
// Never resolves — stays in loading
|
|
124
|
+
const fetcher = vi.fn().mockReturnValue(
|
|
125
|
+
new Promise(() => {})
|
|
126
|
+
) as unknown as typeof fetch;
|
|
127
|
+
const Wrapper = createWrapper(fetcher);
|
|
128
|
+
|
|
129
|
+
render(
|
|
130
|
+
<Wrapper>
|
|
131
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
132
|
+
</Wrapper>
|
|
133
|
+
);
|
|
134
|
+
|
|
135
|
+
expect(screen.getByText(/loading/i)).toBeDefined();
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("renders error state", async () => {
|
|
139
|
+
const fetcher = vi.fn().mockResolvedValue({
|
|
140
|
+
ok: false,
|
|
141
|
+
status: 404,
|
|
142
|
+
}) as unknown as typeof fetch;
|
|
143
|
+
const Wrapper = createWrapper(fetcher);
|
|
144
|
+
|
|
145
|
+
render(
|
|
146
|
+
<Wrapper>
|
|
147
|
+
<PricingDetailPage productId="nonexistent" />
|
|
148
|
+
</Wrapper>
|
|
149
|
+
);
|
|
150
|
+
|
|
151
|
+
await waitFor(() => {
|
|
152
|
+
expect(screen.getByText(/unable to load/i)).toBeDefined();
|
|
153
|
+
});
|
|
154
|
+
});
|
|
155
|
+
|
|
156
|
+
it("renders product name and description", async () => {
|
|
157
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
158
|
+
|
|
159
|
+
render(
|
|
160
|
+
<Wrapper>
|
|
161
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
162
|
+
</Wrapper>
|
|
163
|
+
);
|
|
164
|
+
|
|
165
|
+
await waitFor(() => {
|
|
166
|
+
expect(screen.getByText("RaiseSimpli")).toBeDefined();
|
|
167
|
+
});
|
|
168
|
+
expect(screen.getByText("VC fundraising platform")).toBeDefined();
|
|
169
|
+
});
|
|
170
|
+
|
|
171
|
+
it("renders all offer cards with names and prices", async () => {
|
|
172
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
173
|
+
|
|
174
|
+
render(
|
|
175
|
+
<Wrapper>
|
|
176
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
177
|
+
</Wrapper>
|
|
178
|
+
);
|
|
179
|
+
|
|
180
|
+
await waitFor(() => {
|
|
181
|
+
expect(screen.getAllByText("Starter").length).toBeGreaterThan(0);
|
|
182
|
+
});
|
|
183
|
+
expect(screen.getAllByText("Pro").length).toBeGreaterThan(0);
|
|
184
|
+
expect(screen.getAllByText("Enterprise").length).toBeGreaterThan(0);
|
|
185
|
+
expect(screen.getByText(/\$49/)).toBeDefined();
|
|
186
|
+
expect(screen.getByText(/\$199/)).toBeDefined();
|
|
187
|
+
});
|
|
188
|
+
|
|
189
|
+
it('shows "Free" for free offers', async () => {
|
|
190
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
191
|
+
|
|
192
|
+
render(
|
|
193
|
+
<Wrapper>
|
|
194
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
195
|
+
</Wrapper>
|
|
196
|
+
);
|
|
197
|
+
|
|
198
|
+
await waitFor(() => {
|
|
199
|
+
expect(screen.getAllByText(/free/i).length).toBeGreaterThan(0);
|
|
200
|
+
});
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
it("highlights featured offer", async () => {
|
|
204
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
205
|
+
|
|
206
|
+
render(
|
|
207
|
+
<Wrapper>
|
|
208
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
209
|
+
</Wrapper>
|
|
210
|
+
);
|
|
211
|
+
|
|
212
|
+
await waitFor(() => {
|
|
213
|
+
expect(screen.getAllByText("Pro").length).toBeGreaterThan(0);
|
|
214
|
+
});
|
|
215
|
+
|
|
216
|
+
// Featured offer should have visual distinction
|
|
217
|
+
expect(screen.getByText(/most popular/i)).toBeDefined();
|
|
218
|
+
});
|
|
219
|
+
|
|
220
|
+
it("shows trial badge for offers with trial days", async () => {
|
|
221
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
222
|
+
|
|
223
|
+
render(
|
|
224
|
+
<Wrapper>
|
|
225
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
226
|
+
</Wrapper>
|
|
227
|
+
);
|
|
228
|
+
|
|
229
|
+
await waitFor(() => {
|
|
230
|
+
expect(screen.getByText(/14.day/i)).toBeDefined();
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
it("renders CTA buttons with correct text", async () => {
|
|
235
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
236
|
+
|
|
237
|
+
render(
|
|
238
|
+
<Wrapper>
|
|
239
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
240
|
+
</Wrapper>
|
|
241
|
+
);
|
|
242
|
+
|
|
243
|
+
await waitFor(() => {
|
|
244
|
+
expect(screen.getByText("Start Free")).toBeDefined();
|
|
245
|
+
});
|
|
246
|
+
expect(screen.getByText("Start Trial")).toBeDefined();
|
|
247
|
+
expect(screen.getByText("Contact Sales")).toBeDefined();
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("calls onSelectOffer when CTA clicked", async () => {
|
|
251
|
+
const onSelect = vi.fn();
|
|
252
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
253
|
+
|
|
254
|
+
render(
|
|
255
|
+
<Wrapper>
|
|
256
|
+
<PricingDetailPage productId="raise-simpli" onSelectOffer={onSelect} />
|
|
257
|
+
</Wrapper>
|
|
258
|
+
);
|
|
259
|
+
|
|
260
|
+
await waitFor(() => {
|
|
261
|
+
expect(screen.getByText("Start Free")).toBeDefined();
|
|
262
|
+
});
|
|
263
|
+
|
|
264
|
+
await userEvent.click(screen.getByText("Start Free"));
|
|
265
|
+
expect(onSelect).toHaveBeenCalledWith(
|
|
266
|
+
expect.objectContaining({ slug: "starter-monthly" })
|
|
267
|
+
);
|
|
268
|
+
});
|
|
269
|
+
|
|
270
|
+
it("renders feature comparison table", async () => {
|
|
271
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
272
|
+
|
|
273
|
+
render(
|
|
274
|
+
<Wrapper>
|
|
275
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
276
|
+
</Wrapper>
|
|
277
|
+
);
|
|
278
|
+
|
|
279
|
+
await waitFor(() => {
|
|
280
|
+
expect(screen.getByTestId("comparison-table")).toBeDefined();
|
|
281
|
+
});
|
|
282
|
+
});
|
|
283
|
+
|
|
284
|
+
it("comparison table has a row for each unique feature key", async () => {
|
|
285
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
286
|
+
|
|
287
|
+
render(
|
|
288
|
+
<Wrapper>
|
|
289
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
290
|
+
</Wrapper>
|
|
291
|
+
);
|
|
292
|
+
|
|
293
|
+
await waitFor(() => {
|
|
294
|
+
expect(screen.getByTestId("comparison-row-seats")).toBeDefined();
|
|
295
|
+
});
|
|
296
|
+
expect(screen.getByTestId("comparison-row-projects")).toBeDefined();
|
|
297
|
+
expect(screen.getByTestId("comparison-row-email_support")).toBeDefined();
|
|
298
|
+
expect(screen.getByTestId("comparison-row-priority_support")).toBeDefined();
|
|
299
|
+
});
|
|
300
|
+
|
|
301
|
+
it("comparison table shows values (checkmarks for true, X for false, text for strings)", async () => {
|
|
302
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
303
|
+
|
|
304
|
+
render(
|
|
305
|
+
<Wrapper>
|
|
306
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
307
|
+
</Wrapper>
|
|
308
|
+
);
|
|
309
|
+
|
|
310
|
+
await waitFor(() => {
|
|
311
|
+
// Check for checkmarks (true values) - email support in all plans
|
|
312
|
+
const emailSupportRow = screen.getByTestId("comparison-row-email_support");
|
|
313
|
+
expect(emailSupportRow).toBeDefined();
|
|
314
|
+
});
|
|
315
|
+
|
|
316
|
+
// Priority support: false in Starter, true in Pro and Enterprise
|
|
317
|
+
const prioritySupportRow = screen.getByTestId("comparison-row-priority_support");
|
|
318
|
+
expect(prioritySupportRow).toBeDefined();
|
|
319
|
+
|
|
320
|
+
// Numeric and string values - "Unlimited" text appears
|
|
321
|
+
expect(screen.getAllByText("Unlimited").length).toBeGreaterThan(0);
|
|
322
|
+
});
|
|
323
|
+
|
|
324
|
+
it("hides comparison table when showComparison=false", async () => {
|
|
325
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
326
|
+
|
|
327
|
+
render(
|
|
328
|
+
<Wrapper>
|
|
329
|
+
<PricingDetailPage productId="raise-simpli" showComparison={false} />
|
|
330
|
+
</Wrapper>
|
|
331
|
+
);
|
|
332
|
+
|
|
333
|
+
await waitFor(() => {
|
|
334
|
+
expect(screen.getByText("RaiseSimpli")).toBeDefined();
|
|
335
|
+
});
|
|
336
|
+
|
|
337
|
+
// Comparison table should not be rendered
|
|
338
|
+
expect(screen.queryByTestId("comparison-table")).toBeNull();
|
|
339
|
+
});
|
|
340
|
+
|
|
341
|
+
it("shows all features in cards (not limited like PricingSection)", async () => {
|
|
342
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
343
|
+
|
|
344
|
+
render(
|
|
345
|
+
<Wrapper>
|
|
346
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
347
|
+
</Wrapper>
|
|
348
|
+
);
|
|
349
|
+
|
|
350
|
+
await waitFor(() => {
|
|
351
|
+
// All features should appear in the cards
|
|
352
|
+
expect(screen.getAllByText("Team seats").length).toBeGreaterThan(0);
|
|
353
|
+
expect(screen.getAllByText("Projects").length).toBeGreaterThan(0);
|
|
354
|
+
expect(screen.getAllByText("Email support").length).toBeGreaterThan(0);
|
|
355
|
+
expect(screen.getAllByText("Priority support").length).toBeGreaterThan(0);
|
|
356
|
+
});
|
|
357
|
+
});
|
|
358
|
+
|
|
359
|
+
it("renders detail card test IDs for each offer", async () => {
|
|
360
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
361
|
+
|
|
362
|
+
render(
|
|
363
|
+
<Wrapper>
|
|
364
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
365
|
+
</Wrapper>
|
|
366
|
+
);
|
|
367
|
+
|
|
368
|
+
await waitFor(() => {
|
|
369
|
+
expect(screen.getByTestId("detail-card-starter-monthly")).toBeDefined();
|
|
370
|
+
});
|
|
371
|
+
expect(screen.getByTestId("detail-card-pro-monthly")).toBeDefined();
|
|
372
|
+
expect(screen.getByTestId("detail-card-enterprise-monthly")).toBeDefined();
|
|
373
|
+
});
|
|
374
|
+
|
|
375
|
+
it("renders detail CTA test IDs for each offer", async () => {
|
|
376
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
377
|
+
|
|
378
|
+
render(
|
|
379
|
+
<Wrapper>
|
|
380
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
381
|
+
</Wrapper>
|
|
382
|
+
);
|
|
383
|
+
|
|
384
|
+
await waitFor(() => {
|
|
385
|
+
expect(screen.getByTestId("detail-cta-starter-monthly")).toBeDefined();
|
|
386
|
+
});
|
|
387
|
+
expect(screen.getByTestId("detail-cta-pro-monthly")).toBeDefined();
|
|
388
|
+
expect(screen.getByTestId("detail-cta-enterprise-monthly")).toBeDefined();
|
|
389
|
+
});
|
|
390
|
+
|
|
391
|
+
it("renders main container with pricing-detail test ID", async () => {
|
|
392
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
393
|
+
|
|
394
|
+
render(
|
|
395
|
+
<Wrapper>
|
|
396
|
+
<PricingDetailPage productId="raise-simpli" />
|
|
397
|
+
</Wrapper>
|
|
398
|
+
);
|
|
399
|
+
|
|
400
|
+
await waitFor(() => {
|
|
401
|
+
expect(screen.getByTestId("pricing-detail")).toBeDefined();
|
|
402
|
+
});
|
|
403
|
+
});
|
|
404
|
+
});
|
|
@@ -0,0 +1,295 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PricingDetailPage — Full dedicated pricing page with plan comparison and feature table.
|
|
3
|
+
*
|
|
4
|
+
* Renders all offer cards with complete feature lists, plus a comparison table
|
|
5
|
+
* showing features across all plans.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <PricingDetailPage productId="raise-simpli" showComparison={true} />
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { useProduct } from "../hooks/useProduct";
|
|
13
|
+
import type { ProductOffer, OfferFeature } from "../types";
|
|
14
|
+
|
|
15
|
+
export interface PricingDetailPageProps {
|
|
16
|
+
/** Product slug (e.g., "raise-simpli") */
|
|
17
|
+
productId: string;
|
|
18
|
+
|
|
19
|
+
/** Called when user clicks an offer's CTA button */
|
|
20
|
+
onSelectOffer?: (offer: ProductOffer) => void;
|
|
21
|
+
|
|
22
|
+
/** Whether to show the feature comparison table (defaults to true) */
|
|
23
|
+
showComparison?: boolean;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function PricingDetailPage({
|
|
27
|
+
productId,
|
|
28
|
+
onSelectOffer,
|
|
29
|
+
showComparison = true,
|
|
30
|
+
}: PricingDetailPageProps) {
|
|
31
|
+
const { product, loading, error } = useProduct(productId);
|
|
32
|
+
|
|
33
|
+
if (loading) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="text-center py-16 text-gray-500">
|
|
36
|
+
Loading pricing...
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (error || !product) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="text-center py-16 text-red-600">
|
|
44
|
+
Unable to load pricing. Please try again.
|
|
45
|
+
</div>
|
|
46
|
+
);
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
if (product.offers.length === 0) {
|
|
50
|
+
return (
|
|
51
|
+
<div className="text-center py-16 text-gray-500">
|
|
52
|
+
No pricing plans available at this time.
|
|
53
|
+
</div>
|
|
54
|
+
);
|
|
55
|
+
}
|
|
56
|
+
|
|
57
|
+
// Extract all unique feature keys across all offers (preserving order)
|
|
58
|
+
const allFeatureKeys: string[] = [];
|
|
59
|
+
const featureNameMap: Record<string, string> = {};
|
|
60
|
+
for (const offer of product.offers) {
|
|
61
|
+
for (const feature of offer.features) {
|
|
62
|
+
if (!allFeatureKeys.includes(feature.key)) {
|
|
63
|
+
allFeatureKeys.push(feature.key);
|
|
64
|
+
featureNameMap[feature.key] = feature.name;
|
|
65
|
+
}
|
|
66
|
+
}
|
|
67
|
+
}
|
|
68
|
+
|
|
69
|
+
return (
|
|
70
|
+
<div data-testid="pricing-detail" className="max-w-6xl mx-auto px-6 py-12">
|
|
71
|
+
<div className="text-center mb-12">
|
|
72
|
+
<h1 className="text-4xl font-extrabold tracking-tight text-gray-900 mb-3">
|
|
73
|
+
{product.name}
|
|
74
|
+
</h1>
|
|
75
|
+
<p className="text-lg text-gray-500 max-w-xl mx-auto">
|
|
76
|
+
{product.description}
|
|
77
|
+
</p>
|
|
78
|
+
</div>
|
|
79
|
+
|
|
80
|
+
<div className="flex flex-wrap justify-center gap-6 items-stretch mb-16">
|
|
81
|
+
{product.offers.map((offer) => (
|
|
82
|
+
<DetailCard
|
|
83
|
+
key={offer.id}
|
|
84
|
+
offer={offer}
|
|
85
|
+
onSelect={onSelectOffer}
|
|
86
|
+
/>
|
|
87
|
+
))}
|
|
88
|
+
</div>
|
|
89
|
+
|
|
90
|
+
{showComparison && (
|
|
91
|
+
<ComparisonTable
|
|
92
|
+
offers={product.offers}
|
|
93
|
+
featureKeys={allFeatureKeys}
|
|
94
|
+
featureNameMap={featureNameMap}
|
|
95
|
+
/>
|
|
96
|
+
)}
|
|
97
|
+
</div>
|
|
98
|
+
);
|
|
99
|
+
}
|
|
100
|
+
|
|
101
|
+
interface DetailCardProps {
|
|
102
|
+
offer: ProductOffer;
|
|
103
|
+
onSelect?: (offer: ProductOffer) => void;
|
|
104
|
+
}
|
|
105
|
+
|
|
106
|
+
function DetailCard({ offer, onSelect }: DetailCardProps) {
|
|
107
|
+
const isFeatured = offer.is_featured;
|
|
108
|
+
|
|
109
|
+
return (
|
|
110
|
+
<div
|
|
111
|
+
data-testid={`detail-card-${offer.slug}`}
|
|
112
|
+
className={`
|
|
113
|
+
flex flex-col relative rounded-2xl p-8 w-full max-w-sm flex-1 min-w-[280px]
|
|
114
|
+
${isFeatured
|
|
115
|
+
? "border-2 border-blue-600 shadow-lg scale-[1.03]"
|
|
116
|
+
: "border border-gray-200 bg-white shadow-sm"
|
|
117
|
+
}
|
|
118
|
+
`}
|
|
119
|
+
>
|
|
120
|
+
{isFeatured && (
|
|
121
|
+
<span className="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-xs font-bold px-4 py-1 rounded-full uppercase tracking-wide whitespace-nowrap">
|
|
122
|
+
Most Popular
|
|
123
|
+
</span>
|
|
124
|
+
)}
|
|
125
|
+
|
|
126
|
+
<h2 className="text-xl font-bold text-gray-900 mb-4">{offer.name}</h2>
|
|
127
|
+
|
|
128
|
+
<div className="mb-2">
|
|
129
|
+
{offer.is_free ? (
|
|
130
|
+
<span aria-label="Free">
|
|
131
|
+
<span aria-hidden="true" className="text-4xl font-extrabold text-gray-900">Free</span>
|
|
132
|
+
</span>
|
|
133
|
+
) : (
|
|
134
|
+
<span aria-label={`${Math.floor(parseFloat(offer.unit_price) || 0)} dollars ${offer.interval_display}`}>
|
|
135
|
+
<span aria-hidden="true" className="text-4xl font-extrabold text-gray-900">
|
|
136
|
+
${Math.floor(parseFloat(offer.unit_price) || 0)}
|
|
137
|
+
</span>
|
|
138
|
+
<span aria-hidden="true" className="text-base text-gray-500 ml-1"> {offer.interval_display}</span>
|
|
139
|
+
</span>
|
|
140
|
+
)}
|
|
141
|
+
</div>
|
|
142
|
+
|
|
143
|
+
{offer.trial_days > 0 && (
|
|
144
|
+
<span className="inline-block bg-blue-50 text-blue-700 text-sm font-semibold px-3 py-1 rounded-lg mb-5 w-fit">
|
|
145
|
+
{offer.trial_days}-day free trial
|
|
146
|
+
</span>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
<ul className="list-none p-0 m-0 mb-7 flex-1 space-y-0">
|
|
150
|
+
{offer.features.map((feature) => (
|
|
151
|
+
<li
|
|
152
|
+
key={feature.key}
|
|
153
|
+
className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
|
|
154
|
+
>
|
|
155
|
+
<FeatureIcon value={feature.value} />
|
|
156
|
+
<span>{feature.name}</span>
|
|
157
|
+
{typeof feature.value !== "boolean" && feature.value !== null && feature.value !== undefined && (
|
|
158
|
+
<span className="ml-auto text-gray-900 font-medium">
|
|
159
|
+
{feature.value === -1 ? "Unlimited" : String(feature.value)}
|
|
160
|
+
</span>
|
|
161
|
+
)}
|
|
162
|
+
</li>
|
|
163
|
+
))}
|
|
164
|
+
</ul>
|
|
165
|
+
|
|
166
|
+
<button
|
|
167
|
+
onClick={() => onSelect?.(offer)}
|
|
168
|
+
data-testid={`detail-cta-${offer.slug}`}
|
|
169
|
+
className={`
|
|
170
|
+
w-full py-3.5 px-6 text-base font-bold rounded-xl cursor-pointer transition-colors
|
|
171
|
+
${isFeatured
|
|
172
|
+
? "bg-blue-600 text-white hover:bg-blue-700"
|
|
173
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
|
174
|
+
}
|
|
175
|
+
`}
|
|
176
|
+
>
|
|
177
|
+
{offer.cta_text}
|
|
178
|
+
</button>
|
|
179
|
+
</div>
|
|
180
|
+
);
|
|
181
|
+
}
|
|
182
|
+
|
|
183
|
+
function FeatureIcon({ value }: { value: unknown }) {
|
|
184
|
+
if (value === false) {
|
|
185
|
+
return (
|
|
186
|
+
<svg role="img" aria-label="Not included" className="w-4 h-4 text-gray-300 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
187
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
188
|
+
</svg>
|
|
189
|
+
);
|
|
190
|
+
}
|
|
191
|
+
return (
|
|
192
|
+
<svg role="img" aria-label="Included" className="w-4 h-4 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
193
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
194
|
+
</svg>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
interface ComparisonTableProps {
|
|
199
|
+
offers: ProductOffer[];
|
|
200
|
+
featureKeys: string[];
|
|
201
|
+
featureNameMap: Record<string, string>;
|
|
202
|
+
}
|
|
203
|
+
|
|
204
|
+
function ComparisonTable({ offers, featureKeys, featureNameMap }: ComparisonTableProps) {
|
|
205
|
+
// Build a lookup: offer slug -> feature key -> feature
|
|
206
|
+
const featureLookup: Record<string, Record<string, OfferFeature>> = {};
|
|
207
|
+
for (const offer of offers) {
|
|
208
|
+
featureLookup[offer.slug] = {};
|
|
209
|
+
for (const feature of offer.features) {
|
|
210
|
+
featureLookup[offer.slug][feature.key] = feature;
|
|
211
|
+
}
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
return (
|
|
215
|
+
<div data-testid="comparison-table" className="overflow-x-auto">
|
|
216
|
+
<table className="w-full border-collapse">
|
|
217
|
+
<thead>
|
|
218
|
+
<tr className="border-b-2 border-gray-200">
|
|
219
|
+
<th scope="col" className="text-left py-4 px-4 text-sm font-semibold text-gray-500 uppercase tracking-wider">
|
|
220
|
+
Feature
|
|
221
|
+
</th>
|
|
222
|
+
{offers.map((offer) => (
|
|
223
|
+
<th
|
|
224
|
+
key={offer.slug}
|
|
225
|
+
scope="col"
|
|
226
|
+
aria-label={offer.name}
|
|
227
|
+
className="text-center py-4 px-4 text-sm font-semibold text-gray-900"
|
|
228
|
+
>
|
|
229
|
+
<span aria-hidden="true">{offer.name}</span>
|
|
230
|
+
</th>
|
|
231
|
+
))}
|
|
232
|
+
</tr>
|
|
233
|
+
</thead>
|
|
234
|
+
<tbody>
|
|
235
|
+
{featureKeys.map((key) => (
|
|
236
|
+
<tr
|
|
237
|
+
key={key}
|
|
238
|
+
data-testid={`comparison-row-${key}`}
|
|
239
|
+
className="border-b border-gray-100"
|
|
240
|
+
>
|
|
241
|
+
<td scope="row" className="py-3 px-4 text-sm text-gray-700">
|
|
242
|
+
{featureNameMap[key]}
|
|
243
|
+
</td>
|
|
244
|
+
{offers.map((offer) => {
|
|
245
|
+
const feature = featureLookup[offer.slug]?.[key];
|
|
246
|
+
return (
|
|
247
|
+
<td
|
|
248
|
+
key={offer.slug}
|
|
249
|
+
className="text-center py-3 px-4 text-sm"
|
|
250
|
+
>
|
|
251
|
+
<ComparisonCellValue feature={feature} />
|
|
252
|
+
</td>
|
|
253
|
+
);
|
|
254
|
+
})}
|
|
255
|
+
</tr>
|
|
256
|
+
))}
|
|
257
|
+
</tbody>
|
|
258
|
+
</table>
|
|
259
|
+
</div>
|
|
260
|
+
);
|
|
261
|
+
}
|
|
262
|
+
|
|
263
|
+
function ComparisonCellValue({ feature }: { feature?: OfferFeature }) {
|
|
264
|
+
if (!feature) {
|
|
265
|
+
return <span className="text-gray-300">—</span>;
|
|
266
|
+
}
|
|
267
|
+
|
|
268
|
+
const { value } = feature;
|
|
269
|
+
|
|
270
|
+
if (value === true) {
|
|
271
|
+
return (
|
|
272
|
+
<svg role="img" aria-label="Included" className="w-5 h-5 text-green-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
273
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
274
|
+
</svg>
|
|
275
|
+
);
|
|
276
|
+
}
|
|
277
|
+
|
|
278
|
+
if (value === false) {
|
|
279
|
+
return (
|
|
280
|
+
<svg role="img" aria-label="Not included" className="w-5 h-5 text-gray-300 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
281
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
282
|
+
</svg>
|
|
283
|
+
);
|
|
284
|
+
}
|
|
285
|
+
|
|
286
|
+
if (typeof value === "string") {
|
|
287
|
+
return <span className="text-gray-900 font-medium">{value}</span>;
|
|
288
|
+
}
|
|
289
|
+
|
|
290
|
+
if (typeof value === "number") {
|
|
291
|
+
return <span className="text-gray-900 font-medium">{value === -1 ? "Unlimited" : value}</span>;
|
|
292
|
+
}
|
|
293
|
+
|
|
294
|
+
return <span className="text-gray-500">{String(value)}</span>;
|
|
295
|
+
}
|