@startsimpli/billing 0.4.23 → 0.4.25
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/package.json +1 -1
- package/src/components/CurrentPlanCard.tsx +5 -10
- package/src/components/IntervalToggle.tsx +113 -0
- package/src/components/PlanChange.crochet.test.tsx +274 -0
- package/src/components/PlanChangeConfirm.tsx +193 -0
- package/src/components/PlanPicker.crochet.test.tsx +322 -0
- package/src/components/PricingDetailPage.tsx +144 -47
- package/src/components/PricingPage.test.tsx +2 -2
- package/src/components/PricingPage.tsx +46 -24
- package/src/components/PricingSection.test.tsx +6 -5
- package/src/components/PricingSection.tsx +58 -38
- package/src/components/SubscriptionManager.test.tsx +38 -19
- package/src/components/SubscriptionManager.tsx +168 -99
- package/src/components/UpgradeModal.tsx +89 -27
- package/src/components/__fixtures__/crochetProduct.ts +172 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/usePlanChange.ts +144 -0
- package/src/hooks/useSuccessSync.ts +3 -3
- package/src/index.ts +23 -4
- package/src/types/index.ts +33 -0
- package/src/utils/api.ts +38 -1
- package/src/utils/format.test.ts +57 -0
- package/src/utils/format.ts +54 -0
- package/src/utils/plan.ts +122 -0
- package/src/utils/tiers.interval.test.ts +76 -0
- package/src/utils/tiers.ts +105 -0
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The crochets.site plan picker, against the live catalogue
|
|
3
|
+
* (crochet_patterns-34n7). Quinn, signed in on Free with no subscription row,
|
|
4
|
+
* saw every offer in one row (Lite AND Lite Annual), "Get Started" on the plan
|
|
5
|
+
* he was already on, "$2per month" for $2.99, "no card required" on a trial
|
|
6
|
+
* that takes a card, and "Up to 3 ai designs per month".
|
|
7
|
+
*
|
|
8
|
+
* Same rules on every surface that lists plans: SubscriptionManager
|
|
9
|
+
* (/settings/billing), PricingDetailPage (/pricing), PricingSection (home),
|
|
10
|
+
* PricingPage and UpgradeModal.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi } from "vitest";
|
|
13
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
14
|
+
import userEvent from "@testing-library/user-event";
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
17
|
+
import { SubscriptionManager } from "./SubscriptionManager";
|
|
18
|
+
import { PricingDetailPage } from "./PricingDetailPage";
|
|
19
|
+
import { PricingSection } from "./PricingSection";
|
|
20
|
+
import { PricingPage } from "./PricingPage";
|
|
21
|
+
import { UpgradeModal } from "./UpgradeModal";
|
|
22
|
+
import type { BillingProduct, SubscriptionInfo } from "../types";
|
|
23
|
+
import {
|
|
24
|
+
crochetProduct,
|
|
25
|
+
crochetProductWithTiers,
|
|
26
|
+
crochetLite,
|
|
27
|
+
crochetLiteAnnual,
|
|
28
|
+
crochetPro,
|
|
29
|
+
crochetProAnnual,
|
|
30
|
+
crochetFetcher,
|
|
31
|
+
subscriptionTo,
|
|
32
|
+
} from "./__fixtures__/crochetProduct";
|
|
33
|
+
|
|
34
|
+
const text = (el: Element) => (el.textContent ?? "").replace(/\s+/g, " ");
|
|
35
|
+
|
|
36
|
+
function renderWith(
|
|
37
|
+
ui: React.ReactElement,
|
|
38
|
+
product: BillingProduct = crochetProduct,
|
|
39
|
+
subscription: SubscriptionInfo | null = null,
|
|
40
|
+
) {
|
|
41
|
+
return render(
|
|
42
|
+
<BillingProvider apiBaseUrl="https://api.test/api/v1" fetcher={crochetFetcher(product, subscription)}>
|
|
43
|
+
{ui}
|
|
44
|
+
</BillingProvider>,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function chooseAnnual() {
|
|
49
|
+
await userEvent.click(screen.getByRole("radio", { name: /annual/i }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// /settings/billing
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
describe("SubscriptionManager plan picker (crochet catalogue)", () => {
|
|
56
|
+
const manager = <SubscriptionManager productId="crochet-patterns" returnUrl="https://t/s" />;
|
|
57
|
+
|
|
58
|
+
it("defaults to Monthly and shows one card per tier", async () => {
|
|
59
|
+
renderWith(manager);
|
|
60
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
61
|
+
|
|
62
|
+
expect(screen.getByRole("radio", { name: /monthly/i }).getAttribute("aria-checked")).toBe("true");
|
|
63
|
+
expect(screen.getByRole("radio", { name: /annual/i }).getAttribute("aria-checked")).toBe("false");
|
|
64
|
+
expect(screen.getByTestId("plan-card-crochet-free")).toBeDefined();
|
|
65
|
+
expect(screen.getByTestId("plan-card-crochet-lite")).toBeDefined();
|
|
66
|
+
expect(screen.getByTestId("plan-card-crochet-pro")).toBeDefined();
|
|
67
|
+
expect(screen.queryByTestId("plan-card-crochet-lite-annual")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("Annual swaps Lite for its yearly price, keeps Free, and shows the saving", async () => {
|
|
71
|
+
renderWith(manager);
|
|
72
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
73
|
+
await chooseAnnual();
|
|
74
|
+
|
|
75
|
+
expect(screen.queryByTestId("plan-card-crochet-lite")).toBeNull();
|
|
76
|
+
const lite = screen.getByTestId("plan-card-crochet-lite-annual");
|
|
77
|
+
expect(within(lite).getByRole("heading").textContent).toBe("Lite");
|
|
78
|
+
expect(text(lite)).toContain("$29 per year");
|
|
79
|
+
expect(text(lite)).toContain("$2.42");
|
|
80
|
+
expect(text(lite)).toMatch(/save 19%/i);
|
|
81
|
+
expect(screen.getByTestId("plan-card-crochet-free")).toBeDefined();
|
|
82
|
+
// Pro has no annual price yet: it stays on the page at its monthly price.
|
|
83
|
+
expect(text(screen.getByTestId("plan-card-crochet-pro"))).toContain("$9 per month");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a signed-in user with no subscription is on Free: Current plan, disabled", async () => {
|
|
87
|
+
renderWith(manager, crochetProduct, null);
|
|
88
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
89
|
+
|
|
90
|
+
const cta = screen.getByTestId("upgrade-cta-crochet-free") as HTMLButtonElement;
|
|
91
|
+
expect(cta.textContent).toMatch(/^current plan$/i);
|
|
92
|
+
expect(cta.disabled).toBe(true);
|
|
93
|
+
expect(screen.queryByText(/get started/i)).toBeNull();
|
|
94
|
+
expect(screen.getByText(/you're on the free plan/i)).toBeDefined();
|
|
95
|
+
// Paid cards stay buyable.
|
|
96
|
+
expect((screen.getByTestId("upgrade-cta-crochet-lite") as HTMLButtonElement).disabled).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("renders $2.99 with its cents and a space before the interval", async () => {
|
|
100
|
+
renderWith(manager);
|
|
101
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
102
|
+
const lite = screen.getByTestId("plan-card-crochet-lite");
|
|
103
|
+
expect(text(lite)).toContain("$2.99 per month");
|
|
104
|
+
expect(text(screen.getByTestId("plan-card-crochet-pro"))).toContain("$9 per month");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("Pro's trial copy does not promise no card", async () => {
|
|
108
|
+
renderWith(manager);
|
|
109
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
110
|
+
const pro = screen.getByTestId("plan-card-crochet-pro");
|
|
111
|
+
expect(text(pro)).toMatch(/7-day free trial/i);
|
|
112
|
+
expect(text(pro)).not.toMatch(/no card/i);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("feature labels keep AI capitalised and drop per month when unlimited", async () => {
|
|
116
|
+
renderWith(manager);
|
|
117
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
118
|
+
const lite = screen.getByTestId("plan-card-crochet-lite");
|
|
119
|
+
expect(within(lite).getByText("Up to 3 AI designs per month")).toBeDefined();
|
|
120
|
+
expect(within(lite).getByText("Unlimited saved patterns")).toBeDefined();
|
|
121
|
+
const pro = screen.getByTestId("plan-card-crochet-pro");
|
|
122
|
+
expect(within(pro).getByText("Unlimited AI designs")).toBeDefined();
|
|
123
|
+
expect(screen.queryByText(/ai designs/)).toBeNull(); // no lower-case "ai"
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("on Lite Annual: the switch opens on Annual so their card reads Current plan", async () => {
|
|
127
|
+
renderWith(manager, crochetProduct, subscriptionTo(crochetLiteAnnual));
|
|
128
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
129
|
+
|
|
130
|
+
expect(screen.getByRole("radio", { name: /annual/i }).getAttribute("aria-checked")).toBe("true");
|
|
131
|
+
const annualCta = screen.getByTestId("upgrade-cta-crochet-lite-annual") as HTMLButtonElement;
|
|
132
|
+
expect(annualCta.textContent).toMatch(/^current plan$/i);
|
|
133
|
+
expect(annualCta.disabled).toBe(true);
|
|
134
|
+
|
|
135
|
+
// Flipping to Monthly offers the same plan billed monthly as a switch.
|
|
136
|
+
await userEvent.click(screen.getByRole("radio", { name: /monthly/i }));
|
|
137
|
+
const monthlyCta = screen.getByTestId("upgrade-cta-crochet-lite") as HTMLButtonElement;
|
|
138
|
+
expect(monthlyCta.textContent).toMatch(/switch to monthly/i);
|
|
139
|
+
expect(monthlyCta.disabled).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("on a monthly plan the switch still opens on Monthly", async () => {
|
|
143
|
+
renderWith(manager, crochetProductWithTiers, subscriptionTo(crochetLite));
|
|
144
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
145
|
+
expect(screen.getByRole("radio", { name: /monthly/i }).getAttribute("aria-checked")).toBe("true");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("pairs by the API's tier field once it is served (Pro Annual)", async () => {
|
|
149
|
+
renderWith(manager, crochetProductWithTiers, subscriptionTo(crochetPro));
|
|
150
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
151
|
+
expect(screen.queryByTestId("plan-card-crochet-pro-annual")).toBeNull();
|
|
152
|
+
await chooseAnnual();
|
|
153
|
+
const proAnnual = screen.getByTestId("plan-card-crochet-pro-annual");
|
|
154
|
+
expect(within(proAnnual).getByRole("heading").textContent).toBe("Pro");
|
|
155
|
+
expect(text(proAnnual)).toContain("$89 per year");
|
|
156
|
+
expect((screen.getByTestId("upgrade-cta-crochet-pro-annual") as HTMLButtonElement).textContent)
|
|
157
|
+
.toMatch(/switch to annual/i);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
162
|
+
// /pricing
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
describe("PricingDetailPage plan picker (crochet catalogue)", () => {
|
|
165
|
+
it("signed out: toggle, one card per tier, normal CTAs", async () => {
|
|
166
|
+
const onSelect = vi.fn();
|
|
167
|
+
renderWith(<PricingDetailPage productId="crochet-patterns" onSelectOffer={onSelect} />);
|
|
168
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
169
|
+
|
|
170
|
+
expect(screen.getByRole("radio", { name: /monthly/i }).getAttribute("aria-checked")).toBe("true");
|
|
171
|
+
expect(screen.queryByTestId("detail-card-crochet-lite-annual")).toBeNull();
|
|
172
|
+
const free = screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement;
|
|
173
|
+
expect(free.textContent).toBe("Get Started");
|
|
174
|
+
expect(free.disabled).toBe(false);
|
|
175
|
+
expect(text(screen.getByTestId("detail-card-crochet-lite"))).toContain("$2.99 per month");
|
|
176
|
+
expect(within(screen.getByTestId("detail-card-crochet-lite")).getByText("Up to 3 AI designs per month")).toBeDefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("annual: comparison table has one column per tier and Pro builds on Lite", async () => {
|
|
180
|
+
renderWith(<PricingDetailPage productId="crochet-patterns" />);
|
|
181
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
182
|
+
await chooseAnnual();
|
|
183
|
+
|
|
184
|
+
const lite = screen.getByTestId("detail-card-crochet-lite-annual");
|
|
185
|
+
expect(within(lite).getByRole("heading").textContent).toBe("Lite");
|
|
186
|
+
expect(text(lite)).toContain("$29 per year");
|
|
187
|
+
expect(text(screen.getByTestId("detail-card-crochet-pro"))).toMatch(/everything in lite, plus/i);
|
|
188
|
+
|
|
189
|
+
const headers = within(screen.getByTestId("comparison-table")).getAllByRole("columnheader");
|
|
190
|
+
expect(headers.map((h) => h.getAttribute("aria-label") ?? h.textContent)).toEqual(["Feature", "Free", "Lite", "Pro"]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("signed in on Free with no subscription: Free is Current plan, disabled", async () => {
|
|
194
|
+
const onSelect = vi.fn();
|
|
195
|
+
renderWith(
|
|
196
|
+
<PricingDetailPage
|
|
197
|
+
productId="crochet-patterns"
|
|
198
|
+
onSelectOffer={onSelect}
|
|
199
|
+
currentPlan={{ signedIn: true, subscription: null }}
|
|
200
|
+
/>,
|
|
201
|
+
);
|
|
202
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
203
|
+
const free = screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement;
|
|
204
|
+
expect(free.textContent).toMatch(/^current plan$/i);
|
|
205
|
+
expect(free.disabled).toBe(true);
|
|
206
|
+
await userEvent.click(free);
|
|
207
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("while the plan is still loading nothing is marked current", async () => {
|
|
211
|
+
renderWith(
|
|
212
|
+
<PricingDetailPage
|
|
213
|
+
productId="crochet-patterns"
|
|
214
|
+
currentPlan={{ signedIn: true, subscription: null, loading: true }}
|
|
215
|
+
/>,
|
|
216
|
+
);
|
|
217
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
218
|
+
expect((screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement).disabled).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("signed in on Pro Annual: opens on Annual with Pro Annual current", async () => {
|
|
222
|
+
renderWith(
|
|
223
|
+
<PricingDetailPage
|
|
224
|
+
productId="crochet-patterns"
|
|
225
|
+
currentPlan={{ signedIn: true, subscription: subscriptionTo(crochetProAnnual) }}
|
|
226
|
+
/>,
|
|
227
|
+
crochetProductWithTiers,
|
|
228
|
+
);
|
|
229
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
230
|
+
expect(screen.getByRole("radio", { name: /annual/i }).getAttribute("aria-checked")).toBe("true");
|
|
231
|
+
const cta = screen.getByTestId("detail-cta-crochet-pro-annual") as HTMLButtonElement;
|
|
232
|
+
expect(cta.textContent).toMatch(/^current plan$/i);
|
|
233
|
+
expect(cta.disabled).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("opens on Annual once a slow plan load reveals an annual subscription", async () => {
|
|
237
|
+
const { rerender } = renderWith(
|
|
238
|
+
<PricingDetailPage productId="crochet-patterns" currentPlan={{ signedIn: true, subscription: null, loading: true }} />,
|
|
239
|
+
crochetProductWithTiers,
|
|
240
|
+
);
|
|
241
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
242
|
+
expect(screen.getByRole("radio", { name: /monthly/i }).getAttribute("aria-checked")).toBe("true");
|
|
243
|
+
rerender(
|
|
244
|
+
<BillingProvider apiBaseUrl="https://api.test/api/v1" fetcher={crochetFetcher(crochetProductWithTiers, null)}>
|
|
245
|
+
<PricingDetailPage
|
|
246
|
+
productId="crochet-patterns"
|
|
247
|
+
currentPlan={{ signedIn: true, subscription: subscriptionTo(crochetLiteAnnual) }}
|
|
248
|
+
/>
|
|
249
|
+
</BillingProvider>,
|
|
250
|
+
);
|
|
251
|
+
await waitFor(() =>
|
|
252
|
+
expect(screen.getByRole("radio", { name: /annual/i }).getAttribute("aria-checked")).toBe("true"),
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("signed in on Pro: Pro is current, Free is not", async () => {
|
|
257
|
+
renderWith(
|
|
258
|
+
<PricingDetailPage
|
|
259
|
+
productId="crochet-patterns"
|
|
260
|
+
currentPlan={{ signedIn: true, subscription: subscriptionTo(crochetPro) }}
|
|
261
|
+
/>,
|
|
262
|
+
);
|
|
263
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
264
|
+
expect((screen.getByTestId("detail-cta-crochet-pro") as HTMLButtonElement).disabled).toBe(true);
|
|
265
|
+
expect((screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement).disabled).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
// Home-page section, standalone pricing page, upgrade modal
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
describe("PricingSection plan picker (crochet catalogue)", () => {
|
|
273
|
+
it("toggle, prices, labels, current plan", async () => {
|
|
274
|
+
renderWith(
|
|
275
|
+
<PricingSection productId="crochet-patterns" maxFeatures={10} currentPlan={{ signedIn: true, subscription: null }} />,
|
|
276
|
+
);
|
|
277
|
+
await waitFor(() => expect(screen.getByTestId("pricing-section")).toBeDefined());
|
|
278
|
+
expect(screen.queryByTestId("section-card-crochet-lite-annual")).toBeNull();
|
|
279
|
+
expect(text(screen.getByTestId("section-card-crochet-lite"))).toContain("$2.99 per month");
|
|
280
|
+
expect(within(screen.getByTestId("section-card-crochet-lite")).getByText("Up to 3 AI designs per month")).toBeDefined();
|
|
281
|
+
expect((screen.getByTestId("section-cta-crochet-free") as HTMLButtonElement).disabled).toBe(true);
|
|
282
|
+
await chooseAnnual();
|
|
283
|
+
expect(text(screen.getByTestId("section-card-crochet-lite-annual"))).toContain("$29 per year");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("PricingPage plan picker (crochet catalogue)", () => {
|
|
288
|
+
it("toggle, prices, labels, current plan", async () => {
|
|
289
|
+
renderWith(<PricingPage productId="crochet-patterns" currentPlan={{ signedIn: true, subscription: null }} />);
|
|
290
|
+
await waitFor(() => expect(screen.getByTestId("pricing-page")).toBeDefined());
|
|
291
|
+
expect(screen.queryByTestId("pricing-card-crochet-lite-annual")).toBeNull();
|
|
292
|
+
expect(text(screen.getByTestId("pricing-card-crochet-lite"))).toContain("$2.99 per month");
|
|
293
|
+
expect(within(screen.getByTestId("pricing-card-crochet-lite")).getByText("Unlimited saved patterns")).toBeDefined();
|
|
294
|
+
expect((screen.getByTestId("cta-crochet-free") as HTMLButtonElement).textContent).toMatch(/^current plan$/i);
|
|
295
|
+
await chooseAnnual();
|
|
296
|
+
expect(screen.getByTestId("pricing-card-crochet-lite-annual")).toBeDefined();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("UpgradeModal plan picker (crochet catalogue)", () => {
|
|
301
|
+
it("toggle, prices, labels, current plan", async () => {
|
|
302
|
+
const onSelect = vi.fn();
|
|
303
|
+
renderWith(
|
|
304
|
+
<UpgradeModal
|
|
305
|
+
productId="crochet-patterns"
|
|
306
|
+
open
|
|
307
|
+
onClose={() => {}}
|
|
308
|
+
onSelectOffer={onSelect}
|
|
309
|
+
currentPlan={{ signedIn: true, subscription: null }}
|
|
310
|
+
/>,
|
|
311
|
+
);
|
|
312
|
+
await waitFor(() => expect(screen.getByTestId("upgrade-offers")).toBeDefined());
|
|
313
|
+
expect(screen.queryByTestId("upgrade-offer-crochet-lite-annual")).toBeNull();
|
|
314
|
+
expect(text(screen.getByTestId("upgrade-offer-crochet-lite"))).toContain("$2.99 per month");
|
|
315
|
+
expect(within(screen.getByTestId("upgrade-offer-crochet-lite")).getByText("Up to 3 AI designs per month")).toBeDefined();
|
|
316
|
+
const free = screen.getByTestId("upgrade-cta-crochet-free") as HTMLButtonElement;
|
|
317
|
+
expect(free.textContent).toMatch(/^current plan$/i);
|
|
318
|
+
expect(free.disabled).toBe(true);
|
|
319
|
+
await chooseAnnual();
|
|
320
|
+
expect(text(screen.getByTestId("upgrade-offer-crochet-lite-annual"))).toContain("$29 per year");
|
|
321
|
+
});
|
|
322
|
+
});
|
|
@@ -10,27 +10,55 @@
|
|
|
10
10
|
|
|
11
11
|
import React from "react";
|
|
12
12
|
import { useProduct } from "../hooks/useProduct";
|
|
13
|
-
import type { ProductOffer, OfferFeature } from "../types";
|
|
13
|
+
import type { PlanChangePreview, ProductOffer, OfferFeature } from "../types";
|
|
14
14
|
import { cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
|
|
15
|
+
import { formatFeatureLabel } from "../utils/format";
|
|
16
|
+
import {
|
|
17
|
+
isPayingSubscription,
|
|
18
|
+
isUnlistedOffer,
|
|
19
|
+
planCtaLabel,
|
|
20
|
+
planDirection,
|
|
21
|
+
resolveCurrentOffer,
|
|
22
|
+
trialApplies,
|
|
23
|
+
TRIAL_NOTE_FEATURE_KEY,
|
|
24
|
+
type CurrentPlanState,
|
|
25
|
+
} from "../utils/plan";
|
|
26
|
+
import { usePlanChange } from "../hooks/usePlanChange";
|
|
27
|
+
import { PlanChangeConfirm } from "./PlanChangeConfirm";
|
|
15
28
|
import { FeatureIcon } from "./FeatureIcon";
|
|
29
|
+
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
|
|
16
30
|
|
|
17
31
|
export interface PricingDetailPageProps {
|
|
18
32
|
/** Product slug (e.g., "raise-simpli") */
|
|
19
33
|
productId: string;
|
|
20
34
|
|
|
21
|
-
/**
|
|
22
|
-
|
|
35
|
+
/** Does the purchase / change for the offer the viewer chose. For a
|
|
36
|
+
* signed-in viewer who already has a plan this runs only after they
|
|
37
|
+
* confirm (crochet_patterns-anw61, -kigcj), and gets the backend's preview.
|
|
38
|
+
* Return "navigating" after sending the browser away; throw to show an error. */
|
|
39
|
+
onSelectOffer?: (offer: ProductOffer, preview?: PlanChangePreview | null) => unknown;
|
|
23
40
|
|
|
24
41
|
/** Whether to show the feature comparison table (defaults to true) */
|
|
25
42
|
showComparison?: boolean;
|
|
43
|
+
|
|
44
|
+
/** The viewer's plan, so their card reads "Current plan" and is disabled.
|
|
45
|
+
* Omit on signed-out pages. crochet_patterns-34n7. */
|
|
46
|
+
currentPlan?: CurrentPlanState | null;
|
|
26
47
|
}
|
|
27
48
|
|
|
28
49
|
export function PricingDetailPage({
|
|
29
50
|
productId,
|
|
30
51
|
onSelectOffer,
|
|
31
52
|
showComparison = true,
|
|
53
|
+
currentPlan,
|
|
32
54
|
}: PricingDetailPageProps) {
|
|
33
55
|
const { product, loading, error } = useProduct(productId);
|
|
56
|
+
const currentOffer = resolveCurrentOffer(product?.offers ?? [], currentPlan);
|
|
57
|
+
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
58
|
+
product?.offers ?? [],
|
|
59
|
+
currentOffer?.id ?? null,
|
|
60
|
+
);
|
|
61
|
+
const planChange = usePlanChange(async (offer, preview) => onSelectOffer?.(offer, preview));
|
|
34
62
|
|
|
35
63
|
if (loading) {
|
|
36
64
|
return (
|
|
@@ -56,10 +84,28 @@ export function PricingDetailPage({
|
|
|
56
84
|
);
|
|
57
85
|
}
|
|
58
86
|
|
|
87
|
+
// One offer per tier for the chosen interval: cards, "Everything in X" and
|
|
88
|
+
// the comparison columns all read this, never the raw list (which would
|
|
89
|
+
// add a "Lite Annual" column beside "Lite").
|
|
90
|
+
const visibleOffers = entries.map((e) => e.offer);
|
|
91
|
+
// The viewer's plan once known; null signed out or while it loads, and
|
|
92
|
+
// then every card keeps its sales CTA.
|
|
93
|
+
const paying = !!currentOffer && isPayingSubscription(currentPlan?.subscription);
|
|
94
|
+
const onUnlistedPlan = isUnlistedOffer(product.offers, currentOffer);
|
|
95
|
+
const tierKeyOf = (offerId: string) =>
|
|
96
|
+
entries.find(({ tier }) => tier.monthly?.id === offerId || tier.annual?.id === offerId)?.tier.key ?? null;
|
|
97
|
+
const select = (offer: ProductOffer) => {
|
|
98
|
+
if (!currentOffer) {
|
|
99
|
+
onSelectOffer?.(offer);
|
|
100
|
+
return;
|
|
101
|
+
}
|
|
102
|
+
void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
|
|
103
|
+
};
|
|
104
|
+
|
|
59
105
|
// Extract all unique feature keys across all offers (preserving order)
|
|
60
106
|
const allFeatureKeys: string[] = [];
|
|
61
107
|
const featureNameMap: Record<string, string> = {};
|
|
62
|
-
for (const offer of
|
|
108
|
+
for (const offer of visibleOffers) {
|
|
63
109
|
for (const feature of offer.features) {
|
|
64
110
|
if (!allFeatureKeys.includes(feature.key)) {
|
|
65
111
|
allFeatureKeys.push(feature.key);
|
|
@@ -79,36 +125,90 @@ export function PricingDetailPage({
|
|
|
79
125
|
</p>
|
|
80
126
|
</div>
|
|
81
127
|
|
|
128
|
+
{onUnlistedPlan && currentOffer && (
|
|
129
|
+
<div
|
|
130
|
+
data-testid="current-plan-unlisted"
|
|
131
|
+
role="status"
|
|
132
|
+
className="mb-8 mx-auto max-w-2xl rounded-xl border border-blue-200 bg-blue-50 p-4 text-center"
|
|
133
|
+
>
|
|
134
|
+
<p className="text-sm font-semibold text-blue-900">You're on {currentOffer.name}.</p>
|
|
135
|
+
<p className="mt-1 text-sm text-blue-800">
|
|
136
|
+
It isn't one of the plans below. If you choose another plan, {currentOffer.name} ends and can't be chosen again.
|
|
137
|
+
</p>
|
|
138
|
+
</div>
|
|
139
|
+
)}
|
|
140
|
+
|
|
141
|
+
{planChange.error && !planChange.pending && (
|
|
142
|
+
<div data-testid="plan-change-error" role="alert" className="mb-8 mx-auto max-w-2xl rounded-xl border border-red-200 bg-red-50 p-4">
|
|
143
|
+
<p className="text-sm text-red-700">{planChange.error}</p>
|
|
144
|
+
</div>
|
|
145
|
+
)}
|
|
146
|
+
|
|
147
|
+
{hasChoice && (
|
|
148
|
+
<div className="mb-10">
|
|
149
|
+
<IntervalToggle interval={interval} onChange={setInterval} />
|
|
150
|
+
</div>
|
|
151
|
+
)}
|
|
152
|
+
|
|
82
153
|
<div className="flex flex-wrap justify-center gap-6 items-stretch mb-16">
|
|
83
|
-
{
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
90
|
-
|
|
154
|
+
{entries.map((entry) => {
|
|
155
|
+
const below = tierBelow(visibleOffers, entry.offer);
|
|
156
|
+
const direction = currentOffer ? planDirection(entry.offer, currentOffer, tierKeyOf) : null;
|
|
157
|
+
return (
|
|
158
|
+
<DetailCard
|
|
159
|
+
key={entry.offer.id}
|
|
160
|
+
entry={entry}
|
|
161
|
+
onSelect={select}
|
|
162
|
+
buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
|
|
163
|
+
isCurrent={direction === "current"}
|
|
164
|
+
ctaLabel={direction ? planCtaLabel(entry.offer, direction, paying) : entry.offer.ctaText}
|
|
165
|
+
showTrial={direction ? trialApplies(entry.offer, direction, paying) : entry.offer.trialDays > 0}
|
|
166
|
+
busy={planChange.busy}
|
|
167
|
+
/>
|
|
168
|
+
);
|
|
169
|
+
})}
|
|
91
170
|
</div>
|
|
92
171
|
|
|
93
172
|
{showComparison && (
|
|
94
173
|
<ComparisonTable
|
|
95
|
-
|
|
174
|
+
entries={entries}
|
|
96
175
|
featureKeys={allFeatureKeys}
|
|
97
176
|
featureNameMap={featureNameMap}
|
|
98
177
|
/>
|
|
99
178
|
)}
|
|
179
|
+
|
|
180
|
+
<PlanChangeConfirm
|
|
181
|
+
state={planChange.pending}
|
|
182
|
+
confirming={planChange.confirming}
|
|
183
|
+
error={planChange.pending ? planChange.error : null}
|
|
184
|
+
onConfirm={planChange.confirm}
|
|
185
|
+
onCancel={planChange.cancel}
|
|
186
|
+
/>
|
|
100
187
|
</div>
|
|
101
188
|
);
|
|
102
189
|
}
|
|
103
190
|
|
|
104
191
|
interface DetailCardProps {
|
|
105
|
-
|
|
192
|
+
entry: IntervalEntry;
|
|
106
193
|
onSelect?: (offer: ProductOffer) => void;
|
|
107
194
|
/** Name of the tier just below this one, when there is one. */
|
|
108
195
|
buildsOn?: string | null;
|
|
196
|
+
/** The viewer is on this offer: "Current plan", disabled. */
|
|
197
|
+
isCurrent?: boolean;
|
|
198
|
+
/** The button's words for this viewer (planCtaLabel). */
|
|
199
|
+
ctaLabel?: string;
|
|
200
|
+
/** Show the free-trial pill: only when choosing this card starts one. */
|
|
201
|
+
showTrial?: boolean;
|
|
202
|
+
/** A plan change is in flight: every button waits. */
|
|
203
|
+
busy?: boolean;
|
|
109
204
|
}
|
|
110
205
|
|
|
111
|
-
function DetailCard({
|
|
206
|
+
function DetailCard({ entry, onSelect, buildsOn, isCurrent = false, ctaLabel, showTrial, busy = false }: DetailCardProps) {
|
|
207
|
+
const { offer, tier } = entry;
|
|
208
|
+
const trial = showTrial ?? offer.trialDays > 0;
|
|
209
|
+
const features = trial || !(offer.trialDays > 0)
|
|
210
|
+
? offer.features
|
|
211
|
+
: offer.features.filter((f) => f.key !== TRIAL_NOTE_FEATURE_KEY);
|
|
112
212
|
const isFeatured = offer.isFeatured;
|
|
113
213
|
|
|
114
214
|
return (
|
|
@@ -128,25 +228,19 @@ function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
|
|
|
128
228
|
</span>
|
|
129
229
|
)}
|
|
130
230
|
|
|
131
|
-
<h2 className="text-xl font-bold text-gray-900 mb-4">{
|
|
231
|
+
<h2 className="text-xl font-bold text-gray-900 mb-4">{tier.name}</h2>
|
|
132
232
|
|
|
133
233
|
<div className="mb-2">
|
|
134
|
-
|
|
135
|
-
|
|
136
|
-
|
|
137
|
-
|
|
138
|
-
|
|
139
|
-
|
|
140
|
-
<span aria-hidden="true" className="text-4xl font-extrabold text-gray-900">
|
|
141
|
-
${Math.floor(parseFloat(offer.unitPrice) || 0)}
|
|
142
|
-
</span>
|
|
143
|
-
<span aria-hidden="true" className="text-base text-gray-500 ml-1"> {offer.intervalDisplay}</span>
|
|
144
|
-
</span>
|
|
145
|
-
)}
|
|
234
|
+
<OfferPrice
|
|
235
|
+
offer={offer}
|
|
236
|
+
amountClassName="text-4xl font-extrabold text-gray-900"
|
|
237
|
+
intervalClassName="text-base text-gray-500"
|
|
238
|
+
/>
|
|
239
|
+
<AnnualNote entry={entry} className="mt-1" />
|
|
146
240
|
</div>
|
|
147
241
|
|
|
148
|
-
{
|
|
149
|
-
<span className="inline-block bg-blue-50 text-blue-700 text-sm font-semibold px-3 py-1 rounded-lg mb-5 w-fit">
|
|
242
|
+
{trial && (
|
|
243
|
+
<span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-blue-50 text-blue-700 text-sm font-semibold px-3 py-1 rounded-lg mb-5 w-fit">
|
|
150
244
|
{offer.trialDays}-day free trial
|
|
151
245
|
</span>
|
|
152
246
|
)}
|
|
@@ -158,46 +252,49 @@ function DetailCard({ offer, onSelect, buildsOn }: DetailCardProps) {
|
|
|
158
252
|
)}
|
|
159
253
|
|
|
160
254
|
<ul className="list-none p-0 m-0 mb-7 flex-1 space-y-0">
|
|
161
|
-
{
|
|
255
|
+
{features.map((feature) => (
|
|
162
256
|
<li
|
|
163
257
|
key={feature.key}
|
|
164
258
|
className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
|
|
165
259
|
>
|
|
166
260
|
<FeatureIcon value={feature.value} />
|
|
167
|
-
<span>{feature
|
|
168
|
-
{typeof feature.value
|
|
169
|
-
<span className="ml-auto text-gray-900 font-medium">
|
|
170
|
-
{feature.value === -1 ? "Unlimited" : String(feature.value)}
|
|
171
|
-
</span>
|
|
261
|
+
<span>{formatFeatureLabel(feature)}</span>
|
|
262
|
+
{typeof feature.value === "string" && (
|
|
263
|
+
<span className="ml-auto text-gray-900 font-medium">{feature.value}</span>
|
|
172
264
|
)}
|
|
173
265
|
</li>
|
|
174
266
|
))}
|
|
175
267
|
</ul>
|
|
176
268
|
|
|
177
269
|
<button
|
|
178
|
-
onClick={() => onSelect?.(offer)}
|
|
270
|
+
onClick={() => !isCurrent && !busy && onSelect?.(offer)}
|
|
179
271
|
data-testid={`detail-cta-${offer.slug}`}
|
|
272
|
+
disabled={isCurrent || busy}
|
|
273
|
+
aria-disabled={isCurrent || busy || undefined}
|
|
180
274
|
className={`
|
|
181
|
-
w-full py-3.5 px-6 text-base font-bold rounded-xl
|
|
182
|
-
${
|
|
183
|
-
? "bg-
|
|
184
|
-
:
|
|
275
|
+
w-full py-3.5 px-6 text-base font-bold rounded-xl transition-colors
|
|
276
|
+
${isCurrent
|
|
277
|
+
? "bg-gray-100 text-gray-500 cursor-not-allowed"
|
|
278
|
+
: isFeatured
|
|
279
|
+
? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
|
|
280
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
|
|
185
281
|
}
|
|
186
282
|
`}
|
|
187
283
|
>
|
|
188
|
-
{offer.ctaText}
|
|
284
|
+
{isCurrent ? "Current plan" : ctaLabel ?? offer.ctaText}
|
|
189
285
|
</button>
|
|
190
286
|
</div>
|
|
191
287
|
);
|
|
192
288
|
}
|
|
193
289
|
|
|
194
290
|
interface ComparisonTableProps {
|
|
195
|
-
|
|
291
|
+
entries: IntervalEntry[];
|
|
196
292
|
featureKeys: string[];
|
|
197
293
|
featureNameMap: Record<string, string>;
|
|
198
294
|
}
|
|
199
295
|
|
|
200
|
-
function ComparisonTable({
|
|
296
|
+
function ComparisonTable({ entries, featureKeys, featureNameMap }: ComparisonTableProps) {
|
|
297
|
+
const offers = entries.map((e) => e.offer);
|
|
201
298
|
// offer slug -> feature key -> feature, with cheaper tiers' capabilities
|
|
202
299
|
// carried upward so a paid plan never shows a dash where free shows a tick.
|
|
203
300
|
const featureLookup = cumulativeFeatureLookup(offers);
|
|
@@ -210,14 +307,14 @@ function ComparisonTable({ offers, featureKeys, featureNameMap }: ComparisonTabl
|
|
|
210
307
|
<th scope="col" className="text-left py-4 px-4 text-sm font-semibold text-gray-500 uppercase tracking-wider">
|
|
211
308
|
Feature
|
|
212
309
|
</th>
|
|
213
|
-
{
|
|
310
|
+
{entries.map(({ offer, tier }) => (
|
|
214
311
|
<th
|
|
215
312
|
key={offer.slug}
|
|
216
313
|
scope="col"
|
|
217
|
-
aria-label={
|
|
314
|
+
aria-label={tier.name}
|
|
218
315
|
className="text-center py-4 px-4 text-sm font-semibold text-gray-900"
|
|
219
316
|
>
|
|
220
|
-
<span aria-hidden="true">{
|
|
317
|
+
<span aria-hidden="true">{tier.name}</span>
|
|
221
318
|
</th>
|
|
222
319
|
))}
|
|
223
320
|
</tr>
|
|
@@ -218,9 +218,9 @@ describe("PricingPage", () => {
|
|
|
218
218
|
|
|
219
219
|
await waitFor(() => {
|
|
220
220
|
// Each offer has features; "Team seats" appears in all 3 cards
|
|
221
|
-
expect(screen.getAllByText(
|
|
221
|
+
expect(screen.getAllByText(/team seats$/i).length).toBe(3);
|
|
222
222
|
});
|
|
223
|
-
expect(screen.getAllByText(
|
|
223
|
+
expect(screen.getAllByText(/projects$/i).length).toBe(3);
|
|
224
224
|
});
|
|
225
225
|
|
|
226
226
|
it("shows trial badge when offer has trial days", async () => {
|