@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/billing",
3
- "version": "0.4.39",
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/api": "^0.5.77",
39
- "@startsimpli/ui": "^0.4.178"
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.178"
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
- <div className="text-center mb-12">
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
- <div className="text-center mb-12">
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
- {multi && savings.amountCents !== null && (
57
- <p className="mt-1 text-sm font-medium text-green-700">{copy.savingsLine(formatSavingsAmount(savings.amountCents, offer.currency))}</p>
58
- )}
59
- <p className="mt-1 min-h-[1.25rem] text-sm text-muted-foreground">
60
- {!multi && savings.percent !== null && <span className="font-medium text-green-700">{copy.save(savings.percent)}</span>}
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="mt-4 w-full"
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} />