@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
|
@@ -0,0 +1,70 @@
|
|
|
1
|
+
import { describe, it, expect } from "vitest";
|
|
2
|
+
import {
|
|
3
|
+
defaultBillingCopy,
|
|
4
|
+
withBillingCopy,
|
|
5
|
+
resolveFeatureCopy,
|
|
6
|
+
featureText,
|
|
7
|
+
} from "./index";
|
|
8
|
+
import { trialLabel, planCtaLabel } from "../utils/plan";
|
|
9
|
+
import { formatFeatureLabel } from "../utils/format";
|
|
10
|
+
import { crochetPro, crochetFree } from "../components/__fixtures__/crochetProduct";
|
|
11
|
+
|
|
12
|
+
describe("withBillingCopy", () => {
|
|
13
|
+
it("returns the defaults with no override", () => {
|
|
14
|
+
expect(withBillingCopy()).toEqual(defaultBillingCopy);
|
|
15
|
+
expect(withBillingCopy({})).toEqual(defaultBillingCopy);
|
|
16
|
+
});
|
|
17
|
+
|
|
18
|
+
it("keeps today's strings as the defaults", () => {
|
|
19
|
+
expect(defaultBillingCopy.changePlan).toBe("Change Plan");
|
|
20
|
+
expect(defaultBillingCopy.availablePlans).toBe("Available Plans");
|
|
21
|
+
expect(defaultBillingCopy.downgradeTo("Free")).toBe("Downgrade to Free");
|
|
22
|
+
expect(defaultBillingCopy.upgradeTo("Pro")).toBe("Upgrade to Pro");
|
|
23
|
+
expect(defaultBillingCopy.trialFree(7)).toBe("7-day free trial");
|
|
24
|
+
});
|
|
25
|
+
|
|
26
|
+
it("overrides single keys and merges the status labels per key", () => {
|
|
27
|
+
const c = withBillingCopy({ changePlan: "Pick a plan", status: { active: "On" } as never });
|
|
28
|
+
expect(c.changePlan).toBe("Pick a plan");
|
|
29
|
+
expect(c.availablePlans).toBe("Available Plans");
|
|
30
|
+
expect(c.status.active).toBe("On");
|
|
31
|
+
expect(c.status.past_due).toBe("Past due");
|
|
32
|
+
});
|
|
33
|
+
});
|
|
34
|
+
|
|
35
|
+
describe("resolveFeatureCopy", () => {
|
|
36
|
+
const f = { key: "ai_refine", name: "AI chat to refine any pattern", value: true };
|
|
37
|
+
it("is null without an entry", () => {
|
|
38
|
+
expect(resolveFeatureCopy(f, undefined)).toBeNull();
|
|
39
|
+
expect(resolveFeatureCopy(f, { other: "x" })).toBeNull();
|
|
40
|
+
});
|
|
41
|
+
it("accepts a string or {label, hint}", () => {
|
|
42
|
+
expect(resolveFeatureCopy(f, { ai_refine: "Chat to tweak" })).toEqual({ label: "Chat to tweak" });
|
|
43
|
+
expect(resolveFeatureCopy(f, { ai_refine: { label: "Chat", hint: "Ask for changes" } })).toEqual({ label: "Chat", hint: "Ask for changes" });
|
|
44
|
+
});
|
|
45
|
+
it("featureText falls back to the old label", () => {
|
|
46
|
+
expect(featureText(f, undefined)).toEqual({ label: formatFeatureLabel(f) });
|
|
47
|
+
expect(featureText(f, { ai_refine: "Chat" }).label).toBe("Chat");
|
|
48
|
+
});
|
|
49
|
+
});
|
|
50
|
+
|
|
51
|
+
describe("plan helpers take optional copy", () => {
|
|
52
|
+
it("trialLabel default unchanged, overridable", () => {
|
|
53
|
+
expect(trialLabel(crochetPro)).toBe("7-day free trial");
|
|
54
|
+
expect(trialLabel(crochetPro, withBillingCopy({ trialFree: (d) => `Try it for ${d} days` }))).toBe("Try it for 7 days");
|
|
55
|
+
});
|
|
56
|
+
it("trialLabel with a fee", () => {
|
|
57
|
+
const o = { ...crochetPro, trialFeeCents: 100 };
|
|
58
|
+
expect(trialLabel(o)).toBe("7-day trial · $1 today");
|
|
59
|
+
expect(trialLabel(o, withBillingCopy({ trialWithFee: (d, fee) => `${d} days, ${fee} now` }))).toBe("7 days, $1 now");
|
|
60
|
+
});
|
|
61
|
+
it("planCtaLabel default unchanged, overridable", () => {
|
|
62
|
+
expect(planCtaLabel(crochetFree, "downgrade")).toBe("Downgrade to Free");
|
|
63
|
+
expect(planCtaLabel(crochetPro, "upgrade", true)).toBe("Upgrade to Pro");
|
|
64
|
+
expect(planCtaLabel(crochetPro, "current")).toBe("Current plan");
|
|
65
|
+
const copy = withBillingCopy({ downgradeTo: (n) => `Move down to ${n}`, upgradeTo: (n) => `Move up to ${n}`, currentPlan: "Your pick" });
|
|
66
|
+
expect(planCtaLabel(crochetFree, "downgrade", false, copy)).toBe("Move down to Free");
|
|
67
|
+
expect(planCtaLabel(crochetPro, "upgrade", true, copy)).toBe("Move up to Pro");
|
|
68
|
+
expect(planCtaLabel(crochetPro, "current", false, copy)).toBe("Your pick");
|
|
69
|
+
});
|
|
70
|
+
});
|
|
@@ -0,0 +1,188 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Every overridable customer-facing string of the plan UI. The app owns its
|
|
3
|
+
* vocabulary (what a plan or a design is called); these defaults are the
|
|
4
|
+
* words the components always had. Plan NAMES and prices are not here: they
|
|
5
|
+
* come from the offers.
|
|
6
|
+
*
|
|
7
|
+
* <SubscriptionManager copy={{ changePlan: "Pick a level" }} />
|
|
8
|
+
*/
|
|
9
|
+
import { defaultPlanChangeCopy, type PlanChangeCopy } from "./plan-change-copy";
|
|
10
|
+
|
|
11
|
+
export interface BillingCopy extends PlanChangeCopy {
|
|
12
|
+
// Plan grid (SubscriptionManager) and buttons shared by every plan list.
|
|
13
|
+
changePlan: string;
|
|
14
|
+
availablePlans: string;
|
|
15
|
+
currentPlan: string;
|
|
16
|
+
upgradeTo: (offerName: string) => string;
|
|
17
|
+
downgradeTo: (offerName: string) => string;
|
|
18
|
+
/** Button on the current-plan card: "Upgrade to Pro →". */
|
|
19
|
+
upgradeToArrow: (offerName: string) => string;
|
|
20
|
+
switchToAnnual: string;
|
|
21
|
+
switchToMonthly: string;
|
|
22
|
+
startsOn: (date: string) => string;
|
|
23
|
+
openingCheckout: string;
|
|
24
|
+
onlyOwnerCanChange: string;
|
|
25
|
+
manageBilling: string;
|
|
26
|
+
loadingShort: string;
|
|
27
|
+
|
|
28
|
+
// Trial phrasing ("7-day free trial").
|
|
29
|
+
trialFree: (days: number) => string;
|
|
30
|
+
/** The trial costs a card-check fee, so it is not called free. */
|
|
31
|
+
trialWithFee: (days: number, fee: string) => string;
|
|
32
|
+
|
|
33
|
+
// Status badges, keyed by subscription status.
|
|
34
|
+
status: Record<string, string>;
|
|
35
|
+
endsOn: (date: string) => string;
|
|
36
|
+
/** Spoken form inside the status label: "ends Jun 27, 2026". */
|
|
37
|
+
endsOnAria: (date: string) => string;
|
|
38
|
+
statusAria: (label: string) => string;
|
|
39
|
+
|
|
40
|
+
// Current plan card.
|
|
41
|
+
freePrice: string;
|
|
42
|
+
pastDueMessage: string;
|
|
43
|
+
updateCard: string;
|
|
44
|
+
cancelledMessage: (planName: string, date: string) => string;
|
|
45
|
+
endOfPeriod: string;
|
|
46
|
+
reactivate: string;
|
|
47
|
+
checkoutExpired: (planName: string) => string;
|
|
48
|
+
setupIncomplete: (planName: string) => string;
|
|
49
|
+
startOver: string;
|
|
50
|
+
finishCheckout: string;
|
|
51
|
+
trialEndsToday: (date: string) => string;
|
|
52
|
+
trialEndsTomorrow: (date: string) => string;
|
|
53
|
+
trialEndsIn: (days: number, date: string) => string;
|
|
54
|
+
cancelTrial: string;
|
|
55
|
+
firstCharge: (price: string, date: string) => string;
|
|
56
|
+
nextBilling: (date: string) => string;
|
|
57
|
+
billingPeriod: (start: string, end: string) => string;
|
|
58
|
+
|
|
59
|
+
// SubscriptionManager states and banners.
|
|
60
|
+
freeByDefault: (planName: string) => string;
|
|
61
|
+
chooseToUpgrade: string;
|
|
62
|
+
noSubscription: string;
|
|
63
|
+
chooseToStart: string;
|
|
64
|
+
trialStarted: (planName: string) => string;
|
|
65
|
+
activated: string;
|
|
66
|
+
checkoutCancelled: string;
|
|
67
|
+
paymentPending: string;
|
|
68
|
+
activating: string;
|
|
69
|
+
scheduled: string;
|
|
70
|
+
scheduledUntil: (date: string) => string;
|
|
71
|
+
scheduledUntilPeriodEnd: string;
|
|
72
|
+
|
|
73
|
+
// Pricing pages, modal and section.
|
|
74
|
+
chooseAPlan: string;
|
|
75
|
+
mostPopular: string;
|
|
76
|
+
everythingIn: (tierName: string) => string;
|
|
77
|
+
yourPlanBilled: (interval: "monthly" | "yearly") => string;
|
|
78
|
+
unlistedYoureOn: (planName: string) => string;
|
|
79
|
+
unlistedExplain: (planName: string) => string;
|
|
80
|
+
pricingLoading: string;
|
|
81
|
+
pricingError: string;
|
|
82
|
+
pricingErrorTryAgain: string;
|
|
83
|
+
noPlans: string;
|
|
84
|
+
tryAgain: string;
|
|
85
|
+
checkingPlan: string;
|
|
86
|
+
planCheckFailed: string;
|
|
87
|
+
featureColumn: string;
|
|
88
|
+
included: string;
|
|
89
|
+
notIncluded: string;
|
|
90
|
+
unlimited: string;
|
|
91
|
+
monthly: string;
|
|
92
|
+
annual: string;
|
|
93
|
+
billingInterval: string;
|
|
94
|
+
}
|
|
95
|
+
|
|
96
|
+
export const defaultBillingCopy: BillingCopy = {
|
|
97
|
+
...defaultPlanChangeCopy,
|
|
98
|
+
changePlan: "Change Plan",
|
|
99
|
+
availablePlans: "Available Plans",
|
|
100
|
+
currentPlan: "Current plan",
|
|
101
|
+
upgradeTo: (n) => `Upgrade to ${n}`,
|
|
102
|
+
downgradeTo: (n) => `Downgrade to ${n}`,
|
|
103
|
+
upgradeToArrow: (n) => `Upgrade to ${n} →`,
|
|
104
|
+
switchToAnnual: "Switch to annual",
|
|
105
|
+
switchToMonthly: "Switch to monthly",
|
|
106
|
+
startsOn: (d) => `Starts ${d}`,
|
|
107
|
+
openingCheckout: "Opening checkout…",
|
|
108
|
+
onlyOwnerCanChange: "Only the team owner can change the plan",
|
|
109
|
+
manageBilling: "Manage Billing",
|
|
110
|
+
loadingShort: "Loading...",
|
|
111
|
+
|
|
112
|
+
trialFree: (days) => `${days}-day free trial`,
|
|
113
|
+
trialWithFee: (days, fee) => `${days}-day trial · ${fee} today`,
|
|
114
|
+
|
|
115
|
+
status: {
|
|
116
|
+
active: "Active",
|
|
117
|
+
trialing: "Trialing",
|
|
118
|
+
past_due: "Past due",
|
|
119
|
+
cancelled: "Cancelled",
|
|
120
|
+
incomplete: "Incomplete",
|
|
121
|
+
incomplete_expired: "Expired",
|
|
122
|
+
unpaid: "Unpaid",
|
|
123
|
+
},
|
|
124
|
+
endsOn: (d) => `Ends ${d}`,
|
|
125
|
+
endsOnAria: (d) => `ends ${d}`,
|
|
126
|
+
statusAria: (label) => `Subscription status: ${label}`,
|
|
127
|
+
|
|
128
|
+
freePrice: "Free",
|
|
129
|
+
pastDueMessage: "Your last payment failed. Update your card to keep your subscription active.",
|
|
130
|
+
updateCard: "Update card",
|
|
131
|
+
cancelledMessage: (name, date) => `Your ${name} plan is cancelled. You'll keep access until ${date}.`,
|
|
132
|
+
endOfPeriod: "the end of your period",
|
|
133
|
+
reactivate: "Reactivate",
|
|
134
|
+
checkoutExpired: (name) => `Your ${name} checkout expired. Start over to activate the plan.`,
|
|
135
|
+
setupIncomplete: (name) => `Your ${name} plan setup is incomplete. Finish checkout to activate.`,
|
|
136
|
+
startOver: "Start over",
|
|
137
|
+
finishCheckout: "Finish checkout",
|
|
138
|
+
trialEndsToday: (d) => `Your trial ends today (${d}).`,
|
|
139
|
+
trialEndsTomorrow: (d) => `Your trial ends tomorrow (${d}).`,
|
|
140
|
+
trialEndsIn: (n, d) => `Your trial ends in ${n} days (${d}).`,
|
|
141
|
+
cancelTrial: "Cancel trial",
|
|
142
|
+
firstCharge: (price, d) => `First charge ${price} on ${d}`,
|
|
143
|
+
nextBilling: (d) => `Next billing date: ${d}`,
|
|
144
|
+
billingPeriod: (s, e) => `Billing period: ${s} to ${e}`,
|
|
145
|
+
|
|
146
|
+
freeByDefault: (name) => `You're on the ${name} plan`,
|
|
147
|
+
chooseToUpgrade: "Choose a plan below to upgrade.",
|
|
148
|
+
noSubscription: "No active subscription",
|
|
149
|
+
chooseToStart: "Choose a plan below to get started.",
|
|
150
|
+
trialStarted: (name) => `Your ${name} trial has started.`,
|
|
151
|
+
activated: "Subscription activated successfully!",
|
|
152
|
+
checkoutCancelled: "Checkout was cancelled. You can try again whenever you're ready.",
|
|
153
|
+
paymentPending: "We haven't received the payment confirmation yet. It can take a minute; refresh this page to check again. Please don't pay a second time.",
|
|
154
|
+
activating: "Activating your subscription...",
|
|
155
|
+
scheduled: "Your plan change is scheduled.",
|
|
156
|
+
scheduledUntil: (d) => `You keep your current plan until ${d}.`,
|
|
157
|
+
scheduledUntilPeriodEnd: "You keep your current plan until the end of this billing period.",
|
|
158
|
+
|
|
159
|
+
chooseAPlan: "Choose a plan",
|
|
160
|
+
mostPopular: "Most Popular",
|
|
161
|
+
everythingIn: (tier) => `Everything in ${tier}, plus:`,
|
|
162
|
+
yourPlanBilled: (i) => `Your plan · billed ${i}`,
|
|
163
|
+
unlistedYoureOn: (n) => `You're on ${n}.`,
|
|
164
|
+
unlistedExplain: (n) => `It isn't one of the plans below. If you choose another plan, ${n} ends and can't be chosen again.`,
|
|
165
|
+
pricingLoading: "Loading pricing...",
|
|
166
|
+
pricingError: "Unable to load pricing.",
|
|
167
|
+
pricingErrorTryAgain: "Unable to load pricing. Please try again.",
|
|
168
|
+
noPlans: "No pricing plans available at this time.",
|
|
169
|
+
tryAgain: "Try again",
|
|
170
|
+
checkingPlan: "Checking your plan…",
|
|
171
|
+
planCheckFailed: "We couldn't check your plan, so plan changes are paused.",
|
|
172
|
+
featureColumn: "Feature",
|
|
173
|
+
included: "Included",
|
|
174
|
+
notIncluded: "Not included",
|
|
175
|
+
unlimited: "Unlimited",
|
|
176
|
+
monthly: "Monthly",
|
|
177
|
+
annual: "Annual",
|
|
178
|
+
billingInterval: "Billing interval",
|
|
179
|
+
};
|
|
180
|
+
|
|
181
|
+
/** Per-key override. `status` is merged key by key so one badge can change. */
|
|
182
|
+
export type BillingCopyInput = Partial<BillingCopy>;
|
|
183
|
+
|
|
184
|
+
export const withBillingCopy = (copy?: BillingCopyInput): BillingCopy => ({
|
|
185
|
+
...defaultBillingCopy,
|
|
186
|
+
...copy,
|
|
187
|
+
status: { ...defaultBillingCopy.status, ...copy?.status },
|
|
188
|
+
});
|
|
@@ -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
|
+
});
|
|
@@ -0,0 +1,64 @@
|
|
|
1
|
+
import type { OfferFeature } from "../types";
|
|
2
|
+
import { formatFeatureLabel } from "../utils/format";
|
|
3
|
+
|
|
4
|
+
export type FeatureCopyEntry = string | { label: string; hint?: string };
|
|
5
|
+
|
|
6
|
+
/** Override of a plan feature row, by feature key (`ai_refine`, `trial_note`, …). */
|
|
7
|
+
export type FeatureCopy = Record<string, FeatureCopyEntry>;
|
|
8
|
+
|
|
9
|
+
export interface FeatureText {
|
|
10
|
+
label: string;
|
|
11
|
+
hint?: string;
|
|
12
|
+
}
|
|
13
|
+
|
|
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 {
|
|
52
|
+
const entry = featureCopy?.[feature.key];
|
|
53
|
+
if (entry === undefined || entry === null) return null;
|
|
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) } : {}) };
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** What a feature row says: the override, else the label the card always had. */
|
|
62
|
+
export function featureText(feature: OfferFeature, featureCopy?: FeatureCopy): FeatureText {
|
|
63
|
+
return resolveFeatureCopy(feature, featureCopy) ?? { label: formatFeatureLabel(feature) };
|
|
64
|
+
}
|
|
@@ -0,0 +1,6 @@
|
|
|
1
|
+
export { defaultBillingCopy, withBillingCopy } from "./billing-copy";
|
|
2
|
+
export type { BillingCopy, BillingCopyInput } from "./billing-copy";
|
|
3
|
+
export { defaultPlanChangeCopy } from "./plan-change-copy";
|
|
4
|
+
export type { PlanChangeCopy } from "./plan-change-copy";
|
|
5
|
+
export { featureText, resolveFeatureCopy } from "./feature-copy";
|
|
6
|
+
export type { FeatureCopy, FeatureCopyEntry, FeatureText } from "./feature-copy";
|
|
@@ -0,0 +1,66 @@
|
|
|
1
|
+
/** The words of the plan-change confirmation dialog (PlanChangeConfirm). */
|
|
2
|
+
export interface PlanChangeCopy {
|
|
3
|
+
confirmDowngradeTitle: (offerName: string) => string;
|
|
4
|
+
confirmDowngradeAction: (offerName: string) => string;
|
|
5
|
+
/** Leaving a plan that is not for sale. */
|
|
6
|
+
confirmEndAction: (currentName: string) => string;
|
|
7
|
+
confirmDowngradeAtDate: (currentName: string, date: string) => string;
|
|
8
|
+
confirmDowngradeAtPeriodEnd: (currentName: string) => string;
|
|
9
|
+
confirmDowngradeAfter: (offerName: string, currentName: string) => string;
|
|
10
|
+
confirmEndsNow: (currentName: string) => string;
|
|
11
|
+
confirmUnlistedGone: (currentName: string) => string;
|
|
12
|
+
confirmDowngradeGeneric: (currentName: string) => string;
|
|
13
|
+
confirmSwitchTitle: (offerName: string) => string;
|
|
14
|
+
confirmSwitchAction: (offerName: string) => string;
|
|
15
|
+
confirmSwitchAtDate: (currentName: string, offerName: string, date: string, recurring: string) => string;
|
|
16
|
+
confirmSwitchAtPeriodEnd: (currentName: string, offerName: string, recurring: string) => string;
|
|
17
|
+
confirmNothingToday: string;
|
|
18
|
+
confirmChangeTitle: (offerName: string) => string;
|
|
19
|
+
confirmChangeAction: (offerName: string) => string;
|
|
20
|
+
confirmChangeRules: (offerName: string, recurring: string) => string;
|
|
21
|
+
confirmNoTrial: string;
|
|
22
|
+
confirmUpgradeTitle: (offerName: string) => string;
|
|
23
|
+
confirmUpgradeAction: (offerName: string) => string;
|
|
24
|
+
confirmUpgradeAndPay: (amount: string) => string;
|
|
25
|
+
confirmChargedAmount: (amount: string, recurring: string) => string;
|
|
26
|
+
confirmChargedProrated: (recurring: string) => string;
|
|
27
|
+
confirmChangesNow: (currentName: string, offerName: string) => string;
|
|
28
|
+
/** Used when the viewer's current plan name is unknown. */
|
|
29
|
+
confirmYourCurrentPlan: string;
|
|
30
|
+
confirmKeepPlan: string;
|
|
31
|
+
confirmCancel: string;
|
|
32
|
+
confirmWorking: string;
|
|
33
|
+
}
|
|
34
|
+
|
|
35
|
+
const NO_TRIAL = "There's no free trial when you're already on a paid plan.";
|
|
36
|
+
|
|
37
|
+
export const defaultPlanChangeCopy: PlanChangeCopy = {
|
|
38
|
+
confirmDowngradeTitle: (n) => `Downgrade to ${n}?`,
|
|
39
|
+
confirmDowngradeAction: (n) => `Downgrade to ${n}`,
|
|
40
|
+
confirmEndAction: (c) => `End ${c}`,
|
|
41
|
+
confirmDowngradeAtDate: (c, d) => `Your ${c} plan ends on ${d}. You keep everything in it until then, and you won't be charged again.`,
|
|
42
|
+
confirmDowngradeAtPeriodEnd: (c) => `Your ${c} plan ends at the end of this billing period. You keep everything in it until then, and you won't be charged again.`,
|
|
43
|
+
confirmDowngradeAfter: (n, c) => `After that you're on ${n}, and lose what ${c} includes.`,
|
|
44
|
+
confirmEndsNow: (c) => `Your ${c} plan ends now, and you lose what it includes.`,
|
|
45
|
+
confirmUnlistedGone: (c) => `${c} isn't a plan you can buy, so once it ends you can't get it back.`,
|
|
46
|
+
confirmDowngradeGeneric: (c) => `Your ${c} plan will end. If it is a paid plan you keep it until the end of the period you've paid for.`,
|
|
47
|
+
confirmSwitchTitle: (n) => `Switch to ${n}?`,
|
|
48
|
+
confirmSwitchAction: (n) => `Switch to ${n}`,
|
|
49
|
+
confirmSwitchAtDate: (c, n, d, r) => `You keep ${c} until ${d}. Then your plan changes to ${n} at ${r}.`,
|
|
50
|
+
confirmSwitchAtPeriodEnd: (c, n, r) => `You keep ${c} until the end of this billing period. Then your plan changes to ${n} at ${r}.`,
|
|
51
|
+
confirmNothingToday: "Nothing is charged today.",
|
|
52
|
+
confirmChangeTitle: (n) => `Change to ${n}?`,
|
|
53
|
+
confirmChangeAction: (n) => `Change to ${n}`,
|
|
54
|
+
confirmChangeRules: (n, r) => `${n} is ${r}. Moving to a more expensive plan is charged today for the rest of this billing period (prorated); moving to a cheaper one takes effect when this period ends.`,
|
|
55
|
+
confirmNoTrial: NO_TRIAL,
|
|
56
|
+
confirmUpgradeTitle: (n) => `Upgrade to ${n}?`,
|
|
57
|
+
confirmUpgradeAction: (n) => `Upgrade to ${n}`,
|
|
58
|
+
confirmUpgradeAndPay: (a) => `Upgrade and pay ${a}`,
|
|
59
|
+
confirmChargedAmount: (a, r) => `You'll be charged ${a} today for the rest of this billing period, then ${r}.`,
|
|
60
|
+
confirmChargedProrated: (r) => `You'll be charged today for the rest of this billing period (prorated), then ${r}.`,
|
|
61
|
+
confirmChangesNow: (c, n) => `Your plan changes from ${c} to ${n} now.`,
|
|
62
|
+
confirmYourCurrentPlan: "your current plan",
|
|
63
|
+
confirmKeepPlan: "Keep my plan",
|
|
64
|
+
confirmCancel: "Cancel",
|
|
65
|
+
confirmWorking: "Working…",
|
|
66
|
+
};
|
|
@@ -9,8 +9,9 @@ const when = (iso: string) => {
|
|
|
9
9
|
/** Purchases and use, in the order given (newest first from the server). */
|
|
10
10
|
export function CreditHistoryList({ history, copy }: { history: PatternCreditHistoryEntry[]; copy: CreditsCopy }) {
|
|
11
11
|
if (history.length === 0) return <p className="text-sm text-muted-foreground">{copy.historyEmpty}</p>;
|
|
12
|
+
// The server's own line ("Added 5 patterns") wins; otherwise the entry type's words.
|
|
12
13
|
const label = (e: PatternCreditHistoryEntry) =>
|
|
13
|
-
e.entryType === "grant" && e.reason ? e.reason : copy.entry[e.entryType] ?? e.entryType;
|
|
14
|
+
e.label ? e.label : e.entryType === "grant" && e.reason ? e.reason : copy.entry[e.entryType] ?? e.entryType;
|
|
14
15
|
return (
|
|
15
16
|
<ul aria-label={copy.historyTitle} className="divide-y divide-border rounded-lg border border-border bg-card">
|
|
16
17
|
{history.map((e) => (
|
|
@@ -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
|
+
});
|