@startsimpli/billing 0.4.37 → 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 +5 -5
- package/src/components/PricingDetailPage.tsx +11 -9
- 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/copy/feature-copy.test.tsx +48 -0
- package/src/copy/feature-copy.ts +43 -3
- package/src/credits/CreditPackCard.align.test.tsx +40 -0
- package/src/credits/CreditPackCard.savings.test.tsx +136 -0
- package/src/credits/CreditPackCard.tsx +36 -11
- 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.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": {
|
|
@@ -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.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) {
|
|
@@ -165,7 +174,7 @@ export function PricingDetailPage({
|
|
|
165
174
|
for (const feature of offer.features) {
|
|
166
175
|
if (!allFeatureKeys.includes(feature.key)) {
|
|
167
176
|
allFeatureKeys.push(feature.key);
|
|
168
|
-
const override = resolveFeatureCopy(feature, featureCopy);
|
|
177
|
+
const override = resolveFeatureCopy(feature, featureCopy, { generic: true });
|
|
169
178
|
featureNameMap[feature.key] = override?.label ?? feature.name;
|
|
170
179
|
featureHintMap[feature.key] = override?.hint;
|
|
171
180
|
}
|
|
@@ -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,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,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
|
+
});
|
|
@@ -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 h-full 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 min-h-[1.75rem] 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,19 +47,22 @@ 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>
|
|
35
|
-
<p className="mt-1 min-h-[1.25rem] text-sm text-
|
|
36
|
-
{
|
|
37
|
-
{
|
|
38
|
-
|
|
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
|
+
{multi && copy.perCredit(formatPackPrice(savings.perPattern, offer.currency))}
|
|
39
62
|
</p>
|
|
63
|
+
<div className="mt-auto pt-4">
|
|
40
64
|
<Button
|
|
41
|
-
className="
|
|
65
|
+
className="w-full"
|
|
42
66
|
variant={offer.featured ? "default" : "outline"}
|
|
43
67
|
disabled={buying !== null}
|
|
44
68
|
onClick={() => onBuy(offer.id)}
|
|
@@ -46,6 +70,7 @@ export function CreditPackCard({ offer, copy, buying, onBuy }: CreditPackCardPro
|
|
|
46
70
|
>
|
|
47
71
|
{opening ? copy.buying : copy.buy(name)}
|
|
48
72
|
</Button>
|
|
73
|
+
</div>
|
|
49
74
|
</li>
|
|
50
75
|
);
|
|
51
76
|
}
|
|
@@ -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 items-stretch 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 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)} />
|
|
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
|
+
}
|