@startsimpli/billing 0.4.35 → 0.4.39

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.
@@ -1,5 +1,5 @@
1
1
  import { Button } from "@startsimpli/ui";
2
- import { formatPackPrice, pricePerCredit, savingsPercent } from "../utils/credit-math";
2
+ import { formatPackPrice, formatSavingsAmount, packSavings } from "../utils/credit-math";
3
3
  import type { CreditsCopy } from "./copy";
4
4
  import type { PatternCreditOffer } from "./types";
5
5
 
@@ -11,14 +11,35 @@ export interface CreditPackCardProps {
11
11
  onBuy: (offerId: string) => void;
12
12
  }
13
13
 
14
- /** One pack: its price, the struck-through price and saving when the offer has one, and a Buy button. */
14
+ /**
15
+ * Sash colours. White on green-800 (#166534) is 7.1:1, above WCAG AA 4.5:1;
16
+ * a test computes the ratio from these classes.
17
+ */
18
+ export const SAVINGS_SASH_TOKENS = { bg: "bg-green-800", text: "text-white" } as const;
19
+
20
+ /**
21
+ * One pack: its price, the struck-through price, a corner sash and a dollar
22
+ * saving line on multi-pattern packs, and a Buy button. Server savings fields
23
+ * win; compareAt math is the fallback.
24
+ */
15
25
  export function CreditPackCard({ offer, copy, buying, onBuy }: CreditPackCardProps) {
16
- const saving = savingsPercent(offer);
26
+ const savings = packSavings(offer);
27
+ const multi = offer.credits > 1;
28
+ const showSash = multi && savings.percent !== null;
29
+ const struck = !!offer.compareAt && Number(offer.compareAt) > Number(offer.price);
17
30
  const name = copy.packName(offer.credits);
18
31
  const opening = buying === offer.id;
19
32
  return (
20
- <li className={`flex flex-col rounded-xl border p-4 ${offer.featured ? "border-primary bg-primary/5" : "border-border bg-card"}`}>
21
- <div className="flex items-start justify-between gap-2">
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"}`}>
34
+ {showSash && (
35
+ <span
36
+ data-testid="savings-sash"
37
+ className={`pointer-events-none absolute right-[-2.75rem] top-4 w-36 rotate-45 whitespace-nowrap py-1 text-center text-xs font-semibold shadow-sm ${SAVINGS_SASH_TOKENS.bg} ${SAVINGS_SASH_TOKENS.text}`}
38
+ >
39
+ {copy.savingsSash(savings.percent as number)}
40
+ </span>
41
+ )}
42
+ <div className={`flex flex-wrap items-center gap-2 ${showSash ? "pr-14" : ""}`}>
22
43
  <p className="text-base font-semibold text-foreground">{name}</p>
23
44
  {offer.featured && (
24
45
  <span className="rounded-full bg-primary px-2 py-0.5 text-xs font-semibold text-primary-foreground">{copy.featured}</span>
@@ -26,16 +47,18 @@ export function CreditPackCard({ offer, copy, buying, onBuy }: CreditPackCardPro
26
47
  </div>
27
48
  <p className="mt-3 flex flex-wrap items-baseline gap-x-2">
28
49
  <span className="text-2xl font-bold text-foreground">{formatPackPrice(offer.price, offer.currency)}</span>
29
- {saving !== null && (
50
+ {struck && (
30
51
  <s className="text-sm text-muted-foreground" style={{ textDecoration: "line-through" }}>
31
52
  {formatPackPrice(offer.compareAt, offer.currency)}
32
53
  </s>
33
54
  )}
34
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
+ )}
35
59
  <p className="mt-1 min-h-[1.25rem] text-sm text-muted-foreground">
36
- {saving !== null && <span className="font-medium text-green-700">{copy.save(saving)}</span>}
37
- {saving !== null && offer.credits > 1 && " · "}
38
- {offer.credits > 1 && copy.perCredit(formatPackPrice(pricePerCredit(offer), offer.currency))}
60
+ {!multi && savings.percent !== null && <span className="font-medium text-green-700">{copy.save(savings.percent)}</span>}
61
+ {multi && copy.perCredit(formatPackPrice(savings.perPattern, offer.currency))}
39
62
  </p>
40
63
  <Button
41
64
  className="mt-4 w-full"
@@ -0,0 +1,26 @@
1
+ import { CreditPackCard } from "./CreditPackCard";
2
+ import { withCreditsCopy, type CreditsCopy } from "./copy";
3
+ import type { PatternCreditOffer } from "./types";
4
+
5
+ export interface CreditPackGridProps {
6
+ offers: PatternCreditOffer[];
7
+ /** Called with the offer id. The caller owns what buying means (checkout, or sign-in first for a visitor). */
8
+ onBuy: (offerId: string) => void;
9
+ /** Offer id being opened in checkout, if any. */
10
+ buying?: string | null;
11
+ copy?: Partial<CreditsCopy>;
12
+ className?: string;
13
+ }
14
+
15
+ /** The pack cards on their own: no hook, no balance, no history. For /pricing and home sections. */
16
+ export function CreditPackGrid({ offers, onBuy, buying = null, copy: copyIn, className }: CreditPackGridProps) {
17
+ if (offers.length === 0) return null;
18
+ const copy = withCreditsCopy(copyIn);
19
+ return (
20
+ <ul className={className ?? "grid gap-3 sm:grid-cols-2 lg:grid-cols-3"}>
21
+ {offers.map((offer) => (
22
+ <CreditPackCard key={offer.id} offer={offer} copy={copy} buying={buying} onBuy={onBuy} />
23
+ ))}
24
+ </ul>
25
+ );
26
+ }
@@ -3,7 +3,7 @@
3
3
  import { Button } from "@startsimpli/ui";
4
4
  import { CreditBalanceBadge } from "./CreditBalanceBadge";
5
5
  import { CreditHistoryList } from "./CreditHistoryList";
6
- import { CreditPackCard } from "./CreditPackCard";
6
+ import { CreditPackGrid } from "./CreditPackGrid";
7
7
  import { withCreditsCopy, type CreditsCopy } from "./copy";
8
8
  import type { CreditsView } from "./types";
9
9
 
@@ -45,11 +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
- <ul className="mb-2 grid gap-3 sm:grid-cols-2 lg:grid-cols-3">
49
- {credits.offers.map((offer) => (
50
- <CreditPackCard key={offer.id} offer={offer} copy={copy} buying={credits.buying} onBuy={(id) => void credits.buy(id)} />
51
- ))}
52
- </ul>
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)} />
53
49
  <p className="mb-6 text-xs text-muted-foreground">{copy.secure}</p>
54
50
  <h4 className="mb-2 text-sm font-semibold text-foreground">{copy.historyTitle}</h4>
55
51
  <CreditHistoryList history={credits.history} copy={copy} />
@@ -14,6 +14,10 @@ export interface CreditsCopy {
14
14
  packName: (credits: number) => string;
15
15
  perCredit: (price: string) => string;
16
16
  save: (pct: number) => string;
17
+ /** Corner sash on a multi-pattern pack. Defaults to `save` when only that is overridden. */
18
+ savingsSash: (percent: number) => string;
19
+ /** Dollar saving under the price; `amount` is already formatted ("$4.96"). */
20
+ savingsLine: (amount: string) => string;
17
21
  featured: string;
18
22
  buy: (name: string) => string;
19
23
  buying: string;
@@ -57,6 +61,8 @@ export const defaultCreditsCopy: CreditsCopy = {
57
61
  packName: (c) => `${c} ${plural(c, "credit", "credits")}`,
58
62
  perCredit: (p) => `${p} each`,
59
63
  save: (pct) => `Save ${pct}%`,
64
+ savingsSash: (percent) => `Save ${percent}%`,
65
+ savingsLine: (amount) => `Save ${amount}`,
60
66
  featured: "Best value",
61
67
  buy: (name) => `Buy ${name}`,
62
68
  buying: "Opening checkout…",
@@ -93,4 +99,9 @@ export const defaultCreditsCopy: CreditsCopy = {
93
99
  planAllowanceUsed: "Your plan’s allowance for this month is used. You can use a credit instead.",
94
100
  };
95
101
 
96
- export const withCreditsCopy = (copy?: Partial<CreditsCopy>): CreditsCopy => ({ ...defaultCreditsCopy, ...copy });
102
+ export const withCreditsCopy = (copy?: Partial<CreditsCopy>): CreditsCopy => ({
103
+ ...defaultCreditsCopy,
104
+ // An app that only overrides `save` keeps its wording on the sash.
105
+ ...(copy?.save && !copy.savingsSash ? { savingsSash: copy.save } : {}),
106
+ ...copy,
107
+ });
@@ -0,0 +1,26 @@
1
+ import React from "react";
2
+ import { render, screen } from "@testing-library/react";
3
+ import { describe, expect, it } from "vitest";
4
+ import { CreditHistoryList } from "./CreditHistoryList";
5
+ import { defaultCreditsCopy } from "./copy";
6
+
7
+ const entry = (over = {}) => ({ id: "h1", entryType: "grant", quantity: 5, externalRef: "", reason: "", createdAt: "2026-11-01T00:00:00Z", ...over }) as never;
8
+
9
+ describe("CreditHistoryList label", () => {
10
+ it("renders the server label when given", () => {
11
+ render(<CreditHistoryList copy={defaultCreditsCopy} history={[entry({ label: "Added 5 patterns" }), entry({ id: "h2", entryType: "commit", quantity: -1, label: "Used 1 pattern" })]} />);
12
+ expect(screen.getByText("Added 5 patterns")).toBeTruthy();
13
+ expect(screen.getByText("Used 1 pattern")).toBeTruthy();
14
+ expect(screen.queryByText("Bought")).toBeNull();
15
+ });
16
+ it("falls back to the old rendering without a label", () => {
17
+ render(<CreditHistoryList copy={defaultCreditsCopy} history={[entry(), entry({ id: "h2", entryType: "grant", reason: "Welcome gift" }), entry({ id: "h3", entryType: "commit", quantity: -1 })]} />);
18
+ expect(screen.getByText("Bought")).toBeTruthy();
19
+ expect(screen.getByText("Welcome gift")).toBeTruthy();
20
+ expect(screen.getByText("Used")).toBeTruthy();
21
+ });
22
+ it("ignores an empty label", () => {
23
+ render(<CreditHistoryList copy={defaultCreditsCopy} history={[entry({ label: "" })]} />);
24
+ expect(screen.getByText("Bought")).toBeTruthy();
25
+ });
26
+ });
@@ -1,7 +1,9 @@
1
1
  export { CreditsPanel } from "./CreditsPanel";
2
2
  export type { CreditsPanelProps } from "./CreditsPanel";
3
- export { CreditPackCard } from "./CreditPackCard";
3
+ export { CreditPackCard, SAVINGS_SASH_TOKENS } from "./CreditPackCard";
4
4
  export type { CreditPackCardProps } from "./CreditPackCard";
5
+ export { CreditPackGrid } from "./CreditPackGrid";
6
+ export type { CreditPackGridProps } from "./CreditPackGrid";
5
7
  export { CreditBalanceBadge } from "./CreditBalanceBadge";
6
8
  export { CreditHistoryList } from "./CreditHistoryList";
7
9
  export { UseCreditPrompt } from "./UseCreditPrompt";
package/src/index.ts CHANGED
@@ -43,6 +43,10 @@ export type {
43
43
  UsageMap,
44
44
  } from "./types";
45
45
 
46
+ // Copy / label overrides
47
+ export { defaultBillingCopy, withBillingCopy, defaultPlanChangeCopy, featureText, resolveFeatureCopy } from "./copy";
48
+ export type { BillingCopy, BillingCopyInput, PlanChangeCopy, FeatureCopy, FeatureCopyEntry, FeatureText } from "./copy";
49
+
46
50
  // Components
47
51
  export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate, CurrentPlanCard, LimitNudge, PlanChangeConfirm, planChangeCopy, UsageMeters } from "./components";
48
52
  export type {
@@ -96,7 +100,7 @@ export {
96
100
  statusMessageFrom,
97
101
  } from "./utils/return-urls";
98
102
  export type { CreditReturnPaths, PlanStatusMessage } from "./utils/return-urls";
99
- export { formatPackPrice, pricePerCredit, savingsPercent } from "./utils/credit-math";
100
- export type { PackPrice } from "./utils/credit-math";
103
+ export { formatPackPrice, formatSavingsAmount, packSavings, pricePerCredit, savingsPercent } from "./utils/credit-math";
104
+ export type { PackPrice, PackSavings, PackSavingsInput } from "./utils/credit-math";
101
105
  export { meterRows } from "./utils/usage-meters";
102
106
  export type { MeterRow, MeterState } from "./utils/usage-meters";
@@ -0,0 +1,29 @@
1
+ import { describe, expect, it } from "vitest";
2
+ import { formatSavingsAmount, packSavings } from "./credit-math";
3
+
4
+ const base = { price: "19.99", compareAt: "24.95", credits: 5, currency: "usd" };
5
+
6
+ describe("packSavings", () => {
7
+ it("prefers the server's values", () => {
8
+ expect(packSavings({ ...base, savingsPercent: 21, savingsAmount: 500, pricePerPattern: "3.99" })).toEqual({
9
+ percent: 21, amountCents: 500, perPattern: "3.99",
10
+ });
11
+ });
12
+ it("accepts save_percent and per_pattern_price spellings", () => {
13
+ expect(packSavings({ ...base, savePercent: 22, perPatternPrice: "3.98" })).toMatchObject({ percent: 22, perPattern: "3.98" });
14
+ });
15
+ it("falls back to compareAt math when the server sends nothing", () => {
16
+ expect(packSavings(base)).toEqual({ percent: 20, amountCents: 496, perPattern: "4.00" });
17
+ });
18
+ it("derives the percent from a server amount when there is no compareAt", () => {
19
+ expect(packSavings({ price: "19.99", compareAt: "", credits: 5, savingsAmount: 496 })).toMatchObject({ percent: 20, amountCents: 496 });
20
+ });
21
+ it("reports no saving when the server says zero or there is none", () => {
22
+ expect(packSavings({ ...base, savingsPercent: 0, savingsAmount: 0 })).toMatchObject({ percent: null, amountCents: null });
23
+ expect(packSavings({ price: "4.99", compareAt: "", credits: 1 })).toMatchObject({ percent: null, amountCents: null });
24
+ });
25
+ it("formats cents as a currency amount", () => {
26
+ expect(formatSavingsAmount(496, "usd")).toBe("$4.96");
27
+ expect(formatSavingsAmount(2486, "usd")).toBe("$24.86");
28
+ });
29
+ });
@@ -30,3 +30,53 @@ export function pricePerCredit(offer: Pick<PackPrice, "price" | "credits">): str
30
30
  const total = Number(offer.price);
31
31
  return offer.credits > 0 && Number.isFinite(total) ? (total / offer.credits).toFixed(2) : offer.price;
32
32
  }
33
+
34
+ /** The server's savings fields on an offer (all optional: older backends send none). */
35
+ export interface PackSavingsInput extends PackPrice {
36
+ savingsPercent?: number;
37
+ savePercent?: number;
38
+ savingsAmount?: number;
39
+ pricePerPattern?: string;
40
+ perPatternPrice?: string;
41
+ }
42
+
43
+ export interface PackSavings {
44
+ /** Whole percent saved, or null when the pack saves nothing. */
45
+ percent: number | null;
46
+ /** Integer cents saved, or null when the pack saves nothing. */
47
+ amountCents: number | null;
48
+ /** Price of one pattern, decimal string. */
49
+ perPattern: string;
50
+ }
51
+
52
+ const positiveNumber = (v: unknown): number | null =>
53
+ typeof v === "number" && Number.isFinite(v) && v > 0 ? v : null;
54
+
55
+ /**
56
+ * Savings of a pack. Server values win when present (a server 0 means "no
57
+ * saving"); otherwise derived from compareAt so older backends still work.
58
+ */
59
+ export function packSavings(offer: PackSavingsInput): PackSavings {
60
+ const price = Number(offer.price);
61
+ const was = Number(offer.compareAt);
62
+ const hasCompare = !!offer.compareAt && Number.isFinite(price) && Number.isFinite(was) && was > price && was > 0;
63
+
64
+ let amountCents: number | null;
65
+ if (typeof offer.savingsAmount === "number") amountCents = positiveNumber(Math.round(offer.savingsAmount));
66
+ else amountCents = hasCompare ? positiveNumber(Math.round((was - price) * 100)) : null;
67
+
68
+ const serverPercent = typeof offer.savingsPercent === "number" ? offer.savingsPercent : offer.savePercent;
69
+ let percent: number | null;
70
+ if (typeof serverPercent === "number") percent = positiveNumber(Math.round(serverPercent));
71
+ else if (hasCompare) percent = positiveNumber(savingsPercent(offer));
72
+ else if (amountCents !== null && Number.isFinite(price) && price > 0) {
73
+ percent = positiveNumber(Math.round((amountCents / (price * 100 + amountCents)) * 100));
74
+ } else percent = null;
75
+
76
+ return { percent, amountCents, perPattern: offer.pricePerPattern || offer.perPatternPrice || pricePerCredit(offer) };
77
+ }
78
+
79
+ /** "$4.96" from integer cents. */
80
+ export function formatSavingsAmount(cents: number, currency: string): string {
81
+ return formatPackPrice((cents / 100).toFixed(2), currency);
82
+ }
package/src/utils/plan.ts CHANGED
@@ -8,6 +8,7 @@
8
8
  */
9
9
 
10
10
  import type { OfferFeature, ProductOffer, SubscriptionInfo } from "../types";
11
+ import { defaultBillingCopy, type BillingCopy } from "../copy/billing-copy";
11
12
 
12
13
  const PAID_STATUSES: ReadonlyArray<SubscriptionInfo["status"]> = ["active", "trialing"];
13
14
 
@@ -311,10 +312,13 @@ export function trialApplies(card: ProductOffer, direction: PlanDirection, payin
311
312
  * "7-day free trial", or "7-day trial · $1 today" when starting it charges a
312
313
  * card-check fee (then it is not free, so it must not say so).
313
314
  */
314
- export function trialLabel(offer: Pick<ProductOffer, "trialDays" | "trialFeeCents" | "currency">): string {
315
+ export function trialLabel(
316
+ offer: Pick<ProductOffer, "trialDays" | "trialFeeCents" | "currency">,
317
+ copy: Pick<BillingCopy, "trialFree" | "trialWithFee"> = defaultBillingCopy,
318
+ ): string {
315
319
  const days = offer.trialDays ?? 0;
316
320
  const cents = offer.trialFeeCents ?? 0;
317
- if (cents <= 0) return `${days}-day free trial`;
321
+ if (cents <= 0) return copy.trialFree(days);
318
322
  const amount = cents / 100;
319
323
  let fee: string;
320
324
  try {
@@ -326,7 +330,7 @@ export function trialLabel(offer: Pick<ProductOffer, "trialDays" | "trialFeeCent
326
330
  } catch {
327
331
  fee = `$${amount}`;
328
332
  }
329
- return `${days}-day trial · ${fee} today`;
333
+ return copy.trialWithFee(days, fee);
330
334
  }
331
335
 
332
336
  /**
@@ -334,12 +338,17 @@ export function trialLabel(offer: Pick<ProductOffer, "trialDays" | "trialFeeCent
334
338
  * new buyer; someone already paying is upgraded in place, so they get
335
339
  * "Upgrade to X" (startsim-21f, crochet_patterns-anw61).
336
340
  */
337
- export function planCtaLabel(card: ProductOffer, direction: PlanDirection, paying = false): string {
338
- if (direction === "current") return "Current plan";
339
- if (direction === "switch") return card.intervalUnit === "year" ? "Switch to annual" : "Switch to monthly";
340
- if (direction === "downgrade") return `Downgrade to ${card.name}`;
341
- if (paying) return `Upgrade to ${card.name}`;
342
- return card.ctaText || `Upgrade to ${card.name}`;
341
+ export function planCtaLabel(
342
+ card: ProductOffer,
343
+ direction: PlanDirection,
344
+ paying = false,
345
+ copy: Pick<BillingCopy, "currentPlan" | "switchToAnnual" | "switchToMonthly" | "downgradeTo" | "upgradeTo"> = defaultBillingCopy,
346
+ ): string {
347
+ if (direction === "current") return copy.currentPlan;
348
+ if (direction === "switch") return card.intervalUnit === "year" ? copy.switchToAnnual : copy.switchToMonthly;
349
+ if (direction === "downgrade") return copy.downgradeTo(card.name);
350
+ if (paying) return copy.upgradeTo(card.name);
351
+ return card.ctaText || copy.upgradeTo(card.name);
343
352
  }
344
353
 
345
354
  /**