@startsimpli/billing 0.4.22 → 0.4.24
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/CurrentPlanCard.tsx +5 -10
- package/src/components/IntervalToggle.tsx +113 -0
- package/src/components/PlanPicker.crochet.test.tsx +322 -0
- package/src/components/PricingDetailPage.tsx +69 -41
- package/src/components/PricingPage.test.tsx +2 -2
- package/src/components/PricingPage.tsx +46 -24
- package/src/components/PricingSection.test.tsx +6 -5
- package/src/components/PricingSection.tsx +58 -38
- package/src/components/SubscriptionManager.test.tsx +17 -10
- package/src/components/SubscriptionManager.tsx +49 -40
- package/src/components/UpgradeModal.tsx +39 -21
- package/src/components/__fixtures__/crochetProduct.ts +172 -0
- package/src/index.ts +5 -2
- package/src/types/index.ts +6 -0
- package/src/utils/api.test.ts +26 -0
- package/src/utils/api.ts +5 -0
- package/src/utils/format.test.ts +57 -0
- package/src/utils/format.ts +54 -0
- package/src/utils/ga-ids.test.ts +23 -0
- package/src/utils/ga-ids.ts +43 -0
- package/src/utils/plan.ts +38 -0
- package/src/utils/tiers.interval.test.ts +76 -0
- package/src/utils/tiers.ts +105 -0
package/package.json
CHANGED
|
@@ -16,6 +16,7 @@
|
|
|
16
16
|
|
|
17
17
|
import React from "react";
|
|
18
18
|
import type { OfferFeature, SubscriptionInfo, UsageMap } from "../types";
|
|
19
|
+
import { formatFeatureLabel, formatPrice } from "../utils/format";
|
|
19
20
|
|
|
20
21
|
export interface CurrentPlanCardProps {
|
|
21
22
|
subscription: SubscriptionInfo;
|
|
@@ -32,8 +33,7 @@ export interface CurrentPlanCardProps {
|
|
|
32
33
|
|
|
33
34
|
function priceLabel(offer: SubscriptionInfo["offer"]): string {
|
|
34
35
|
if (offer.isFree) return "Free";
|
|
35
|
-
|
|
36
|
-
return `$${n}`;
|
|
36
|
+
return formatPrice(offer.unitPrice, offer.currency);
|
|
37
37
|
}
|
|
38
38
|
|
|
39
39
|
function isProgressBarFeature(f: OfferFeature): boolean {
|
|
@@ -42,13 +42,7 @@ function isProgressBarFeature(f: OfferFeature): boolean {
|
|
|
42
42
|
|
|
43
43
|
function featureLabel(f: OfferFeature): string | null {
|
|
44
44
|
if (f.value === false) return null;
|
|
45
|
-
|
|
46
|
-
const limit = typeof f.limit === "number" ? f.limit
|
|
47
|
-
: typeof f.value === "number" ? (f.value as number) : null;
|
|
48
|
-
if (limit === -1) return `Unlimited ${f.name.toLowerCase()}`;
|
|
49
|
-
if (limit !== null && limit > 0) return `Up to ${limit} ${f.name.toLowerCase()}`;
|
|
50
|
-
}
|
|
51
|
-
return f.name;
|
|
45
|
+
return formatFeatureLabel(f);
|
|
52
46
|
}
|
|
53
47
|
|
|
54
48
|
function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string | null }) {
|
|
@@ -277,7 +271,8 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
|
|
|
277
271
|
) : (
|
|
278
272
|
<span aria-label={`${priceLabel(offer)} ${offer.intervalDisplay}`}>
|
|
279
273
|
<span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">{priceLabel(offer)}</span>
|
|
280
|
-
|
|
274
|
+
{" "}
|
|
275
|
+
<span aria-hidden="true" className="text-base text-gray-500">{offer.intervalDisplay}</span>
|
|
281
276
|
</span>
|
|
282
277
|
)}
|
|
283
278
|
</div>
|
|
@@ -0,0 +1,113 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Monthly / Annual switch for plan pickers (crochet_patterns-34n7).
|
|
3
|
+
*
|
|
4
|
+
* Every plan list pairs each tier's monthly and annual offers and shows one
|
|
5
|
+
* card per tier for the chosen interval. Monthly by default — unless the
|
|
6
|
+
* viewer's current plan is an annual offer, so their card reads "Current
|
|
7
|
+
* plan" without a click. Nothing is remembered.
|
|
8
|
+
*/
|
|
9
|
+
import React, { useMemo, useState } from "react";
|
|
10
|
+
import type { ProductOffer } from "../types";
|
|
11
|
+
import {
|
|
12
|
+
annualSavings,
|
|
13
|
+
groupOffersByTier,
|
|
14
|
+
tierOffer,
|
|
15
|
+
type BillingInterval,
|
|
16
|
+
type OfferTier,
|
|
17
|
+
} from "../utils/tiers";
|
|
18
|
+
import { formatPrice } from "../utils/format";
|
|
19
|
+
|
|
20
|
+
export interface IntervalEntry {
|
|
21
|
+
tier: OfferTier;
|
|
22
|
+
offer: ProductOffer;
|
|
23
|
+
}
|
|
24
|
+
|
|
25
|
+
export function useIntervalOffers(offers: ProductOffer[], currentOfferId: string | null = null) {
|
|
26
|
+
// Null until the viewer picks one, so the default can follow a plan that
|
|
27
|
+
// loads after the first render.
|
|
28
|
+
const [chosen, setInterval] = useState<BillingInterval | null>(null);
|
|
29
|
+
const currentIsAnnual = offers.some((o) => o.id === currentOfferId && o.intervalUnit === "year");
|
|
30
|
+
const interval: BillingInterval = chosen ?? (currentIsAnnual ? "annual" : "monthly");
|
|
31
|
+
const tiers = useMemo(() => groupOffersByTier(offers), [offers]);
|
|
32
|
+
const hasChoice = tiers.some((t) => t.monthly !== null && t.annual !== null);
|
|
33
|
+
const entries: IntervalEntry[] = tiers.map((tier) => ({ tier, offer: tierOffer(tier, interval) }));
|
|
34
|
+
return { interval, setInterval, hasChoice, tiers, entries };
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function IntervalToggle({
|
|
38
|
+
interval,
|
|
39
|
+
onChange,
|
|
40
|
+
}: {
|
|
41
|
+
interval: BillingInterval;
|
|
42
|
+
onChange: (interval: BillingInterval) => void;
|
|
43
|
+
}) {
|
|
44
|
+
const option = (value: BillingInterval, label: string) => {
|
|
45
|
+
const selected = interval === value;
|
|
46
|
+
return (
|
|
47
|
+
<button
|
|
48
|
+
type="button"
|
|
49
|
+
role="radio"
|
|
50
|
+
aria-checked={selected}
|
|
51
|
+
onClick={() => onChange(value)}
|
|
52
|
+
className={`px-4 py-1.5 text-sm font-semibold rounded-full transition-colors cursor-pointer ${
|
|
53
|
+
selected ? "bg-white text-gray-900 shadow-sm" : "text-gray-500 hover:text-gray-800"
|
|
54
|
+
}`}
|
|
55
|
+
>
|
|
56
|
+
{label}
|
|
57
|
+
</button>
|
|
58
|
+
);
|
|
59
|
+
};
|
|
60
|
+
return (
|
|
61
|
+
<div className="flex justify-center">
|
|
62
|
+
<div
|
|
63
|
+
role="radiogroup"
|
|
64
|
+
aria-label="Billing interval"
|
|
65
|
+
data-testid="interval-toggle"
|
|
66
|
+
className="inline-flex items-center gap-1 rounded-full bg-gray-100 p-1"
|
|
67
|
+
>
|
|
68
|
+
{option("monthly", "Monthly")}
|
|
69
|
+
{option("annual", "Annual")}
|
|
70
|
+
</div>
|
|
71
|
+
</div>
|
|
72
|
+
);
|
|
73
|
+
}
|
|
74
|
+
|
|
75
|
+
/** "$2.42/month billed yearly · Save 19%" under an annual price. */
|
|
76
|
+
export function AnnualNote({ entry, className = "" }: { entry: IntervalEntry; className?: string }) {
|
|
77
|
+
if (entry.offer !== entry.tier.annual) return null;
|
|
78
|
+
const savings = annualSavings(entry.tier);
|
|
79
|
+
if (!savings) return null;
|
|
80
|
+
return (
|
|
81
|
+
<p data-testid="annual-note" className={`text-xs text-gray-500 ${className}`}>
|
|
82
|
+
{formatPrice(savings.perMonth, entry.offer.currency)}/month billed yearly
|
|
83
|
+
{savings.percent > 0 && <span className="font-semibold text-green-700"> · Save {savings.percent}%</span>}
|
|
84
|
+
</p>
|
|
85
|
+
);
|
|
86
|
+
}
|
|
87
|
+
|
|
88
|
+
/** The price line every card shares: "$2.99 per month", or "Free". */
|
|
89
|
+
export function OfferPrice({
|
|
90
|
+
offer,
|
|
91
|
+
amountClassName,
|
|
92
|
+
intervalClassName = "text-gray-500",
|
|
93
|
+
}: {
|
|
94
|
+
offer: ProductOffer;
|
|
95
|
+
amountClassName: string;
|
|
96
|
+
intervalClassName?: string;
|
|
97
|
+
}) {
|
|
98
|
+
if (offer.isFree) {
|
|
99
|
+
return (
|
|
100
|
+
<span aria-label="Free">
|
|
101
|
+
<span aria-hidden="true" className={amountClassName}>Free</span>
|
|
102
|
+
</span>
|
|
103
|
+
);
|
|
104
|
+
}
|
|
105
|
+
const price = formatPrice(offer.unitPrice, offer.currency);
|
|
106
|
+
return (
|
|
107
|
+
<span aria-label={`${price} ${offer.intervalDisplay}`}>
|
|
108
|
+
<span aria-hidden="true" className={amountClassName}>{price}</span>
|
|
109
|
+
{" "}
|
|
110
|
+
<span aria-hidden="true" className={intervalClassName}>{offer.intervalDisplay}</span>
|
|
111
|
+
</span>
|
|
112
|
+
);
|
|
113
|
+
}
|
|
@@ -0,0 +1,322 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The crochets.site plan picker, against the live catalogue
|
|
3
|
+
* (crochet_patterns-34n7). Quinn, signed in on Free with no subscription row,
|
|
4
|
+
* saw every offer in one row (Lite AND Lite Annual), "Get Started" on the plan
|
|
5
|
+
* he was already on, "$2per month" for $2.99, "no card required" on a trial
|
|
6
|
+
* that takes a card, and "Up to 3 ai designs per month".
|
|
7
|
+
*
|
|
8
|
+
* Same rules on every surface that lists plans: SubscriptionManager
|
|
9
|
+
* (/settings/billing), PricingDetailPage (/pricing), PricingSection (home),
|
|
10
|
+
* PricingPage and UpgradeModal.
|
|
11
|
+
*/
|
|
12
|
+
import { describe, it, expect, vi } from "vitest";
|
|
13
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
14
|
+
import userEvent from "@testing-library/user-event";
|
|
15
|
+
import React from "react";
|
|
16
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
17
|
+
import { SubscriptionManager } from "./SubscriptionManager";
|
|
18
|
+
import { PricingDetailPage } from "./PricingDetailPage";
|
|
19
|
+
import { PricingSection } from "./PricingSection";
|
|
20
|
+
import { PricingPage } from "./PricingPage";
|
|
21
|
+
import { UpgradeModal } from "./UpgradeModal";
|
|
22
|
+
import type { BillingProduct, SubscriptionInfo } from "../types";
|
|
23
|
+
import {
|
|
24
|
+
crochetProduct,
|
|
25
|
+
crochetProductWithTiers,
|
|
26
|
+
crochetLite,
|
|
27
|
+
crochetLiteAnnual,
|
|
28
|
+
crochetPro,
|
|
29
|
+
crochetProAnnual,
|
|
30
|
+
crochetFetcher,
|
|
31
|
+
subscriptionTo,
|
|
32
|
+
} from "./__fixtures__/crochetProduct";
|
|
33
|
+
|
|
34
|
+
const text = (el: Element) => (el.textContent ?? "").replace(/\s+/g, " ");
|
|
35
|
+
|
|
36
|
+
function renderWith(
|
|
37
|
+
ui: React.ReactElement,
|
|
38
|
+
product: BillingProduct = crochetProduct,
|
|
39
|
+
subscription: SubscriptionInfo | null = null,
|
|
40
|
+
) {
|
|
41
|
+
return render(
|
|
42
|
+
<BillingProvider apiBaseUrl="https://api.test/api/v1" fetcher={crochetFetcher(product, subscription)}>
|
|
43
|
+
{ui}
|
|
44
|
+
</BillingProvider>,
|
|
45
|
+
);
|
|
46
|
+
}
|
|
47
|
+
|
|
48
|
+
async function chooseAnnual() {
|
|
49
|
+
await userEvent.click(screen.getByRole("radio", { name: /annual/i }));
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
53
|
+
// /settings/billing
|
|
54
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
55
|
+
describe("SubscriptionManager plan picker (crochet catalogue)", () => {
|
|
56
|
+
const manager = <SubscriptionManager productId="crochet-patterns" returnUrl="https://t/s" />;
|
|
57
|
+
|
|
58
|
+
it("defaults to Monthly and shows one card per tier", async () => {
|
|
59
|
+
renderWith(manager);
|
|
60
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
61
|
+
|
|
62
|
+
expect(screen.getByRole("radio", { name: /monthly/i }).getAttribute("aria-checked")).toBe("true");
|
|
63
|
+
expect(screen.getByRole("radio", { name: /annual/i }).getAttribute("aria-checked")).toBe("false");
|
|
64
|
+
expect(screen.getByTestId("plan-card-crochet-free")).toBeDefined();
|
|
65
|
+
expect(screen.getByTestId("plan-card-crochet-lite")).toBeDefined();
|
|
66
|
+
expect(screen.getByTestId("plan-card-crochet-pro")).toBeDefined();
|
|
67
|
+
expect(screen.queryByTestId("plan-card-crochet-lite-annual")).toBeNull();
|
|
68
|
+
});
|
|
69
|
+
|
|
70
|
+
it("Annual swaps Lite for its yearly price, keeps Free, and shows the saving", async () => {
|
|
71
|
+
renderWith(manager);
|
|
72
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
73
|
+
await chooseAnnual();
|
|
74
|
+
|
|
75
|
+
expect(screen.queryByTestId("plan-card-crochet-lite")).toBeNull();
|
|
76
|
+
const lite = screen.getByTestId("plan-card-crochet-lite-annual");
|
|
77
|
+
expect(within(lite).getByRole("heading").textContent).toBe("Lite");
|
|
78
|
+
expect(text(lite)).toContain("$29 per year");
|
|
79
|
+
expect(text(lite)).toContain("$2.42");
|
|
80
|
+
expect(text(lite)).toMatch(/save 19%/i);
|
|
81
|
+
expect(screen.getByTestId("plan-card-crochet-free")).toBeDefined();
|
|
82
|
+
// Pro has no annual price yet: it stays on the page at its monthly price.
|
|
83
|
+
expect(text(screen.getByTestId("plan-card-crochet-pro"))).toContain("$9 per month");
|
|
84
|
+
});
|
|
85
|
+
|
|
86
|
+
it("a signed-in user with no subscription is on Free: Current plan, disabled", async () => {
|
|
87
|
+
renderWith(manager, crochetProduct, null);
|
|
88
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
89
|
+
|
|
90
|
+
const cta = screen.getByTestId("upgrade-cta-crochet-free") as HTMLButtonElement;
|
|
91
|
+
expect(cta.textContent).toMatch(/^current plan$/i);
|
|
92
|
+
expect(cta.disabled).toBe(true);
|
|
93
|
+
expect(screen.queryByText(/get started/i)).toBeNull();
|
|
94
|
+
expect(screen.getByText(/you're on the free plan/i)).toBeDefined();
|
|
95
|
+
// Paid cards stay buyable.
|
|
96
|
+
expect((screen.getByTestId("upgrade-cta-crochet-lite") as HTMLButtonElement).disabled).toBe(false);
|
|
97
|
+
});
|
|
98
|
+
|
|
99
|
+
it("renders $2.99 with its cents and a space before the interval", async () => {
|
|
100
|
+
renderWith(manager);
|
|
101
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
102
|
+
const lite = screen.getByTestId("plan-card-crochet-lite");
|
|
103
|
+
expect(text(lite)).toContain("$2.99 per month");
|
|
104
|
+
expect(text(screen.getByTestId("plan-card-crochet-pro"))).toContain("$9 per month");
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
it("Pro's trial copy does not promise no card", async () => {
|
|
108
|
+
renderWith(manager);
|
|
109
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
110
|
+
const pro = screen.getByTestId("plan-card-crochet-pro");
|
|
111
|
+
expect(text(pro)).toMatch(/7-day free trial/i);
|
|
112
|
+
expect(text(pro)).not.toMatch(/no card/i);
|
|
113
|
+
});
|
|
114
|
+
|
|
115
|
+
it("feature labels keep AI capitalised and drop per month when unlimited", async () => {
|
|
116
|
+
renderWith(manager);
|
|
117
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
118
|
+
const lite = screen.getByTestId("plan-card-crochet-lite");
|
|
119
|
+
expect(within(lite).getByText("Up to 3 AI designs per month")).toBeDefined();
|
|
120
|
+
expect(within(lite).getByText("Unlimited saved patterns")).toBeDefined();
|
|
121
|
+
const pro = screen.getByTestId("plan-card-crochet-pro");
|
|
122
|
+
expect(within(pro).getByText("Unlimited AI designs")).toBeDefined();
|
|
123
|
+
expect(screen.queryByText(/ai designs/)).toBeNull(); // no lower-case "ai"
|
|
124
|
+
});
|
|
125
|
+
|
|
126
|
+
it("on Lite Annual: the switch opens on Annual so their card reads Current plan", async () => {
|
|
127
|
+
renderWith(manager, crochetProduct, subscriptionTo(crochetLiteAnnual));
|
|
128
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
129
|
+
|
|
130
|
+
expect(screen.getByRole("radio", { name: /annual/i }).getAttribute("aria-checked")).toBe("true");
|
|
131
|
+
const annualCta = screen.getByTestId("upgrade-cta-crochet-lite-annual") as HTMLButtonElement;
|
|
132
|
+
expect(annualCta.textContent).toMatch(/^current plan$/i);
|
|
133
|
+
expect(annualCta.disabled).toBe(true);
|
|
134
|
+
|
|
135
|
+
// Flipping to Monthly offers the same plan billed monthly as a switch.
|
|
136
|
+
await userEvent.click(screen.getByRole("radio", { name: /monthly/i }));
|
|
137
|
+
const monthlyCta = screen.getByTestId("upgrade-cta-crochet-lite") as HTMLButtonElement;
|
|
138
|
+
expect(monthlyCta.textContent).toMatch(/switch to monthly/i);
|
|
139
|
+
expect(monthlyCta.disabled).toBe(false);
|
|
140
|
+
});
|
|
141
|
+
|
|
142
|
+
it("on a monthly plan the switch still opens on Monthly", async () => {
|
|
143
|
+
renderWith(manager, crochetProductWithTiers, subscriptionTo(crochetLite));
|
|
144
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
145
|
+
expect(screen.getByRole("radio", { name: /monthly/i }).getAttribute("aria-checked")).toBe("true");
|
|
146
|
+
});
|
|
147
|
+
|
|
148
|
+
it("pairs by the API's tier field once it is served (Pro Annual)", async () => {
|
|
149
|
+
renderWith(manager, crochetProductWithTiers, subscriptionTo(crochetPro));
|
|
150
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
151
|
+
expect(screen.queryByTestId("plan-card-crochet-pro-annual")).toBeNull();
|
|
152
|
+
await chooseAnnual();
|
|
153
|
+
const proAnnual = screen.getByTestId("plan-card-crochet-pro-annual");
|
|
154
|
+
expect(within(proAnnual).getByRole("heading").textContent).toBe("Pro");
|
|
155
|
+
expect(text(proAnnual)).toContain("$89 per year");
|
|
156
|
+
expect((screen.getByTestId("upgrade-cta-crochet-pro-annual") as HTMLButtonElement).textContent)
|
|
157
|
+
.toMatch(/switch to annual/i);
|
|
158
|
+
});
|
|
159
|
+
});
|
|
160
|
+
|
|
161
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
162
|
+
// /pricing
|
|
163
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
164
|
+
describe("PricingDetailPage plan picker (crochet catalogue)", () => {
|
|
165
|
+
it("signed out: toggle, one card per tier, normal CTAs", async () => {
|
|
166
|
+
const onSelect = vi.fn();
|
|
167
|
+
renderWith(<PricingDetailPage productId="crochet-patterns" onSelectOffer={onSelect} />);
|
|
168
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
169
|
+
|
|
170
|
+
expect(screen.getByRole("radio", { name: /monthly/i }).getAttribute("aria-checked")).toBe("true");
|
|
171
|
+
expect(screen.queryByTestId("detail-card-crochet-lite-annual")).toBeNull();
|
|
172
|
+
const free = screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement;
|
|
173
|
+
expect(free.textContent).toBe("Get Started");
|
|
174
|
+
expect(free.disabled).toBe(false);
|
|
175
|
+
expect(text(screen.getByTestId("detail-card-crochet-lite"))).toContain("$2.99 per month");
|
|
176
|
+
expect(within(screen.getByTestId("detail-card-crochet-lite")).getByText("Up to 3 AI designs per month")).toBeDefined();
|
|
177
|
+
});
|
|
178
|
+
|
|
179
|
+
it("annual: comparison table has one column per tier and Pro builds on Lite", async () => {
|
|
180
|
+
renderWith(<PricingDetailPage productId="crochet-patterns" />);
|
|
181
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
182
|
+
await chooseAnnual();
|
|
183
|
+
|
|
184
|
+
const lite = screen.getByTestId("detail-card-crochet-lite-annual");
|
|
185
|
+
expect(within(lite).getByRole("heading").textContent).toBe("Lite");
|
|
186
|
+
expect(text(lite)).toContain("$29 per year");
|
|
187
|
+
expect(text(screen.getByTestId("detail-card-crochet-pro"))).toMatch(/everything in lite, plus/i);
|
|
188
|
+
|
|
189
|
+
const headers = within(screen.getByTestId("comparison-table")).getAllByRole("columnheader");
|
|
190
|
+
expect(headers.map((h) => h.getAttribute("aria-label") ?? h.textContent)).toEqual(["Feature", "Free", "Lite", "Pro"]);
|
|
191
|
+
});
|
|
192
|
+
|
|
193
|
+
it("signed in on Free with no subscription: Free is Current plan, disabled", async () => {
|
|
194
|
+
const onSelect = vi.fn();
|
|
195
|
+
renderWith(
|
|
196
|
+
<PricingDetailPage
|
|
197
|
+
productId="crochet-patterns"
|
|
198
|
+
onSelectOffer={onSelect}
|
|
199
|
+
currentPlan={{ signedIn: true, subscription: null }}
|
|
200
|
+
/>,
|
|
201
|
+
);
|
|
202
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
203
|
+
const free = screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement;
|
|
204
|
+
expect(free.textContent).toMatch(/^current plan$/i);
|
|
205
|
+
expect(free.disabled).toBe(true);
|
|
206
|
+
await userEvent.click(free);
|
|
207
|
+
expect(onSelect).not.toHaveBeenCalled();
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
it("while the plan is still loading nothing is marked current", async () => {
|
|
211
|
+
renderWith(
|
|
212
|
+
<PricingDetailPage
|
|
213
|
+
productId="crochet-patterns"
|
|
214
|
+
currentPlan={{ signedIn: true, subscription: null, loading: true }}
|
|
215
|
+
/>,
|
|
216
|
+
);
|
|
217
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
218
|
+
expect((screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement).disabled).toBe(false);
|
|
219
|
+
});
|
|
220
|
+
|
|
221
|
+
it("signed in on Pro Annual: opens on Annual with Pro Annual current", async () => {
|
|
222
|
+
renderWith(
|
|
223
|
+
<PricingDetailPage
|
|
224
|
+
productId="crochet-patterns"
|
|
225
|
+
currentPlan={{ signedIn: true, subscription: subscriptionTo(crochetProAnnual) }}
|
|
226
|
+
/>,
|
|
227
|
+
crochetProductWithTiers,
|
|
228
|
+
);
|
|
229
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
230
|
+
expect(screen.getByRole("radio", { name: /annual/i }).getAttribute("aria-checked")).toBe("true");
|
|
231
|
+
const cta = screen.getByTestId("detail-cta-crochet-pro-annual") as HTMLButtonElement;
|
|
232
|
+
expect(cta.textContent).toMatch(/^current plan$/i);
|
|
233
|
+
expect(cta.disabled).toBe(true);
|
|
234
|
+
});
|
|
235
|
+
|
|
236
|
+
it("opens on Annual once a slow plan load reveals an annual subscription", async () => {
|
|
237
|
+
const { rerender } = renderWith(
|
|
238
|
+
<PricingDetailPage productId="crochet-patterns" currentPlan={{ signedIn: true, subscription: null, loading: true }} />,
|
|
239
|
+
crochetProductWithTiers,
|
|
240
|
+
);
|
|
241
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
242
|
+
expect(screen.getByRole("radio", { name: /monthly/i }).getAttribute("aria-checked")).toBe("true");
|
|
243
|
+
rerender(
|
|
244
|
+
<BillingProvider apiBaseUrl="https://api.test/api/v1" fetcher={crochetFetcher(crochetProductWithTiers, null)}>
|
|
245
|
+
<PricingDetailPage
|
|
246
|
+
productId="crochet-patterns"
|
|
247
|
+
currentPlan={{ signedIn: true, subscription: subscriptionTo(crochetLiteAnnual) }}
|
|
248
|
+
/>
|
|
249
|
+
</BillingProvider>,
|
|
250
|
+
);
|
|
251
|
+
await waitFor(() =>
|
|
252
|
+
expect(screen.getByRole("radio", { name: /annual/i }).getAttribute("aria-checked")).toBe("true"),
|
|
253
|
+
);
|
|
254
|
+
});
|
|
255
|
+
|
|
256
|
+
it("signed in on Pro: Pro is current, Free is not", async () => {
|
|
257
|
+
renderWith(
|
|
258
|
+
<PricingDetailPage
|
|
259
|
+
productId="crochet-patterns"
|
|
260
|
+
currentPlan={{ signedIn: true, subscription: subscriptionTo(crochetPro) }}
|
|
261
|
+
/>,
|
|
262
|
+
);
|
|
263
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
264
|
+
expect((screen.getByTestId("detail-cta-crochet-pro") as HTMLButtonElement).disabled).toBe(true);
|
|
265
|
+
expect((screen.getByTestId("detail-cta-crochet-free") as HTMLButtonElement).disabled).toBe(false);
|
|
266
|
+
});
|
|
267
|
+
});
|
|
268
|
+
|
|
269
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
270
|
+
// Home-page section, standalone pricing page, upgrade modal
|
|
271
|
+
// ─────────────────────────────────────────────────────────────────────────────
|
|
272
|
+
describe("PricingSection plan picker (crochet catalogue)", () => {
|
|
273
|
+
it("toggle, prices, labels, current plan", async () => {
|
|
274
|
+
renderWith(
|
|
275
|
+
<PricingSection productId="crochet-patterns" maxFeatures={10} currentPlan={{ signedIn: true, subscription: null }} />,
|
|
276
|
+
);
|
|
277
|
+
await waitFor(() => expect(screen.getByTestId("pricing-section")).toBeDefined());
|
|
278
|
+
expect(screen.queryByTestId("section-card-crochet-lite-annual")).toBeNull();
|
|
279
|
+
expect(text(screen.getByTestId("section-card-crochet-lite"))).toContain("$2.99 per month");
|
|
280
|
+
expect(within(screen.getByTestId("section-card-crochet-lite")).getByText("Up to 3 AI designs per month")).toBeDefined();
|
|
281
|
+
expect((screen.getByTestId("section-cta-crochet-free") as HTMLButtonElement).disabled).toBe(true);
|
|
282
|
+
await chooseAnnual();
|
|
283
|
+
expect(text(screen.getByTestId("section-card-crochet-lite-annual"))).toContain("$29 per year");
|
|
284
|
+
});
|
|
285
|
+
});
|
|
286
|
+
|
|
287
|
+
describe("PricingPage plan picker (crochet catalogue)", () => {
|
|
288
|
+
it("toggle, prices, labels, current plan", async () => {
|
|
289
|
+
renderWith(<PricingPage productId="crochet-patterns" currentPlan={{ signedIn: true, subscription: null }} />);
|
|
290
|
+
await waitFor(() => expect(screen.getByTestId("pricing-page")).toBeDefined());
|
|
291
|
+
expect(screen.queryByTestId("pricing-card-crochet-lite-annual")).toBeNull();
|
|
292
|
+
expect(text(screen.getByTestId("pricing-card-crochet-lite"))).toContain("$2.99 per month");
|
|
293
|
+
expect(within(screen.getByTestId("pricing-card-crochet-lite")).getByText("Unlimited saved patterns")).toBeDefined();
|
|
294
|
+
expect((screen.getByTestId("cta-crochet-free") as HTMLButtonElement).textContent).toMatch(/^current plan$/i);
|
|
295
|
+
await chooseAnnual();
|
|
296
|
+
expect(screen.getByTestId("pricing-card-crochet-lite-annual")).toBeDefined();
|
|
297
|
+
});
|
|
298
|
+
});
|
|
299
|
+
|
|
300
|
+
describe("UpgradeModal plan picker (crochet catalogue)", () => {
|
|
301
|
+
it("toggle, prices, labels, current plan", async () => {
|
|
302
|
+
const onSelect = vi.fn();
|
|
303
|
+
renderWith(
|
|
304
|
+
<UpgradeModal
|
|
305
|
+
productId="crochet-patterns"
|
|
306
|
+
open
|
|
307
|
+
onClose={() => {}}
|
|
308
|
+
onSelectOffer={onSelect}
|
|
309
|
+
currentPlan={{ signedIn: true, subscription: null }}
|
|
310
|
+
/>,
|
|
311
|
+
);
|
|
312
|
+
await waitFor(() => expect(screen.getByTestId("upgrade-offers")).toBeDefined());
|
|
313
|
+
expect(screen.queryByTestId("upgrade-offer-crochet-lite-annual")).toBeNull();
|
|
314
|
+
expect(text(screen.getByTestId("upgrade-offer-crochet-lite"))).toContain("$2.99 per month");
|
|
315
|
+
expect(within(screen.getByTestId("upgrade-offer-crochet-lite")).getByText("Up to 3 AI designs per month")).toBeDefined();
|
|
316
|
+
const free = screen.getByTestId("upgrade-cta-crochet-free") as HTMLButtonElement;
|
|
317
|
+
expect(free.textContent).toMatch(/^current plan$/i);
|
|
318
|
+
expect(free.disabled).toBe(true);
|
|
319
|
+
await chooseAnnual();
|
|
320
|
+
expect(text(screen.getByTestId("upgrade-offer-crochet-lite-annual"))).toContain("$29 per year");
|
|
321
|
+
});
|
|
322
|
+
});
|