@startsimpli/billing 0.4.23 → 0.4.25

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