@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,278 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD tests for PricingPage component.
|
|
3
|
+
*
|
|
4
|
+
* PricingPage is the main drop-in component that fetches product data
|
|
5
|
+
* and renders pricing cards for each active offer.
|
|
6
|
+
*
|
|
7
|
+
* Usage: <PricingPage productId="raise-simpli" />
|
|
8
|
+
*/
|
|
9
|
+
|
|
10
|
+
import { describe, it, expect, vi } from "vitest";
|
|
11
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
12
|
+
import userEvent from "@testing-library/user-event";
|
|
13
|
+
import React from "react";
|
|
14
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
15
|
+
import { PricingPage } from "./PricingPage";
|
|
16
|
+
import type { BillingProduct } from "../types";
|
|
17
|
+
|
|
18
|
+
const mockProduct: BillingProduct = {
|
|
19
|
+
id: "prod-1",
|
|
20
|
+
slug: "raise-simpli",
|
|
21
|
+
name: "RaiseSimpli",
|
|
22
|
+
description: "VC fundraising platform",
|
|
23
|
+
offers: [
|
|
24
|
+
{
|
|
25
|
+
id: "offer-1",
|
|
26
|
+
name: "Starter",
|
|
27
|
+
slug: "starter-monthly",
|
|
28
|
+
unit_price: "0.00",
|
|
29
|
+
currency: "USD",
|
|
30
|
+
pricing_model: "flat",
|
|
31
|
+
interval_unit: "month",
|
|
32
|
+
interval_count: 1,
|
|
33
|
+
interval_display: "per month",
|
|
34
|
+
is_recurring: true,
|
|
35
|
+
is_free: true,
|
|
36
|
+
features: [
|
|
37
|
+
{ key: "seats", name: "Team seats", value: 1, limit: 1 },
|
|
38
|
+
{ key: "projects", name: "Projects", value: 3, limit: 3 },
|
|
39
|
+
],
|
|
40
|
+
is_active: true,
|
|
41
|
+
is_featured: false,
|
|
42
|
+
trial_days: 0,
|
|
43
|
+
sort_order: 0,
|
|
44
|
+
cta_text: "Start Free",
|
|
45
|
+
},
|
|
46
|
+
{
|
|
47
|
+
id: "offer-2",
|
|
48
|
+
name: "Pro",
|
|
49
|
+
slug: "pro-monthly",
|
|
50
|
+
unit_price: "49.00",
|
|
51
|
+
currency: "USD",
|
|
52
|
+
pricing_model: "flat",
|
|
53
|
+
interval_unit: "month",
|
|
54
|
+
interval_count: 1,
|
|
55
|
+
interval_display: "per month",
|
|
56
|
+
is_recurring: true,
|
|
57
|
+
is_free: false,
|
|
58
|
+
features: [
|
|
59
|
+
{ key: "seats", name: "Team seats", value: 5, limit: 5 },
|
|
60
|
+
{ key: "projects", name: "Projects", value: "Unlimited" },
|
|
61
|
+
],
|
|
62
|
+
is_active: true,
|
|
63
|
+
is_featured: true,
|
|
64
|
+
trial_days: 14,
|
|
65
|
+
sort_order: 1,
|
|
66
|
+
cta_text: "Start Trial",
|
|
67
|
+
},
|
|
68
|
+
{
|
|
69
|
+
id: "offer-3",
|
|
70
|
+
name: "Enterprise",
|
|
71
|
+
slug: "enterprise-monthly",
|
|
72
|
+
unit_price: "199.00",
|
|
73
|
+
currency: "USD",
|
|
74
|
+
pricing_model: "per_seat",
|
|
75
|
+
interval_unit: "month",
|
|
76
|
+
interval_count: 1,
|
|
77
|
+
interval_display: "per month",
|
|
78
|
+
is_recurring: true,
|
|
79
|
+
is_free: false,
|
|
80
|
+
features: [
|
|
81
|
+
{ key: "seats", name: "Team seats", value: "Unlimited" },
|
|
82
|
+
{ key: "projects", name: "Projects", value: "Unlimited" },
|
|
83
|
+
],
|
|
84
|
+
is_active: true,
|
|
85
|
+
is_featured: false,
|
|
86
|
+
trial_days: 0,
|
|
87
|
+
sort_order: 2,
|
|
88
|
+
cta_text: "Contact Sales",
|
|
89
|
+
},
|
|
90
|
+
],
|
|
91
|
+
};
|
|
92
|
+
|
|
93
|
+
function createWrapper(fetchImpl: typeof fetch) {
|
|
94
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
95
|
+
return (
|
|
96
|
+
<BillingProvider
|
|
97
|
+
apiBaseUrl="https://api.test.com/api/v1"
|
|
98
|
+
authToken="tok_123"
|
|
99
|
+
fetcher={fetchImpl}
|
|
100
|
+
>
|
|
101
|
+
{children}
|
|
102
|
+
</BillingProvider>
|
|
103
|
+
);
|
|
104
|
+
};
|
|
105
|
+
}
|
|
106
|
+
|
|
107
|
+
function mockFetchSuccess() {
|
|
108
|
+
return vi.fn().mockResolvedValue({
|
|
109
|
+
ok: true,
|
|
110
|
+
json: () => Promise.resolve(mockProduct),
|
|
111
|
+
}) as unknown as typeof fetch;
|
|
112
|
+
}
|
|
113
|
+
|
|
114
|
+
describe("PricingPage", () => {
|
|
115
|
+
it("renders loading state initially", () => {
|
|
116
|
+
// Never resolves — stays in loading
|
|
117
|
+
const fetcher = vi.fn().mockReturnValue(
|
|
118
|
+
new Promise(() => {})
|
|
119
|
+
) as unknown as typeof fetch;
|
|
120
|
+
const Wrapper = createWrapper(fetcher);
|
|
121
|
+
|
|
122
|
+
render(
|
|
123
|
+
<Wrapper>
|
|
124
|
+
<PricingPage productId="raise-simpli" />
|
|
125
|
+
</Wrapper>
|
|
126
|
+
);
|
|
127
|
+
|
|
128
|
+
expect(screen.getByText(/loading/i)).toBeDefined();
|
|
129
|
+
});
|
|
130
|
+
|
|
131
|
+
it("renders product name and description", async () => {
|
|
132
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
133
|
+
|
|
134
|
+
render(
|
|
135
|
+
<Wrapper>
|
|
136
|
+
<PricingPage productId="raise-simpli" />
|
|
137
|
+
</Wrapper>
|
|
138
|
+
);
|
|
139
|
+
|
|
140
|
+
await waitFor(() => {
|
|
141
|
+
expect(screen.getByText("RaiseSimpli")).toBeDefined();
|
|
142
|
+
});
|
|
143
|
+
expect(screen.getByText("VC fundraising platform")).toBeDefined();
|
|
144
|
+
});
|
|
145
|
+
|
|
146
|
+
it("renders a card for each active offer", async () => {
|
|
147
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
148
|
+
|
|
149
|
+
render(
|
|
150
|
+
<Wrapper>
|
|
151
|
+
<PricingPage productId="raise-simpli" />
|
|
152
|
+
</Wrapper>
|
|
153
|
+
);
|
|
154
|
+
|
|
155
|
+
await waitFor(() => {
|
|
156
|
+
expect(screen.getByText("Starter")).toBeDefined();
|
|
157
|
+
});
|
|
158
|
+
expect(screen.getByText("Pro")).toBeDefined();
|
|
159
|
+
expect(screen.getByText("Enterprise")).toBeDefined();
|
|
160
|
+
});
|
|
161
|
+
|
|
162
|
+
it("shows pricing for each offer", async () => {
|
|
163
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
164
|
+
|
|
165
|
+
render(
|
|
166
|
+
<Wrapper>
|
|
167
|
+
<PricingPage productId="raise-simpli" />
|
|
168
|
+
</Wrapper>
|
|
169
|
+
);
|
|
170
|
+
|
|
171
|
+
await waitFor(() => {
|
|
172
|
+
expect(screen.getAllByText(/free/i).length).toBeGreaterThan(0);
|
|
173
|
+
});
|
|
174
|
+
expect(screen.getByText(/\$49/)).toBeDefined();
|
|
175
|
+
expect(screen.getByText(/\$199/)).toBeDefined();
|
|
176
|
+
});
|
|
177
|
+
|
|
178
|
+
it("highlights the featured offer", async () => {
|
|
179
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
180
|
+
|
|
181
|
+
render(
|
|
182
|
+
<Wrapper>
|
|
183
|
+
<PricingPage productId="raise-simpli" />
|
|
184
|
+
</Wrapper>
|
|
185
|
+
);
|
|
186
|
+
|
|
187
|
+
await waitFor(() => {
|
|
188
|
+
expect(screen.getByText("Pro")).toBeDefined();
|
|
189
|
+
});
|
|
190
|
+
|
|
191
|
+
// Featured offer should have some visual distinction
|
|
192
|
+
expect(screen.getByText(/most popular/i)).toBeDefined();
|
|
193
|
+
});
|
|
194
|
+
|
|
195
|
+
it("renders CTA button text from offer", async () => {
|
|
196
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
197
|
+
|
|
198
|
+
render(
|
|
199
|
+
<Wrapper>
|
|
200
|
+
<PricingPage productId="raise-simpli" />
|
|
201
|
+
</Wrapper>
|
|
202
|
+
);
|
|
203
|
+
|
|
204
|
+
await waitFor(() => {
|
|
205
|
+
expect(screen.getByText("Start Free")).toBeDefined();
|
|
206
|
+
});
|
|
207
|
+
expect(screen.getByText("Start Trial")).toBeDefined();
|
|
208
|
+
expect(screen.getByText("Contact Sales")).toBeDefined();
|
|
209
|
+
});
|
|
210
|
+
|
|
211
|
+
it("renders features list for each offer", async () => {
|
|
212
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
213
|
+
|
|
214
|
+
render(
|
|
215
|
+
<Wrapper>
|
|
216
|
+
<PricingPage productId="raise-simpli" />
|
|
217
|
+
</Wrapper>
|
|
218
|
+
);
|
|
219
|
+
|
|
220
|
+
await waitFor(() => {
|
|
221
|
+
// Each offer has features; "Team seats" appears in all 3 cards
|
|
222
|
+
expect(screen.getAllByText("Team seats").length).toBe(3);
|
|
223
|
+
});
|
|
224
|
+
expect(screen.getAllByText("Projects").length).toBe(3);
|
|
225
|
+
});
|
|
226
|
+
|
|
227
|
+
it("shows trial badge when offer has trial days", async () => {
|
|
228
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
229
|
+
|
|
230
|
+
render(
|
|
231
|
+
<Wrapper>
|
|
232
|
+
<PricingPage productId="raise-simpli" />
|
|
233
|
+
</Wrapper>
|
|
234
|
+
);
|
|
235
|
+
|
|
236
|
+
await waitFor(() => {
|
|
237
|
+
expect(screen.getByText(/14.day/i)).toBeDefined();
|
|
238
|
+
});
|
|
239
|
+
});
|
|
240
|
+
|
|
241
|
+
it("renders error state", async () => {
|
|
242
|
+
const fetcher = vi.fn().mockResolvedValue({
|
|
243
|
+
ok: false,
|
|
244
|
+
status: 404,
|
|
245
|
+
}) as unknown as typeof fetch;
|
|
246
|
+
const Wrapper = createWrapper(fetcher);
|
|
247
|
+
|
|
248
|
+
render(
|
|
249
|
+
<Wrapper>
|
|
250
|
+
<PricingPage productId="nonexistent" />
|
|
251
|
+
</Wrapper>
|
|
252
|
+
);
|
|
253
|
+
|
|
254
|
+
await waitFor(() => {
|
|
255
|
+
expect(screen.getByText(/unable to load/i)).toBeDefined();
|
|
256
|
+
});
|
|
257
|
+
});
|
|
258
|
+
|
|
259
|
+
it("calls onSelectOffer when CTA is clicked", async () => {
|
|
260
|
+
const onSelect = vi.fn();
|
|
261
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
262
|
+
|
|
263
|
+
render(
|
|
264
|
+
<Wrapper>
|
|
265
|
+
<PricingPage productId="raise-simpli" onSelectOffer={onSelect} />
|
|
266
|
+
</Wrapper>
|
|
267
|
+
);
|
|
268
|
+
|
|
269
|
+
await waitFor(() => {
|
|
270
|
+
expect(screen.getByText("Start Free")).toBeDefined();
|
|
271
|
+
});
|
|
272
|
+
|
|
273
|
+
await userEvent.click(screen.getByText("Start Free"));
|
|
274
|
+
expect(onSelect).toHaveBeenCalledWith(
|
|
275
|
+
expect.objectContaining({ slug: "starter-monthly" })
|
|
276
|
+
);
|
|
277
|
+
});
|
|
278
|
+
});
|
|
@@ -0,0 +1,153 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PricingPage — Drop-in pricing page component.
|
|
3
|
+
*
|
|
4
|
+
* Fetches product data by slug and renders pricing cards for each active offer.
|
|
5
|
+
* Uses standard Tailwind utility classes for styling.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <PricingPage productId="raise-simpli" onSelectOffer={(offer) => checkout(offer)} />
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { useProduct } from "../hooks/useProduct";
|
|
13
|
+
import type { ProductOffer } from "../types";
|
|
14
|
+
|
|
15
|
+
export interface PricingPageProps {
|
|
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
|
+
|
|
23
|
+
export function PricingPage({ productId, onSelectOffer }: PricingPageProps) {
|
|
24
|
+
const { product, loading, error } = useProduct(productId);
|
|
25
|
+
|
|
26
|
+
if (loading) {
|
|
27
|
+
return (
|
|
28
|
+
<div data-testid="pricing-loading" className="text-center py-16 text-gray-500">
|
|
29
|
+
Loading pricing...
|
|
30
|
+
</div>
|
|
31
|
+
);
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
if (error || !product) {
|
|
35
|
+
return (
|
|
36
|
+
<div data-testid="pricing-error" className="text-center py-16 text-red-600">
|
|
37
|
+
Unable to load pricing. Please try again.
|
|
38
|
+
</div>
|
|
39
|
+
);
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
if (product.offers.length === 0) {
|
|
43
|
+
return (
|
|
44
|
+
<div className="text-center py-16 text-gray-500">
|
|
45
|
+
No pricing plans available at this time.
|
|
46
|
+
</div>
|
|
47
|
+
);
|
|
48
|
+
}
|
|
49
|
+
|
|
50
|
+
return (
|
|
51
|
+
<div data-testid="pricing-page" className="max-w-5xl mx-auto px-6 py-12">
|
|
52
|
+
<div className="text-center mb-12">
|
|
53
|
+
<h1 className="text-4xl font-extrabold tracking-tight text-gray-900 mb-3">
|
|
54
|
+
{product.name}
|
|
55
|
+
</h1>
|
|
56
|
+
<p className="text-lg text-gray-500 max-w-xl mx-auto">
|
|
57
|
+
{product.description}
|
|
58
|
+
</p>
|
|
59
|
+
</div>
|
|
60
|
+
|
|
61
|
+
<div data-testid="pricing-cards" className="flex flex-wrap justify-center gap-6 items-stretch">
|
|
62
|
+
{product.offers.map((offer) => (
|
|
63
|
+
<PricingCard
|
|
64
|
+
key={offer.id}
|
|
65
|
+
offer={offer}
|
|
66
|
+
onSelect={onSelectOffer}
|
|
67
|
+
/>
|
|
68
|
+
))}
|
|
69
|
+
</div>
|
|
70
|
+
</div>
|
|
71
|
+
);
|
|
72
|
+
}
|
|
73
|
+
|
|
74
|
+
interface PricingCardProps {
|
|
75
|
+
offer: ProductOffer;
|
|
76
|
+
onSelect?: (offer: ProductOffer) => void;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function PricingCard({ offer, onSelect }: PricingCardProps) {
|
|
80
|
+
const isFeatured = offer.is_featured;
|
|
81
|
+
|
|
82
|
+
return (
|
|
83
|
+
<div
|
|
84
|
+
data-testid={`pricing-card-${offer.slug}`}
|
|
85
|
+
data-featured={isFeatured || undefined}
|
|
86
|
+
className={`
|
|
87
|
+
flex flex-col relative rounded-2xl p-8 w-full max-w-sm flex-1 min-w-[280px]
|
|
88
|
+
${isFeatured
|
|
89
|
+
? "border-2 border-blue-600 shadow-lg scale-[1.03]"
|
|
90
|
+
: "border border-gray-200 bg-white shadow-sm"
|
|
91
|
+
}
|
|
92
|
+
`}
|
|
93
|
+
>
|
|
94
|
+
{isFeatured && (
|
|
95
|
+
<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">
|
|
96
|
+
Most Popular
|
|
97
|
+
</span>
|
|
98
|
+
)}
|
|
99
|
+
|
|
100
|
+
<h2 className="text-xl font-bold text-gray-900 mb-4">{offer.name}</h2>
|
|
101
|
+
|
|
102
|
+
<div className="mb-2">
|
|
103
|
+
{offer.is_free ? (
|
|
104
|
+
<span className="text-4xl font-extrabold text-gray-900">Free</span>
|
|
105
|
+
) : (
|
|
106
|
+
<span>
|
|
107
|
+
<span className="text-4xl font-extrabold text-gray-900">
|
|
108
|
+
${Math.floor(parseFloat(offer.unit_price) || 0)}
|
|
109
|
+
</span>
|
|
110
|
+
<span className="text-base text-gray-500 ml-1"> {offer.interval_display}</span>
|
|
111
|
+
</span>
|
|
112
|
+
)}
|
|
113
|
+
</div>
|
|
114
|
+
|
|
115
|
+
{offer.trial_days > 0 && (
|
|
116
|
+
<span
|
|
117
|
+
data-testid="trial-badge"
|
|
118
|
+
className="inline-block bg-blue-50 text-blue-700 text-sm font-semibold px-3 py-1 rounded-lg mb-5 w-fit"
|
|
119
|
+
>
|
|
120
|
+
{offer.trial_days}-day free trial
|
|
121
|
+
</span>
|
|
122
|
+
)}
|
|
123
|
+
|
|
124
|
+
<ul className="list-none p-0 m-0 mb-7 flex-1 space-y-0">
|
|
125
|
+
{offer.features.map((feature) => (
|
|
126
|
+
<li
|
|
127
|
+
key={feature.key}
|
|
128
|
+
className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
|
|
129
|
+
>
|
|
130
|
+
<svg className="w-4 h-4 text-green-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
|
|
131
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
132
|
+
</svg>
|
|
133
|
+
<span>{feature.name}</span>
|
|
134
|
+
</li>
|
|
135
|
+
))}
|
|
136
|
+
</ul>
|
|
137
|
+
|
|
138
|
+
<button
|
|
139
|
+
onClick={() => onSelect?.(offer)}
|
|
140
|
+
data-testid={`cta-${offer.slug}`}
|
|
141
|
+
className={`
|
|
142
|
+
w-full py-3.5 px-6 text-base font-bold rounded-xl cursor-pointer transition-colors
|
|
143
|
+
${isFeatured
|
|
144
|
+
? "bg-blue-600 text-white hover:bg-blue-700"
|
|
145
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
|
146
|
+
}
|
|
147
|
+
`}
|
|
148
|
+
>
|
|
149
|
+
{offer.cta_text}
|
|
150
|
+
</button>
|
|
151
|
+
</div>
|
|
152
|
+
);
|
|
153
|
+
}
|