@startsimpli/billing 0.4.26 → 0.4.28
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 +3 -8
- package/src/components/PricingDetailPage.tsx +6 -16
- package/src/components/PricingSection.review.test.tsx +106 -0
- package/src/components/PricingSection.test.tsx +4 -2
- package/src/components/PricingSection.tsx +32 -21
- package/src/components/SubscriptionManager.test.tsx +4 -4
- package/src/components/SubscriptionManager.tsx +43 -12
- package/src/hooks/usePlanChange.ts +13 -6
- package/src/utils/tiers.ts +23 -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
|
|
|
@@ -99,14 +99,9 @@ export function OfferPrice({
|
|
|
99
99
|
amountClassName: string;
|
|
100
100
|
intervalClassName?: string;
|
|
101
101
|
}) {
|
|
102
|
-
|
|
103
|
-
|
|
104
|
-
|
|
105
|
-
<span aria-hidden="true" className={amountClassName}>Free</span>
|
|
106
|
-
</span>
|
|
107
|
-
);
|
|
108
|
-
}
|
|
109
|
-
const price = formatPrice(offer.unitPrice, offer.currency);
|
|
102
|
+
// A free plan reads "$0 per month" like the others; its name already says
|
|
103
|
+
// Free, so the price does not repeat it (crochet_patterns-vveb).
|
|
104
|
+
const price = formatPrice(offer.isFree ? 0 : offer.unitPrice, offer.currency);
|
|
110
105
|
return (
|
|
111
106
|
<span aria-label={`${price} ${offer.intervalDisplay}`}>
|
|
112
107
|
<span aria-hidden="true" className={amountClassName}>{price}</span>
|
|
@@ -11,7 +11,7 @@
|
|
|
11
11
|
import React from "react";
|
|
12
12
|
import { useProduct } from "../hooks/useProduct";
|
|
13
13
|
import type { PlanChangePreview, ProductOffer, OfferFeature } from "../types";
|
|
14
|
-
import { cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
|
|
14
|
+
import { cardFeatures, cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
|
|
15
15
|
import { formatFeatureLabel } from "../utils/format";
|
|
16
16
|
import {
|
|
17
17
|
isPayingSubscription,
|
|
@@ -20,7 +20,6 @@ import {
|
|
|
20
20
|
planDirection,
|
|
21
21
|
resolveCurrentOffer,
|
|
22
22
|
trialApplies,
|
|
23
|
-
TRIAL_NOTE_FEATURE_KEY,
|
|
24
23
|
type CurrentPlanState,
|
|
25
24
|
} from "../utils/plan";
|
|
26
25
|
import { usePlanChange } from "../hooks/usePlanChange";
|
|
@@ -280,10 +279,6 @@ interface DetailCardProps {
|
|
|
280
279
|
busy?: boolean;
|
|
281
280
|
}
|
|
282
281
|
|
|
283
|
-
function sameFeature(a: OfferFeature, b: OfferFeature): boolean {
|
|
284
|
-
return a.value === b.value && (a.limit ?? null) === (b.limit ?? null);
|
|
285
|
-
}
|
|
286
|
-
|
|
287
282
|
function DetailCard({
|
|
288
283
|
entry, onSelect, buildsOn, inherited, yourPlanNote, isCurrent = false, ctaLabel, showTrial, busy = false, spinning = false,
|
|
289
284
|
}: DetailCardProps) {
|
|
@@ -292,12 +287,7 @@ function DetailCard({
|
|
|
292
287
|
// Each row once (crochet_patterns-tv7fb): no "not included" rows (the
|
|
293
288
|
// comparison table has them), no trial row (the pill says it, or the trial
|
|
294
289
|
// does not apply), and nothing the tier below already has.
|
|
295
|
-
const features = offer
|
|
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
|
-
});
|
|
290
|
+
const features = cardFeatures(offer, inherited);
|
|
301
291
|
// Featured is the tier's, so Pro stays highlighted on Annual too
|
|
302
292
|
// (crochet_patterns-ftrka).
|
|
303
293
|
const isFeatured = !!(tier.monthly?.isFeatured || tier.annual?.isFeatured);
|
|
@@ -308,13 +298,13 @@ function DetailCard({
|
|
|
308
298
|
className={`
|
|
309
299
|
flex flex-col relative rounded-2xl p-8 w-full max-w-sm flex-1 min-w-[280px]
|
|
310
300
|
${isFeatured
|
|
311
|
-
? "border-2 border-
|
|
301
|
+
? "border-2 border-[color:var(--billing-accent,#2563eb)] shadow-lg"
|
|
312
302
|
: "border border-gray-200 bg-white shadow-sm"
|
|
313
303
|
}
|
|
314
304
|
`}
|
|
315
305
|
>
|
|
316
306
|
{isFeatured && (
|
|
317
|
-
<span className="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-
|
|
307
|
+
<span className="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-[var(--billing-accent,#2563eb)] text-white text-xs font-bold px-4 py-1 rounded-full uppercase tracking-wide whitespace-nowrap">
|
|
318
308
|
Most Popular
|
|
319
309
|
</span>
|
|
320
310
|
)}
|
|
@@ -336,7 +326,7 @@ function DetailCard({
|
|
|
336
326
|
|
|
337
327
|
<div className="mb-5 min-h-8">
|
|
338
328
|
{trial ? (
|
|
339
|
-
<span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-
|
|
329
|
+
<span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-sm font-semibold px-3 py-1 rounded-lg w-fit">
|
|
340
330
|
{offer.trialDays}-day free trial
|
|
341
331
|
</span>
|
|
342
332
|
) : yourPlanNote ? (
|
|
@@ -376,7 +366,7 @@ function DetailCard({
|
|
|
376
366
|
${isCurrent
|
|
377
367
|
? "bg-gray-100 text-gray-500 cursor-not-allowed"
|
|
378
368
|
: isFeatured
|
|
379
|
-
? "bg-
|
|
369
|
+
? "bg-[var(--billing-accent,#2563eb)] text-white hover:bg-[var(--billing-accent-hover,#1d4ed8)] cursor-pointer disabled:opacity-70 disabled:cursor-wait"
|
|
380
370
|
: "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer disabled:opacity-70 disabled:cursor-wait"
|
|
381
371
|
}
|
|
382
372
|
`}
|
|
@@ -0,0 +1,106 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The compact PricingSection (crochet Home) follows the same card rules as the
|
|
3
|
+
* full pricing page (design review, Home page):
|
|
4
|
+
* - Under "Everything in X, plus:" a card lists only what it adds: no rows the
|
|
5
|
+
* tier below has, no "not included" rows, no trial row (crochet_patterns-469n).
|
|
6
|
+
* - Every card reserves the pill and lead-in rows (crochet_patterns-gac4).
|
|
7
|
+
* - A free plan's price reads "$0 per month", not "Free" twice (crochet_patterns-vveb).
|
|
8
|
+
* - The highlight colour comes from --billing-accent so an app can theme it
|
|
9
|
+
* (crochet_patterns-fpv4); blue stays the default.
|
|
10
|
+
*/
|
|
11
|
+
import React from "react";
|
|
12
|
+
import { describe, it, expect } from "vitest";
|
|
13
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
14
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
15
|
+
import { PricingSection } from "./PricingSection";
|
|
16
|
+
|
|
17
|
+
const offer = (o: Record<string, unknown>) => ({
|
|
18
|
+
currency: "USD", pricingModel: "flat", intervalUnit: "month", intervalCount: 1,
|
|
19
|
+
intervalDisplay: "per month", isRecurring: true, isActive: true, isFeatured: false,
|
|
20
|
+
trialDays: 0, ctaText: "Choose", description: "", ...o,
|
|
21
|
+
});
|
|
22
|
+
|
|
23
|
+
// The live crochet-patterns offers (api.startsimpli.com, 2026-10-18), monthly only.
|
|
24
|
+
const product = {
|
|
25
|
+
id: "p", slug: "crochet-patterns", name: "Crochet Patterns", description: "",
|
|
26
|
+
offers: [
|
|
27
|
+
offer({ id: "f", slug: "crochet-free", name: "Free", unitPrice: "0.00", isFree: true, sortOrder: 0, features: [
|
|
28
|
+
{ key: "ai_designs", name: "AI pattern designs", value: false },
|
|
29
|
+
{ key: "browse_patterns", name: "Browse and buy patterns", value: true },
|
|
30
|
+
{ key: "save_patterns", name: "Save patterns to your library", value: true },
|
|
31
|
+
{ key: "track_progress", name: "Track your progress", value: true },
|
|
32
|
+
{ key: "pdf_export", name: "Print-ready PDF export", value: true, limit: null },
|
|
33
|
+
] }),
|
|
34
|
+
offer({ id: "l", slug: "crochet-lite", name: "Lite", unitPrice: "2.99", isFree: false, sortOrder: 1, features: [
|
|
35
|
+
{ key: "ai_designs", name: "AI pattern designs", value: true },
|
|
36
|
+
{ key: "ai_designs_per_month", name: "AI designs per month", value: 3 },
|
|
37
|
+
{ key: "ai_refine", name: "AI chat to refine any pattern", value: true },
|
|
38
|
+
{ key: "pdf_export", name: "Print-ready PDF export", value: true },
|
|
39
|
+
{ key: "save_patterns", name: "Save patterns to your library", value: true },
|
|
40
|
+
{ key: "track_progress", name: "Track your progress", value: true },
|
|
41
|
+
{ key: "patterns_per_month", name: "Saved patterns", value: -1 },
|
|
42
|
+
] }),
|
|
43
|
+
offer({ id: "pr", slug: "crochet-pro", name: "Pro", unitPrice: "9.00", isFree: false, sortOrder: 2, isFeatured: true, trialDays: 7, features: [
|
|
44
|
+
{ key: "ai_designs", name: "AI pattern designs, made with our best model and checked by a design review", value: true },
|
|
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
|
+
};
|
|
54
|
+
|
|
55
|
+
function renderSection() {
|
|
56
|
+
const fetcher = (async () => new Response(JSON.stringify(product), { status: 200, headers: { "Content-Type": "application/json" } })) as typeof fetch;
|
|
57
|
+
return render(
|
|
58
|
+
<BillingProvider apiBaseUrl="https://api.test.com/api/v1" fetcher={fetcher}>
|
|
59
|
+
<PricingSection productId="crochet-patterns" maxFeatures={4} />
|
|
60
|
+
</BillingProvider>,
|
|
61
|
+
);
|
|
62
|
+
}
|
|
63
|
+
|
|
64
|
+
const rows = (slug: string) =>
|
|
65
|
+
within(screen.getByTestId(`section-card-${slug}`)).getAllByRole("listitem").map((li) => li.textContent?.trim());
|
|
66
|
+
|
|
67
|
+
describe("PricingSection card rules", () => {
|
|
68
|
+
it("Pro lists only what Lite lacks, so 15 designs and Custom sizing are not cut by the 4-row cap", async () => {
|
|
69
|
+
renderSection();
|
|
70
|
+
await waitFor(() => screen.getByTestId("section-card-crochet-pro"));
|
|
71
|
+
const pro = rows("crochet-pro");
|
|
72
|
+
expect(pro.some((r) => /15/.test(r ?? ""))).toBe(true);
|
|
73
|
+
expect(pro).toContain("Custom sizing");
|
|
74
|
+
expect(pro).not.toContain("AI chat to refine any pattern");
|
|
75
|
+
expect(pro).not.toContain("Print-ready PDF export");
|
|
76
|
+
expect(pro.some((r) => /7-day free trial with/.test(r ?? ""))).toBe(false);
|
|
77
|
+
});
|
|
78
|
+
|
|
79
|
+
it("Free drops its not-included row; Lite does not repeat Free's rows", async () => {
|
|
80
|
+
renderSection();
|
|
81
|
+
await waitFor(() => screen.getByTestId("section-card-crochet-lite"));
|
|
82
|
+
expect(rows("crochet-free")).not.toContain("AI pattern designs");
|
|
83
|
+
const lite = rows("crochet-lite");
|
|
84
|
+
expect(lite).not.toContain("Print-ready PDF export");
|
|
85
|
+
expect(lite).not.toContain("Track your progress");
|
|
86
|
+
});
|
|
87
|
+
|
|
88
|
+
it("every card reserves the lead-in row, and the free price is $0 per month", async () => {
|
|
89
|
+
renderSection();
|
|
90
|
+
await waitFor(() => screen.getByTestId("section-card-crochet-free"));
|
|
91
|
+
for (const slug of ["crochet-free", "crochet-lite", "crochet-pro"]) {
|
|
92
|
+
expect(screen.getByTestId(`section-lead-${slug}`)).toBeDefined();
|
|
93
|
+
}
|
|
94
|
+
expect(screen.getByTestId("section-lead-crochet-free").textContent).toBe("");
|
|
95
|
+
const free = screen.getByTestId("section-card-crochet-free");
|
|
96
|
+
expect(within(free).getByLabelText("$0 per month")).toBeDefined();
|
|
97
|
+
expect(within(free).getAllByText("Free")).toHaveLength(1); // the name only
|
|
98
|
+
});
|
|
99
|
+
|
|
100
|
+
it("the featured card and its button use the themeable accent", async () => {
|
|
101
|
+
renderSection();
|
|
102
|
+
await waitFor(() => screen.getByTestId("section-card-crochet-pro"));
|
|
103
|
+
expect(screen.getByTestId("section-card-crochet-pro").className).toContain("var(--billing-accent,#2563eb)");
|
|
104
|
+
expect(screen.getByTestId("section-cta-crochet-pro").className).toContain("bg-[var(--billing-accent,#2563eb)]");
|
|
105
|
+
});
|
|
106
|
+
});
|
|
@@ -221,7 +221,9 @@ describe("PricingSection", () => {
|
|
|
221
221
|
// "Up to 1 team seats" since crochet_patterns-34n7, so match the noun.
|
|
222
222
|
// We should see "Team seats" and "Projects" (first 2), but not "Storage"
|
|
223
223
|
expect(screen.getAllByText(/team seats$/i).length).toBe(3); // All 3 cards
|
|
224
|
-
|
|
224
|
+
// Enterprise's "Unlimited" projects is Pro's too, so it is not repeated
|
|
225
|
+
// under "Everything in Pro, plus:" (crochet_patterns-469n).
|
|
226
|
+
expect(screen.getAllByText(/projects$/i).length).toBe(2);
|
|
225
227
|
// Storage should appear fewer times or not at all since we're limiting to 2 features per card
|
|
226
228
|
const storageElements = screen.queryAllByText("Storage");
|
|
227
229
|
expect(storageElements.length).toBeLessThan(3); // Should not appear in all cards
|
|
@@ -242,7 +244,7 @@ describe("PricingSection", () => {
|
|
|
242
244
|
|
|
243
245
|
// With default maxFeatures=4, should show first 4 features
|
|
244
246
|
expect(screen.getAllByText(/team seats$/i).length).toBe(3);
|
|
245
|
-
expect(screen.getAllByText(/projects$/i).length).toBe(
|
|
247
|
+
expect(screen.getAllByText(/projects$/i).length).toBe(2); // Enterprise does not repeat Pro's Unlimited
|
|
246
248
|
expect(screen.getAllByText("Storage").length).toBe(3);
|
|
247
249
|
expect(screen.getAllByText("Support").length).toBe(3);
|
|
248
250
|
});
|
|
@@ -10,8 +10,8 @@
|
|
|
10
10
|
|
|
11
11
|
import React from "react";
|
|
12
12
|
import { useProduct } from "../hooks/useProduct";
|
|
13
|
-
import type { ProductOffer } from "../types";
|
|
14
|
-
import { tierBelow } from "../utils/tiers";
|
|
13
|
+
import type { OfferFeature, ProductOffer } from "../types";
|
|
14
|
+
import { cardFeatures, cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
|
|
15
15
|
import { FeatureIcon } from "./FeatureIcon";
|
|
16
16
|
import { formatFeatureLabel } from "../utils/format";
|
|
17
17
|
import { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
|
|
@@ -71,6 +71,9 @@ export function PricingSection({
|
|
|
71
71
|
// One offer per tier for the chosen interval (crochet_patterns-34n7).
|
|
72
72
|
const visibleOffers = entries.map((e) => e.offer);
|
|
73
73
|
const currentOfferId = resolveCurrentOfferId(product.offers, currentPlan);
|
|
74
|
+
// What each tier already has, so a card under "Everything in X, plus:" lists
|
|
75
|
+
// only what it adds, as on the full pricing page (crochet_patterns-469n).
|
|
76
|
+
const cumulative = cumulativeFeatureLookup(visibleOffers);
|
|
74
77
|
|
|
75
78
|
return (
|
|
76
79
|
<div data-testid="pricing-section" className="w-full px-4 py-8">
|
|
@@ -89,6 +92,7 @@ export function PricingSection({
|
|
|
89
92
|
onSelect={onSelectOffer}
|
|
90
93
|
maxFeatures={maxFeatures}
|
|
91
94
|
buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
|
|
95
|
+
inherited={below ? cumulative[below.slug] ?? null : null}
|
|
92
96
|
isCurrent={entry.offer.id === currentOfferId}
|
|
93
97
|
/>
|
|
94
98
|
);
|
|
@@ -105,12 +109,15 @@ interface SectionCardProps {
|
|
|
105
109
|
maxFeatures: number;
|
|
106
110
|
/** Name of the tier just below this one, when there is one. */
|
|
107
111
|
buildsOn?: string | null;
|
|
112
|
+
/** What the tier below already has (cumulative); rows it matches are not repeated. */
|
|
113
|
+
inherited?: Record<string, OfferFeature> | null;
|
|
108
114
|
}
|
|
109
115
|
|
|
110
|
-
function SectionCard({ entry, onSelect, maxFeatures, buildsOn, isCurrent = false }: SectionCardProps) {
|
|
116
|
+
function SectionCard({ entry, onSelect, maxFeatures, buildsOn, inherited, isCurrent = false }: SectionCardProps) {
|
|
111
117
|
const { offer, tier } = entry;
|
|
112
|
-
|
|
113
|
-
const
|
|
118
|
+
// Featured is the tier's, so the highlight survives the Annual toggle.
|
|
119
|
+
const isFeatured = !!(tier.monthly?.isFeatured || tier.annual?.isFeatured);
|
|
120
|
+
const visibleFeatures = cardFeatures(offer, inherited).slice(0, maxFeatures);
|
|
114
121
|
|
|
115
122
|
return (
|
|
116
123
|
<div
|
|
@@ -119,7 +126,7 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, isCurrent = false
|
|
|
119
126
|
className={`
|
|
120
127
|
flex flex-col relative rounded-xl p-6 flex-1 min-w-[220px] max-w-xs
|
|
121
128
|
${isFeatured
|
|
122
|
-
? "border-2 border-
|
|
129
|
+
? "border-2 border-[color:var(--billing-accent,#2563eb)] shadow-md"
|
|
123
130
|
: "border border-gray-200 bg-white shadow-sm"
|
|
124
131
|
}
|
|
125
132
|
`}
|
|
@@ -132,23 +139,27 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, isCurrent = false
|
|
|
132
139
|
amountClassName="text-2xl font-extrabold text-gray-900"
|
|
133
140
|
intervalClassName="text-sm text-gray-500"
|
|
134
141
|
/>
|
|
135
|
-
<
|
|
142
|
+
<div className="mt-1 min-h-4">
|
|
143
|
+
<AnnualNote entry={entry} />
|
|
144
|
+
</div>
|
|
136
145
|
</div>
|
|
137
146
|
|
|
138
|
-
{
|
|
139
|
-
|
|
140
|
-
|
|
141
|
-
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
147
|
+
{/* Every card reserves the pill and lead-in rows, so matching features
|
|
148
|
+
start at the same height on each card (crochet_patterns-gac4). */}
|
|
149
|
+
<div className="mb-3 min-h-5">
|
|
150
|
+
{offer.trialDays > 0 && (
|
|
151
|
+
<span
|
|
152
|
+
data-testid="trial-badge"
|
|
153
|
+
className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-xs font-semibold px-2 py-0.5 rounded-md w-fit"
|
|
154
|
+
>
|
|
155
|
+
{offer.trialDays}-day free trial
|
|
156
|
+
</span>
|
|
157
|
+
)}
|
|
158
|
+
</div>
|
|
146
159
|
|
|
147
|
-
{
|
|
148
|
-
|
|
149
|
-
|
|
150
|
-
</p>
|
|
151
|
-
)}
|
|
160
|
+
<p data-testid={`section-lead-${offer.slug}`} className="text-sm font-semibold text-gray-900 mb-1 min-h-5">
|
|
161
|
+
{buildsOn ? `Everything in ${buildsOn}, plus:` : null}
|
|
162
|
+
</p>
|
|
152
163
|
|
|
153
164
|
<ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
|
|
154
165
|
{visibleFeatures.map((feature) => (
|
|
@@ -172,7 +183,7 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, isCurrent = false
|
|
|
172
183
|
${isCurrent
|
|
173
184
|
? "bg-gray-100 text-gray-500 cursor-not-allowed"
|
|
174
185
|
: isFeatured
|
|
175
|
-
? "bg-
|
|
186
|
+
? "bg-[var(--billing-accent,#2563eb)] text-white hover:bg-[var(--billing-accent-hover,#1d4ed8)] cursor-pointer"
|
|
176
187
|
: "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
|
|
177
188
|
}
|
|
178
189
|
`}
|
|
@@ -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 () => {
|
|
@@ -934,7 +934,7 @@ describe("SubscriptionManager", () => {
|
|
|
934
934
|
|
|
935
935
|
const nextBilling = screen.getByTestId("next-billing-date");
|
|
936
936
|
expect(nextBilling.textContent).toMatch(/next billing date/i);
|
|
937
|
-
expect(nextBilling.textContent).toMatch(/2026
|
|
937
|
+
expect(nextBilling.textContent).toMatch(/Feb 1, 2026/);
|
|
938
938
|
// The verbose "Billing period:" line is hidden on active paid subs.
|
|
939
939
|
expect(screen.queryByText(/^Billing period:/i)).toBeNull();
|
|
940
940
|
});
|
|
@@ -311,8 +311,27 @@ export function SubscriptionManager({
|
|
|
311
311
|
// startsim-21f, crochet_patterns-anw61.
|
|
312
312
|
const paying = isPayingSubscription(usableSubscription);
|
|
313
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;
|
|
314
320
|
const directionFor = (cardOffer: ProductOffer): PlanDirection =>
|
|
315
|
-
|
|
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;
|
|
316
335
|
|
|
317
336
|
const handleOfferClick = (offer: ProductOffer) => {
|
|
318
337
|
// Non-owner viewers can SEE the plans but can't change them — startsim-wo9.
|
|
@@ -329,8 +348,8 @@ export function SubscriptionManager({
|
|
|
329
348
|
// only apps), so the Upgrade CTA simply doesn't render — startsim-ft2.
|
|
330
349
|
const featuredPaidOffer: ProductOffer | null = (() => {
|
|
331
350
|
if (!product) return null;
|
|
332
|
-
if (
|
|
333
|
-
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);
|
|
334
353
|
return above.find((o) => o.isFeatured) ?? above[0] ?? null;
|
|
335
354
|
}
|
|
336
355
|
return (
|
|
@@ -356,7 +375,12 @@ export function SubscriptionManager({
|
|
|
356
375
|
<svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
357
376
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
358
377
|
</svg>
|
|
359
|
-
<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>
|
|
360
384
|
</div>
|
|
361
385
|
)}
|
|
362
386
|
|
|
@@ -497,7 +521,13 @@ export function SubscriptionManager({
|
|
|
497
521
|
const direction = directionFor(offer);
|
|
498
522
|
const isCurrent = direction === "current";
|
|
499
523
|
const isDowngrade = direction === "downgrade" || direction === "switch";
|
|
500
|
-
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);
|
|
501
531
|
const showTrial = trialApplies(offer, direction, paying);
|
|
502
532
|
|
|
503
533
|
// Border: current = solid ring; featured upgrade = blue; else gray.
|
|
@@ -522,7 +552,7 @@ export function SubscriptionManager({
|
|
|
522
552
|
data-testid={`plan-card-${offer.slug}`}
|
|
523
553
|
data-direction={direction}
|
|
524
554
|
data-current={isCurrent || undefined}
|
|
525
|
-
className={`relative rounded-xl p-6 transition-colors ${cardBorder}`}
|
|
555
|
+
className={`relative flex flex-col rounded-xl p-6 transition-colors ${cardBorder}`}
|
|
526
556
|
>
|
|
527
557
|
<h4 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h4>
|
|
528
558
|
<div className="mb-3">
|
|
@@ -540,12 +570,12 @@ export function SubscriptionManager({
|
|
|
540
570
|
<PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
|
|
541
571
|
<button
|
|
542
572
|
data-testid={`upgrade-cta-${offer.slug}`}
|
|
543
|
-
onClick={() => !isCurrent && !isViewerNonOwner && !planChange.busy && handleOfferClick(offer)}
|
|
544
|
-
disabled={isCurrent || isViewerNonOwner || planChange.busy}
|
|
545
|
-
aria-disabled={isCurrent || isViewerNonOwner || planChange.busy || undefined}
|
|
546
|
-
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}
|
|
547
577
|
title={isViewerNonOwner ? "Only the team owner can change the plan" : undefined}
|
|
548
|
-
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" : ""}`}
|
|
549
579
|
>
|
|
550
580
|
{ctaLabel}
|
|
551
581
|
</button>
|
|
@@ -561,7 +591,8 @@ export function SubscriptionManager({
|
|
|
561
591
|
Free-tier users (provider='none') have no Stripe customer, so the
|
|
562
592
|
portal call would 404 — they get the Upgrade CTA on the current-plan
|
|
563
593
|
card instead. startsim-b1z. */}
|
|
564
|
-
{
|
|
594
|
+
{/* A cancelled paid plan keeps it too: its invoices are there (crochet_patterns-pes38). */}
|
|
595
|
+
{usableSubscription && !usableSubscription.offer.isFree && usableSubscription.viewerIsOwner !== false && (
|
|
565
596
|
<div className="pt-4 border-t border-gray-100">
|
|
566
597
|
<button
|
|
567
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/tiers.ts
CHANGED
|
@@ -6,6 +6,7 @@
|
|
|
6
6
|
* showed a paid plan as missing what the free plan had.
|
|
7
7
|
*/
|
|
8
8
|
import type { ProductOffer, OfferFeature } from "../types";
|
|
9
|
+
import { TRIAL_NOTE_FEATURE_KEY } from "./plan";
|
|
9
10
|
/**
|
|
10
11
|
* Plans are cumulative: a paid tier includes what the cheaper tier includes.
|
|
11
12
|
*
|
|
@@ -158,3 +159,25 @@ export function annualSavings(tier: OfferTier): { perMonth: number; percent: num
|
|
|
158
159
|
const percent = yearly < twelve ? Math.floor(((twelve - yearly) / twelve) * 100) : 0;
|
|
159
160
|
return { perMonth: yearly / 12, percent };
|
|
160
161
|
}
|
|
162
|
+
|
|
163
|
+
function sameFeature(a: OfferFeature, b: OfferFeature): boolean {
|
|
164
|
+
return a.value === b.value && (a.limit ?? null) === (b.limit ?? null);
|
|
165
|
+
}
|
|
166
|
+
|
|
167
|
+
/**
|
|
168
|
+
* The rows a pricing card lists (crochet_patterns-tv7fb, -469n): no "not
|
|
169
|
+
* included" rows, no trial row (the pill says it), and nothing the tier below
|
|
170
|
+
* already has, so a card under "Everything in X, plus:" lists only what it adds.
|
|
171
|
+
* Shared by the full pricing page and the compact section on landing pages.
|
|
172
|
+
*/
|
|
173
|
+
export function cardFeatures(
|
|
174
|
+
offer: ProductOffer,
|
|
175
|
+
inherited: Record<string, OfferFeature> | null | undefined,
|
|
176
|
+
): OfferFeature[] {
|
|
177
|
+
return offer.features.filter((f) => {
|
|
178
|
+
if (f.value === false) return false;
|
|
179
|
+
if (f.key === TRIAL_NOTE_FEATURE_KEY) return false;
|
|
180
|
+
const below = inherited?.[f.key];
|
|
181
|
+
return !(below && sameFeature(below, f));
|
|
182
|
+
});
|
|
183
|
+
}
|