@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.
@@ -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 { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
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
- /** Called when user clicks an offer's CTA button */
25
- onSelectOffer?: (offer: ProductOffer) => void;
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
- resolveCurrentOfferId(product?.offers ?? [], currentPlan),
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 text-red-600">
58
- Unable to load pricing. Please try again.
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
- const currentOfferId = resolveCurrentOfferId(product.offers, currentPlan);
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&apos;re on {currentOffer.name}.</p>
177
+ <p className="mt-1 text-sm text-blue-800">
178
+ It isn&apos;t one of the plans below. If you choose another plan, {currentOffer.name} ends and can&apos;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&apos;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={onSelectOffer}
229
+ onSelect={select}
114
230
  buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
115
- isCurrent={entry.offer.id === currentOfferId}
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 DetailCard({ entry, onSelect, buildsOn, isCurrent = false }: DetailCardProps) {
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 isFeatured = offer.isFeatured;
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 scale-[1.03]"
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
- <AnnualNote entry={entry} className="mt-1" />
332
+ <div className="mt-1 min-h-4">
333
+ <AnnualNote entry={entry} />
334
+ </div>
171
335
  </div>
172
336
 
173
- {offer.trialDays > 0 && (
174
- <span className="inline-block bg-blue-50 text-blue-700 text-sm font-semibold px-3 py-1 rounded-lg mb-5 w-fit">
175
- {offer.trialDays}-day free trial
176
- </span>
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
- {buildsOn && (
180
- <p className="text-sm font-semibold text-gray-900 mb-1">
181
- Everything in {buildsOn}, plus:
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
- {offer.features.map((feature) => (
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
- {isCurrent ? "Current plan" : offer.ctaText}
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
  );