@startsimpli/billing 0.4.25 → 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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/billing",
3
- "version": "0.4.25",
3
+ "version": "0.4.26",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -37,9 +37,12 @@ export function useIntervalOffers(offers: ProductOffer[], currentOfferId: string
37
37
  export function IntervalToggle({
38
38
  interval,
39
39
  onChange,
40
+ disabled = false,
40
41
  }: {
41
42
  interval: BillingInterval;
42
43
  onChange: (interval: BillingInterval) => void;
44
+ /** A plan change or checkout is in flight. */
45
+ disabled?: boolean;
43
46
  }) {
44
47
  const option = (value: BillingInterval, label: string) => {
45
48
  const selected = interval === value;
@@ -48,8 +51,9 @@ export function IntervalToggle({
48
51
  type="button"
49
52
  role="radio"
50
53
  aria-checked={selected}
51
- onClick={() => onChange(value)}
52
- className={`px-4 py-1.5 text-sm font-semibold rounded-full transition-colors cursor-pointer ${
54
+ disabled={disabled}
55
+ onClick={() => !disabled && onChange(value)}
56
+ className={`px-4 py-1.5 text-sm font-semibold rounded-full transition-colors cursor-pointer disabled:cursor-wait disabled:opacity-60 ${
53
57
  selected ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-800"
54
58
  }`}
55
59
  >
@@ -215,7 +215,11 @@ describe("PricingDetailPage plan picker (crochet catalogue)", () => {
215
215
  />,
216
216
  );
217
217
  await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
218
- expect((screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement).disabled).toBe(false);
218
+ const free = screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement;
219
+ expect(free.textContent).not.toMatch(/current plan/i);
220
+ // ...and no button works until it is known: a paying user's click on Free
221
+ // would otherwise schedule a cancellation (crochet_patterns-ktjto).
222
+ expect(free.disabled).toBe(true);
219
223
  });
220
224
 
221
225
  it("signed in on Pro Annual: opens on Annual with Pro Annual current", async () => {
@@ -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
+ });
@@ -42,8 +42,19 @@ export interface PricingDetailPageProps {
42
42
  showComparison?: boolean;
43
43
 
44
44
  /** The viewer's plan, so their card reads "Current plan" and is disabled.
45
- * 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). */
46
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;
47
58
  }
48
59
 
49
60
  export function PricingDetailPage({
@@ -51,14 +62,20 @@ export function PricingDetailPage({
51
62
  onSelectOffer,
52
63
  showComparison = true,
53
64
  currentPlan,
65
+ busy: hostBusy = false,
66
+ title,
67
+ subtitle,
54
68
  }: PricingDetailPageProps) {
55
- const { product, loading, error } = useProduct(productId);
69
+ const { product, loading, error, refetch } = useProduct(productId);
56
70
  const currentOffer = resolveCurrentOffer(product?.offers ?? [], currentPlan);
57
71
  const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
58
72
  product?.offers ?? [],
59
73
  currentOffer?.id ?? null,
60
74
  );
61
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);
62
79
 
63
80
  if (loading) {
64
81
  return (
@@ -70,8 +87,16 @@ export function PricingDetailPage({
70
87
 
71
88
  if (error || !product) {
72
89
  return (
73
- <div className="text-center py-16 text-red-600">
74
- 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>
75
100
  </div>
76
101
  );
77
102
  }
@@ -94,13 +119,30 @@ export function PricingDetailPage({
94
119
  const onUnlistedPlan = isUnlistedOffer(product.offers, currentOffer);
95
120
  const tierKeyOf = (offerId: string) =>
96
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;
97
126
  const select = (offer: ProductOffer) => {
127
+ if (busy || planUnknown) return;
98
128
  if (!currentOffer) {
99
- onSelectOffer?.(offer);
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
+ );
100
136
  return;
101
137
  }
138
+ setSelecting(null);
102
139
  void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
103
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);
104
146
 
105
147
  // Extract all unique feature keys across all offers (preserving order)
106
148
  const allFeatureKeys: string[] = [];
@@ -118,10 +160,10 @@ export function PricingDetailPage({
118
160
  <div data-testid="pricing-detail" className="max-w-6xl mx-auto px-6 py-12">
119
161
  <div className="text-center mb-12">
120
162
  <h1 className="text-4xl font-extrabold tracking-tight text-gray-900 mb-3">
121
- {product.name}
163
+ {title ?? product.name}
122
164
  </h1>
123
165
  <p className="text-lg text-gray-500 max-w-xl mx-auto">
124
- {product.description}
166
+ {subtitle ?? product.description}
125
167
  </p>
126
168
  </div>
127
169
 
@@ -144,9 +186,35 @@ export function PricingDetailPage({
144
186
  </div>
145
187
  )}
146
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
+
147
215
  {hasChoice && (
148
216
  <div className="mb-10">
149
- <IntervalToggle interval={interval} onChange={setInterval} />
217
+ <IntervalToggle interval={interval} onChange={setInterval} disabled={busy} />
150
218
  </div>
151
219
  )}
152
220
 
@@ -160,10 +228,13 @@ export function PricingDetailPage({
160
228
  entry={entry}
161
229
  onSelect={select}
162
230
  buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
231
+ inherited={below ? cumulative[below.slug] ?? null : null}
163
232
  isCurrent={direction === "current"}
233
+ yourPlanNote={direction === "switch" && currentOffer ? `Your plan · billed ${currentOffer.intervalUnit === "year" ? "yearly" : "monthly"}` : null}
164
234
  ctaLabel={direction ? planCtaLabel(entry.offer, direction, paying) : entry.offer.ctaText}
165
235
  showTrial={direction ? trialApplies(entry.offer, direction, paying) : entry.offer.trialDays > 0}
166
- busy={planChange.busy}
236
+ busy={busy || planUnknown}
237
+ spinning={spinningId === entry.offer.id}
167
238
  />
168
239
  );
169
240
  })}
@@ -193,6 +264,12 @@ interface DetailCardProps {
193
264
  onSelect?: (offer: ProductOffer) => void;
194
265
  /** Name of the tier just below this one, when there is one. */
195
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;
196
273
  /** The viewer is on this offer: "Current plan", disabled. */
197
274
  isCurrent?: boolean;
198
275
  /** The button's words for this viewer (planCtaLabel). */
@@ -203,13 +280,27 @@ interface DetailCardProps {
203
280
  busy?: boolean;
204
281
  }
205
282
 
206
- function DetailCard({ entry, onSelect, buildsOn, isCurrent = false, ctaLabel, showTrial, busy = 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) {
207
290
  const { offer, tier } = entry;
208
291
  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);
212
- const isFeatured = offer.isFeatured;
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);
213
304
 
214
305
  return (
215
306
  <div
@@ -217,7 +308,7 @@ function DetailCard({ entry, onSelect, buildsOn, isCurrent = false, ctaLabel, sh
217
308
  className={`
218
309
  flex flex-col relative rounded-2xl p-8 w-full max-w-sm flex-1 min-w-[280px]
219
310
  ${isFeatured
220
- ? "border-2 border-blue-600 shadow-lg scale-[1.03]"
311
+ ? "border-2 border-blue-600 shadow-lg"
221
312
  : "border border-gray-200 bg-white shadow-sm"
222
313
  }
223
314
  `}
@@ -230,26 +321,34 @@ function DetailCard({ entry, onSelect, buildsOn, isCurrent = false, ctaLabel, sh
230
321
 
231
322
  <h2 className="text-xl font-bold text-gray-900 mb-4">{tier.name}</h2>
232
323
 
324
+ {/* Every card reserves the same rows (price, note, pill, lead-in) so
325
+ matching features line up across cards (crochet_patterns-h74l1). */}
233
326
  <div className="mb-2">
234
327
  <OfferPrice
235
328
  offer={offer}
236
329
  amountClassName="text-4xl font-extrabold text-gray-900"
237
330
  intervalClassName="text-base text-gray-500"
238
331
  />
239
- <AnnualNote entry={entry} className="mt-1" />
332
+ <div className="mt-1 min-h-4">
333
+ <AnnualNote entry={entry} />
334
+ </div>
240
335
  </div>
241
336
 
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">
244
- {offer.trialDays}-day free trial
245
- </span>
246
- )}
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>
247
348
 
248
- {buildsOn && (
249
- <p className="text-sm font-semibold text-gray-900 mb-1">
250
- Everything in {buildsOn}, plus:
251
- </p>
252
- )}
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>
253
352
 
254
353
  <ul className="list-none p-0 m-0 mb-7 flex-1 space-y-0">
255
354
  {features.map((feature) => (
@@ -271,16 +370,20 @@ function DetailCard({ entry, onSelect, buildsOn, isCurrent = false, ctaLabel, sh
271
370
  data-testid={`detail-cta-${offer.slug}`}
272
371
  disabled={isCurrent || busy}
273
372
  aria-disabled={isCurrent || busy || undefined}
373
+ aria-busy={spinning || undefined}
274
374
  className={`
275
375
  w-full py-3.5 px-6 text-base font-bold rounded-xl transition-colors
276
376
  ${isCurrent
277
377
  ? "bg-gray-100 text-gray-500 cursor-not-allowed"
278
378
  : 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"
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"
281
381
  }
282
382
  `}
283
383
  >
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
+ )}
284
387
  {isCurrent ? "Current plan" : ctaLabel ?? offer.ctaText}
285
388
  </button>
286
389
  </div>
@@ -348,10 +348,12 @@ describe("SubscriptionManager", () => {
348
348
  expect(screen.getByTestId("plan-card-pro-monthly")).toBeDefined();
349
349
  expect(screen.getByTestId("plan-card-enterprise-monthly")).toBeDefined();
350
350
 
351
- // The matching one (Pro) carries the badge; the others do not.
352
- expect(screen.getByTestId("current-plan-badge-pro-monthly")).toBeDefined();
353
- expect(screen.queryByTestId("current-plan-badge-starter-monthly")).toBeNull();
354
- expect(screen.queryByTestId("current-plan-badge-enterprise-monthly")).toBeNull();
351
+ // The matching one (Pro) is marked current; the others are not.
352
+ expect(screen.getByTestId("plan-card-pro-monthly").getAttribute("data-current")).toBe("true");
353
+ expect(screen.getByTestId("plan-card-starter-monthly").getAttribute("data-current")).toBeNull();
354
+ expect(screen.getByTestId("plan-card-enterprise-monthly").getAttribute("data-current")).toBeNull();
355
+ // One marker: the disabled "Current plan" button, no badge beside it (crochet_patterns-n8iib).
356
+ expect(within(screen.getByTestId("plan-card-pro-monthly")).getAllByText(/current plan/i)).toHaveLength(1);
355
357
  });
356
358
 
357
359
  it("calls onPlanChange when upgrade option clicked", async () => {
@@ -891,11 +893,11 @@ describe("SubscriptionManager", () => {
891
893
  expect((cta as HTMLButtonElement).disabled).toBe(false);
892
894
  }
893
895
 
894
- const badge = screen.queryByTestId(`current-plan-badge-${cardSlug}`);
896
+ const current = screen.getByTestId(`plan-card-${cardSlug}`).getAttribute("data-current");
895
897
  if (shouldShowCurrentBadge) {
896
- expect(badge).not.toBeNull();
898
+ expect(current).toBe("true");
897
899
  } else {
898
- expect(badge).toBeNull();
900
+ expect(current).toBeNull();
899
901
  }
900
902
 
901
903
  // Trial copy: only show on the upgrade direction for trialDays > 0 offers
@@ -304,7 +304,8 @@ export function SubscriptionManager({
304
304
  // Show every tier in the Change Plan grid (not just non-current ones) so
305
305
  // users can compare and see what they have; `entries` is one offer per tier
306
306
  // for the chosen interval. The card matching their current plan gets a
307
- // "Current plan" badge + disabled CTA. startsim-1f1, crochet_patterns-34n7.
307
+ // a ring and a disabled "Current plan" CTA — one marker, not a badge as well
308
+ // (crochet_patterns-n8iib). startsim-1f1, crochet_patterns-34n7.
308
309
 
309
310
  // Direction and label per card, shared with /pricing (utils/plan):
310
311
  // startsim-21f, crochet_patterns-anw61.
@@ -520,16 +521,9 @@ export function SubscriptionManager({
520
521
  key={offer.id}
521
522
  data-testid={`plan-card-${offer.slug}`}
522
523
  data-direction={direction}
524
+ data-current={isCurrent || undefined}
523
525
  className={`relative rounded-xl p-6 transition-colors ${cardBorder}`}
524
526
  >
525
- {isCurrent && (
526
- <span
527
- data-testid={`current-plan-badge-${offer.slug}`}
528
- className="absolute -top-2.5 right-4 inline-block bg-primary text-primary-foreground text-xs font-bold uppercase tracking-wide px-2.5 py-0.5 rounded-full"
529
- >
530
- Current plan
531
- </span>
532
- )}
533
527
  <h4 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h4>
534
528
  <div className="mb-3">
535
529
  <OfferPrice
package/src/utils/plan.ts CHANGED
@@ -213,6 +213,8 @@ export interface CurrentPlanState {
213
213
  loading?: boolean;
214
214
  /** The subscription fetch failed. */
215
215
  error?: unknown;
216
+ /** Fetch the subscription again (offered when it failed). */
217
+ retry?: () => void;
216
218
  }
217
219
 
218
220
  /**