@startsimpli/billing 0.4.24 → 0.4.26
This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
- package/package.json +1 -1
- package/src/components/IntervalToggle.tsx +6 -2
- package/src/components/PlanChange.crochet.test.tsx +274 -0
- package/src/components/PlanChangeConfirm.tsx +193 -0
- package/src/components/PlanPicker.crochet.test.tsx +5 -1
- package/src/components/PricingDetailPage.review.test.tsx +256 -0
- package/src/components/PricingDetailPage.tsx +208 -36
- package/src/components/SubscriptionManager.test.tsx +30 -16
- package/src/components/SubscriptionManager.tsx +128 -74
- package/src/components/UpgradeModal.tsx +57 -13
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/usePlanChange.ts +144 -0
- package/src/hooks/useSuccessSync.ts +3 -3
- package/src/index.ts +20 -4
- package/src/types/index.ts +29 -0
- package/src/utils/api.ts +38 -1
- package/src/utils/plan.ts +86 -0
package/package.json
CHANGED
|
@@ -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
|
-
|
|
52
|
-
|
|
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
|
>
|
|
@@ -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
|
+
}
|
|
@@ -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
|
-
|
|
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 () => {
|