@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.
- package/package.json +5 -5
- package/src/components/CurrentPlanCard.tsx +40 -47
- package/src/components/FeatureLabel.tsx +18 -0
- package/src/components/IntervalToggle.tsx +7 -3
- package/src/components/PlanChangeConfirm.tsx +31 -46
- package/src/components/PricingDetailPage.tsx +50 -27
- package/src/components/PricingPage.tsx +23 -11
- package/src/components/PricingSection.tsx +24 -10
- package/src/components/SubscriptionManager.tsx +48 -42
- package/src/components/UpgradeModal.tsx +19 -8
- package/src/components/billing-copy.test.tsx +261 -0
- package/src/copy/billing-copy.test.ts +70 -0
- package/src/copy/billing-copy.ts +188 -0
- package/src/copy/feature-copy.test.tsx +48 -0
- package/src/copy/feature-copy.ts +64 -0
- package/src/copy/index.ts +6 -0
- package/src/copy/plan-change-copy.ts +66 -0
- package/src/credits/CreditHistoryList.tsx +2 -1
- package/src/credits/CreditPackCard.savings.test.tsx +136 -0
- package/src/credits/CreditPackCard.tsx +32 -9
- package/src/credits/CreditPackGrid.tsx +26 -0
- package/src/credits/CreditsPanel.tsx +2 -6
- package/src/credits/copy.ts +12 -1
- package/src/credits/credits.label.test.tsx +26 -0
- package/src/credits/index.ts +3 -1
- package/src/index.ts +6 -2
- package/src/utils/credit-math.savings.test.ts +29 -0
- package/src/utils/credit-math.ts +50 -0
- package/src/utils/plan.ts +18 -9
|
@@ -1,5 +1,5 @@
|
|
|
1
1
|
import { Button } from "@startsimpli/ui";
|
|
2
|
-
import { formatPackPrice,
|
|
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
|
-
/**
|
|
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
|
|
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
|
-
|
|
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
|
-
{
|
|
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
|
-
{
|
|
37
|
-
{
|
|
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 {
|
|
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
|
-
<
|
|
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} />
|
package/src/credits/copy.ts
CHANGED
|
@@ -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 => ({
|
|
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
|
+
});
|
package/src/credits/index.ts
CHANGED
|
@@ -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
|
+
});
|
package/src/utils/credit-math.ts
CHANGED
|
@@ -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(
|
|
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
|
|
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
|
|
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(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
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
|
/**
|