@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,319 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* TDD tests for PricingSection component.
|
|
3
|
+
*
|
|
4
|
+
* PricingSection is a compact horizontal pricing section for embedding in landing pages.
|
|
5
|
+
* It renders pricing cards in a horizontal row with limited feature display.
|
|
6
|
+
*
|
|
7
|
+
* Usage: <PricingSection productId="raise-simpli" maxFeatures={3} onSelectOffer={handleSelect} />
|
|
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 { PricingSection } from "./PricingSection";
|
|
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
|
+
{ key: "storage", name: "Storage", value: "1GB" },
|
|
40
|
+
{ key: "support", name: "Support", value: "Email" },
|
|
41
|
+
{ key: "api", name: "API Access", value: "Limited" },
|
|
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 },
|
|
63
|
+
{ key: "projects", name: "Projects", value: "Unlimited" },
|
|
64
|
+
{ key: "storage", name: "Storage", value: "100GB" },
|
|
65
|
+
{ key: "support", name: "Support", value: "Priority" },
|
|
66
|
+
{ key: "api", name: "API Access", value: "Full" },
|
|
67
|
+
],
|
|
68
|
+
is_active: true,
|
|
69
|
+
is_featured: true,
|
|
70
|
+
trial_days: 14,
|
|
71
|
+
sort_order: 1,
|
|
72
|
+
cta_text: "Start Trial",
|
|
73
|
+
},
|
|
74
|
+
{
|
|
75
|
+
id: "offer-3",
|
|
76
|
+
name: "Enterprise",
|
|
77
|
+
slug: "enterprise-monthly",
|
|
78
|
+
unit_price: "199.00",
|
|
79
|
+
currency: "USD",
|
|
80
|
+
pricing_model: "per_seat",
|
|
81
|
+
interval_unit: "month",
|
|
82
|
+
interval_count: 1,
|
|
83
|
+
interval_display: "per month",
|
|
84
|
+
is_recurring: true,
|
|
85
|
+
is_free: false,
|
|
86
|
+
features: [
|
|
87
|
+
{ key: "seats", name: "Team seats", value: "Unlimited" },
|
|
88
|
+
{ key: "projects", name: "Projects", value: "Unlimited" },
|
|
89
|
+
{ key: "storage", name: "Storage", value: "Unlimited" },
|
|
90
|
+
{ key: "support", name: "Support", value: "24/7" },
|
|
91
|
+
{ key: "api", name: "API Access", value: "Full" },
|
|
92
|
+
{ key: "sla", name: "SLA", value: "99.9%" },
|
|
93
|
+
],
|
|
94
|
+
is_active: true,
|
|
95
|
+
is_featured: false,
|
|
96
|
+
trial_days: 0,
|
|
97
|
+
sort_order: 2,
|
|
98
|
+
cta_text: "Contact Sales",
|
|
99
|
+
},
|
|
100
|
+
],
|
|
101
|
+
};
|
|
102
|
+
|
|
103
|
+
function createWrapper(fetchImpl: typeof fetch) {
|
|
104
|
+
return function Wrapper({ children }: { children: React.ReactNode }) {
|
|
105
|
+
return (
|
|
106
|
+
<BillingProvider
|
|
107
|
+
apiBaseUrl="https://api.test.com/api/v1"
|
|
108
|
+
authToken="tok_123"
|
|
109
|
+
fetcher={fetchImpl}
|
|
110
|
+
>
|
|
111
|
+
{children}
|
|
112
|
+
</BillingProvider>
|
|
113
|
+
);
|
|
114
|
+
};
|
|
115
|
+
}
|
|
116
|
+
|
|
117
|
+
function mockFetchSuccess() {
|
|
118
|
+
return vi.fn().mockResolvedValue({
|
|
119
|
+
ok: true,
|
|
120
|
+
json: () => Promise.resolve(mockProduct),
|
|
121
|
+
}) as unknown as typeof fetch;
|
|
122
|
+
}
|
|
123
|
+
|
|
124
|
+
describe("PricingSection", () => {
|
|
125
|
+
it("renders loading state initially", () => {
|
|
126
|
+
// Never resolves — stays in loading
|
|
127
|
+
const fetcher = vi.fn().mockReturnValue(
|
|
128
|
+
new Promise(() => {})
|
|
129
|
+
) as unknown as typeof fetch;
|
|
130
|
+
const Wrapper = createWrapper(fetcher);
|
|
131
|
+
|
|
132
|
+
render(
|
|
133
|
+
<Wrapper>
|
|
134
|
+
<PricingSection productId="raise-simpli" />
|
|
135
|
+
</Wrapper>
|
|
136
|
+
);
|
|
137
|
+
|
|
138
|
+
expect(screen.getByText(/loading/i)).toBeDefined();
|
|
139
|
+
});
|
|
140
|
+
|
|
141
|
+
it("renders section with all offers", async () => {
|
|
142
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
143
|
+
|
|
144
|
+
render(
|
|
145
|
+
<Wrapper>
|
|
146
|
+
<PricingSection productId="raise-simpli" />
|
|
147
|
+
</Wrapper>
|
|
148
|
+
);
|
|
149
|
+
|
|
150
|
+
await waitFor(() => {
|
|
151
|
+
expect(screen.getByTestId("pricing-section")).toBeDefined();
|
|
152
|
+
});
|
|
153
|
+
expect(screen.getByTestId("section-card-starter-monthly")).toBeDefined();
|
|
154
|
+
expect(screen.getByTestId("section-card-pro-monthly")).toBeDefined();
|
|
155
|
+
expect(screen.getByTestId("section-card-enterprise-monthly")).toBeDefined();
|
|
156
|
+
});
|
|
157
|
+
|
|
158
|
+
it("shows plan names and prices", async () => {
|
|
159
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
160
|
+
|
|
161
|
+
render(
|
|
162
|
+
<Wrapper>
|
|
163
|
+
<PricingSection productId="raise-simpli" />
|
|
164
|
+
</Wrapper>
|
|
165
|
+
);
|
|
166
|
+
|
|
167
|
+
await waitFor(() => {
|
|
168
|
+
expect(screen.getByText("Starter")).toBeDefined();
|
|
169
|
+
});
|
|
170
|
+
expect(screen.getByText("Pro")).toBeDefined();
|
|
171
|
+
expect(screen.getByText("Enterprise")).toBeDefined();
|
|
172
|
+
});
|
|
173
|
+
|
|
174
|
+
it("shows Free for free offers and $X/interval for paid", async () => {
|
|
175
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
176
|
+
|
|
177
|
+
render(
|
|
178
|
+
<Wrapper>
|
|
179
|
+
<PricingSection productId="raise-simpli" />
|
|
180
|
+
</Wrapper>
|
|
181
|
+
);
|
|
182
|
+
|
|
183
|
+
await waitFor(() => {
|
|
184
|
+
expect(screen.getAllByText(/free/i).length).toBeGreaterThan(0);
|
|
185
|
+
});
|
|
186
|
+
expect(screen.getByText(/\$49/)).toBeDefined();
|
|
187
|
+
expect(screen.getByText(/\$199/)).toBeDefined();
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("highlights featured offer with data-featured attribute", async () => {
|
|
191
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
192
|
+
|
|
193
|
+
const { container } = render(
|
|
194
|
+
<Wrapper>
|
|
195
|
+
<PricingSection productId="raise-simpli" />
|
|
196
|
+
</Wrapper>
|
|
197
|
+
);
|
|
198
|
+
|
|
199
|
+
await waitFor(() => {
|
|
200
|
+
expect(screen.getByText("Pro")).toBeDefined();
|
|
201
|
+
});
|
|
202
|
+
|
|
203
|
+
const proCard = container.querySelector('[data-testid="section-card-pro-monthly"]');
|
|
204
|
+
expect(proCard).toBeDefined();
|
|
205
|
+
expect(proCard?.getAttribute("data-featured")).toBe("true");
|
|
206
|
+
});
|
|
207
|
+
|
|
208
|
+
it("shows limited features when maxFeatures prop is set", async () => {
|
|
209
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
210
|
+
|
|
211
|
+
render(
|
|
212
|
+
<Wrapper>
|
|
213
|
+
<PricingSection productId="raise-simpli" maxFeatures={2} />
|
|
214
|
+
</Wrapper>
|
|
215
|
+
);
|
|
216
|
+
|
|
217
|
+
await waitFor(() => {
|
|
218
|
+
expect(screen.getByText("Starter")).toBeDefined();
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
// Starter has 5 features, but should only show 2
|
|
222
|
+
// We should see "Team seats" and "Projects" (first 2), but not "Storage"
|
|
223
|
+
expect(screen.getAllByText("Team seats").length).toBe(3); // All 3 cards
|
|
224
|
+
expect(screen.getAllByText("Projects").length).toBe(3); // All 3 cards
|
|
225
|
+
// Storage should appear fewer times or not at all since we're limiting to 2 features per card
|
|
226
|
+
const storageElements = screen.queryAllByText("Storage");
|
|
227
|
+
expect(storageElements.length).toBeLessThan(3); // Should not appear in all cards
|
|
228
|
+
});
|
|
229
|
+
|
|
230
|
+
it("shows all features when maxFeatures not set (default 4)", async () => {
|
|
231
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
232
|
+
|
|
233
|
+
render(
|
|
234
|
+
<Wrapper>
|
|
235
|
+
<PricingSection productId="raise-simpli" />
|
|
236
|
+
</Wrapper>
|
|
237
|
+
);
|
|
238
|
+
|
|
239
|
+
await waitFor(() => {
|
|
240
|
+
expect(screen.getByText("Starter")).toBeDefined();
|
|
241
|
+
});
|
|
242
|
+
|
|
243
|
+
// With default maxFeatures=4, should show first 4 features
|
|
244
|
+
expect(screen.getAllByText("Team seats").length).toBe(3);
|
|
245
|
+
expect(screen.getAllByText("Projects").length).toBe(3);
|
|
246
|
+
expect(screen.getAllByText("Storage").length).toBe(3);
|
|
247
|
+
expect(screen.getAllByText("Support").length).toBe(3);
|
|
248
|
+
});
|
|
249
|
+
|
|
250
|
+
it("shows CTA button text from offer", async () => {
|
|
251
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
252
|
+
|
|
253
|
+
render(
|
|
254
|
+
<Wrapper>
|
|
255
|
+
<PricingSection productId="raise-simpli" />
|
|
256
|
+
</Wrapper>
|
|
257
|
+
);
|
|
258
|
+
|
|
259
|
+
await waitFor(() => {
|
|
260
|
+
expect(screen.getByTestId("section-cta-starter-monthly")).toBeDefined();
|
|
261
|
+
});
|
|
262
|
+
|
|
263
|
+
expect(screen.getByText("Start Free")).toBeDefined();
|
|
264
|
+
expect(screen.getByText("Start Trial")).toBeDefined();
|
|
265
|
+
expect(screen.getByText("Contact Sales")).toBeDefined();
|
|
266
|
+
});
|
|
267
|
+
|
|
268
|
+
it("calls onSelectOffer when CTA clicked", async () => {
|
|
269
|
+
const onSelect = vi.fn();
|
|
270
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
271
|
+
|
|
272
|
+
render(
|
|
273
|
+
<Wrapper>
|
|
274
|
+
<PricingSection productId="raise-simpli" onSelectOffer={onSelect} />
|
|
275
|
+
</Wrapper>
|
|
276
|
+
);
|
|
277
|
+
|
|
278
|
+
await waitFor(() => {
|
|
279
|
+
expect(screen.getByText("Start Free")).toBeDefined();
|
|
280
|
+
});
|
|
281
|
+
|
|
282
|
+
await userEvent.click(screen.getByText("Start Free"));
|
|
283
|
+
expect(onSelect).toHaveBeenCalledWith(
|
|
284
|
+
expect.objectContaining({ slug: "starter-monthly" })
|
|
285
|
+
);
|
|
286
|
+
});
|
|
287
|
+
|
|
288
|
+
it("shows trial badge when offer has trial days", async () => {
|
|
289
|
+
const Wrapper = createWrapper(mockFetchSuccess());
|
|
290
|
+
|
|
291
|
+
render(
|
|
292
|
+
<Wrapper>
|
|
293
|
+
<PricingSection productId="raise-simpli" />
|
|
294
|
+
</Wrapper>
|
|
295
|
+
);
|
|
296
|
+
|
|
297
|
+
await waitFor(() => {
|
|
298
|
+
expect(screen.getByText(/14.day/i)).toBeDefined();
|
|
299
|
+
});
|
|
300
|
+
});
|
|
301
|
+
|
|
302
|
+
it("renders error state", async () => {
|
|
303
|
+
const fetcher = vi.fn().mockResolvedValue({
|
|
304
|
+
ok: false,
|
|
305
|
+
status: 404,
|
|
306
|
+
}) as unknown as typeof fetch;
|
|
307
|
+
const Wrapper = createWrapper(fetcher);
|
|
308
|
+
|
|
309
|
+
render(
|
|
310
|
+
<Wrapper>
|
|
311
|
+
<PricingSection productId="nonexistent" />
|
|
312
|
+
</Wrapper>
|
|
313
|
+
);
|
|
314
|
+
|
|
315
|
+
await waitFor(() => {
|
|
316
|
+
expect(screen.getByText(/unable to load/i)).toBeDefined();
|
|
317
|
+
});
|
|
318
|
+
});
|
|
319
|
+
});
|
|
@@ -0,0 +1,154 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* PricingSection — Compact horizontal pricing section for embedding in landing pages.
|
|
3
|
+
*
|
|
4
|
+
* Renders pricing cards in a horizontal row with limited feature display.
|
|
5
|
+
* Uses standard Tailwind utility classes for styling.
|
|
6
|
+
*
|
|
7
|
+
* Usage:
|
|
8
|
+
* <PricingSection productId="raise-simpli" maxFeatures={3} onSelectOffer={handleSelect} />
|
|
9
|
+
*/
|
|
10
|
+
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { useProduct } from "../hooks/useProduct";
|
|
13
|
+
import type { ProductOffer } from "../types";
|
|
14
|
+
|
|
15
|
+
export interface PricingSectionProps {
|
|
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
|
+
/** Maximum number of features to display per card (default 4) */
|
|
23
|
+
maxFeatures?: number;
|
|
24
|
+
}
|
|
25
|
+
|
|
26
|
+
export function PricingSection({
|
|
27
|
+
productId,
|
|
28
|
+
onSelectOffer,
|
|
29
|
+
maxFeatures = 4,
|
|
30
|
+
}: PricingSectionProps) {
|
|
31
|
+
const { product, loading, error } = useProduct(productId);
|
|
32
|
+
|
|
33
|
+
if (loading) {
|
|
34
|
+
return (
|
|
35
|
+
<div className="text-center py-10 text-gray-500">
|
|
36
|
+
Loading pricing...
|
|
37
|
+
</div>
|
|
38
|
+
);
|
|
39
|
+
}
|
|
40
|
+
|
|
41
|
+
if (error || !product) {
|
|
42
|
+
return (
|
|
43
|
+
<div className="text-center py-10 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
|
+
return (
|
|
58
|
+
<div data-testid="pricing-section" className="w-full px-4 py-8">
|
|
59
|
+
<div className="flex flex-row flex-wrap justify-center gap-4 items-stretch">
|
|
60
|
+
{product.offers.map((offer) => (
|
|
61
|
+
<SectionCard
|
|
62
|
+
key={offer.id}
|
|
63
|
+
offer={offer}
|
|
64
|
+
onSelect={onSelectOffer}
|
|
65
|
+
maxFeatures={maxFeatures}
|
|
66
|
+
/>
|
|
67
|
+
))}
|
|
68
|
+
</div>
|
|
69
|
+
</div>
|
|
70
|
+
);
|
|
71
|
+
}
|
|
72
|
+
|
|
73
|
+
interface SectionCardProps {
|
|
74
|
+
offer: ProductOffer;
|
|
75
|
+
onSelect?: (offer: ProductOffer) => void;
|
|
76
|
+
maxFeatures: number;
|
|
77
|
+
}
|
|
78
|
+
|
|
79
|
+
function SectionCard({ offer, onSelect, maxFeatures }: SectionCardProps) {
|
|
80
|
+
const isFeatured = offer.is_featured;
|
|
81
|
+
const visibleFeatures = offer.features.slice(0, maxFeatures);
|
|
82
|
+
|
|
83
|
+
return (
|
|
84
|
+
<div
|
|
85
|
+
data-testid={`section-card-${offer.slug}`}
|
|
86
|
+
data-featured={isFeatured ? "true" : undefined}
|
|
87
|
+
className={`
|
|
88
|
+
flex flex-col relative rounded-xl p-6 flex-1 min-w-[220px] max-w-xs
|
|
89
|
+
${isFeatured
|
|
90
|
+
? "border-2 border-blue-600 shadow-md"
|
|
91
|
+
: "border border-gray-200 bg-white shadow-sm"
|
|
92
|
+
}
|
|
93
|
+
`}
|
|
94
|
+
>
|
|
95
|
+
<h3 className="text-lg font-bold text-gray-900 mb-2">{offer.name}</h3>
|
|
96
|
+
|
|
97
|
+
<div className="mb-2">
|
|
98
|
+
{offer.is_free ? (
|
|
99
|
+
<span className="text-2xl font-extrabold text-gray-900">Free</span>
|
|
100
|
+
) : (
|
|
101
|
+
<span>
|
|
102
|
+
<span className="text-2xl font-extrabold text-gray-900">
|
|
103
|
+
${Math.floor(parseFloat(offer.unit_price) || 0)}
|
|
104
|
+
</span>
|
|
105
|
+
<span className="text-sm text-gray-500 ml-1"> {offer.interval_display}</span>
|
|
106
|
+
</span>
|
|
107
|
+
)}
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
{offer.trial_days > 0 && (
|
|
111
|
+
<span
|
|
112
|
+
data-testid="trial-badge"
|
|
113
|
+
className="inline-block bg-blue-50 text-blue-700 text-xs font-semibold px-2 py-0.5 rounded-md mb-3 w-fit"
|
|
114
|
+
>
|
|
115
|
+
{offer.trial_days}-day free trial
|
|
116
|
+
</span>
|
|
117
|
+
)}
|
|
118
|
+
|
|
119
|
+
<ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
|
|
120
|
+
{visibleFeatures.map((feature) => (
|
|
121
|
+
<li
|
|
122
|
+
key={feature.key}
|
|
123
|
+
className="flex items-center gap-2 py-1.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
|
|
124
|
+
>
|
|
125
|
+
<svg
|
|
126
|
+
className="w-3.5 h-3.5 text-green-500 shrink-0"
|
|
127
|
+
fill="none"
|
|
128
|
+
viewBox="0 0 24 24"
|
|
129
|
+
stroke="currentColor"
|
|
130
|
+
strokeWidth={3}
|
|
131
|
+
>
|
|
132
|
+
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
133
|
+
</svg>
|
|
134
|
+
<span>{feature.name}</span>
|
|
135
|
+
</li>
|
|
136
|
+
))}
|
|
137
|
+
</ul>
|
|
138
|
+
|
|
139
|
+
<button
|
|
140
|
+
onClick={() => onSelect?.(offer)}
|
|
141
|
+
data-testid={`section-cta-${offer.slug}`}
|
|
142
|
+
className={`
|
|
143
|
+
w-full py-2.5 px-4 text-sm font-bold rounded-lg cursor-pointer transition-colors
|
|
144
|
+
${isFeatured
|
|
145
|
+
? "bg-blue-600 text-white hover:bg-blue-700"
|
|
146
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200"
|
|
147
|
+
}
|
|
148
|
+
`}
|
|
149
|
+
>
|
|
150
|
+
{offer.cta_text}
|
|
151
|
+
</button>
|
|
152
|
+
</div>
|
|
153
|
+
);
|
|
154
|
+
}
|