@startsimpli/billing 0.4.25 → 0.4.27
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/BillingStates.review.test.tsx +146 -0
- package/src/components/CurrentPlanCard.tsx +40 -18
- package/src/components/IntervalToggle.tsx +6 -2
- package/src/components/PlanPicker.crochet.test.tsx +5 -1
- package/src/components/PricingDetailPage.review.test.tsx +256 -0
- package/src/components/PricingDetailPage.tsx +131 -28
- package/src/components/SubscriptionManager.test.tsx +13 -11
- package/src/components/SubscriptionManager.tsx +46 -21
- package/src/hooks/usePlanChange.ts +13 -6
- package/src/utils/plan.ts +2 -0
package/package.json
CHANGED
|
@@ -0,0 +1,146 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Billing page states from the crochet design review (2026-10-18):
|
|
3
|
+
* - a plan ending at period end has one status, "Ends <date>", and the free
|
|
4
|
+
* plan it falls back to reads "Starts <date>", disabled (crochet_patterns-3bzni);
|
|
5
|
+
* a cancelled paid plan keeps Manage Billing for its invoices (-pes38)
|
|
6
|
+
* - a trial says when the first charge is and how much (-iwl0f), shows its end
|
|
7
|
+
* date once (-rjg2e), and counts day one of a 7-day trial as 7 days (-lm7nu)
|
|
8
|
+
* - a failed payment is not dated with the period end (-lb8u3)
|
|
9
|
+
* - back from a trial checkout: "Your Pro trial has started" (-reg6a)
|
|
10
|
+
* - the clicked plan button says "Opening checkout…" while it opens (-jnkso)
|
|
11
|
+
* - a complimentary plan on a tier marks that tier's card current (-lgqp2)
|
|
12
|
+
*/
|
|
13
|
+
import { describe, it, expect, vi } from "vitest";
|
|
14
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
15
|
+
import userEvent from "@testing-library/user-event";
|
|
16
|
+
import React from "react";
|
|
17
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
18
|
+
import { CurrentPlanCard } from "./CurrentPlanCard";
|
|
19
|
+
import { SubscriptionManager } from "./SubscriptionManager";
|
|
20
|
+
import type { BillingProduct, ProductOffer, SubscriptionInfo } from "../types";
|
|
21
|
+
|
|
22
|
+
const base: Omit<ProductOffer, "id" | "name" | "slug" | "unitPrice" | "isFree" | "sortOrder"> = {
|
|
23
|
+
currency: "USD",
|
|
24
|
+
pricingModel: "flat",
|
|
25
|
+
intervalUnit: "month",
|
|
26
|
+
intervalCount: 1,
|
|
27
|
+
intervalDisplay: "per month",
|
|
28
|
+
isRecurring: true,
|
|
29
|
+
features: [],
|
|
30
|
+
isActive: true,
|
|
31
|
+
isFeatured: false,
|
|
32
|
+
trialDays: 0,
|
|
33
|
+
ctaText: "",
|
|
34
|
+
};
|
|
35
|
+
const FREE: ProductOffer = { ...base, id: "o-free", name: "Free", slug: "crochet-free", unitPrice: "0.00", isFree: true, sortOrder: 0, tier: "free" };
|
|
36
|
+
const LITE: ProductOffer = { ...base, id: "o-lite", name: "Lite", slug: "crochet-lite", unitPrice: "2.99", isFree: false, sortOrder: 1, tier: "lite", ctaText: "Get Lite" };
|
|
37
|
+
const PRO: ProductOffer = { ...base, id: "o-pro", name: "Pro", slug: "crochet-pro", unitPrice: "9.00", isFree: false, sortOrder: 2, tier: "pro", trialDays: 7, isFeatured: true, ctaText: "Start Free Trial" };
|
|
38
|
+
const COMP: ProductOffer = { ...base, id: "o-comp", name: "Pro (complimentary)", slug: "crochet-pro-comp", unitPrice: "0.00", isFree: true, sortOrder: 2, tier: "pro", isActive: false };
|
|
39
|
+
const PRODUCT: BillingProduct = { id: "p", slug: "crochet-patterns", name: "Crochet", description: "", offers: [FREE, LITE, PRO] };
|
|
40
|
+
|
|
41
|
+
const sub = (offer: ProductOffer, over: Partial<SubscriptionInfo> = {}): SubscriptionInfo => ({
|
|
42
|
+
id: "s1",
|
|
43
|
+
offer,
|
|
44
|
+
status: "active",
|
|
45
|
+
currentPeriodStart: "2026-10-17T00:00:00Z",
|
|
46
|
+
currentPeriodEnd: "2099-11-17T00:00:00Z",
|
|
47
|
+
cancelAtPeriodEnd: false,
|
|
48
|
+
trialEnd: null,
|
|
49
|
+
viewerIsOwner: true,
|
|
50
|
+
...over,
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
function renderManager(subscription: SubscriptionInfo | null, props: Partial<React.ComponentProps<typeof SubscriptionManager>> = {}) {
|
|
54
|
+
const fetcher = vi.fn((url: string) => {
|
|
55
|
+
if (url.includes("/billing/subscription/current/")) {
|
|
56
|
+
return Promise.resolve(subscription
|
|
57
|
+
? { ok: true, json: () => Promise.resolve(subscription) }
|
|
58
|
+
: { ok: false, status: 404, json: () => Promise.resolve({}) });
|
|
59
|
+
}
|
|
60
|
+
if (url.includes("/billing/products/")) return Promise.resolve({ ok: true, json: () => Promise.resolve(PRODUCT) });
|
|
61
|
+
if (url.includes("/billing/success-sync/")) return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
|
62
|
+
if (url.includes("/billing/usage/")) return Promise.resolve({ ok: true, json: () => Promise.resolve({}) });
|
|
63
|
+
return Promise.resolve({ ok: false, status: 404, json: () => Promise.resolve({}) });
|
|
64
|
+
}) as unknown as typeof fetch;
|
|
65
|
+
return render(
|
|
66
|
+
<BillingProvider apiBaseUrl="https://api.test/api/v1" fetcher={fetcher}>
|
|
67
|
+
<SubscriptionManager productId="crochet-patterns" returnUrl="https://x/settings/billing" {...props} />
|
|
68
|
+
</BillingProvider>,
|
|
69
|
+
);
|
|
70
|
+
}
|
|
71
|
+
|
|
72
|
+
describe("CurrentPlanCard states", () => {
|
|
73
|
+
it("a plan cancelled at period end reads 'Ends <date>', not Active", () => {
|
|
74
|
+
render(<CurrentPlanCard subscription={sub(PRO, { cancelAtPeriodEnd: true, currentPeriodEnd: "2099-11-17T00:00:00Z" })} usage={{}} />);
|
|
75
|
+
expect(screen.getByTestId("plan-status").textContent).toBe("Ends Nov 17, 2099");
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
it("a cancelled plan reads the same as one cancelled at period end", () => {
|
|
79
|
+
render(<CurrentPlanCard subscription={sub(PRO, { status: "cancelled", currentPeriodEnd: "2099-11-17T00:00:00Z" })} usage={{}} />);
|
|
80
|
+
expect(screen.getByTestId("plan-status").textContent).toBe("Ends Nov 17, 2099");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("a trial says the first charge and its amount, with the end date shown once", () => {
|
|
84
|
+
const trialEnd = "2099-10-24T00:00:00Z";
|
|
85
|
+
render(<CurrentPlanCard subscription={sub(PRO, { status: "trialing", trialEnd, currentPeriodEnd: trialEnd })} usage={{}} onPortalClick={() => {}} />);
|
|
86
|
+
expect(screen.getByTestId("first-charge").textContent).toBe("First charge $9 on Oct 24, 2099");
|
|
87
|
+
expect(screen.queryByText(/billing period/i)).toBeNull();
|
|
88
|
+
expect(screen.getByTestId("plan-status").textContent).toBe("Trialing");
|
|
89
|
+
});
|
|
90
|
+
|
|
91
|
+
it("day one of a 7-day trial reads 7 days", () => {
|
|
92
|
+
const trialEnd = new Date(Date.now() + 7 * 86_400_000 - 60_000).toISOString();
|
|
93
|
+
render(<CurrentPlanCard subscription={sub(PRO, { status: "trialing", trialEnd })} usage={{}} onPortalClick={() => {}} />);
|
|
94
|
+
expect(screen.getByTestId("banner-trialing").textContent).toMatch(/ends in 7 days/);
|
|
95
|
+
});
|
|
96
|
+
|
|
97
|
+
it("a failed payment is not dated with the period end", () => {
|
|
98
|
+
render(<CurrentPlanCard subscription={sub(PRO, { status: "past_due", currentPeriodEnd: "2099-11-17T00:00:00Z" })} usage={{}} onPortalClick={() => {}} />);
|
|
99
|
+
const banner = screen.getByTestId("banner-past-due");
|
|
100
|
+
expect(banner.textContent).toMatch(/your last payment failed/i);
|
|
101
|
+
expect(banner.textContent).not.toMatch(/2099/);
|
|
102
|
+
});
|
|
103
|
+
|
|
104
|
+
it("the next billing date uses the same date format as the banners", () => {
|
|
105
|
+
render(<CurrentPlanCard subscription={sub(PRO)} usage={{}} />);
|
|
106
|
+
expect(screen.getByTestId("next-billing-date").textContent).toBe("Next billing date: Nov 17, 2099");
|
|
107
|
+
});
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
describe("SubscriptionManager states", () => {
|
|
111
|
+
it("while Pro ends at period end, the Free card reads 'Starts <date>' and is disabled", async () => {
|
|
112
|
+
renderManager(sub(PRO, { cancelAtPeriodEnd: true }));
|
|
113
|
+
const btn = await screen.findByTestId("upgrade-cta-crochet-free");
|
|
114
|
+
expect(btn.textContent).toBe("Starts Nov 17, 2099");
|
|
115
|
+
expect((btn as HTMLButtonElement).disabled).toBe(true);
|
|
116
|
+
});
|
|
117
|
+
|
|
118
|
+
it("a cancelled paid plan keeps Manage Billing for its invoices", async () => {
|
|
119
|
+
renderManager(sub(PRO, { status: "cancelled" }));
|
|
120
|
+
expect(await screen.findByTestId("portal-button")).toBeTruthy();
|
|
121
|
+
});
|
|
122
|
+
|
|
123
|
+
it("back from a trial checkout says the trial has started", async () => {
|
|
124
|
+
renderManager(sub(PRO, { status: "trialing", trialEnd: "2099-10-24T00:00:00Z" }), { statusMessage: "success" });
|
|
125
|
+
expect((await screen.findByTestId("status-banner-success", {}, { timeout: 4000 })).textContent).toContain("Your Pro trial has started.");
|
|
126
|
+
});
|
|
127
|
+
|
|
128
|
+
it("the clicked plan button says 'Opening checkout…' and is disabled while checkout opens", async () => {
|
|
129
|
+
const onPlanChange = vi.fn(() => new Promise(() => {}));
|
|
130
|
+
renderManager(null, { onPlanChange });
|
|
131
|
+
const lite = await screen.findByTestId("upgrade-cta-crochet-lite");
|
|
132
|
+
await userEvent.click(lite);
|
|
133
|
+
await waitFor(() => expect(lite.textContent).toBe("Opening checkout…"));
|
|
134
|
+
expect((lite as HTMLButtonElement).disabled).toBe(true);
|
|
135
|
+
expect((screen.getByTestId("upgrade-cta-crochet-pro") as HTMLButtonElement).disabled).toBe(true);
|
|
136
|
+
});
|
|
137
|
+
|
|
138
|
+
it("a complimentary plan on the Pro tier marks the Pro card current, with no trial offered", async () => {
|
|
139
|
+
renderManager(sub(COMP, { currentPeriodEnd: null }));
|
|
140
|
+
const pro = await screen.findByTestId("upgrade-cta-crochet-pro");
|
|
141
|
+
expect(pro.textContent).toBe("Current plan");
|
|
142
|
+
expect((pro as HTMLButtonElement).disabled).toBe(true);
|
|
143
|
+
expect(screen.getByTestId("upgrade-cta-crochet-lite").textContent).toBe("Downgrade to Lite");
|
|
144
|
+
expect(screen.queryByText(/start free trial/i)).toBeNull();
|
|
145
|
+
});
|
|
146
|
+
});
|
|
@@ -45,7 +45,25 @@ function featureLabel(f: OfferFeature): string | null {
|
|
|
45
45
|
return formatFeatureLabel(f);
|
|
46
46
|
}
|
|
47
47
|
|
|
48
|
-
|
|
48
|
+
/**
|
|
49
|
+
* The card's one status pill. A plan that is ending (cancelled, or cancelled
|
|
50
|
+
* at period end) reads "Ends <date>": it used to say "Active" beside a
|
|
51
|
+
* "your plan is cancelled" banner (crochet_patterns-3bzni, -pes38). A trial
|
|
52
|
+
* reads "Trialing" only; its end date is in the banner (crochet_patterns-rjg2e).
|
|
53
|
+
*/
|
|
54
|
+
function StatusBadge({ status, endsOn }: { status: string; endsOn: string | null }) {
|
|
55
|
+
if (endsOn) {
|
|
56
|
+
return (
|
|
57
|
+
<span
|
|
58
|
+
data-testid="plan-status"
|
|
59
|
+
role="status"
|
|
60
|
+
aria-label={`Subscription status: ends ${prettyDate(endsOn)}`}
|
|
61
|
+
className="inline-block text-xs font-semibold px-3 py-1 rounded-full bg-gray-100 text-gray-600"
|
|
62
|
+
>
|
|
63
|
+
{`Ends ${prettyDate(endsOn)}`}
|
|
64
|
+
</span>
|
|
65
|
+
);
|
|
66
|
+
}
|
|
49
67
|
let cls = "inline-block text-xs font-semibold px-3 py-1 rounded-full ";
|
|
50
68
|
switch (status) {
|
|
51
69
|
case "active": cls += "bg-green-50 text-green-700"; break;
|
|
@@ -75,16 +93,10 @@ function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string |
|
|
|
75
93
|
className={cls}
|
|
76
94
|
>
|
|
77
95
|
{readable}
|
|
78
|
-
{status === "trialing" && trialEnd && (
|
|
79
|
-
<span className="ml-1">until {formatDate(trialEnd)}</span>
|
|
80
|
-
)}
|
|
81
96
|
</span>
|
|
82
97
|
);
|
|
83
98
|
}
|
|
84
99
|
|
|
85
|
-
function formatDate(iso: string): string {
|
|
86
|
-
return new Date(iso).toISOString().slice(0, 10);
|
|
87
|
-
}
|
|
88
100
|
|
|
89
101
|
function ProgressBar({ used, limit }: { used: number; limit: number }) {
|
|
90
102
|
// Cap at 100% so a hard-overage display can't blow out the layout.
|
|
@@ -126,10 +138,11 @@ function FeatureRow({ feature, used }: { feature: OfferFeature; used: number | n
|
|
|
126
138
|
);
|
|
127
139
|
}
|
|
128
140
|
|
|
129
|
-
/**
|
|
141
|
+
/** Calendar days (UTC, as the dates are shown) from today to the ISO date;
|
|
142
|
+
* negative if past. Day one of a 7-day trial is 7, not 6 (crochet_patterns-lm7nu). */
|
|
130
143
|
function daysUntil(iso: string): number {
|
|
131
|
-
const
|
|
132
|
-
return Math.
|
|
144
|
+
const day = (d: Date) => Date.UTC(d.getUTCFullYear(), d.getUTCMonth(), d.getUTCDate());
|
|
145
|
+
return Math.round((day(new Date(iso)) - day(new Date())) / 86_400_000);
|
|
133
146
|
}
|
|
134
147
|
|
|
135
148
|
/** Pretty "Jun 27, 2026" style — easier to scan than ISO yyyy-mm-dd in banners. */
|
|
@@ -179,6 +192,8 @@ function StateBanner({ testId, variant, message, action }: BannerProps) {
|
|
|
179
192
|
export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick }: CurrentPlanCardProps) {
|
|
180
193
|
const offer = subscription.offer;
|
|
181
194
|
const features = offer.features ?? [];
|
|
195
|
+
const ending = subscription.status === "cancelled" || subscription.cancelAtPeriodEnd;
|
|
196
|
+
const endsOn = ending ? subscription.currentPeriodEnd : null;
|
|
182
197
|
|
|
183
198
|
// State-driven banner choice — at most one banner at the top of the card.
|
|
184
199
|
// Precedence: past_due > cancelled-in-period > trialing > none.
|
|
@@ -191,8 +206,8 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
|
|
|
191
206
|
variant="danger"
|
|
192
207
|
message={
|
|
193
208
|
<>
|
|
194
|
-
|
|
195
|
-
|
|
209
|
+
{/* Not dated: the period end is not when the payment failed (crochet_patterns-lb8u3). */}
|
|
210
|
+
Your last payment failed. Update your card to keep your subscription active.
|
|
196
211
|
</>
|
|
197
212
|
}
|
|
198
213
|
action={{ label: "Update card", onClick: onPortalClick }}
|
|
@@ -260,7 +275,7 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
|
|
|
260
275
|
{stateBanner}
|
|
261
276
|
<div className="flex items-center justify-between mb-4">
|
|
262
277
|
<h2 className="text-xl font-bold text-gray-900">{offer.name}</h2>
|
|
263
|
-
<StatusBadge status={subscription.status}
|
|
278
|
+
<StatusBadge status={subscription.status} endsOn={endsOn} />
|
|
264
279
|
</div>
|
|
265
280
|
|
|
266
281
|
<div className="mb-4">
|
|
@@ -280,14 +295,21 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
|
|
|
280
295
|
{/* Active paid subs surface "Next billing date" prominently — that's
|
|
281
296
|
what users actually want to know. Free + cancelled + no-period subs
|
|
282
297
|
fall back to the period range / nothing. startsim-7j4. */}
|
|
283
|
-
{
|
|
298
|
+
{/* A trial says when it first charges and how much, in place of a
|
|
299
|
+
billing period that only repeats the trial end (crochet_patterns-iwl0f).
|
|
300
|
+
One date format on the card, the banners' (crochet_patterns-rjg2e). */}
|
|
301
|
+
{subscription.status === "trialing" && subscription.trialEnd && !offer.isFree && !ending ? (
|
|
302
|
+
<p data-testid="first-charge" className="text-sm text-gray-600 mb-6">
|
|
303
|
+
{`First charge ${priceLabel(offer)} on ${prettyDate(subscription.trialEnd)}`}
|
|
304
|
+
</p>
|
|
305
|
+
) : subscription.currentPeriodEnd && !offer.isFree && subscription.status === "active" && !subscription.cancelAtPeriodEnd ? (
|
|
284
306
|
<p data-testid="next-billing-date" className="text-sm text-gray-600 mb-6">
|
|
285
|
-
Next billing date: {
|
|
307
|
+
Next billing date: {prettyDate(subscription.currentPeriodEnd)}
|
|
286
308
|
</p>
|
|
287
|
-
) : subscription.currentPeriodStart && subscription.currentPeriodEnd ? (
|
|
309
|
+
) : subscription.currentPeriodStart && subscription.currentPeriodEnd && !ending ? (
|
|
288
310
|
<p className="text-sm text-gray-500 mb-6">
|
|
289
|
-
Billing period: {
|
|
290
|
-
{
|
|
311
|
+
Billing period: {prettyDate(subscription.currentPeriodStart)} to{" "}
|
|
312
|
+
{prettyDate(subscription.currentPeriodEnd)}
|
|
291
313
|
</p>
|
|
292
314
|
) : null}
|
|
293
315
|
|
|
@@ -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
|
>
|
|
@@ -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 () => {
|
|
@@ -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
|
|
74
|
-
Unable to load pricing
|
|
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
|
-
|
|
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'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={
|
|
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
|
|
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
|
-
|
|
210
|
-
|
|
211
|
-
|
|
212
|
-
const
|
|
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
|
|
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
|
-
<
|
|
332
|
+
<div className="mt-1 min-h-4">
|
|
333
|
+
<AnnualNote entry={entry} />
|
|
334
|
+
</div>
|
|
240
335
|
</div>
|
|
241
336
|
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
{offer.
|
|
245
|
-
|
|
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
|
-
{
|
|
249
|
-
|
|
250
|
-
|
|
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>
|
|
@@ -290,8 +290,8 @@ describe("SubscriptionManager", () => {
|
|
|
290
290
|
// Check trialing status
|
|
291
291
|
expect(screen.getByText(/trialing/i)).toBeDefined();
|
|
292
292
|
|
|
293
|
-
//
|
|
294
|
-
expect(screen.
|
|
293
|
+
// The trial end is shown in the banner's format (crochet_patterns-rjg2e).
|
|
294
|
+
expect(screen.getByTestId("first-charge").textContent).toMatch(/Jan 15, 2026/);
|
|
295
295
|
});
|
|
296
296
|
|
|
297
297
|
it("shows billing period dates", async () => {
|
|
@@ -319,7 +319,7 @@ describe("SubscriptionManager", () => {
|
|
|
319
319
|
// or free states after startsim-7j4. Both dates are still in the DOM via
|
|
320
320
|
// the grid card's renderings, so target the current-plan card specifically.
|
|
321
321
|
const currentPlan = screen.getByTestId("current-plan");
|
|
322
|
-
expect(currentPlan.textContent).toMatch(/2026
|
|
322
|
+
expect(currentPlan.textContent).toMatch(/Feb 1, 2026/);
|
|
323
323
|
});
|
|
324
324
|
|
|
325
325
|
it("shows ALL offers in the Change Plan grid (current marked, not hidden) — startsim-1f1", async () => {
|
|
@@ -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)
|
|
352
|
-
expect(screen.getByTestId("
|
|
353
|
-
expect(screen.
|
|
354
|
-
expect(screen.
|
|
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
|
|
896
|
+
const current = screen.getByTestId(`plan-card-${cardSlug}`).getAttribute("data-current");
|
|
895
897
|
if (shouldShowCurrentBadge) {
|
|
896
|
-
expect(
|
|
898
|
+
expect(current).toBe("true");
|
|
897
899
|
} else {
|
|
898
|
-
expect(
|
|
900
|
+
expect(current).toBeNull();
|
|
899
901
|
}
|
|
900
902
|
|
|
901
903
|
// Trial copy: only show on the upgrade direction for trialDays > 0 offers
|
|
@@ -932,7 +934,7 @@ describe("SubscriptionManager", () => {
|
|
|
932
934
|
|
|
933
935
|
const nextBilling = screen.getByTestId("next-billing-date");
|
|
934
936
|
expect(nextBilling.textContent).toMatch(/next billing date/i);
|
|
935
|
-
expect(nextBilling.textContent).toMatch(/2026
|
|
937
|
+
expect(nextBilling.textContent).toMatch(/Feb 1, 2026/);
|
|
936
938
|
// The verbose "Billing period:" line is hidden on active paid subs.
|
|
937
939
|
expect(screen.queryByText(/^Billing period:/i)).toBeNull();
|
|
938
940
|
});
|
|
@@ -304,14 +304,34 @@ 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"
|
|
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.
|
|
311
312
|
const paying = isPayingSubscription(usableSubscription);
|
|
312
313
|
const onUnlistedPlan = !!product && isUnlistedOffer(product.offers, usableSubscription?.offer);
|
|
314
|
+
// A complimentary plan on a listed tier ("Pro (complimentary)", tier pro)
|
|
315
|
+
// IS that tier: its card reads "Current plan" and nothing sells the viewer a
|
|
316
|
+
// trial of what they have (crochet_patterns-lgqp2).
|
|
317
|
+
const ownTierKey = onUnlistedPlan && usableSubscription?.offer.tier ? `tier:${usableSubscription.offer.tier}` : null;
|
|
318
|
+
const ownTier = ownTierKey ? entries.find(({ tier }) => tier.key === ownTierKey) ?? null : null;
|
|
319
|
+
const rankOffer = ownTier?.offer ?? currentOffer;
|
|
313
320
|
const directionFor = (cardOffer: ProductOffer): PlanDirection =>
|
|
314
|
-
|
|
321
|
+
ownTier && tierKeyOf(cardOffer.id) === ownTier.tier.key
|
|
322
|
+
? "current"
|
|
323
|
+
: planDirection(cardOffer, rankOffer, tierKeyOf);
|
|
324
|
+
|
|
325
|
+
// A paid plan that is ending falls back to the free plan on its end date, so
|
|
326
|
+
// the free card says when instead of offering a second downgrade
|
|
327
|
+
// (crochet_patterns-3bzni, -pes38).
|
|
328
|
+
const endingOn =
|
|
329
|
+
usableSubscription && !usableSubscription.offer.isFree &&
|
|
330
|
+
(usableSubscription.status === "cancelled" || usableSubscription.cancelAtPeriodEnd) &&
|
|
331
|
+
usableSubscription.currentPeriodEnd &&
|
|
332
|
+
new Date(usableSubscription.currentPeriodEnd).getTime() > Date.now()
|
|
333
|
+
? usableSubscription.currentPeriodEnd
|
|
334
|
+
: null;
|
|
315
335
|
|
|
316
336
|
const handleOfferClick = (offer: ProductOffer) => {
|
|
317
337
|
// Non-owner viewers can SEE the plans but can't change them — startsim-wo9.
|
|
@@ -328,8 +348,8 @@ export function SubscriptionManager({
|
|
|
328
348
|
// only apps), so the Upgrade CTA simply doesn't render — startsim-ft2.
|
|
329
349
|
const featuredPaidOffer: ProductOffer | null = (() => {
|
|
330
350
|
if (!product) return null;
|
|
331
|
-
if (
|
|
332
|
-
const above = product.offers.filter((o) => !o.isFree && o.sortOrder >
|
|
351
|
+
if (rankOffer) {
|
|
352
|
+
const above = product.offers.filter((o) => !o.isFree && o.sortOrder > rankOffer.sortOrder);
|
|
333
353
|
return above.find((o) => o.isFeatured) ?? above[0] ?? null;
|
|
334
354
|
}
|
|
335
355
|
return (
|
|
@@ -355,7 +375,12 @@ export function SubscriptionManager({
|
|
|
355
375
|
<svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
356
376
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
357
377
|
</svg>
|
|
358
|
-
<p className="text-sm font-medium text-green-800">
|
|
378
|
+
<p className="text-sm font-medium text-green-800">
|
|
379
|
+
{/* A trial is not yet a paid plan (crochet_patterns-reg6a). */}
|
|
380
|
+
{usableSubscription?.status === "trialing"
|
|
381
|
+
? `Your ${usableSubscription.offer.name} trial has started.`
|
|
382
|
+
: "Subscription activated successfully!"}
|
|
383
|
+
</p>
|
|
359
384
|
</div>
|
|
360
385
|
)}
|
|
361
386
|
|
|
@@ -496,7 +521,13 @@ export function SubscriptionManager({
|
|
|
496
521
|
const direction = directionFor(offer);
|
|
497
522
|
const isCurrent = direction === "current";
|
|
498
523
|
const isDowngrade = direction === "downgrade" || direction === "switch";
|
|
499
|
-
const
|
|
524
|
+
const fallsBackHere = !!endingOn && offer.isFree;
|
|
525
|
+
// Straight to checkout: say so until the page goes (crochet_patterns-jnkso).
|
|
526
|
+
const opening = planChange.busy && planChange.choosing?.id === offer.id && !planChange.pending && !offer.isFree && !paying;
|
|
527
|
+
const ctaLabel = fallsBackHere
|
|
528
|
+
? `Starts ${new Date(endingOn).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" })}`
|
|
529
|
+
: opening ? "Opening checkout\u2026"
|
|
530
|
+
: planCtaLabel(offer, direction, paying);
|
|
500
531
|
const showTrial = trialApplies(offer, direction, paying);
|
|
501
532
|
|
|
502
533
|
// Border: current = solid ring; featured upgrade = blue; else gray.
|
|
@@ -520,16 +551,9 @@ export function SubscriptionManager({
|
|
|
520
551
|
key={offer.id}
|
|
521
552
|
data-testid={`plan-card-${offer.slug}`}
|
|
522
553
|
data-direction={direction}
|
|
523
|
-
|
|
554
|
+
data-current={isCurrent || undefined}
|
|
555
|
+
className={`relative flex flex-col rounded-xl p-6 transition-colors ${cardBorder}`}
|
|
524
556
|
>
|
|
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
557
|
<h4 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h4>
|
|
534
558
|
<div className="mb-3">
|
|
535
559
|
<OfferPrice
|
|
@@ -546,12 +570,12 @@ export function SubscriptionManager({
|
|
|
546
570
|
<PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
|
|
547
571
|
<button
|
|
548
572
|
data-testid={`upgrade-cta-${offer.slug}`}
|
|
549
|
-
onClick={() => !isCurrent && !isViewerNonOwner && !planChange.busy && handleOfferClick(offer)}
|
|
550
|
-
disabled={isCurrent || isViewerNonOwner || planChange.busy}
|
|
551
|
-
aria-disabled={isCurrent || isViewerNonOwner || planChange.busy || undefined}
|
|
552
|
-
aria-busy={planChange.busy && planChange.
|
|
573
|
+
onClick={() => !isCurrent && !fallsBackHere && !isViewerNonOwner && !planChange.busy && handleOfferClick(offer)}
|
|
574
|
+
disabled={isCurrent || fallsBackHere || isViewerNonOwner || planChange.busy}
|
|
575
|
+
aria-disabled={isCurrent || fallsBackHere || isViewerNonOwner || planChange.busy || undefined}
|
|
576
|
+
aria-busy={planChange.busy && planChange.choosing?.id === offer.id ? true : undefined}
|
|
553
577
|
title={isViewerNonOwner ? "Only the team owner can change the plan" : undefined}
|
|
554
|
-
className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
|
|
578
|
+
className={`mt-auto w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${fallsBackHere ? "bg-gray-100 text-gray-500 cursor-not-allowed" : ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
|
|
555
579
|
>
|
|
556
580
|
{ctaLabel}
|
|
557
581
|
</button>
|
|
@@ -567,7 +591,8 @@ export function SubscriptionManager({
|
|
|
567
591
|
Free-tier users (provider='none') have no Stripe customer, so the
|
|
568
592
|
portal call would 404 — they get the Upgrade CTA on the current-plan
|
|
569
593
|
card instead. startsim-b1z. */}
|
|
570
|
-
{
|
|
594
|
+
{/* A cancelled paid plan keeps it too: its invoices are there (crochet_patterns-pes38). */}
|
|
595
|
+
{usableSubscription && !usableSubscription.offer.isFree && usableSubscription.viewerIsOwner !== false && (
|
|
571
596
|
<div className="pt-4 border-t border-gray-100">
|
|
572
597
|
<button
|
|
573
598
|
data-testid="portal-button"
|
|
@@ -48,6 +48,8 @@ export interface UsePlanChangeResult {
|
|
|
48
48
|
/** Open when the viewer must confirm. */
|
|
49
49
|
pending: PlanChangeConfirmState | null;
|
|
50
50
|
busy: boolean;
|
|
51
|
+
/** The offer being chosen while `busy`, so its button can say what is happening. */
|
|
52
|
+
choosing: ProductOffer | null;
|
|
51
53
|
/** The confirm step itself is running. */
|
|
52
54
|
confirming: boolean;
|
|
53
55
|
error: string | null;
|
|
@@ -57,7 +59,12 @@ export interface UsePlanChangeResult {
|
|
|
57
59
|
export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
|
|
58
60
|
const { client } = useBillingContext();
|
|
59
61
|
const [pending, setPending] = useState<PlanChangeConfirmState | null>(null);
|
|
60
|
-
const [busy,
|
|
62
|
+
const [busy, setBusyState] = useState(false);
|
|
63
|
+
const [choosing, setChoosing] = useState<ProductOffer | null>(null);
|
|
64
|
+
const setBusy = useCallback((value: boolean, offer: ProductOffer | null = null) => {
|
|
65
|
+
setBusyState(value);
|
|
66
|
+
setChoosing(value ? offer : null);
|
|
67
|
+
}, []);
|
|
61
68
|
const [confirming, setConfirming] = useState(false);
|
|
62
69
|
const [error, setError] = useState<string | null>(null);
|
|
63
70
|
const inFlight = useRef(false);
|
|
@@ -78,14 +85,14 @@ export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
|
|
|
78
85
|
return false;
|
|
79
86
|
}
|
|
80
87
|
},
|
|
81
|
-
[proceed],
|
|
88
|
+
[proceed, setBusy],
|
|
82
89
|
);
|
|
83
90
|
|
|
84
91
|
const choose = useCallback(
|
|
85
92
|
async (offer: ProductOffer, context: PlanChangeContext) => {
|
|
86
93
|
if (inFlight.current) return;
|
|
87
94
|
inFlight.current = true;
|
|
88
|
-
setBusy(true);
|
|
95
|
+
setBusy(true, offer);
|
|
89
96
|
setError(null);
|
|
90
97
|
|
|
91
98
|
const leavingUnlisted = !!context.leavingUnlisted;
|
|
@@ -118,7 +125,7 @@ export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
|
|
|
118
125
|
leavingUnlisted: preview?.currentOffer ? !preview.currentOffer.isListed : leavingUnlisted,
|
|
119
126
|
});
|
|
120
127
|
},
|
|
121
|
-
[client, run],
|
|
128
|
+
[client, run, setBusy],
|
|
122
129
|
);
|
|
123
130
|
|
|
124
131
|
const confirm = useCallback(async () => {
|
|
@@ -136,9 +143,9 @@ export function usePlanChange(proceed: PlanChangeProceed): UsePlanChangeResult {
|
|
|
136
143
|
setError(null);
|
|
137
144
|
inFlight.current = false;
|
|
138
145
|
setBusy(false);
|
|
139
|
-
}, [confirming]);
|
|
146
|
+
}, [confirming, setBusy]);
|
|
140
147
|
|
|
141
148
|
const clearError = useCallback(() => setError(null), []);
|
|
142
149
|
|
|
143
|
-
return { choose, confirm, cancel, pending, busy, confirming, error, clearError };
|
|
150
|
+
return { choose, confirm, cancel, pending, busy, choosing, confirming, error, clearError };
|
|
144
151
|
}
|
package/src/utils/plan.ts
CHANGED