@startsimpli/billing 0.4.37 → 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/PricingDetailPage.tsx +1 -1
- package/src/copy/feature-copy.test.tsx +48 -0
- package/src/copy/feature-copy.ts +43 -3
- 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/index.ts +3 -1
- package/src/index.ts +2 -2
- package/src/utils/credit-math.savings.test.ts +29 -0
- package/src/utils/credit-math.ts +50 -0
package/package.json
CHANGED
|
@@ -1,6 +1,6 @@
|
|
|
1
1
|
{
|
|
2
2
|
"name": "@startsimpli/billing",
|
|
3
|
-
"version": "0.4.
|
|
3
|
+
"version": "0.4.39",
|
|
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.
|
|
39
|
-
"@startsimpli/ui": "^0.4.
|
|
38
|
+
"@startsimpli/api": "^0.5.77",
|
|
39
|
+
"@startsimpli/ui": "^0.4.178"
|
|
40
40
|
},
|
|
41
41
|
"peerDependenciesMeta": {
|
|
42
42
|
"@startsimpli/api": {
|
|
@@ -61,8 +61,8 @@
|
|
|
61
61
|
"tsup": "^8.5.1",
|
|
62
62
|
"typescript": "^6.0.3",
|
|
63
63
|
"vitest": "^4.1.5",
|
|
64
|
-
"@startsimpli/
|
|
65
|
-
"@startsimpli/
|
|
64
|
+
"@startsimpli/api": "0.5.77",
|
|
65
|
+
"@startsimpli/ui": "0.4.178"
|
|
66
66
|
},
|
|
67
67
|
"keywords": [
|
|
68
68
|
"billing",
|
|
@@ -165,7 +165,7 @@ export function PricingDetailPage({
|
|
|
165
165
|
for (const feature of offer.features) {
|
|
166
166
|
if (!allFeatureKeys.includes(feature.key)) {
|
|
167
167
|
allFeatureKeys.push(feature.key);
|
|
168
|
-
const override = resolveFeatureCopy(feature, featureCopy);
|
|
168
|
+
const override = resolveFeatureCopy(feature, featureCopy, { generic: true });
|
|
169
169
|
featureNameMap[feature.key] = override?.label ?? feature.name;
|
|
170
170
|
featureHintMap[feature.key] = override?.hint;
|
|
171
171
|
}
|
|
@@ -0,0 +1,48 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { FeatureLabel } from "../components/FeatureLabel";
|
|
5
|
+
import { featureText, resolveFeatureCopy } from "./feature-copy";
|
|
6
|
+
import type { OfferFeature } from "../types";
|
|
7
|
+
|
|
8
|
+
const f = (over: Partial<OfferFeature> = {}): OfferFeature => ({ key: "ai_designs_per_month", name: "AI designs per month", value: 3, ...over });
|
|
9
|
+
const copy = { ai_designs_per_month: "{value} new patterns each month" };
|
|
10
|
+
|
|
11
|
+
describe("featureCopy {value} placeholder", () => {
|
|
12
|
+
it("fills from the feature value", () => {
|
|
13
|
+
expect(featureText(f({ value: 3 }), copy).label).toBe("3 new patterns each month");
|
|
14
|
+
expect(featureText(f({ value: 15 }), copy).label).toBe("15 new patterns each month");
|
|
15
|
+
});
|
|
16
|
+
it("prefers the limit over the value", () => {
|
|
17
|
+
expect(featureText(f({ value: true, limit: 15 }), copy).label).toBe("15 new patterns each month");
|
|
18
|
+
expect(featureText(f({ value: 3, limit: 15 }), copy).label).toBe("15 new patterns each month");
|
|
19
|
+
expect(featureText(f({ value: 3, limit: null }), copy).label).toBe("3 new patterns each month");
|
|
20
|
+
});
|
|
21
|
+
it("says unlimited for -1 and the string 'unlimited', matching the case of the spot", () => {
|
|
22
|
+
expect(featureText(f({ value: -1 }), copy).label).toBe("Unlimited new patterns each month");
|
|
23
|
+
expect(featureText(f({ limit: -1 }), { ai_designs_per_month: "Make {value} patterns" }).label).toBe("Make unlimited patterns");
|
|
24
|
+
expect(featureText(f({ value: "unlimited" }), copy).label).toBe("Unlimited new patterns each month");
|
|
25
|
+
});
|
|
26
|
+
it("drops the placeholder for true / other strings / null", () => {
|
|
27
|
+
expect(featureText(f({ value: true }), copy).label).toBe("new patterns each month");
|
|
28
|
+
expect(featureText(f({ value: "pro" }), copy).label).toBe("new patterns each month");
|
|
29
|
+
expect(featureText(f({ value: null }), { ai_designs_per_month: "Patterns ({value}) monthly" }).label).toBe("Patterns monthly");
|
|
30
|
+
});
|
|
31
|
+
it("works in {label, hint} and fills every occurrence", () => {
|
|
32
|
+
const out = featureText(f({ value: 3 }), { ai_designs_per_month: { label: "{value} patterns", hint: "Up to {value} a month, resets on the 1st" } });
|
|
33
|
+
expect(out).toEqual({ label: "3 patterns", hint: "Up to 3 a month, resets on the 1st" });
|
|
34
|
+
});
|
|
35
|
+
it("leaves text without a placeholder and the default label untouched", () => {
|
|
36
|
+
expect(featureText(f(), { ai_designs_per_month: "Patterns per month" }).label).toBe("Patterns per month");
|
|
37
|
+
expect(featureText(f({ value: 3 })).label).toBe("Up to 3 ai designs per month".replace("ai designs", "AI designs"));
|
|
38
|
+
expect(resolveFeatureCopy(f(), {})).toBeNull();
|
|
39
|
+
});
|
|
40
|
+
it("generic mode (comparison-table row labels) removes the placeholder", () => {
|
|
41
|
+
expect(resolveFeatureCopy(f({ value: 3 }), copy, { generic: true })?.label).toBe("new patterns each month");
|
|
42
|
+
});
|
|
43
|
+
it("renders through FeatureLabel", () => {
|
|
44
|
+
render(<FeatureLabel feature={f({ value: 15 })} featureCopy={{ ai_designs_per_month: { label: "{value} new patterns each month", hint: "of {value}" } }} />);
|
|
45
|
+
expect(screen.getByText(/15 new patterns each month/)).toBeTruthy();
|
|
46
|
+
expect(screen.getByTestId("feature-hint-ai_designs_per_month").textContent).toBe("of 15");
|
|
47
|
+
});
|
|
48
|
+
});
|
package/src/copy/feature-copy.ts
CHANGED
|
@@ -11,11 +11,51 @@ export interface FeatureText {
|
|
|
11
11
|
hint?: string;
|
|
12
12
|
}
|
|
13
13
|
|
|
14
|
-
|
|
15
|
-
|
|
14
|
+
const PLACEHOLDER = /\{value\}/g;
|
|
15
|
+
|
|
16
|
+
/**
|
|
17
|
+
* The number a `{value}` placeholder reads: the feature's `limit` when it is a
|
|
18
|
+
* number, else its `value` when that is a number. -1 or the string "unlimited"
|
|
19
|
+
* is unlimited; anything else (true, other strings, null) has no number.
|
|
20
|
+
*/
|
|
21
|
+
function placeholderValue(feature: Pick<OfferFeature, "limit" | "value">): number | "unlimited" | null {
|
|
22
|
+
const n = typeof feature.limit === "number" ? feature.limit : typeof feature.value === "number" ? feature.value : null;
|
|
23
|
+
if (n === -1) return "unlimited";
|
|
24
|
+
if (n !== null && Number.isFinite(n) && n >= 0) return n;
|
|
25
|
+
if (n === null && typeof feature.value === "string" && feature.value.trim().toLowerCase() === "unlimited") return "unlimited";
|
|
26
|
+
return null;
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
function fillPlaceholder(text: string, value: number | "unlimited" | null): string {
|
|
30
|
+
if (!PLACEHOLDER.test(text)) return text;
|
|
31
|
+
PLACEHOLDER.lastIndex = 0;
|
|
32
|
+
if (value === null) {
|
|
33
|
+
return text.replace(PLACEHOLDER, "").replace(/\(\s*\)/g, "").replace(/\s{2,}/g, " ").trim();
|
|
34
|
+
}
|
|
35
|
+
return text.replace(PLACEHOLDER, (_m, offset: number) => {
|
|
36
|
+
if (value !== "unlimited") return String(value);
|
|
37
|
+
const atStart = text.slice(0, offset).trim() === "";
|
|
38
|
+
return atStart ? "Unlimited" : "unlimited";
|
|
39
|
+
});
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
/**
|
|
43
|
+
* The override for this feature, or null when the app set none. `{value}` in a
|
|
44
|
+
* label or hint is filled from the feature (see placeholderValue). `generic`
|
|
45
|
+
* drops the placeholder, for a row label shared by several plans.
|
|
46
|
+
*/
|
|
47
|
+
export function resolveFeatureCopy(
|
|
48
|
+
feature: Pick<OfferFeature, "key"> & Partial<Pick<OfferFeature, "limit" | "value">>,
|
|
49
|
+
featureCopy?: FeatureCopy,
|
|
50
|
+
opts?: { generic?: boolean },
|
|
51
|
+
): FeatureText | null {
|
|
16
52
|
const entry = featureCopy?.[feature.key];
|
|
17
53
|
if (entry === undefined || entry === null) return null;
|
|
18
|
-
|
|
54
|
+
const value = opts?.generic ? null : placeholderValue({ limit: feature.limit, value: feature.value ?? null });
|
|
55
|
+
const fill = (t: string) => fillPlaceholder(t, value);
|
|
56
|
+
return typeof entry === "string"
|
|
57
|
+
? { label: fill(entry) }
|
|
58
|
+
: { label: fill(entry.label), ...(entry.hint ? { hint: fill(entry.hint) } : {}) };
|
|
19
59
|
}
|
|
20
60
|
|
|
21
61
|
/** What a feature row says: the override, else the label the card always had. */
|
|
@@ -0,0 +1,136 @@
|
|
|
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, CreditsPanel, SAVINGS_SASH_TOKENS, defaultCreditsCopy, withCreditsCopy, type CreditsView } from "./index";
|
|
5
|
+
|
|
6
|
+
const five = { id: "p5", slug: "5", name: "5 pack", credits: 5, price: "19.99", currency: "usd", compareAt: "24.95", featured: false };
|
|
7
|
+
const fifteen = { id: "p15", slug: "15", name: "15 pack", credits: 15, price: "49.99", currency: "usd", compareAt: "74.85", featured: true };
|
|
8
|
+
const single = { id: "p1", slug: "1", name: "1", credits: 1, price: "4.99", currency: "usd", compareAt: "", featured: false };
|
|
9
|
+
const server5 = { ...five, savingsPercent: 20, savingsAmount: 496, pricePerPattern: "4.00" };
|
|
10
|
+
const server15 = { ...fifteen, savingsPercent: 33, savingsAmount: 2486, pricePerPattern: "3.33" };
|
|
11
|
+
|
|
12
|
+
const card = (offer: object, copy = defaultCreditsCopy) =>
|
|
13
|
+
render(<ul><CreditPackCard offer={offer as never} copy={copy} buying={null} onBuy={vi.fn()} /></ul>);
|
|
14
|
+
|
|
15
|
+
describe("CreditPackCard savings", () => {
|
|
16
|
+
it("shows the sash, the dollar line and the per-pattern line for the 5 and 15 packs", () => {
|
|
17
|
+
const { unmount } = card(server5);
|
|
18
|
+
expect(screen.getByTestId("savings-sash").textContent).toBe("Save 20%");
|
|
19
|
+
expect(screen.getByText("Save $4.96")).toBeTruthy();
|
|
20
|
+
expect(screen.getByText("$4.00 each")).toBeTruthy();
|
|
21
|
+
unmount();
|
|
22
|
+
card(server15);
|
|
23
|
+
expect(screen.getByTestId("savings-sash").textContent).toBe("Save 33%");
|
|
24
|
+
expect(screen.getByText("Save $24.86")).toBeTruthy();
|
|
25
|
+
expect(screen.getByText("$3.33 each")).toBeTruthy();
|
|
26
|
+
});
|
|
27
|
+
|
|
28
|
+
it("has no sash and no dollar line for a single pattern", () => {
|
|
29
|
+
card(single);
|
|
30
|
+
expect(screen.queryByTestId("savings-sash")).toBeNull();
|
|
31
|
+
expect(screen.queryByText(/^Save/)).toBeNull();
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
it("follows the data: change the offer and the text changes", () => {
|
|
35
|
+
card({ ...server5, savingsPercent: 35, savingsAmount: 1234, pricePerPattern: "2.50" });
|
|
36
|
+
expect(screen.getByTestId("savings-sash").textContent).toBe("Save 35%");
|
|
37
|
+
expect(screen.getByText("Save $12.34")).toBeTruthy();
|
|
38
|
+
expect(screen.getByText("$2.50 each")).toBeTruthy();
|
|
39
|
+
});
|
|
40
|
+
|
|
41
|
+
it("falls back to compareAt math against an older backend", () => {
|
|
42
|
+
card(five);
|
|
43
|
+
expect(screen.getByTestId("savings-sash").textContent).toBe("Save 20%");
|
|
44
|
+
expect(screen.getByText("Save $4.96")).toBeTruthy();
|
|
45
|
+
expect(screen.getByText("$4.00 each")).toBeTruthy();
|
|
46
|
+
});
|
|
47
|
+
|
|
48
|
+
it("has no sash when a multi pack saves nothing", () => {
|
|
49
|
+
card({ ...five, compareAt: "" });
|
|
50
|
+
expect(screen.queryByTestId("savings-sash")).toBeNull();
|
|
51
|
+
expect(screen.queryByText(/^Save/)).toBeNull();
|
|
52
|
+
expect(screen.getByText("$4.00 each")).toBeTruthy();
|
|
53
|
+
});
|
|
54
|
+
|
|
55
|
+
it("applies copy overrides to the sash and the line", () => {
|
|
56
|
+
card(server5, withCreditsCopy({ savingsSash: (p) => `${p}% off`, savingsLine: (a) => `You keep ${a}` }));
|
|
57
|
+
expect(screen.getByTestId("savings-sash").textContent).toBe("20% off");
|
|
58
|
+
expect(screen.getByText("You keep $4.96")).toBeTruthy();
|
|
59
|
+
});
|
|
60
|
+
|
|
61
|
+
it("keeps the older save key working for the sash", () => {
|
|
62
|
+
card(server5, withCreditsCopy({ save: (p) => `Spar ${p}%` }));
|
|
63
|
+
expect(screen.getByTestId("savings-sash").textContent).toBe("Spar 20%");
|
|
64
|
+
});
|
|
65
|
+
|
|
66
|
+
it("clips the ribbon inside the card corner", () => {
|
|
67
|
+
card(server5);
|
|
68
|
+
const li = screen.getByRole("listitem");
|
|
69
|
+
expect(li.className).toContain("relative");
|
|
70
|
+
expect(li.className).toContain("overflow-hidden");
|
|
71
|
+
const sash = screen.getByTestId("savings-sash");
|
|
72
|
+
expect(sash.className).toContain("rotate-45");
|
|
73
|
+
expect(sash.className).toContain("whitespace-nowrap");
|
|
74
|
+
expect(sash.className).toContain("pointer-events-none");
|
|
75
|
+
});
|
|
76
|
+
});
|
|
77
|
+
|
|
78
|
+
// Tailwind default palette values for the classes the sash may use.
|
|
79
|
+
const PALETTE: Record<string, string> = { "bg-green-800": "#166534", "bg-green-900": "#14532d", "bg-emerald-800": "#065f46", "text-white": "#ffffff" };
|
|
80
|
+
const lum = (hex: string) => {
|
|
81
|
+
const c = [1, 3, 5].map((i) => parseInt(hex.slice(i, i + 2), 16) / 255).map((v) => (v <= 0.03928 ? v / 12.92 : ((v + 0.055) / 1.055) ** 2.4));
|
|
82
|
+
return 0.2126 * c[0] + 0.7152 * c[1] + 0.0722 * c[2];
|
|
83
|
+
};
|
|
84
|
+
const contrast = (a: string, b: string) => {
|
|
85
|
+
const [hi, lo] = [lum(a), lum(b)].sort((x, y) => y - x);
|
|
86
|
+
return (hi + 0.05) / (lo + 0.05);
|
|
87
|
+
};
|
|
88
|
+
|
|
89
|
+
describe("sash accessibility", () => {
|
|
90
|
+
it("meets WCAG AA (4.5:1) for its text on its fill", () => {
|
|
91
|
+
const bg = PALETTE[SAVINGS_SASH_TOKENS.bg];
|
|
92
|
+
const fg = PALETTE[SAVINGS_SASH_TOKENS.text];
|
|
93
|
+
expect(bg && fg).toBeTruthy();
|
|
94
|
+
expect(contrast(bg, fg)).toBeGreaterThanOrEqual(4.5);
|
|
95
|
+
});
|
|
96
|
+
it("meets AA for the savings line text (green-700) on white", () => {
|
|
97
|
+
expect(contrast("#15803d", "#ffffff")).toBeGreaterThanOrEqual(4.5);
|
|
98
|
+
});
|
|
99
|
+
it("uses the declared classes on the sash", () => {
|
|
100
|
+
card(server5);
|
|
101
|
+
const cls = screen.getByTestId("savings-sash").className;
|
|
102
|
+
expect(cls).toContain(SAVINGS_SASH_TOKENS.bg);
|
|
103
|
+
expect(cls).toContain(SAVINGS_SASH_TOKENS.text);
|
|
104
|
+
});
|
|
105
|
+
});
|
|
106
|
+
|
|
107
|
+
describe("CreditPackGrid", () => {
|
|
108
|
+
it("renders one card per offer with no hook and calls the caller's buy handler", () => {
|
|
109
|
+
const onBuy = vi.fn();
|
|
110
|
+
render(<CreditPackGrid offers={[single, server5, server15] as never} onBuy={onBuy} />);
|
|
111
|
+
expect(screen.getAllByRole("listitem")).toHaveLength(3);
|
|
112
|
+
expect(screen.getAllByTestId("savings-sash")).toHaveLength(2);
|
|
113
|
+
screen.getByRole("button", { name: "Buy 5 credits" }).click();
|
|
114
|
+
expect(onBuy).toHaveBeenCalledWith("p5");
|
|
115
|
+
});
|
|
116
|
+
it("marks the opening pack, disables the others, and takes copy", () => {
|
|
117
|
+
render(<CreditPackGrid offers={[server5, server15] as never} onBuy={vi.fn()} buying="p5" copy={{ buying: "Opening…" }} />);
|
|
118
|
+
expect(screen.getByRole("button", { name: "Opening…" })).toBeTruthy();
|
|
119
|
+
expect((screen.getByRole("button", { name: "Buy 15 credits" }) as HTMLButtonElement).disabled).toBe(true);
|
|
120
|
+
});
|
|
121
|
+
it("renders nothing for no offers", () => {
|
|
122
|
+
const { container } = render(<CreditPackGrid offers={[]} onBuy={vi.fn()} />);
|
|
123
|
+
expect(container.innerHTML).toBe("");
|
|
124
|
+
});
|
|
125
|
+
});
|
|
126
|
+
|
|
127
|
+
describe("CreditsPanel copy", () => {
|
|
128
|
+
it("passes the savings copy through to the cards", () => {
|
|
129
|
+
const credits: CreditsView = {
|
|
130
|
+
status: "ready", balance: 0, offers: [server5 as never], history: [], error: null, buying: null,
|
|
131
|
+
refresh: vi.fn(async () => {}), buy: vi.fn(async () => {}), sync: vi.fn(async () => ({ granted: true, balance: 0 })),
|
|
132
|
+
};
|
|
133
|
+
render(<CreditsPanel credits={credits} copy={{ savingsLine: (a) => `Keep ${a}` }} />);
|
|
134
|
+
expect(screen.getByText("Keep $4.96")).toBeTruthy();
|
|
135
|
+
});
|
|
136
|
+
});
|
|
@@ -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
|
+
});
|
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
|
@@ -100,7 +100,7 @@ export {
|
|
|
100
100
|
statusMessageFrom,
|
|
101
101
|
} from "./utils/return-urls";
|
|
102
102
|
export type { CreditReturnPaths, PlanStatusMessage } from "./utils/return-urls";
|
|
103
|
-
export { formatPackPrice, pricePerCredit, savingsPercent } from "./utils/credit-math";
|
|
104
|
-
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";
|
|
105
105
|
export { meterRows } from "./utils/usage-meters";
|
|
106
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
|
+
}
|