@startsimpli/billing 0.4.24 → 0.4.26
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/IntervalToggle.tsx +6 -2
- package/src/components/PlanChange.crochet.test.tsx +274 -0
- package/src/components/PlanChangeConfirm.tsx +193 -0
- package/src/components/PlanPicker.crochet.test.tsx +5 -1
- package/src/components/PricingDetailPage.review.test.tsx +256 -0
- package/src/components/PricingDetailPage.tsx +208 -36
- package/src/components/SubscriptionManager.test.tsx +30 -16
- package/src/components/SubscriptionManager.tsx +128 -74
- package/src/components/UpgradeModal.tsx +57 -13
- 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 +20 -4
- package/src/types/index.ts +29 -0
- package/src/utils/api.ts +38 -1
- package/src/utils/plan.ts +86 -0
|
@@ -0,0 +1,256 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The /pricing design review (crochets.site, 2026-10-18), against a product
|
|
3
|
+
* shaped like the live crochet-patterns catalogue: Free, Lite (+ annual),
|
|
4
|
+
* Pro (+ annual). Pro monthly is the featured offer; Pro Annual is not.
|
|
5
|
+
*/
|
|
6
|
+
|
|
7
|
+
import { describe, it, expect, vi } from "vitest";
|
|
8
|
+
import { act, fireEvent, render, screen, waitFor, within } from "@testing-library/react";
|
|
9
|
+
import React from "react";
|
|
10
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
11
|
+
import { PricingDetailPage, type PricingDetailPageProps } from "./PricingDetailPage";
|
|
12
|
+
import type { BillingProduct, OfferFeature, SubscriptionInfo } from "../types";
|
|
13
|
+
|
|
14
|
+
function offer(
|
|
15
|
+
over: Partial<BillingProduct["offers"][number]> & { name: string; slug: string; features: OfferFeature[] },
|
|
16
|
+
) {
|
|
17
|
+
return {
|
|
18
|
+
id: over.slug,
|
|
19
|
+
unitPrice: "0.00",
|
|
20
|
+
currency: "USD",
|
|
21
|
+
pricingModel: "flat" as const,
|
|
22
|
+
intervalUnit: "month" as const,
|
|
23
|
+
intervalCount: 1,
|
|
24
|
+
intervalDisplay: "per month",
|
|
25
|
+
isRecurring: true,
|
|
26
|
+
isFree: false,
|
|
27
|
+
isActive: true,
|
|
28
|
+
isFeatured: false,
|
|
29
|
+
trialDays: 0,
|
|
30
|
+
sortOrder: 0,
|
|
31
|
+
ctaText: "Choose",
|
|
32
|
+
...over,
|
|
33
|
+
};
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const LITE: OfferFeature[] = [
|
|
37
|
+
{ key: "ai_designs_per_month", name: "AI designs per month", value: 3 },
|
|
38
|
+
{ key: "ai_refine", name: "AI chat to refine any pattern", value: true },
|
|
39
|
+
{ key: "pdf_export", name: "Print-ready PDF export", value: true },
|
|
40
|
+
{ key: "save_patterns", name: "Save patterns to your library", value: true },
|
|
41
|
+
{ key: "track_progress", name: "Track your progress", value: true },
|
|
42
|
+
{ key: "patterns_per_month", name: "Saved patterns", value: -1 },
|
|
43
|
+
];
|
|
44
|
+
const PRO: OfferFeature[] = [
|
|
45
|
+
{ key: "patterns_per_month", name: "Saved patterns", value: -1 },
|
|
46
|
+
{ key: "ai_refine", name: "AI chat to refine any pattern", value: true },
|
|
47
|
+
{ key: "pdf_export", name: "Print-ready PDF export", value: true },
|
|
48
|
+
{ key: "custom_sizing", name: "Custom sizing", value: true },
|
|
49
|
+
{ key: "trial_note", name: "7-day free trial with AI designs included", value: true },
|
|
50
|
+
{ key: "ai_designs_per_month", name: "AI designs per month", value: 15 },
|
|
51
|
+
];
|
|
52
|
+
|
|
53
|
+
const product: BillingProduct = {
|
|
54
|
+
id: "prod-crochet",
|
|
55
|
+
slug: "crochet-patterns",
|
|
56
|
+
name: "Crochet Patterns",
|
|
57
|
+
description: "AI-powered crochet pattern generator",
|
|
58
|
+
offers: [
|
|
59
|
+
offer({
|
|
60
|
+
name: "Free", slug: "crochet-free", isFree: true, sortOrder: 0, ctaText: "Get Started",
|
|
61
|
+
features: [
|
|
62
|
+
{ key: "ai_designs", name: "AI pattern designs", value: false },
|
|
63
|
+
{ key: "save_patterns", name: "Save patterns to your library", value: true },
|
|
64
|
+
{ key: "track_progress", name: "Track your progress", value: true },
|
|
65
|
+
{ key: "pdf_export", name: "Print-ready PDF export", value: true },
|
|
66
|
+
],
|
|
67
|
+
}),
|
|
68
|
+
offer({ name: "Lite", slug: "crochet-lite", tier: "lite", unitPrice: "2.99", sortOrder: 1, ctaText: "Get Lite", features: LITE }),
|
|
69
|
+
offer({
|
|
70
|
+
name: "Lite Annual", slug: "crochet-lite-annual", tier: "lite", unitPrice: "29.00", sortOrder: 1,
|
|
71
|
+
intervalUnit: "year", intervalDisplay: "per year", ctaText: "Get Lite", features: LITE,
|
|
72
|
+
}),
|
|
73
|
+
offer({
|
|
74
|
+
name: "Pro", slug: "crochet-pro", tier: "pro", unitPrice: "9.00", sortOrder: 2, trialDays: 7,
|
|
75
|
+
isFeatured: true, ctaText: "Start Free Trial", features: PRO,
|
|
76
|
+
}),
|
|
77
|
+
offer({
|
|
78
|
+
name: "Pro Annual", slug: "crochet-pro-annual", tier: "pro", unitPrice: "89.00", sortOrder: 2, trialDays: 7,
|
|
79
|
+
intervalUnit: "year", intervalDisplay: "per year", ctaText: "Start Free Trial", features: PRO,
|
|
80
|
+
}),
|
|
81
|
+
],
|
|
82
|
+
};
|
|
83
|
+
|
|
84
|
+
function okFetcher(p: BillingProduct = product) {
|
|
85
|
+
return vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(p) }) as unknown as typeof fetch;
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
function renderPricing(props: Partial<PricingDetailPageProps> = {}, fetcher: typeof fetch = okFetcher()) {
|
|
89
|
+
return render(
|
|
90
|
+
<BillingProvider apiBaseUrl="https://api.test.com/api/v1" fetcher={fetcher}>
|
|
91
|
+
<PricingDetailPage productId="crochet-patterns" {...props} />
|
|
92
|
+
</BillingProvider>,
|
|
93
|
+
);
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
const card = (slug: string) => screen.getByTestId(`detail-card-${slug}`);
|
|
97
|
+
const cta = (slug: string) => screen.getByTestId(`detail-cta-${slug}`) as HTMLButtonElement;
|
|
98
|
+
const cards = () => screen.getAllByTestId(/^detail-card-/);
|
|
99
|
+
const toAnnual = () => fireEvent.click(screen.getByRole("radio", { name: "Annual" }));
|
|
100
|
+
|
|
101
|
+
function liteMonthly(): SubscriptionInfo {
|
|
102
|
+
return {
|
|
103
|
+
id: "sub-1",
|
|
104
|
+
status: "active",
|
|
105
|
+
offer: product.offers[1],
|
|
106
|
+
} as unknown as SubscriptionInfo;
|
|
107
|
+
}
|
|
108
|
+
|
|
109
|
+
describe("Pro stays the highlighted tier on Annual (crochet_patterns-ftrka)", () => {
|
|
110
|
+
it("keeps Most Popular on Pro Annual although only the monthly offer is featured", async () => {
|
|
111
|
+
renderPricing();
|
|
112
|
+
await waitFor(() => expect(card("crochet-pro")).toBeDefined());
|
|
113
|
+
expect(within(card("crochet-pro")).getByText(/most popular/i)).toBeDefined();
|
|
114
|
+
|
|
115
|
+
toAnnual();
|
|
116
|
+
expect(within(card("crochet-pro-annual")).getByText(/most popular/i)).toBeDefined();
|
|
117
|
+
expect(within(card("crochet-lite-annual")).queryByText(/most popular/i)).toBeNull();
|
|
118
|
+
});
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
describe("cards list only what a tier adds (crochet_patterns-tv7fb)", () => {
|
|
122
|
+
it("does not repeat Free's rows on Lite", async () => {
|
|
123
|
+
renderPricing();
|
|
124
|
+
await waitFor(() => expect(card("crochet-lite")).toBeDefined());
|
|
125
|
+
const lite = within(card("crochet-lite"));
|
|
126
|
+
expect(lite.getByText(/everything in free, plus/i)).toBeDefined();
|
|
127
|
+
expect(lite.queryByText("Print-ready PDF export")).toBeNull();
|
|
128
|
+
expect(lite.queryByText("Save patterns to your library")).toBeNull();
|
|
129
|
+
expect(lite.queryByText("Track your progress")).toBeNull();
|
|
130
|
+
expect(lite.getByText(/AI chat to refine/)).toBeDefined();
|
|
131
|
+
});
|
|
132
|
+
|
|
133
|
+
it("does not repeat Lite's rows on Pro, but keeps Pro's bigger allowance", async () => {
|
|
134
|
+
renderPricing();
|
|
135
|
+
await waitFor(() => expect(card("crochet-pro")).toBeDefined());
|
|
136
|
+
const pro = within(card("crochet-pro"));
|
|
137
|
+
expect(pro.queryByText(/AI chat to refine/)).toBeNull();
|
|
138
|
+
expect(pro.queryByText("Print-ready PDF export")).toBeNull();
|
|
139
|
+
expect(pro.queryByText(/saved patterns/i)).toBeNull();
|
|
140
|
+
expect(pro.getByText(/custom sizing/i)).toBeDefined();
|
|
141
|
+
expect(pro.getByText(/15 AI designs/)).toBeDefined();
|
|
142
|
+
});
|
|
143
|
+
|
|
144
|
+
it("shows the trial once, as the pill, and no 'not included' rows on Free", async () => {
|
|
145
|
+
renderPricing();
|
|
146
|
+
await waitFor(() => expect(card("crochet-pro")).toBeDefined());
|
|
147
|
+
expect(screen.getByTestId("trial-pill-crochet-pro")).toBeDefined();
|
|
148
|
+
expect(within(card("crochet-pro")).queryByText(/with AI designs included/)).toBeNull();
|
|
149
|
+
expect(within(card("crochet-free")).queryByText("AI pattern designs")).toBeNull();
|
|
150
|
+
// The comparison table still has every row.
|
|
151
|
+
expect(screen.getByTestId("comparison-row-ai_designs")).toBeDefined();
|
|
152
|
+
});
|
|
153
|
+
});
|
|
154
|
+
|
|
155
|
+
describe("load error offers Try again (crochet_patterns-aeuji)", () => {
|
|
156
|
+
it("refetches the offers from the button", async () => {
|
|
157
|
+
const fetcher = vi.fn()
|
|
158
|
+
.mockResolvedValueOnce({ ok: false, status: 500, json: () => Promise.resolve({}) })
|
|
159
|
+
.mockResolvedValue({ ok: true, json: () => Promise.resolve(product) }) as unknown as typeof fetch;
|
|
160
|
+
renderPricing({}, fetcher);
|
|
161
|
+
const retry = await screen.findByTestId("pricing-retry");
|
|
162
|
+
fireEvent.click(retry);
|
|
163
|
+
await waitFor(() => expect(card("crochet-pro")).toBeDefined());
|
|
164
|
+
});
|
|
165
|
+
});
|
|
166
|
+
|
|
167
|
+
describe("plan buttons wait while the viewer's plan is unknown (crochet_patterns-ktjto)", () => {
|
|
168
|
+
it("disables every button and says the plan is being checked", async () => {
|
|
169
|
+
const onSelectOffer = vi.fn();
|
|
170
|
+
renderPricing({ onSelectOffer, currentPlan: { signedIn: true, subscription: null, loading: true } });
|
|
171
|
+
await waitFor(() => expect(card("crochet-free")).toBeDefined());
|
|
172
|
+
expect(screen.getByTestId("plan-unknown").textContent).toMatch(/checking your plan/i);
|
|
173
|
+
for (const c of cards()) {
|
|
174
|
+
const slug = c.getAttribute("data-testid")!.replace("detail-card-", "");
|
|
175
|
+
expect(cta(slug).disabled, slug).toBe(true);
|
|
176
|
+
}
|
|
177
|
+
fireEvent.click(cta("crochet-free"));
|
|
178
|
+
expect(onSelectOffer).not.toHaveBeenCalled();
|
|
179
|
+
});
|
|
180
|
+
|
|
181
|
+
it("offers a retry when the plan failed to load", async () => {
|
|
182
|
+
const retry = vi.fn();
|
|
183
|
+
renderPricing({ currentPlan: { signedIn: true, subscription: null, error: new Error("x"), retry } });
|
|
184
|
+
await waitFor(() => expect(card("crochet-free")).toBeDefined());
|
|
185
|
+
expect(cta("crochet-free").disabled).toBe(true);
|
|
186
|
+
fireEvent.click(screen.getByTestId("plan-retry"));
|
|
187
|
+
expect(retry).toHaveBeenCalledTimes(1);
|
|
188
|
+
});
|
|
189
|
+
|
|
190
|
+
it("leaves a signed-out viewer's buttons live", async () => {
|
|
191
|
+
renderPricing({ currentPlan: { signedIn: false, subscription: null } });
|
|
192
|
+
await waitFor(() => expect(card("crochet-free")).toBeDefined());
|
|
193
|
+
expect(screen.queryByTestId("plan-unknown")).toBeNull();
|
|
194
|
+
expect(cta("crochet-lite").disabled).toBe(false);
|
|
195
|
+
});
|
|
196
|
+
});
|
|
197
|
+
|
|
198
|
+
describe("one purchase at a time (crochet_patterns-j2we6)", () => {
|
|
199
|
+
it("disables every button and the toggle while the host's checkout runs", async () => {
|
|
200
|
+
renderPricing({ busy: true });
|
|
201
|
+
await waitFor(() => expect(card("crochet-pro")).toBeDefined());
|
|
202
|
+
expect(cta("crochet-lite").disabled).toBe(true);
|
|
203
|
+
expect(cta("crochet-pro").disabled).toBe(true);
|
|
204
|
+
expect((screen.getByRole("radio", { name: "Annual" }) as HTMLButtonElement).disabled).toBe(true);
|
|
205
|
+
});
|
|
206
|
+
|
|
207
|
+
it("spins the clicked button and holds the rest until the checkout settles", async () => {
|
|
208
|
+
let finish: (v?: unknown) => void = () => {};
|
|
209
|
+
const onSelectOffer = vi.fn(() => new Promise((r) => { finish = r; }));
|
|
210
|
+
renderPricing({ onSelectOffer });
|
|
211
|
+
await waitFor(() => expect(card("crochet-pro")).toBeDefined());
|
|
212
|
+
|
|
213
|
+
fireEvent.click(cta("crochet-lite"));
|
|
214
|
+
await waitFor(() => expect(cta("crochet-lite").getAttribute("aria-busy")).toBe("true"));
|
|
215
|
+
expect(cta("crochet-pro").disabled).toBe(true);
|
|
216
|
+
fireEvent.click(cta("crochet-pro"));
|
|
217
|
+
expect(onSelectOffer).toHaveBeenCalledTimes(1);
|
|
218
|
+
|
|
219
|
+
await act(async () => { finish(); });
|
|
220
|
+
await waitFor(() => expect(cta("crochet-pro").disabled).toBe(false));
|
|
221
|
+
});
|
|
222
|
+
});
|
|
223
|
+
|
|
224
|
+
describe("the page names itself (crochet_patterns-ln6hf)", () => {
|
|
225
|
+
it("uses the host's title and subtitle", async () => {
|
|
226
|
+
renderPricing({ title: "Pricing", subtitle: "Free to start." });
|
|
227
|
+
expect(await screen.findByRole("heading", { level: 1, name: "Pricing" })).toBeDefined();
|
|
228
|
+
expect(screen.getByText("Free to start.")).toBeDefined();
|
|
229
|
+
});
|
|
230
|
+
|
|
231
|
+
it("falls back to the product name", async () => {
|
|
232
|
+
renderPricing();
|
|
233
|
+
expect(await screen.findByRole("heading", { level: 1, name: "Crochet Patterns" })).toBeDefined();
|
|
234
|
+
});
|
|
235
|
+
});
|
|
236
|
+
|
|
237
|
+
describe("a Lite monthly subscriber on Annual (crochet_patterns-m1psz)", () => {
|
|
238
|
+
it("offers Switch to annual on their own tier, marked as their plan", async () => {
|
|
239
|
+
renderPricing({ currentPlan: { signedIn: true, subscription: liteMonthly() } });
|
|
240
|
+
await waitFor(() => expect(card("crochet-lite")).toBeDefined());
|
|
241
|
+
toAnnual();
|
|
242
|
+
expect(cta("crochet-lite-annual").textContent).toMatch(/switch to annual/i);
|
|
243
|
+
expect(screen.getByTestId("your-plan-note-crochet-lite-annual").textContent).toBe("Your plan · billed monthly");
|
|
244
|
+
});
|
|
245
|
+
});
|
|
246
|
+
|
|
247
|
+
describe("cards share row slots (crochet_patterns-h74l1)", () => {
|
|
248
|
+
it("gives every card the lead-in row and no featured card is scaled", async () => {
|
|
249
|
+
renderPricing();
|
|
250
|
+
await waitFor(() => expect(card("crochet-pro")).toBeDefined());
|
|
251
|
+
for (const slug of ["crochet-free", "crochet-lite", "crochet-pro"]) {
|
|
252
|
+
expect(screen.getByTestId(`card-lead-${slug}`)).toBeDefined();
|
|
253
|
+
expect(card(slug).className).not.toMatch(/scale-/);
|
|
254
|
+
}
|
|
255
|
+
});
|
|
256
|
+
});
|
|
@@ -10,10 +10,21 @@
|
|
|
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
15
|
import { formatFeatureLabel } from "../utils/format";
|
|
16
|
-
import {
|
|
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";
|
|
17
28
|
import { FeatureIcon } from "./FeatureIcon";
|
|
18
29
|
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
|
|
19
30
|
|
|
@@ -21,15 +32,29 @@ export interface PricingDetailPageProps {
|
|
|
21
32
|
/** Product slug (e.g., "raise-simpli") */
|
|
22
33
|
productId: string;
|
|
23
34
|
|
|
24
|
-
/**
|
|
25
|
-
|
|
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;
|
|
26
40
|
|
|
27
41
|
/** Whether to show the feature comparison table (defaults to true) */
|
|
28
42
|
showComparison?: boolean;
|
|
29
43
|
|
|
30
44
|
/** The viewer's plan, so their card reads "Current plan" and is disabled.
|
|
31
|
-
* Omit on signed-out pages. crochet_patterns-34n7.
|
|
45
|
+
* Omit on signed-out pages. crochet_patterns-34n7. While it loads or has
|
|
46
|
+
* failed every plan button waits (crochet_patterns-ktjto). */
|
|
32
47
|
currentPlan?: CurrentPlanState | null;
|
|
48
|
+
|
|
49
|
+
/** A purchase the host started is in flight (e.g. an auto-checkout after
|
|
50
|
+
* sign-up): every plan button and the interval toggle wait. */
|
|
51
|
+
busy?: boolean;
|
|
52
|
+
|
|
53
|
+
/** Page heading; defaults to the product name. */
|
|
54
|
+
title?: React.ReactNode;
|
|
55
|
+
|
|
56
|
+
/** Line under the heading; defaults to the product description. */
|
|
57
|
+
subtitle?: React.ReactNode;
|
|
33
58
|
}
|
|
34
59
|
|
|
35
60
|
export function PricingDetailPage({
|
|
@@ -37,12 +62,20 @@ export function PricingDetailPage({
|
|
|
37
62
|
onSelectOffer,
|
|
38
63
|
showComparison = true,
|
|
39
64
|
currentPlan,
|
|
65
|
+
busy: hostBusy = false,
|
|
66
|
+
title,
|
|
67
|
+
subtitle,
|
|
40
68
|
}: PricingDetailPageProps) {
|
|
41
|
-
const { product, loading, error } = useProduct(productId);
|
|
69
|
+
const { product, loading, error, refetch } = useProduct(productId);
|
|
70
|
+
const currentOffer = resolveCurrentOffer(product?.offers ?? [], currentPlan);
|
|
42
71
|
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
43
72
|
product?.offers ?? [],
|
|
44
|
-
|
|
73
|
+
currentOffer?.id ?? null,
|
|
45
74
|
);
|
|
75
|
+
const planChange = usePlanChange(async (offer, preview) => onSelectOffer?.(offer, preview));
|
|
76
|
+
// A purchase by a viewer with no plan (signed out) skips usePlanChange, so
|
|
77
|
+
// it is tracked here: the clicked card spins and every other button waits.
|
|
78
|
+
const [selecting, setSelecting] = React.useState<string | null>(null);
|
|
46
79
|
|
|
47
80
|
if (loading) {
|
|
48
81
|
return (
|
|
@@ -54,8 +87,16 @@ export function PricingDetailPage({
|
|
|
54
87
|
|
|
55
88
|
if (error || !product) {
|
|
56
89
|
return (
|
|
57
|
-
<div className="text-center py-16
|
|
58
|
-
Unable to load pricing
|
|
90
|
+
<div role="alert" className="text-center py-16">
|
|
91
|
+
<p className="text-red-600">Unable to load pricing.</p>
|
|
92
|
+
<button
|
|
93
|
+
type="button"
|
|
94
|
+
data-testid="pricing-retry"
|
|
95
|
+
onClick={refetch}
|
|
96
|
+
className="mt-4 py-2 px-5 text-sm font-semibold rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
|
|
97
|
+
>
|
|
98
|
+
Try again
|
|
99
|
+
</button>
|
|
59
100
|
</div>
|
|
60
101
|
);
|
|
61
102
|
}
|
|
@@ -72,7 +113,36 @@ export function PricingDetailPage({
|
|
|
72
113
|
// the comparison columns all read this, never the raw list (which would
|
|
73
114
|
// add a "Lite Annual" column beside "Lite").
|
|
74
115
|
const visibleOffers = entries.map((e) => e.offer);
|
|
75
|
-
|
|
116
|
+
// The viewer's plan once known; null signed out or while it loads, and
|
|
117
|
+
// then every card keeps its sales CTA.
|
|
118
|
+
const paying = !!currentOffer && isPayingSubscription(currentPlan?.subscription);
|
|
119
|
+
const onUnlistedPlan = isUnlistedOffer(product.offers, currentOffer);
|
|
120
|
+
const tierKeyOf = (offerId: string) =>
|
|
121
|
+
entries.find(({ tier }) => tier.monthly?.id === offerId || tier.annual?.id === offerId)?.tier.key ?? null;
|
|
122
|
+
// Signed in but the plan is not known yet (or failed to load): a click
|
|
123
|
+
// could buy what they already have or drop a paying plan to Free.
|
|
124
|
+
const planUnknown = !!currentPlan && (!!currentPlan.loading || (currentPlan.signedIn && !!currentPlan.error));
|
|
125
|
+
const busy = planChange.busy || hostBusy || selecting !== null;
|
|
126
|
+
const select = (offer: ProductOffer) => {
|
|
127
|
+
if (busy || planUnknown) return;
|
|
128
|
+
if (!currentOffer) {
|
|
129
|
+
setSelecting(offer.id);
|
|
130
|
+
void Promise.resolve()
|
|
131
|
+
.then(() => onSelectOffer?.(offer))
|
|
132
|
+
.then(
|
|
133
|
+
(outcome) => { if (outcome !== "navigating") setSelecting(null); },
|
|
134
|
+
() => setSelecting(null),
|
|
135
|
+
);
|
|
136
|
+
return;
|
|
137
|
+
}
|
|
138
|
+
setSelecting(null);
|
|
139
|
+
void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
|
|
140
|
+
};
|
|
141
|
+
const spinningId = busy ? selecting ?? planChange.pending?.offer.id ?? null : null;
|
|
142
|
+
|
|
143
|
+
// What each tier already has, so a card under "Everything in X, plus:"
|
|
144
|
+
// lists only what it adds (crochet_patterns-tv7fb).
|
|
145
|
+
const cumulative = cumulativeFeatureLookup(visibleOffers);
|
|
76
146
|
|
|
77
147
|
// Extract all unique feature keys across all offers (preserving order)
|
|
78
148
|
const allFeatureKeys: string[] = [];
|
|
@@ -90,29 +160,81 @@ export function PricingDetailPage({
|
|
|
90
160
|
<div data-testid="pricing-detail" className="max-w-6xl mx-auto px-6 py-12">
|
|
91
161
|
<div className="text-center mb-12">
|
|
92
162
|
<h1 className="text-4xl font-extrabold tracking-tight text-gray-900 mb-3">
|
|
93
|
-
{product.name}
|
|
163
|
+
{title ?? product.name}
|
|
94
164
|
</h1>
|
|
95
165
|
<p className="text-lg text-gray-500 max-w-xl mx-auto">
|
|
96
|
-
{product.description}
|
|
166
|
+
{subtitle ?? product.description}
|
|
97
167
|
</p>
|
|
98
168
|
</div>
|
|
99
169
|
|
|
170
|
+
{onUnlistedPlan && currentOffer && (
|
|
171
|
+
<div
|
|
172
|
+
data-testid="current-plan-unlisted"
|
|
173
|
+
role="status"
|
|
174
|
+
className="mb-8 mx-auto max-w-2xl rounded-xl border border-blue-200 bg-blue-50 p-4 text-center"
|
|
175
|
+
>
|
|
176
|
+
<p className="text-sm font-semibold text-blue-900">You're on {currentOffer.name}.</p>
|
|
177
|
+
<p className="mt-1 text-sm text-blue-800">
|
|
178
|
+
It isn't one of the plans below. If you choose another plan, {currentOffer.name} ends and can't be chosen again.
|
|
179
|
+
</p>
|
|
180
|
+
</div>
|
|
181
|
+
)}
|
|
182
|
+
|
|
183
|
+
{planChange.error && !planChange.pending && (
|
|
184
|
+
<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">
|
|
185
|
+
<p className="text-sm text-red-700">{planChange.error}</p>
|
|
186
|
+
</div>
|
|
187
|
+
)}
|
|
188
|
+
|
|
189
|
+
{planUnknown && (
|
|
190
|
+
<div
|
|
191
|
+
data-testid="plan-unknown"
|
|
192
|
+
role="status"
|
|
193
|
+
className="mb-8 mx-auto max-w-2xl rounded-xl border border-gray-200 bg-gray-50 p-4 text-center"
|
|
194
|
+
>
|
|
195
|
+
{currentPlan?.loading ? (
|
|
196
|
+
<p className="text-sm text-gray-700">Checking your plan…</p>
|
|
197
|
+
) : (
|
|
198
|
+
<>
|
|
199
|
+
<p className="text-sm text-gray-700">We couldn't check your plan, so plan changes are paused.</p>
|
|
200
|
+
{currentPlan?.retry && (
|
|
201
|
+
<button
|
|
202
|
+
type="button"
|
|
203
|
+
data-testid="plan-retry"
|
|
204
|
+
onClick={currentPlan.retry}
|
|
205
|
+
className="mt-2 text-sm font-semibold text-blue-700 hover:underline cursor-pointer"
|
|
206
|
+
>
|
|
207
|
+
Try again
|
|
208
|
+
</button>
|
|
209
|
+
)}
|
|
210
|
+
</>
|
|
211
|
+
)}
|
|
212
|
+
</div>
|
|
213
|
+
)}
|
|
214
|
+
|
|
100
215
|
{hasChoice && (
|
|
101
216
|
<div className="mb-10">
|
|
102
|
-
<IntervalToggle interval={interval} onChange={setInterval} />
|
|
217
|
+
<IntervalToggle interval={interval} onChange={setInterval} disabled={busy} />
|
|
103
218
|
</div>
|
|
104
219
|
)}
|
|
105
220
|
|
|
106
221
|
<div className="flex flex-wrap justify-center gap-6 items-stretch mb-16">
|
|
107
222
|
{entries.map((entry) => {
|
|
108
223
|
const below = tierBelow(visibleOffers, entry.offer);
|
|
224
|
+
const direction = currentOffer ? planDirection(entry.offer, currentOffer, tierKeyOf) : null;
|
|
109
225
|
return (
|
|
110
226
|
<DetailCard
|
|
111
227
|
key={entry.offer.id}
|
|
112
228
|
entry={entry}
|
|
113
|
-
onSelect={
|
|
229
|
+
onSelect={select}
|
|
114
230
|
buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
|
|
115
|
-
|
|
231
|
+
inherited={below ? cumulative[below.slug] ?? null : null}
|
|
232
|
+
isCurrent={direction === "current"}
|
|
233
|
+
yourPlanNote={direction === "switch" && currentOffer ? `Your plan · billed ${currentOffer.intervalUnit === "year" ? "yearly" : "monthly"}` : null}
|
|
234
|
+
ctaLabel={direction ? planCtaLabel(entry.offer, direction, paying) : entry.offer.ctaText}
|
|
235
|
+
showTrial={direction ? trialApplies(entry.offer, direction, paying) : entry.offer.trialDays > 0}
|
|
236
|
+
busy={busy || planUnknown}
|
|
237
|
+
spinning={spinningId === entry.offer.id}
|
|
116
238
|
/>
|
|
117
239
|
);
|
|
118
240
|
})}
|
|
@@ -125,6 +247,14 @@ export function PricingDetailPage({
|
|
|
125
247
|
featureNameMap={featureNameMap}
|
|
126
248
|
/>
|
|
127
249
|
)}
|
|
250
|
+
|
|
251
|
+
<PlanChangeConfirm
|
|
252
|
+
state={planChange.pending}
|
|
253
|
+
confirming={planChange.confirming}
|
|
254
|
+
error={planChange.pending ? planChange.error : null}
|
|
255
|
+
onConfirm={planChange.confirm}
|
|
256
|
+
onCancel={planChange.cancel}
|
|
257
|
+
/>
|
|
128
258
|
</div>
|
|
129
259
|
);
|
|
130
260
|
}
|
|
@@ -134,13 +264,43 @@ interface DetailCardProps {
|
|
|
134
264
|
onSelect?: (offer: ProductOffer) => void;
|
|
135
265
|
/** Name of the tier just below this one, when there is one. */
|
|
136
266
|
buildsOn?: string | null;
|
|
267
|
+
/** What the tier below already has (cumulative); rows it matches are not repeated. */
|
|
268
|
+
inherited?: Record<string, OfferFeature> | null;
|
|
269
|
+
/** "Your plan · billed monthly" on the other-interval card of the viewer's plan. */
|
|
270
|
+
yourPlanNote?: string | null;
|
|
271
|
+
/** This card's purchase is in flight: its button spins. */
|
|
272
|
+
spinning?: boolean;
|
|
137
273
|
/** The viewer is on this offer: "Current plan", disabled. */
|
|
138
274
|
isCurrent?: boolean;
|
|
275
|
+
/** The button's words for this viewer (planCtaLabel). */
|
|
276
|
+
ctaLabel?: string;
|
|
277
|
+
/** Show the free-trial pill: only when choosing this card starts one. */
|
|
278
|
+
showTrial?: boolean;
|
|
279
|
+
/** A plan change is in flight: every button waits. */
|
|
280
|
+
busy?: boolean;
|
|
139
281
|
}
|
|
140
282
|
|
|
141
|
-
function
|
|
283
|
+
function sameFeature(a: OfferFeature, b: OfferFeature): boolean {
|
|
284
|
+
return a.value === b.value && (a.limit ?? null) === (b.limit ?? null);
|
|
285
|
+
}
|
|
286
|
+
|
|
287
|
+
function DetailCard({
|
|
288
|
+
entry, onSelect, buildsOn, inherited, yourPlanNote, isCurrent = false, ctaLabel, showTrial, busy = false, spinning = false,
|
|
289
|
+
}: DetailCardProps) {
|
|
142
290
|
const { offer, tier } = entry;
|
|
143
|
-
const
|
|
291
|
+
const trial = showTrial ?? offer.trialDays > 0;
|
|
292
|
+
// Each row once (crochet_patterns-tv7fb): no "not included" rows (the
|
|
293
|
+
// comparison table has them), no trial row (the pill says it, or the trial
|
|
294
|
+
// does not apply), and nothing the tier below already has.
|
|
295
|
+
const features = offer.features.filter((f) => {
|
|
296
|
+
if (f.value === false) return false;
|
|
297
|
+
if (f.key === TRIAL_NOTE_FEATURE_KEY) return false;
|
|
298
|
+
const below = inherited?.[f.key];
|
|
299
|
+
return !(below && sameFeature(below, f));
|
|
300
|
+
});
|
|
301
|
+
// Featured is the tier's, so Pro stays highlighted on Annual too
|
|
302
|
+
// (crochet_patterns-ftrka).
|
|
303
|
+
const isFeatured = !!(tier.monthly?.isFeatured || tier.annual?.isFeatured);
|
|
144
304
|
|
|
145
305
|
return (
|
|
146
306
|
<div
|
|
@@ -148,7 +308,7 @@ function DetailCard({ entry, onSelect, buildsOn, isCurrent = false }: DetailCard
|
|
|
148
308
|
className={`
|
|
149
309
|
flex flex-col relative rounded-2xl p-8 w-full max-w-sm flex-1 min-w-[280px]
|
|
150
310
|
${isFeatured
|
|
151
|
-
? "border-2 border-blue-600 shadow-lg
|
|
311
|
+
? "border-2 border-blue-600 shadow-lg"
|
|
152
312
|
: "border border-gray-200 bg-white shadow-sm"
|
|
153
313
|
}
|
|
154
314
|
`}
|
|
@@ -161,29 +321,37 @@ function DetailCard({ entry, onSelect, buildsOn, isCurrent = false }: DetailCard
|
|
|
161
321
|
|
|
162
322
|
<h2 className="text-xl font-bold text-gray-900 mb-4">{tier.name}</h2>
|
|
163
323
|
|
|
324
|
+
{/* Every card reserves the same rows (price, note, pill, lead-in) so
|
|
325
|
+
matching features line up across cards (crochet_patterns-h74l1). */}
|
|
164
326
|
<div className="mb-2">
|
|
165
327
|
<OfferPrice
|
|
166
328
|
offer={offer}
|
|
167
329
|
amountClassName="text-4xl font-extrabold text-gray-900"
|
|
168
330
|
intervalClassName="text-base text-gray-500"
|
|
169
331
|
/>
|
|
170
|
-
<
|
|
332
|
+
<div className="mt-1 min-h-4">
|
|
333
|
+
<AnnualNote entry={entry} />
|
|
334
|
+
</div>
|
|
171
335
|
</div>
|
|
172
336
|
|
|
173
|
-
|
|
174
|
-
|
|
175
|
-
{offer.
|
|
176
|
-
|
|
177
|
-
|
|
337
|
+
<div className="mb-5 min-h-8">
|
|
338
|
+
{trial ? (
|
|
339
|
+
<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 w-fit">
|
|
340
|
+
{offer.trialDays}-day free trial
|
|
341
|
+
</span>
|
|
342
|
+
) : yourPlanNote ? (
|
|
343
|
+
<span data-testid={`your-plan-note-${offer.slug}`} className="inline-block bg-gray-100 text-gray-700 text-sm font-semibold px-3 py-1 rounded-lg w-fit">
|
|
344
|
+
{yourPlanNote}
|
|
345
|
+
</span>
|
|
346
|
+
) : null}
|
|
347
|
+
</div>
|
|
178
348
|
|
|
179
|
-
{
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
</p>
|
|
183
|
-
)}
|
|
349
|
+
<p data-testid={`card-lead-${offer.slug}`} className="text-sm font-semibold text-gray-900 mb-1 min-h-5">
|
|
350
|
+
{buildsOn ? `Everything in ${buildsOn}, plus:` : null}
|
|
351
|
+
</p>
|
|
184
352
|
|
|
185
353
|
<ul className="list-none p-0 m-0 mb-7 flex-1 space-y-0">
|
|
186
|
-
{
|
|
354
|
+
{features.map((feature) => (
|
|
187
355
|
<li
|
|
188
356
|
key={feature.key}
|
|
189
357
|
className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
|
|
@@ -198,21 +366,25 @@ function DetailCard({ entry, onSelect, buildsOn, isCurrent = false }: DetailCard
|
|
|
198
366
|
</ul>
|
|
199
367
|
|
|
200
368
|
<button
|
|
201
|
-
onClick={() => !isCurrent && onSelect?.(offer)}
|
|
369
|
+
onClick={() => !isCurrent && !busy && onSelect?.(offer)}
|
|
202
370
|
data-testid={`detail-cta-${offer.slug}`}
|
|
203
|
-
disabled={isCurrent}
|
|
204
|
-
aria-disabled={isCurrent || undefined}
|
|
371
|
+
disabled={isCurrent || busy}
|
|
372
|
+
aria-disabled={isCurrent || busy || undefined}
|
|
373
|
+
aria-busy={spinning || undefined}
|
|
205
374
|
className={`
|
|
206
375
|
w-full py-3.5 px-6 text-base font-bold rounded-xl transition-colors
|
|
207
376
|
${isCurrent
|
|
208
377
|
? "bg-gray-100 text-gray-500 cursor-not-allowed"
|
|
209
378
|
: isFeatured
|
|
210
|
-
? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
|
|
211
|
-
: "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
|
|
379
|
+
? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer disabled:opacity-70 disabled:cursor-wait"
|
|
380
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer disabled:opacity-70 disabled:cursor-wait"
|
|
212
381
|
}
|
|
213
382
|
`}
|
|
214
383
|
>
|
|
215
|
-
{
|
|
384
|
+
{spinning && (
|
|
385
|
+
<span aria-hidden="true" className="inline-block align-[-2px] mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-b-transparent" />
|
|
386
|
+
)}
|
|
387
|
+
{isCurrent ? "Current plan" : ctaLabel ?? offer.ctaText}
|
|
216
388
|
</button>
|
|
217
389
|
</div>
|
|
218
390
|
);
|