@startsimpli/billing 0.4.23 → 0.4.25

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
package/package.json CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/billing",
3
- "version": "0.4.23",
3
+ "version": "0.4.25",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "main": "./src/index.ts",
6
6
  "types": "./src/index.ts",
@@ -16,6 +16,7 @@
16
16
 
17
17
  import React from "react";
18
18
  import type { OfferFeature, SubscriptionInfo, UsageMap } from "../types";
19
+ import { formatFeatureLabel, formatPrice } from "../utils/format";
19
20
 
20
21
  export interface CurrentPlanCardProps {
21
22
  subscription: SubscriptionInfo;
@@ -32,8 +33,7 @@ export interface CurrentPlanCardProps {
32
33
 
33
34
  function priceLabel(offer: SubscriptionInfo["offer"]): string {
34
35
  if (offer.isFree) return "Free";
35
- const n = Math.floor(parseFloat(offer.unitPrice) || 0);
36
- return `$${n}`;
36
+ return formatPrice(offer.unitPrice, offer.currency);
37
37
  }
38
38
 
39
39
  function isProgressBarFeature(f: OfferFeature): boolean {
@@ -42,13 +42,7 @@ function isProgressBarFeature(f: OfferFeature): boolean {
42
42
 
43
43
  function featureLabel(f: OfferFeature): string | null {
44
44
  if (f.value === false) return null;
45
- if (typeof f.value === "number" || typeof f.limit === "number") {
46
- const limit = typeof f.limit === "number" ? f.limit
47
- : typeof f.value === "number" ? (f.value as number) : null;
48
- if (limit === -1) return `Unlimited ${f.name.toLowerCase()}`;
49
- if (limit !== null && limit > 0) return `Up to ${limit} ${f.name.toLowerCase()}`;
50
- }
51
- return f.name;
45
+ return formatFeatureLabel(f);
52
46
  }
53
47
 
54
48
  function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string | null }) {
@@ -277,7 +271,8 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
277
271
  ) : (
278
272
  <span aria-label={`${priceLabel(offer)} ${offer.intervalDisplay}`}>
279
273
  <span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">{priceLabel(offer)}</span>
280
- <span aria-hidden="true" className="text-base text-gray-500 ml-1">{offer.intervalDisplay}</span>
274
+ {" "}
275
+ <span aria-hidden="true" className="text-base text-gray-500">{offer.intervalDisplay}</span>
281
276
  </span>
282
277
  )}
283
278
  </div>
@@ -0,0 +1,113 @@
1
+ /**
2
+ * Monthly / Annual switch for plan pickers (crochet_patterns-34n7).
3
+ *
4
+ * Every plan list pairs each tier's monthly and annual offers and shows one
5
+ * card per tier for the chosen interval. Monthly by default — unless the
6
+ * viewer's current plan is an annual offer, so their card reads "Current
7
+ * plan" without a click. Nothing is remembered.
8
+ */
9
+ import React, { useMemo, useState } from "react";
10
+ import type { ProductOffer } from "../types";
11
+ import {
12
+ annualSavings,
13
+ groupOffersByTier,
14
+ tierOffer,
15
+ type BillingInterval,
16
+ type OfferTier,
17
+ } from "../utils/tiers";
18
+ import { formatPrice } from "../utils/format";
19
+
20
+ export interface IntervalEntry {
21
+ tier: OfferTier;
22
+ offer: ProductOffer;
23
+ }
24
+
25
+ export function useIntervalOffers(offers: ProductOffer[], currentOfferId: string | null = null) {
26
+ // Null until the viewer picks one, so the default can follow a plan that
27
+ // loads after the first render.
28
+ const [chosen, setInterval] = useState<BillingInterval | null>(null);
29
+ const currentIsAnnual = offers.some((o) => o.id === currentOfferId && o.intervalUnit === "year");
30
+ const interval: BillingInterval = chosen ?? (currentIsAnnual ? "annual" : "monthly");
31
+ const tiers = useMemo(() => groupOffersByTier(offers), [offers]);
32
+ const hasChoice = tiers.some((t) => t.monthly !== null && t.annual !== null);
33
+ const entries: IntervalEntry[] = tiers.map((tier) => ({ tier, offer: tierOffer(tier, interval) }));
34
+ return { interval, setInterval, hasChoice, tiers, entries };
35
+ }
36
+
37
+ export function IntervalToggle({
38
+ interval,
39
+ onChange,
40
+ }: {
41
+ interval: BillingInterval;
42
+ onChange: (interval: BillingInterval) => void;
43
+ }) {
44
+ const option = (value: BillingInterval, label: string) => {
45
+ const selected = interval === value;
46
+ return (
47
+ <button
48
+ type="button"
49
+ role="radio"
50
+ aria-checked={selected}
51
+ onClick={() => onChange(value)}
52
+ className={`px-4 py-1.5 text-sm font-semibold rounded-full transition-colors cursor-pointer ${
53
+ selected ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-800"
54
+ }`}
55
+ >
56
+ {label}
57
+ </button>
58
+ );
59
+ };
60
+ return (
61
+ <div className="flex justify-center">
62
+ <div
63
+ role="radiogroup"
64
+ aria-label="Billing interval"
65
+ data-testid="interval-toggle"
66
+ className="inline-flex items-center gap-1 rounded-full bg-gray-100 p-1"
67
+ >
68
+ {option("monthly", "Monthly")}
69
+ {option("annual", "Annual")}
70
+ </div>
71
+ </div>
72
+ );
73
+ }
74
+
75
+ /** "$2.42/month billed yearly · Save 19%" under an annual price. */
76
+ export function AnnualNote({ entry, className = "" }: { entry: IntervalEntry; className?: string }) {
77
+ if (entry.offer !== entry.tier.annual) return null;
78
+ const savings = annualSavings(entry.tier);
79
+ if (!savings) return null;
80
+ return (
81
+ <p data-testid="annual-note" className={`text-xs text-gray-500 ${className}`}>
82
+ {formatPrice(savings.perMonth, entry.offer.currency)}/month billed yearly
83
+ {savings.percent > 0 && <span className="font-semibold text-green-700"> · Save {savings.percent}%</span>}
84
+ </p>
85
+ );
86
+ }
87
+
88
+ /** The price line every card shares: "$2.99 per month", or "Free". */
89
+ export function OfferPrice({
90
+ offer,
91
+ amountClassName,
92
+ intervalClassName = "text-gray-500",
93
+ }: {
94
+ offer: ProductOffer;
95
+ amountClassName: string;
96
+ intervalClassName?: string;
97
+ }) {
98
+ if (offer.isFree) {
99
+ return (
100
+ <span aria-label="Free">
101
+ <span aria-hidden="true" className={amountClassName}>Free</span>
102
+ </span>
103
+ );
104
+ }
105
+ const price = formatPrice(offer.unitPrice, offer.currency);
106
+ return (
107
+ <span aria-label={`${price} ${offer.intervalDisplay}`}>
108
+ <span aria-hidden="true" className={amountClassName}>{price}</span>
109
+ {" "}
110
+ <span aria-hidden="true" className={intervalClassName}>{offer.intervalDisplay}</span>
111
+ </span>
112
+ );
113
+ }
@@ -0,0 +1,274 @@
1
+ /**
2
+ * Choosing a plan when you already have one (crochets.site design review,
3
+ * 2026-10-18):
4
+ * - crochet_patterns-anw61 / -ep670: a paying Lite user was offered "Start
5
+ * Free Trial" under a "7-day free trial" pill. The backend switches Lite to
6
+ * Pro in place and charges the prorated difference at once: no trial.
7
+ * - crochet_patterns-kigcj / -lr7mj: "Get Started" / "Downgrade to Free"
8
+ * ended a paid plan (at period end) or a complimentary one (now) on one
9
+ * click, with no confirmation.
10
+ * - crochet_patterns-aijez: a complimentary Pro viewer saw no plan of theirs
11
+ * on /pricing.
12
+ * - crochet_patterns-nag60: "Subscription activated successfully!" beside
13
+ * "You're on the Free plan" after paying.
14
+ * - crochet_patterns-9e9o: a second click started a second checkout.
15
+ */
16
+ import { describe, it, expect, vi } from "vitest";
17
+ import { render, screen, waitFor, within } from "@testing-library/react";
18
+ import userEvent from "@testing-library/user-event";
19
+ import React from "react";
20
+ import { BillingProvider } from "../hooks/BillingProvider";
21
+ import { SubscriptionManager } from "./SubscriptionManager";
22
+ import { PricingDetailPage } from "./PricingDetailPage";
23
+ import { UpgradeModal } from "./UpgradeModal";
24
+ import type { ProductOffer, SubscriptionInfo } from "../types";
25
+ import { crochetProduct, crochetLite, crochetFree, subscriptionTo } from "./__fixtures__/crochetProduct";
26
+
27
+ const text = (el: Element) => (el.textContent ?? "").replace(/\s+/g, " ");
28
+
29
+ const comp: ProductOffer = {
30
+ ...crochetFree,
31
+ id: "comp-id",
32
+ name: "Pro (complimentary)",
33
+ slug: "crochet-comp",
34
+ isActive: false,
35
+ sortOrder: 99,
36
+ features: [{ key: "pro_designs", name: "Pro designs", value: true }],
37
+ };
38
+
39
+ type Preview = Record<string, unknown>;
40
+
41
+ /** Serves product + subscription, and the plan-change preview per offer id
42
+ * (snake_case, as Django sends it). Records every request. */
43
+ function fetcherFor(subscription: SubscriptionInfo | null, previews: Record<string, Preview> = {}) {
44
+ const fn = vi.fn((url: string, init?: RequestInit) => {
45
+ const ok = (body: unknown, status = 200) =>
46
+ Promise.resolve({ ok: true, status, json: () => Promise.resolve(body) });
47
+ if (url.includes("/billing/subscription/current/")) {
48
+ return subscription === null
49
+ ? Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) })
50
+ : ok(subscription);
51
+ }
52
+ if (url.includes("/billing/products/")) return ok(crochetProduct);
53
+ if (url.includes("/billing/usage/")) return ok({});
54
+ if (url.includes("/billing/plan-change-preview/")) {
55
+ const { offer_id } = JSON.parse(String(init?.body ?? "{}"));
56
+ return previews[offer_id] ? ok(previews[offer_id]) : Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
57
+ }
58
+ if (url.includes("/billing/subscribe-free/")) return ok({ subscription_id: "s", status: "active", scheduled_downgrade: true, effective_at: "2026-11-17T20:23:09Z" });
59
+ if (url.includes("/billing/success-sync/")) return ok({ status: "active" });
60
+ return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
61
+ });
62
+ return fn;
63
+ }
64
+
65
+ function renderWith(ui: React.ReactElement, fetcher: ReturnType<typeof fetcherFor>) {
66
+ return render(
67
+ <BillingProvider apiBaseUrl="https://api.test/api/v1" fetcher={fetcher as unknown as typeof fetch}>
68
+ {ui}
69
+ </BillingProvider>,
70
+ );
71
+ }
72
+
73
+ const calls = (f: ReturnType<typeof fetcherFor>, path: string) =>
74
+ f.mock.calls.filter((c) => String(c[0]).includes(path));
75
+
76
+ const LITE_TO_PRO: Preview = {
77
+ mode: "immediate", offer_id: "pro-id", amount_due: "5.80", currency: "usd",
78
+ effective_at: "2026-10-18T22:00:00Z", trial_days: 0,
79
+ current_offer: { slug: "crochet-lite", name: "Lite", is_listed: true },
80
+ };
81
+ const LITE_TO_FREE: Preview = {
82
+ mode: "at_period_end", offer_id: "free-id", amount_due: "0.00", currency: "usd",
83
+ effective_at: "2026-11-17T20:23:09Z", trial_days: 0,
84
+ current_offer: { slug: "crochet-lite", name: "Lite", is_listed: true },
85
+ };
86
+ const COMP_TO_FREE: Preview = {
87
+ mode: "immediate", offer_id: "free-id", amount_due: "0.00", currency: "usd",
88
+ effective_at: "2026-10-18T22:00:00Z", trial_days: 0,
89
+ current_offer: { slug: "crochet-comp", name: "Pro (complimentary)", is_listed: false },
90
+ };
91
+
92
+ const signedIn = (subscription: SubscriptionInfo | null) => ({ signedIn: true, subscription });
93
+
94
+ // ─────────────────────────────────────────────────────────────────────────────
95
+ // /pricing
96
+ // ─────────────────────────────────────────────────────────────────────────────
97
+ describe("/pricing for a paying Lite subscriber", () => {
98
+ const lite = subscriptionTo(crochetLite);
99
+
100
+ it("offers Pro as an upgrade, with no trial pill or trial line, and Free as a downgrade", async () => {
101
+ renderWith(<PricingDetailPage productId="crochet-patterns" currentPlan={signedIn(lite)} />, fetcherFor(lite));
102
+ const pro = await screen.findByTestId("detail-card-crochet-pro");
103
+ expect(within(pro).getByRole("button").textContent).toBe("Upgrade to Pro");
104
+ expect(text(pro)).not.toMatch(/free trial/i);
105
+ expect(screen.queryByTestId("trial-pill-crochet-pro")).toBeNull();
106
+ expect(screen.getByTestId("detail-cta-crochet-free").textContent).toBe("Downgrade to Free");
107
+ expect(screen.getByTestId("detail-cta-crochet-lite").textContent).toBe("Current plan");
108
+ });
109
+
110
+ it("upgrading asks first, showing what is charged today, then proceeds with the preview", async () => {
111
+ const onSelectOffer = vi.fn();
112
+ const f = fetcherFor(lite, { "pro-id": LITE_TO_PRO });
113
+ renderWith(<PricingDetailPage productId="crochet-patterns" currentPlan={signedIn(lite)} onSelectOffer={onSelectOffer} />, f);
114
+ await userEvent.click(await screen.findByTestId("detail-cta-crochet-pro"));
115
+
116
+ const dialog = await screen.findByTestId("plan-change-confirm");
117
+ expect(text(dialog)).toContain("$5.80 today");
118
+ expect(text(dialog)).toContain("then $9 per month");
119
+ expect(text(dialog)).toMatch(/no free trial/i);
120
+ expect(onSelectOffer).not.toHaveBeenCalled();
121
+
122
+ await userEvent.click(within(dialog).getByTestId("plan-change-confirm-button"));
123
+ expect(onSelectOffer).toHaveBeenCalledTimes(1);
124
+ expect(onSelectOffer.mock.calls[0][0].slug).toBe("crochet-pro");
125
+ expect(onSelectOffer.mock.calls[0][1]).toMatchObject({ mode: "immediate", amountDue: "5.80" });
126
+ });
127
+
128
+ it("Downgrade to Free says the date Lite ends, and Keep my plan changes nothing", async () => {
129
+ const onSelectOffer = vi.fn();
130
+ const f = fetcherFor(lite, { "free-id": LITE_TO_FREE });
131
+ renderWith(<PricingDetailPage productId="crochet-patterns" currentPlan={signedIn(lite)} onSelectOffer={onSelectOffer} />, f);
132
+ await userEvent.click(await screen.findByTestId("detail-cta-crochet-free"));
133
+
134
+ const dialog = await screen.findByTestId("plan-change-confirm");
135
+ expect(text(dialog)).toContain("Your Lite plan ends on November 17, 2026");
136
+ await userEvent.click(within(dialog).getByTestId("plan-change-cancel"));
137
+ expect(screen.queryByTestId("plan-change-confirm")).toBeNull();
138
+ expect(onSelectOffer).not.toHaveBeenCalled();
139
+ });
140
+ });
141
+
142
+ describe("/pricing for a complimentary Pro account", () => {
143
+ const sub = subscriptionTo(comp);
144
+
145
+ it("says which plan they are on, and Free is a downgrade that warns it can't be had back", async () => {
146
+ const onSelectOffer = vi.fn();
147
+ const f = fetcherFor(sub, { "free-id": COMP_TO_FREE });
148
+ renderWith(<PricingDetailPage productId="crochet-patterns" currentPlan={signedIn(sub)} onSelectOffer={onSelectOffer} />, f);
149
+
150
+ const banner = await screen.findByTestId("current-plan-unlisted");
151
+ expect(text(banner)).toContain("You're on Pro (complimentary)");
152
+ const free = screen.getByTestId("detail-cta-crochet-free");
153
+ expect(free.textContent).toBe("Downgrade to Free");
154
+
155
+ await userEvent.click(free);
156
+ const dialog = await screen.findByTestId("plan-change-confirm");
157
+ expect(text(dialog)).toContain("ends now");
158
+ expect(text(dialog)).toMatch(/can't get it back/);
159
+ expect(onSelectOffer).not.toHaveBeenCalled();
160
+ });
161
+ });
162
+
163
+ describe("/pricing for a new buyer", () => {
164
+ it("Free user: Pro still sells its trial, and goes straight to checkout without a preview", async () => {
165
+ const onSelectOffer = vi.fn();
166
+ const f = fetcherFor(null);
167
+ renderWith(<PricingDetailPage productId="crochet-patterns" currentPlan={signedIn(null)} onSelectOffer={onSelectOffer} />, f);
168
+ const cta = await screen.findByTestId("detail-cta-crochet-pro");
169
+ expect(cta.textContent).toBe("Start Free Trial");
170
+ expect(screen.getByTestId("trial-pill-crochet-pro")).toBeDefined();
171
+
172
+ await userEvent.click(cta);
173
+ expect(onSelectOffer).toHaveBeenCalledTimes(1);
174
+ expect(calls(f, "/billing/plan-change-preview/")).toHaveLength(0);
175
+ });
176
+
177
+ it("a second click while the first is in flight does nothing", async () => {
178
+ const onSelectOffer = vi.fn(() => "navigating");
179
+ renderWith(<PricingDetailPage productId="crochet-patterns" currentPlan={signedIn(null)} onSelectOffer={onSelectOffer} />, fetcherFor(null));
180
+ const cta = await screen.findByTestId("detail-cta-crochet-pro");
181
+ await userEvent.click(cta);
182
+ await userEvent.click(cta);
183
+ await userEvent.click(screen.getByTestId("detail-cta-crochet-lite"));
184
+ expect(onSelectOffer).toHaveBeenCalledTimes(1);
185
+ expect((cta as HTMLButtonElement).disabled).toBe(true);
186
+ });
187
+ });
188
+
189
+ // ─────────────────────────────────────────────────────────────────────────────
190
+ // /settings/billing
191
+ // ─────────────────────────────────────────────────────────────────────────────
192
+ describe("/settings/billing for a paying Lite subscriber", () => {
193
+ const lite = subscriptionTo(crochetLite);
194
+ const manager = (props: Partial<React.ComponentProps<typeof SubscriptionManager>> = {}) => (
195
+ <SubscriptionManager productId="crochet-patterns" returnUrl="https://t/s" {...props} />
196
+ );
197
+
198
+ it("Pro reads Upgrade to Pro with no trial copy", async () => {
199
+ renderWith(manager(), fetcherFor(lite));
200
+ const card = await screen.findByTestId("plan-card-crochet-pro");
201
+ expect(within(card).getByRole("button").textContent).toBe("Upgrade to Pro");
202
+ expect(within(card).queryByTestId("trial-copy")).toBeNull();
203
+ expect(text(card)).not.toMatch(/free trial/i);
204
+ });
205
+
206
+ it("Downgrade to Free calls subscribe-free only after the viewer confirms", async () => {
207
+ const f = fetcherFor(lite, { "free-id": LITE_TO_FREE });
208
+ renderWith(manager(), f);
209
+ await userEvent.click(await screen.findByTestId("upgrade-cta-crochet-free"));
210
+ const dialog = await screen.findByTestId("plan-change-confirm");
211
+ expect(text(dialog)).toContain("November 17, 2026");
212
+ expect(calls(f, "/billing/subscribe-free/")).toHaveLength(0);
213
+
214
+ await userEvent.click(within(dialog).getByTestId("plan-change-confirm-button"));
215
+ await waitFor(() => expect(calls(f, "/billing/subscribe-free/")).toHaveLength(1));
216
+ await waitFor(() => expect(screen.queryByTestId("plan-change-confirm")).toBeNull());
217
+ });
218
+
219
+ it("a failed downgrade says so in the dialog instead of vanishing", async () => {
220
+ const f = fetcherFor(lite, { "free-id": LITE_TO_FREE });
221
+ const base = f.getMockImplementation()!;
222
+ f.mockImplementation((url: string, init?: RequestInit) =>
223
+ String(url).includes("/billing/subscribe-free/")
224
+ ? Promise.resolve({ ok: false, status: 502, json: () => Promise.resolve({ detail: "We couldn't change your plan just now." }) })
225
+ : base(url, init));
226
+ renderWith(manager(), f);
227
+ await userEvent.click(await screen.findByTestId("upgrade-cta-crochet-free"));
228
+ await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
229
+ expect((await screen.findByTestId("plan-change-confirm-error")).textContent).toContain("couldn't change your plan");
230
+ });
231
+ });
232
+
233
+ describe("/settings/billing back from checkout", () => {
234
+ it("shows Activating, not activated beside Free, while the paid plan is not found", async () => {
235
+ const f = fetcherFor(null);
236
+ renderWith(<SubscriptionManager productId="crochet-patterns" returnUrl="https://t/s" statusMessage="success" checkoutSessionId="cs_test_1" />, f);
237
+ await screen.findByTestId("status-banner-activating");
238
+ expect(screen.queryByTestId("status-banner-success")).toBeNull();
239
+ expect(screen.queryByTestId("free-by-default")).toBeNull();
240
+ // The sync is told which checkout was paid.
241
+ await waitFor(() => expect(calls(f, "/billing/success-sync/")).toHaveLength(1));
242
+ expect(JSON.parse(String(calls(f, "/billing/success-sync/")[0][1]?.body))).toEqual({
243
+ session_id: "cs_test_1", product_slug: "crochet-patterns",
244
+ });
245
+ });
246
+
247
+ it("shows activated once the paid plan is there", async () => {
248
+ renderWith(<SubscriptionManager productId="crochet-patterns" returnUrl="https://t/s" statusMessage="success" checkoutSessionId="cs_test_1" />,
249
+ fetcherFor(subscriptionTo(crochetLite)));
250
+ await screen.findByTestId("status-banner-success");
251
+ expect(screen.queryByTestId("status-banner-activating")).toBeNull();
252
+ });
253
+ });
254
+
255
+ // ─────────────────────────────────────────────────────────────────────────────
256
+ // Upgrade modal (pattern pages)
257
+ // ─────────────────────────────────────────────────────────────────────────────
258
+ describe("UpgradeModal for a paying Lite subscriber", () => {
259
+ it("Upgrade to Pro, no trial pill, confirm before charging", async () => {
260
+ const lite = subscriptionTo(crochetLite);
261
+ const onSelectOffer = vi.fn();
262
+ renderWith(
263
+ <UpgradeModal productId="crochet-patterns" open onClose={() => {}} onSelectOffer={onSelectOffer} currentPlan={signedIn(lite)} />,
264
+ fetcherFor(lite, { "pro-id": LITE_TO_PRO }),
265
+ );
266
+ const cta = await screen.findByTestId("upgrade-cta-crochet-pro");
267
+ expect(cta.textContent).toBe("Upgrade to Pro");
268
+ expect(screen.queryByTestId("trial-pill-crochet-pro")).toBeNull();
269
+ await userEvent.click(cta);
270
+ expect(text(await screen.findByTestId("plan-change-confirm"))).toContain("$5.80 today");
271
+ expect(onSelectOffer).not.toHaveBeenCalled();
272
+ });
273
+ });
274
+
@@ -0,0 +1,193 @@
1
+ /**
2
+ * PlanChangeConfirm — the step between a plan button and a plan change that
3
+ * charges now, ends a plan, or changes one at period end
4
+ * (crochet_patterns-anw61, -kigcj, -lr7mj). It says what happens, from the
5
+ * backend's preview: the amount charged today, or the date the change takes
6
+ * effect, or that a plan which is not for sale cannot be had back.
7
+ */
8
+
9
+ import React, { useEffect, useRef } from "react";
10
+ import type { ProductOffer } from "../types";
11
+ import type { PlanChangeConfirmState } from "../hooks/usePlanChange";
12
+ import { formatPrice } from "../utils/format";
13
+
14
+ export interface PlanChangeConfirmProps {
15
+ state: PlanChangeConfirmState | null;
16
+ confirming: boolean;
17
+ error: string | null;
18
+ onConfirm: () => void;
19
+ onCancel: () => void;
20
+ }
21
+
22
+ function formatDay(iso: string | null | undefined): string | null {
23
+ if (!iso) return null;
24
+ const d = new Date(iso);
25
+ if (Number.isNaN(d.getTime())) return null;
26
+ return d.toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" });
27
+ }
28
+
29
+ function recurring(offer: ProductOffer): string {
30
+ const price = formatPrice(offer.unitPrice, offer.currency);
31
+ return offer.intervalDisplay ? `${price} ${offer.intervalDisplay}` : price;
32
+ }
33
+
34
+ interface Copy {
35
+ title: string;
36
+ body: string[];
37
+ action: string;
38
+ }
39
+
40
+ /** The dialog's words. Exported for tests. */
41
+ export function planChangeCopy(state: PlanChangeConfirmState): Copy {
42
+ const { offer, preview, currentName, leavingUnlisted } = state;
43
+ const current = currentName ?? "your current plan";
44
+ const date = formatDay(preview?.effectiveAt);
45
+ const mode = preview?.mode ?? null;
46
+
47
+ if (offer.isFree) {
48
+ const title = `Downgrade to ${offer.name}?`;
49
+ if (mode === "at_period_end") {
50
+ return {
51
+ title,
52
+ body: [
53
+ date
54
+ ? `Your ${current} plan ends on ${date}. You keep everything in it until then, and you won't be charged again.`
55
+ : `Your ${current} plan ends at the end of this billing period. You keep everything in it until then, and you won't be charged again.`,
56
+ `After that you're on ${offer.name}, and lose what ${current} includes.`,
57
+ ],
58
+ action: `Downgrade to ${offer.name}`,
59
+ };
60
+ }
61
+ if (leavingUnlisted) {
62
+ return {
63
+ title,
64
+ body: [
65
+ `Your ${current} plan ends now, and you lose what it includes.`,
66
+ `${current} isn't a plan you can buy, so once it ends you can't get it back.`,
67
+ ],
68
+ action: `End ${current}`,
69
+ };
70
+ }
71
+ if (mode === "immediate") {
72
+ return {
73
+ title,
74
+ body: [`Your ${current} plan ends now, and you lose what it includes.`],
75
+ action: `Downgrade to ${offer.name}`,
76
+ };
77
+ }
78
+ return {
79
+ title,
80
+ body: [
81
+ `Your ${current} plan will end. If it is a paid plan you keep it until the end of the period you've paid for.`,
82
+ ],
83
+ action: `Downgrade to ${offer.name}`,
84
+ };
85
+ }
86
+
87
+ if (mode === "at_period_end") {
88
+ return {
89
+ title: `Switch to ${offer.name}?`,
90
+ body: [
91
+ date
92
+ ? `You keep ${current} until ${date}. Then your plan changes to ${offer.name} at ${recurring(offer)}.`
93
+ : `You keep ${current} until the end of this billing period. Then your plan changes to ${offer.name} at ${recurring(offer)}.`,
94
+ "Nothing is charged today.",
95
+ ],
96
+ action: `Switch to ${offer.name}`,
97
+ };
98
+ }
99
+
100
+ if (!preview) {
101
+ // The preview could not be had: say only what the rules guarantee.
102
+ return {
103
+ title: `Change to ${offer.name}?`,
104
+ body: [
105
+ `${offer.name} is ${recurring(offer)}. Moving to a more expensive plan is charged today for the rest of this billing period (prorated); moving to a cheaper one takes effect when this period ends.`,
106
+ "There's no free trial when you're already on a paid plan.",
107
+ ],
108
+ action: `Change to ${offer.name}`,
109
+ };
110
+ }
111
+
112
+ const amount = preview?.amountDue != null ? formatPrice(preview.amountDue, preview.currency.toUpperCase()) : null;
113
+ return {
114
+ title: `Upgrade to ${offer.name}?`,
115
+ body: [
116
+ amount
117
+ ? `You'll be charged ${amount} today for the rest of this billing period, then ${recurring(offer)}.`
118
+ : `You'll be charged today for the rest of this billing period (prorated), then ${recurring(offer)}.`,
119
+ `Your plan changes from ${current} to ${offer.name} now. There's no free trial when you're already on a paid plan.`,
120
+ ],
121
+ action: amount ? `Upgrade and pay ${amount}` : `Upgrade to ${offer.name}`,
122
+ };
123
+ }
124
+
125
+ export function PlanChangeConfirm({ state, confirming, error, onConfirm, onCancel }: PlanChangeConfirmProps) {
126
+ const cancelRef = useRef<HTMLButtonElement>(null);
127
+ useEffect(() => {
128
+ if (state) cancelRef.current?.focus();
129
+ }, [state]);
130
+ useEffect(() => {
131
+ if (!state) return;
132
+ const onKey = (e: KeyboardEvent) => {
133
+ if (e.key === "Escape") onCancel();
134
+ };
135
+ window.addEventListener("keydown", onKey);
136
+ return () => window.removeEventListener("keydown", onKey);
137
+ }, [state, onCancel]);
138
+
139
+ if (!state) return null;
140
+ const copy = planChangeCopy(state);
141
+ const destructive = state.offer.isFree;
142
+
143
+ return (
144
+ <div className="fixed inset-0 z-[60] flex items-center justify-center p-4">
145
+ <div className="absolute inset-0 bg-black/40" aria-hidden="true" onClick={confirming ? undefined : onCancel} />
146
+ <div
147
+ role="dialog"
148
+ aria-modal="true"
149
+ aria-labelledby="plan-change-confirm-title"
150
+ data-testid="plan-change-confirm"
151
+ className="relative w-full max-w-md rounded-2xl bg-white p-6 shadow-xl"
152
+ >
153
+ <h2 id="plan-change-confirm-title" className="text-lg font-bold text-gray-900 mb-3">
154
+ {copy.title}
155
+ </h2>
156
+ <div className="space-y-2 mb-5">
157
+ {copy.body.map((line) => (
158
+ <p key={line} className="text-sm text-gray-700">{line}</p>
159
+ ))}
160
+ </div>
161
+ {error && (
162
+ <p data-testid="plan-change-confirm-error" role="alert" className="mb-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
163
+ {error}
164
+ </p>
165
+ )}
166
+ <div className="flex flex-col-reverse gap-2 sm:flex-row sm:justify-end">
167
+ <button
168
+ ref={cancelRef}
169
+ type="button"
170
+ data-testid="plan-change-cancel"
171
+ onClick={onCancel}
172
+ disabled={confirming}
173
+ className="px-4 py-2.5 text-sm font-semibold rounded-xl border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
174
+ >
175
+ {destructive ? "Keep my plan" : "Cancel"}
176
+ </button>
177
+ <button
178
+ type="button"
179
+ data-testid="plan-change-confirm-button"
180
+ onClick={onConfirm}
181
+ disabled={confirming}
182
+ aria-busy={confirming || undefined}
183
+ className={`px-4 py-2.5 text-sm font-bold rounded-xl text-white cursor-pointer disabled:cursor-not-allowed disabled:opacity-60 ${
184
+ destructive ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700"
185
+ }`}
186
+ >
187
+ {confirming ? "Working…" : copy.action}
188
+ </button>
189
+ </div>
190
+ </div>
191
+ </div>
192
+ );
193
+ }