@startsimpli/billing 0.4.34 → 0.4.37
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.ts +24 -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/credits.label.test.tsx +26 -0
- package/src/index.ts +4 -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,24 @@
|
|
|
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
|
+
/** The override for this feature, or null when the app set none. */
|
|
15
|
+
export function resolveFeatureCopy(feature: Pick<OfferFeature, "key">, featureCopy?: FeatureCopy): FeatureText | null {
|
|
16
|
+
const entry = featureCopy?.[feature.key];
|
|
17
|
+
if (entry === undefined || entry === null) return null;
|
|
18
|
+
return typeof entry === "string" ? { label: entry } : { label: entry.label, ...(entry.hint ? { hint: entry.hint } : {}) };
|
|
19
|
+
}
|
|
20
|
+
|
|
21
|
+
/** What a feature row says: the override, else the label the card always had. */
|
|
22
|
+
export function featureText(feature: OfferFeature, featureCopy?: FeatureCopy): FeatureText {
|
|
23
|
+
return resolveFeatureCopy(feature, featureCopy) ?? { label: formatFeatureLabel(feature) };
|
|
24
|
+
}
|
|
@@ -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,26 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { render, screen } from "@testing-library/react";
|
|
3
|
+
import { describe, expect, it } from "vitest";
|
|
4
|
+
import { CreditHistoryList } from "./CreditHistoryList";
|
|
5
|
+
import { defaultCreditsCopy } from "./copy";
|
|
6
|
+
|
|
7
|
+
const entry = (over = {}) => ({ id: "h1", entryType: "grant", quantity: 5, externalRef: "", reason: "", createdAt: "2026-11-01T00:00:00Z", ...over }) as never;
|
|
8
|
+
|
|
9
|
+
describe("CreditHistoryList label", () => {
|
|
10
|
+
it("renders the server label when given", () => {
|
|
11
|
+
render(<CreditHistoryList copy={defaultCreditsCopy} history={[entry({ label: "Added 5 patterns" }), entry({ id: "h2", entryType: "commit", quantity: -1, label: "Used 1 pattern" })]} />);
|
|
12
|
+
expect(screen.getByText("Added 5 patterns")).toBeTruthy();
|
|
13
|
+
expect(screen.getByText("Used 1 pattern")).toBeTruthy();
|
|
14
|
+
expect(screen.queryByText("Bought")).toBeNull();
|
|
15
|
+
});
|
|
16
|
+
it("falls back to the old rendering without a label", () => {
|
|
17
|
+
render(<CreditHistoryList copy={defaultCreditsCopy} history={[entry(), entry({ id: "h2", entryType: "grant", reason: "Welcome gift" }), entry({ id: "h3", entryType: "commit", quantity: -1 })]} />);
|
|
18
|
+
expect(screen.getByText("Bought")).toBeTruthy();
|
|
19
|
+
expect(screen.getByText("Welcome gift")).toBeTruthy();
|
|
20
|
+
expect(screen.getByText("Used")).toBeTruthy();
|
|
21
|
+
});
|
|
22
|
+
it("ignores an empty label", () => {
|
|
23
|
+
render(<CreditHistoryList copy={defaultCreditsCopy} history={[entry({ label: "" })]} />);
|
|
24
|
+
expect(screen.getByText("Bought")).toBeTruthy();
|
|
25
|
+
});
|
|
26
|
+
});
|
package/src/index.ts
CHANGED
|
@@ -43,6 +43,10 @@ export type {
|
|
|
43
43
|
UsageMap,
|
|
44
44
|
} from "./types";
|
|
45
45
|
|
|
46
|
+
// Copy / label overrides
|
|
47
|
+
export { defaultBillingCopy, withBillingCopy, defaultPlanChangeCopy, featureText, resolveFeatureCopy } from "./copy";
|
|
48
|
+
export type { BillingCopy, BillingCopyInput, PlanChangeCopy, FeatureCopy, FeatureCopyEntry, FeatureText } from "./copy";
|
|
49
|
+
|
|
46
50
|
// Components
|
|
47
51
|
export { PricingPage, PricingSection, PricingDetailPage, UpgradeModal, ManageSubscription, SubscriptionManager, PlanGate, CurrentPlanCard, LimitNudge, PlanChangeConfirm, planChangeCopy, UsageMeters } from "./components";
|
|
48
52
|
export type {
|
package/src/utils/plan.ts
CHANGED
|
@@ -8,6 +8,7 @@
|
|
|
8
8
|
*/
|
|
9
9
|
|
|
10
10
|
import type { OfferFeature, ProductOffer, SubscriptionInfo } from "../types";
|
|
11
|
+
import { defaultBillingCopy, type BillingCopy } from "../copy/billing-copy";
|
|
11
12
|
|
|
12
13
|
const PAID_STATUSES: ReadonlyArray<SubscriptionInfo["status"]> = ["active", "trialing"];
|
|
13
14
|
|
|
@@ -311,10 +312,13 @@ export function trialApplies(card: ProductOffer, direction: PlanDirection, payin
|
|
|
311
312
|
* "7-day free trial", or "7-day trial · $1 today" when starting it charges a
|
|
312
313
|
* card-check fee (then it is not free, so it must not say so).
|
|
313
314
|
*/
|
|
314
|
-
export function trialLabel(
|
|
315
|
+
export function trialLabel(
|
|
316
|
+
offer: Pick<ProductOffer, "trialDays" | "trialFeeCents" | "currency">,
|
|
317
|
+
copy: Pick<BillingCopy, "trialFree" | "trialWithFee"> = defaultBillingCopy,
|
|
318
|
+
): string {
|
|
315
319
|
const days = offer.trialDays ?? 0;
|
|
316
320
|
const cents = offer.trialFeeCents ?? 0;
|
|
317
|
-
if (cents <= 0) return
|
|
321
|
+
if (cents <= 0) return copy.trialFree(days);
|
|
318
322
|
const amount = cents / 100;
|
|
319
323
|
let fee: string;
|
|
320
324
|
try {
|
|
@@ -326,7 +330,7 @@ export function trialLabel(offer: Pick<ProductOffer, "trialDays" | "trialFeeCent
|
|
|
326
330
|
} catch {
|
|
327
331
|
fee = `$${amount}`;
|
|
328
332
|
}
|
|
329
|
-
return
|
|
333
|
+
return copy.trialWithFee(days, fee);
|
|
330
334
|
}
|
|
331
335
|
|
|
332
336
|
/**
|
|
@@ -334,12 +338,17 @@ export function trialLabel(offer: Pick<ProductOffer, "trialDays" | "trialFeeCent
|
|
|
334
338
|
* new buyer; someone already paying is upgraded in place, so they get
|
|
335
339
|
* "Upgrade to X" (startsim-21f, crochet_patterns-anw61).
|
|
336
340
|
*/
|
|
337
|
-
export function planCtaLabel(
|
|
338
|
-
|
|
339
|
-
|
|
340
|
-
|
|
341
|
-
|
|
342
|
-
|
|
341
|
+
export function planCtaLabel(
|
|
342
|
+
card: ProductOffer,
|
|
343
|
+
direction: PlanDirection,
|
|
344
|
+
paying = false,
|
|
345
|
+
copy: Pick<BillingCopy, "currentPlan" | "switchToAnnual" | "switchToMonthly" | "downgradeTo" | "upgradeTo"> = defaultBillingCopy,
|
|
346
|
+
): string {
|
|
347
|
+
if (direction === "current") return copy.currentPlan;
|
|
348
|
+
if (direction === "switch") return card.intervalUnit === "year" ? copy.switchToAnnual : copy.switchToMonthly;
|
|
349
|
+
if (direction === "downgrade") return copy.downgradeTo(card.name);
|
|
350
|
+
if (paying) return copy.upgradeTo(card.name);
|
|
351
|
+
return card.ctaText || copy.upgradeTo(card.name);
|
|
343
352
|
}
|
|
344
353
|
|
|
345
354
|
/**
|