@startsimpli/billing 0.4.39 → 0.4.42
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 +4 -4
- package/src/components/PricingDetailPage.tsx +10 -8
- package/src/components/PricingHeader.header.test.tsx +52 -0
- package/src/components/PricingHeader.tsx +16 -0
- package/src/components/PricingPage.tsx +9 -9
- package/src/credits/CreditPackCard.align.test.tsx +40 -0
- package/src/credits/CreditPackCard.tsx +10 -8
- package/src/credits/CreditPackGrid.tsx +1 -1
- package/src/credits/CreditsPanel.tsx +1 -1
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/billing",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.42",
|
|
4
4
|
"description": "Universal billing integration for StartSimpli Next.js apps",
|
|
5
5
|
"license": "MIT",
|
|
6
6
|
"homepage": "https://startsimpli.com",
|
|
@@ -35,8 +35,8 @@
|
|
|
35
35
|
"next": ">=14.0.0",
|
|
36
36
|
"react": "^18.0.0 || ^19.0.0",
|
|
37
37
|
"react-dom": "^18.0.0 || ^19.0.0",
|
|
38
|
-
"@startsimpli/
|
|
39
|
-
"@startsimpli/
|
|
38
|
+
"@startsimpli/ui": "^0.4.179",
|
|
39
|
+
"@startsimpli/api": "^0.5.77"
|
|
40
40
|
},
|
|
41
41
|
"peerDependenciesMeta": {
|
|
42
42
|
"@startsimpli/api": {
|
|
@@ -62,7 +62,7 @@
|
|
|
62
62
|
"typescript": "^6.0.3",
|
|
63
63
|
"vitest": "^4.1.5",
|
|
64
64
|
"@startsimpli/api": "0.5.77",
|
|
65
|
-
"@startsimpli/ui": "0.4.
|
|
65
|
+
"@startsimpli/ui": "0.4.179"
|
|
66
66
|
},
|
|
67
67
|
"keywords": [
|
|
68
68
|
"billing",
|
|
@@ -14,6 +14,7 @@ import type { PlanChangePreview, ProductOffer, OfferFeature } from "../types";
|
|
|
14
14
|
import { cardFeatures, cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
|
|
15
15
|
import { resolveFeatureCopy, withBillingCopy, type BillingCopy, type BillingCopyInput, type FeatureCopy } from "../copy";
|
|
16
16
|
import { FeatureLabel } from "./FeatureLabel";
|
|
17
|
+
import { PricingHeader, type PricingHeadingLevel } from "./PricingHeader";
|
|
17
18
|
import {
|
|
18
19
|
isPayingSubscription,
|
|
19
20
|
isUnlistedOffer,
|
|
@@ -57,6 +58,12 @@ export interface PricingDetailPageProps {
|
|
|
57
58
|
/** Line under the heading; defaults to the product description. */
|
|
58
59
|
subtitle?: React.ReactNode;
|
|
59
60
|
|
|
61
|
+
/** Heading element for the title (default "h1"); use "h2" when the host page has its own h1. */
|
|
62
|
+
headingLevel?: PricingHeadingLevel;
|
|
63
|
+
|
|
64
|
+
/** Render no title/subtitle block at all (default false). */
|
|
65
|
+
hideHeader?: boolean;
|
|
66
|
+
|
|
60
67
|
/** Wording overrides (trial phrasing, buttons, headings…); defaults are the original strings. */
|
|
61
68
|
copy?: BillingCopyInput;
|
|
62
69
|
|
|
@@ -72,6 +79,8 @@ export function PricingDetailPage({
|
|
|
72
79
|
busy: hostBusy = false,
|
|
73
80
|
title,
|
|
74
81
|
subtitle,
|
|
82
|
+
headingLevel = "h1",
|
|
83
|
+
hideHeader = false,
|
|
75
84
|
copy: copyOverride,
|
|
76
85
|
featureCopy,
|
|
77
86
|
}: PricingDetailPageProps) {
|
|
@@ -174,14 +183,7 @@ export function PricingDetailPage({
|
|
|
174
183
|
|
|
175
184
|
return (
|
|
176
185
|
<div data-testid="pricing-detail" className="max-w-6xl mx-auto px-6 py-12">
|
|
177
|
-
<
|
|
178
|
-
<h1 className="text-4xl font-extrabold tracking-tight text-gray-900 mb-3">
|
|
179
|
-
{title ?? product.name}
|
|
180
|
-
</h1>
|
|
181
|
-
<p className="text-lg text-gray-500 max-w-xl mx-auto">
|
|
182
|
-
{subtitle ?? product.description}
|
|
183
|
-
</p>
|
|
184
|
-
</div>
|
|
186
|
+
{!hideHeader && <PricingHeader title={title ?? product.name} subtitle={subtitle ?? product.description} headingLevel={headingLevel} />}
|
|
185
187
|
|
|
186
188
|
{onUnlistedPlan && currentOffer && (
|
|
187
189
|
<div
|
|
@@ -0,0 +1,52 @@
|
|
|
1
|
+
import { describe, expect, it, vi } from "vitest";
|
|
2
|
+
import { render, screen, waitFor } from "@testing-library/react";
|
|
3
|
+
import React from "react";
|
|
4
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
5
|
+
import { PricingDetailPage } from "./PricingDetailPage";
|
|
6
|
+
import { PricingPage } from "./PricingPage";
|
|
7
|
+
|
|
8
|
+
const product = {
|
|
9
|
+
id: "p", slug: "prod", name: "Prod Name", description: "Prod description",
|
|
10
|
+
offers: [{
|
|
11
|
+
id: "o1", name: "Free", slug: "free", unitPrice: "0.00", currency: "USD", pricingModel: "flat",
|
|
12
|
+
intervalUnit: "month", intervalCount: 1, intervalDisplay: "per month", isRecurring: true, isFree: true,
|
|
13
|
+
features: [], isActive: true, isFeatured: false, trialDays: 0, sortOrder: 0, ctaText: "Start",
|
|
14
|
+
}],
|
|
15
|
+
};
|
|
16
|
+
const fetcher = vi.fn().mockResolvedValue({ ok: true, json: () => Promise.resolve(product) }) as unknown as typeof fetch;
|
|
17
|
+
const wrap = (el: React.ReactElement) => <BillingProvider apiBaseUrl="https://api.test/api/v1" fetcher={fetcher}>{el}</BillingProvider>;
|
|
18
|
+
|
|
19
|
+
const pages = [
|
|
20
|
+
["PricingDetailPage", "pricing-detail", (p: object) => <PricingDetailPage productId="prod" {...p} />],
|
|
21
|
+
["PricingPage", "pricing-page", (p: object) => <PricingPage productId="prod" onSelectOffer={() => {}} {...p} />],
|
|
22
|
+
] as const;
|
|
23
|
+
|
|
24
|
+
describe.each(pages)("%s header", (_n, testId, make) => {
|
|
25
|
+
const mount = async (props = {}) => {
|
|
26
|
+
const r = render(wrap(make(props)));
|
|
27
|
+
await waitFor(() => expect(screen.getByTestId(testId)).toBeDefined());
|
|
28
|
+
return r;
|
|
29
|
+
};
|
|
30
|
+
it("draws the h1 title and description by default", async () => {
|
|
31
|
+
await mount();
|
|
32
|
+
const h1 = screen.getByRole("heading", { level: 1 });
|
|
33
|
+
expect(h1.textContent).toBe("Prod Name");
|
|
34
|
+
expect(h1.className).toContain("text-4xl");
|
|
35
|
+
expect(screen.getByText("Prod description")).toBeTruthy();
|
|
36
|
+
});
|
|
37
|
+
it("renders the title as h2 with headingLevel='h2' and no h1", async () => {
|
|
38
|
+
await mount({ headingLevel: "h2" });
|
|
39
|
+
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
|
40
|
+
expect(screen.getByRole("heading", { level: 2, name: "Prod Name" }).className).toContain("text-4xl");
|
|
41
|
+
});
|
|
42
|
+
it("renders h3 with headingLevel='h3'", async () => {
|
|
43
|
+
await mount({ headingLevel: "h3" });
|
|
44
|
+
expect(screen.getByRole("heading", { level: 3, name: "Prod Name" })).toBeTruthy();
|
|
45
|
+
});
|
|
46
|
+
it("removes the whole header with hideHeader", async () => {
|
|
47
|
+
await mount({ hideHeader: true });
|
|
48
|
+
expect(screen.queryByText("Prod Name")).toBeNull();
|
|
49
|
+
expect(screen.queryByText("Prod description")).toBeNull();
|
|
50
|
+
expect(screen.queryByRole("heading", { level: 1 })).toBeNull();
|
|
51
|
+
});
|
|
52
|
+
});
|
|
@@ -0,0 +1,16 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
|
|
3
|
+
export type PricingHeadingLevel = "h1" | "h2" | "h3";
|
|
4
|
+
|
|
5
|
+
/** Title and subtitle block at the top of a pricing page. */
|
|
6
|
+
export function PricingHeader({
|
|
7
|
+
title, subtitle, headingLevel = "h1",
|
|
8
|
+
}: { title: React.ReactNode; subtitle: React.ReactNode; headingLevel?: PricingHeadingLevel }) {
|
|
9
|
+
const Heading = headingLevel;
|
|
10
|
+
return (
|
|
11
|
+
<div className="text-center mb-12">
|
|
12
|
+
<Heading className="text-4xl font-extrabold tracking-tight text-gray-900 mb-3">{title}</Heading>
|
|
13
|
+
<p className="text-lg text-gray-500 max-w-xl mx-auto">{subtitle}</p>
|
|
14
|
+
</div>
|
|
15
|
+
);
|
|
16
|
+
}
|
|
@@ -14,6 +14,7 @@ import type { ProductOffer } from "../types";
|
|
|
14
14
|
import { FeatureIcon } from "./FeatureIcon";
|
|
15
15
|
import { withBillingCopy, type BillingCopy, type BillingCopyInput, type FeatureCopy } from "../copy";
|
|
16
16
|
import { FeatureLabel } from "./FeatureLabel";
|
|
17
|
+
import { PricingHeader, type PricingHeadingLevel } from "./PricingHeader";
|
|
17
18
|
import { resolveCurrentOfferId, trialLabel, type CurrentPlanState } from "../utils/plan";
|
|
18
19
|
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
|
|
19
20
|
|
|
@@ -28,6 +29,12 @@ export interface PricingPageProps {
|
|
|
28
29
|
* Omit on signed-out pages. crochet_patterns-34n7. */
|
|
29
30
|
currentPlan?: CurrentPlanState | null;
|
|
30
31
|
|
|
32
|
+
/** Heading element for the title (default "h1"); use "h2" when the host page has its own h1. */
|
|
33
|
+
headingLevel?: PricingHeadingLevel;
|
|
34
|
+
|
|
35
|
+
/** Render no title/subtitle block at all (default false). */
|
|
36
|
+
hideHeader?: boolean;
|
|
37
|
+
|
|
31
38
|
/** Wording overrides (trial phrasing, buttons, headings…); defaults are the original strings. */
|
|
32
39
|
copy?: BillingCopyInput;
|
|
33
40
|
|
|
@@ -35,7 +42,7 @@ export interface PricingPageProps {
|
|
|
35
42
|
featureCopy?: FeatureCopy;
|
|
36
43
|
}
|
|
37
44
|
|
|
38
|
-
export function PricingPage({ productId, onSelectOffer, currentPlan, copy: copyOverride, featureCopy }: PricingPageProps) {
|
|
45
|
+
export function PricingPage({ productId, onSelectOffer, currentPlan, copy: copyOverride, featureCopy, headingLevel = "h1", hideHeader = false }: PricingPageProps) {
|
|
39
46
|
const copy = withBillingCopy(copyOverride);
|
|
40
47
|
const { product, loading, error } = useProduct(productId);
|
|
41
48
|
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
@@ -71,14 +78,7 @@ export function PricingPage({ productId, onSelectOffer, currentPlan, copy: copyO
|
|
|
71
78
|
|
|
72
79
|
return (
|
|
73
80
|
<div data-testid="pricing-page" className="max-w-5xl mx-auto px-6 py-12">
|
|
74
|
-
<
|
|
75
|
-
<h1 className="text-4xl font-extrabold tracking-tight text-gray-900 mb-3">
|
|
76
|
-
{product.name}
|
|
77
|
-
</h1>
|
|
78
|
-
<p className="text-lg text-gray-500 max-w-xl mx-auto">
|
|
79
|
-
{product.description}
|
|
80
|
-
</p>
|
|
81
|
-
</div>
|
|
81
|
+
{!hideHeader && <PricingHeader title={product.name} subtitle={product.description} headingLevel={headingLevel} />}
|
|
82
82
|
|
|
83
83
|
{hasChoice && (
|
|
84
84
|
<div className="mb-10">
|
|
@@ -0,0 +1,40 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it, vi } from "vitest";
|
|
4
|
+
import { CreditPackCard, CreditPackGrid } from "./index";
|
|
5
|
+
import { defaultCreditsCopy } from "./copy";
|
|
6
|
+
|
|
7
|
+
const single = { id: "p1", slug: "1", name: "1", credits: 1, price: "4.99", currency: "usd", compareAt: "", featured: false };
|
|
8
|
+
const five = { id: "p5", slug: "5", name: "5", credits: 5, price: "19.99", currency: "usd", compareAt: "24.95", featured: false, savingsPercent: 20, savingsAmount: 496 };
|
|
9
|
+
|
|
10
|
+
describe("pack card alignment", () => {
|
|
11
|
+
it("is a full-height flex column and pushes Buy to the bottom", () => {
|
|
12
|
+
render(<ul><CreditPackCard offer={single as never} copy={defaultCreditsCopy} buying={null} onBuy={vi.fn()} /></ul>);
|
|
13
|
+
const li = screen.getByRole("listitem");
|
|
14
|
+
expect(li.className).toMatch(/\bflex\b/);
|
|
15
|
+
expect(li.className).toContain("flex-col");
|
|
16
|
+
expect(li.className).toContain("h-full");
|
|
17
|
+
const wrap = screen.getByRole("button").parentElement as HTMLElement;
|
|
18
|
+
expect(wrap.className).toContain("mt-auto");
|
|
19
|
+
});
|
|
20
|
+
|
|
21
|
+
it("reserves the same savings-line slot on a single card as on a multi card", () => {
|
|
22
|
+
const { unmount } = render(<ul><CreditPackCard offer={single as never} copy={defaultCreditsCopy} buying={null} onBuy={vi.fn()} /></ul>);
|
|
23
|
+
const slotS = screen.getByTestId("savings-slot");
|
|
24
|
+
expect(slotS.textContent).toBe("");
|
|
25
|
+
expect(slotS.className).toContain("min-h-[1.25rem]");
|
|
26
|
+
unmount();
|
|
27
|
+
render(<ul><CreditPackCard offer={five as never} copy={defaultCreditsCopy} buying={null} onBuy={vi.fn()} /></ul>);
|
|
28
|
+
const slotM = screen.getByTestId("savings-slot");
|
|
29
|
+
expect(slotM.textContent).toBe("Save $4.96");
|
|
30
|
+
expect(slotM.className).toContain("min-h-[1.25rem]");
|
|
31
|
+
expect(screen.getByTestId("per-pattern-slot").className).toContain("min-h-[1.25rem]");
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("grid stretches its items", () => {
|
|
35
|
+
render(<CreditPackGrid offers={[single, five] as never} onBuy={vi.fn()} />);
|
|
36
|
+
const ul = screen.getAllByRole("listitem")[0].parentElement as HTMLElement;
|
|
37
|
+
expect(ul.className).toContain("items-stretch");
|
|
38
|
+
expect(ul.className).not.toContain("items-start");
|
|
39
|
+
});
|
|
40
|
+
});
|
|
@@ -30,7 +30,7 @@ export function CreditPackCard({ offer, copy, buying, onBuy }: CreditPackCardPro
|
|
|
30
30
|
const name = copy.packName(offer.credits);
|
|
31
31
|
const opening = buying === offer.id;
|
|
32
32
|
return (
|
|
33
|
-
<li className={`relative flex flex-col overflow-hidden rounded-xl border p-4 ${offer.featured ? "border-primary bg-primary/5" : "border-border bg-card"}`}>
|
|
33
|
+
<li className={`relative flex h-full flex-col overflow-hidden rounded-xl border p-4 ${offer.featured ? "border-primary bg-primary/5" : "border-border bg-card"}`}>
|
|
34
34
|
{showSash && (
|
|
35
35
|
<span
|
|
36
36
|
data-testid="savings-sash"
|
|
@@ -39,7 +39,7 @@ export function CreditPackCard({ offer, copy, buying, onBuy }: CreditPackCardPro
|
|
|
39
39
|
{copy.savingsSash(savings.percent as number)}
|
|
40
40
|
</span>
|
|
41
41
|
)}
|
|
42
|
-
<div className={`flex flex-wrap items-center gap-2 ${showSash ? "pr-14" : ""}`}>
|
|
42
|
+
<div className={`flex min-h-[1.75rem] flex-wrap items-center gap-2 ${showSash ? "pr-14" : ""}`}>
|
|
43
43
|
<p className="text-base font-semibold text-foreground">{name}</p>
|
|
44
44
|
{offer.featured && (
|
|
45
45
|
<span className="rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground">{copy.featured}</span>
|
|
@@ -53,15 +53,16 @@ export function CreditPackCard({ offer, copy, buying, onBuy }: CreditPackCardPro
|
|
|
53
53
|
</s>
|
|
54
54
|
)}
|
|
55
55
|
</p>
|
|
56
|
-
|
|
57
|
-
|
|
58
|
-
|
|
59
|
-
|
|
60
|
-
|
|
56
|
+
<p data-testid="savings-slot" className="mt-1 min-h-[1.25rem] text-sm font-medium text-green-700">
|
|
57
|
+
{multi && savings.amountCents !== null && copy.savingsLine(formatSavingsAmount(savings.amountCents, offer.currency))}
|
|
58
|
+
{!multi && savings.percent !== null && copy.save(savings.percent)}
|
|
59
|
+
</p>
|
|
60
|
+
<p data-testid="per-pattern-slot" className="mt-1 min-h-[1.25rem] text-sm text-muted-foreground">
|
|
61
61
|
{multi && copy.perCredit(formatPackPrice(savings.perPattern, offer.currency))}
|
|
62
62
|
</p>
|
|
63
|
+
<div className="mt-auto pt-4">
|
|
63
64
|
<Button
|
|
64
|
-
className="
|
|
65
|
+
className="w-full"
|
|
65
66
|
variant={offer.featured ? "default" : "outline"}
|
|
66
67
|
disabled={buying !== null}
|
|
67
68
|
onClick={() => onBuy(offer.id)}
|
|
@@ -69,6 +70,7 @@ export function CreditPackCard({ offer, copy, buying, onBuy }: CreditPackCardPro
|
|
|
69
70
|
>
|
|
70
71
|
{opening ? copy.buying : copy.buy(name)}
|
|
71
72
|
</Button>
|
|
73
|
+
</div>
|
|
72
74
|
</li>
|
|
73
75
|
);
|
|
74
76
|
}
|
|
@@ -17,7 +17,7 @@ export function CreditPackGrid({ offers, onBuy, buying = null, copy: copyIn, cla
|
|
|
17
17
|
if (offers.length === 0) return null;
|
|
18
18
|
const copy = withCreditsCopy(copyIn);
|
|
19
19
|
return (
|
|
20
|
-
<ul className={className ?? "grid gap-3 sm:grid-cols-2 lg:grid-cols-3"}>
|
|
20
|
+
<ul className={className ?? "grid items-stretch gap-3 sm:grid-cols-2 lg:grid-cols-3"}>
|
|
21
21
|
{offers.map((offer) => (
|
|
22
22
|
<CreditPackCard key={offer.id} offer={offer} copy={copy} buying={buying} onBuy={onBuy} />
|
|
23
23
|
))}
|
|
@@ -45,7 +45,7 @@ export function CreditsPanel({ credits, copy: copyIn, headingId = "credits-headi
|
|
|
45
45
|
{credits.error && (
|
|
46
46
|
<p role="alert" className="mb-4 rounded-lg border border-red-200 bg-red-50 p-3 text-sm text-red-700">{credits.error}</p>
|
|
47
47
|
)}
|
|
48
|
-
<CreditPackGrid className="mb-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3" offers={credits.offers} copy={copy} buying={credits.buying} onBuy={(id) => void credits.buy(id)} />
|
|
48
|
+
<CreditPackGrid className="mb-2 grid items-stretch gap-3 sm:grid-cols-2 lg:grid-cols-3" offers={credits.offers} copy={copy} buying={credits.buying} onBuy={(id) => void credits.buy(id)} />
|
|
49
49
|
<p className="mb-6 text-xs text-muted-foreground">{copy.secure}</p>
|
|
50
50
|
<h4 className="mb-2 text-sm font-semibold text-foreground">{copy.historyTitle}</h4>
|
|
51
51
|
<CreditHistoryList history={credits.history} copy={copy} />
|