@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 CHANGED
@@ -1,6 +1,6 @@
1
1
  {
2
2
  "name": "@startsimpli/billing",
3
- "version": "0.4.34",
3
+ "version": "0.4.37",
4
4
  "description": "Universal billing integration for StartSimpli Next.js apps",
5
5
  "license": "MIT",
6
6
  "homepage": "https://startsimpli.com",
@@ -35,8 +35,8 @@
35
35
  "next": ">=14.0.0",
36
36
  "react": "^18.0.0 || ^19.0.0",
37
37
  "react-dom": "^18.0.0 || ^19.0.0",
38
- "@startsimpli/api": "^0.5.73",
39
- "@startsimpli/ui": "^0.4.175"
38
+ "@startsimpli/api": "^0.5.75",
39
+ "@startsimpli/ui": "^0.4.177"
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/api": "0.5.73",
65
- "@startsimpli/ui": "0.4.175"
64
+ "@startsimpli/ui": "0.4.177",
65
+ "@startsimpli/api": "0.5.75"
66
66
  },
67
67
  "keywords": [
68
68
  "billing",
@@ -16,7 +16,9 @@
16
16
 
17
17
  import React from "react";
18
18
  import type { OfferFeature, SubscriptionInfo, UsageMap } from "../types";
19
- import { formatFeatureLabel, formatPrice } from "../utils/format";
19
+ import { formatPrice } from "../utils/format";
20
+ import { FeatureLabel } from "./FeatureLabel";
21
+ import { withBillingCopy, type BillingCopy, type BillingCopyInput, type FeatureCopy } from "../copy";
20
22
 
21
23
  export interface CurrentPlanCardProps {
22
24
  subscription: SubscriptionInfo;
@@ -29,10 +31,14 @@ export interface CurrentPlanCardProps {
29
31
  * "Reactivate", trial "Cancel trial". SubscriptionManager wires this to
30
32
  * usePortal so apps don't reinvent it. startsim-br2 + startsim-xe5 + startsim-dyg. */
31
33
  onPortalClick?: () => void;
34
+ /** Wording overrides (badges, banners, button labels). */
35
+ copy?: BillingCopyInput;
36
+ /** Feature-row overrides by feature key. */
37
+ featureCopy?: FeatureCopy;
32
38
  }
33
39
 
34
- function priceLabel(offer: SubscriptionInfo["offer"]): string {
35
- if (offer.isFree) return "Free";
40
+ function priceLabel(offer: SubscriptionInfo["offer"], free = "Free"): string {
41
+ if (offer.isFree) return free;
36
42
  return formatPrice(offer.unitPrice, offer.currency);
37
43
  }
38
44
 
@@ -40,27 +46,22 @@ function isProgressBarFeature(f: OfferFeature): boolean {
40
46
  return typeof f.limit === "number" && f.limit > 0;
41
47
  }
42
48
 
43
- function featureLabel(f: OfferFeature): string | null {
44
- if (f.value === false) return null;
45
- return formatFeatureLabel(f);
46
- }
47
-
48
49
  /**
49
50
  * The card's one status pill. A plan that is ending (cancelled, or cancelled
50
51
  * at period end) reads "Ends <date>": it used to say "Active" beside a
51
52
  * "your plan is cancelled" banner (crochet_patterns-3bzni, -pes38). A trial
52
53
  * reads "Trialing" only; its end date is in the banner (crochet_patterns-rjg2e).
53
54
  */
54
- function StatusBadge({ status, endsOn }: { status: string; endsOn: string | null }) {
55
+ function StatusBadge({ status, endsOn, copy }: { status: string; endsOn: string | null; copy: BillingCopy }) {
55
56
  if (endsOn) {
56
57
  return (
57
58
  <span
58
59
  data-testid="plan-status"
59
60
  role="status"
60
- aria-label={`Subscription status: ends ${prettyDate(endsOn)}`}
61
+ aria-label={copy.statusAria(copy.endsOnAria(prettyDate(endsOn)))}
61
62
  className="inline-block text-xs font-semibold px-3 py-1 rounded-full bg-gray-100 text-gray-600"
62
63
  >
63
- {`Ends ${prettyDate(endsOn)}`}
64
+ {copy.endsOn(prettyDate(endsOn))}
64
65
  </span>
65
66
  );
66
67
  }
@@ -75,21 +76,13 @@ function StatusBadge({ status, endsOn }: { status: string; endsOn: string | null
75
76
  case "unpaid": cls += "bg-red-50 text-red-700"; break;
76
77
  default: cls += "bg-gray-100 text-gray-600";
77
78
  }
78
- const labels: Record<string, string> = {
79
- active: "Active",
80
- trialing: "Trialing",
81
- past_due: "Past due",
82
- cancelled: "Cancelled",
83
- incomplete: "Incomplete",
84
- incomplete_expired: "Expired",
85
- unpaid: "Unpaid",
86
- };
79
+ const labels = copy.status;
87
80
  const readable = labels[status] ?? status;
88
81
  return (
89
82
  <span
90
83
  data-testid="plan-status"
91
84
  role="status"
92
- aria-label={`Subscription status: ${readable}`}
85
+ aria-label={copy.statusAria(readable)}
93
86
  className={cls}
94
87
  >
95
88
  {readable}
@@ -112,9 +105,8 @@ function ProgressBar({ used, limit }: { used: number; limit: number }) {
112
105
  );
113
106
  }
114
107
 
115
- function FeatureRow({ feature, used }: { feature: OfferFeature; used: number | null }) {
116
- const label = featureLabel(feature);
117
- if (label === null) return null;
108
+ function FeatureRow({ feature, used, featureCopy }: { feature: OfferFeature; used: number | null; featureCopy?: FeatureCopy }) {
109
+ if (feature.value === false) return null;
118
110
 
119
111
  const showBar = isProgressBarFeature(feature) && used !== null;
120
112
  const limit = feature.limit as number;
@@ -126,7 +118,7 @@ function FeatureRow({ feature, used }: { feature: OfferFeature; used: number | n
126
118
  stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
127
119
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
128
120
  </svg>
129
- <span className="flex-1">{label}</span>
121
+ <FeatureLabel feature={feature} featureCopy={featureCopy} className="flex-1" />
130
122
  {showBar && (
131
123
  <span data-testid={`usage-count-${feature.key}`} className="text-xs text-gray-500 tabular-nums">
132
124
  {used} / {limit}
@@ -189,7 +181,8 @@ function StateBanner({ testId, variant, message, action }: BannerProps) {
189
181
  );
190
182
  }
191
183
 
192
- export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick }: CurrentPlanCardProps) {
184
+ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick, copy: copyOverride, featureCopy }: CurrentPlanCardProps) {
185
+ const copy = withBillingCopy(copyOverride);
193
186
  const offer = subscription.offer;
194
187
  const features = offer.features ?? [];
195
188
  const ending = subscription.status === "cancelled" || subscription.cancelAtPeriodEnd;
@@ -207,26 +200,26 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
207
200
  message={
208
201
  <>
209
202
  {/* Not dated: the period end is not when the payment failed (crochet_patterns-lb8u3). */}
210
- Your last payment failed. Update your card to keep your subscription active.
203
+ {copy.pastDueMessage}
211
204
  </>
212
205
  }
213
- action={{ label: "Update card", onClick: onPortalClick }}
206
+ action={{ label: copy.updateCard, onClick: onPortalClick }}
214
207
  />
215
208
  );
216
209
  } else if (subscription.status === "cancelled" || subscription.cancelAtPeriodEnd) {
217
210
  // startsim-dyg: cancelled-in-period — user keeps access until the end of
218
211
  // the period, can reactivate via portal.
219
- const endDate = subscription.currentPeriodEnd ? prettyDate(subscription.currentPeriodEnd) : "the end of your period";
212
+ const endDate = subscription.currentPeriodEnd ? prettyDate(subscription.currentPeriodEnd) : copy.endOfPeriod;
220
213
  stateBanner = (
221
214
  <StateBanner
222
215
  testId="banner-cancelled-in-period"
223
216
  variant="neutral"
224
217
  message={
225
218
  <>
226
- Your {offer.name} plan is cancelled. You'll keep access until {endDate}.
219
+ {copy.cancelledMessage(offer.name, endDate)}
227
220
  </>
228
221
  }
229
- action={onPortalClick ? { label: "Reactivate", onClick: onPortalClick } : undefined}
222
+ action={onPortalClick ? { label: copy.reactivate, onClick: onPortalClick } : undefined}
230
223
  />
231
224
  );
232
225
  } else if ((subscription.status === "incomplete" || subscription.status === "incomplete_expired") && onPortalClick) {
@@ -241,11 +234,11 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
241
234
  variant="warning"
242
235
  message={
243
236
  expired
244
- ? `Your ${offer.name} checkout expired. Start over to activate the plan.`
245
- : `Your ${offer.name} plan setup is incomplete. Finish checkout to activate.`
237
+ ? copy.checkoutExpired(offer.name)
238
+ : copy.setupIncomplete(offer.name)
246
239
  }
247
240
  action={{
248
- label: expired ? "Start over" : "Finish checkout",
241
+ label: expired ? copy.startOver : copy.finishCheckout,
249
242
  onClick: onPortalClick,
250
243
  }}
251
244
  />
@@ -256,16 +249,16 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
256
249
  const days = daysUntil(subscription.trialEnd);
257
250
  const variant: "info" | "warning" = days <= 3 ? "warning" : "info";
258
251
  const label = days <= 0
259
- ? `Your trial ends today (${prettyDate(subscription.trialEnd)}).`
252
+ ? copy.trialEndsToday(prettyDate(subscription.trialEnd))
260
253
  : days === 1
261
- ? `Your trial ends tomorrow (${prettyDate(subscription.trialEnd)}).`
262
- : `Your trial ends in ${days} days (${prettyDate(subscription.trialEnd)}).`;
254
+ ? copy.trialEndsTomorrow(prettyDate(subscription.trialEnd))
255
+ : copy.trialEndsIn(days, prettyDate(subscription.trialEnd));
263
256
  stateBanner = (
264
257
  <StateBanner
265
258
  testId="banner-trialing"
266
259
  variant={variant}
267
260
  message={label}
268
- action={onPortalClick ? { label: "Cancel trial", onClick: onPortalClick } : undefined}
261
+ action={onPortalClick ? { label: copy.cancelTrial, onClick: onPortalClick } : undefined}
269
262
  />
270
263
  );
271
264
  }
@@ -275,17 +268,17 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
275
268
  {stateBanner}
276
269
  <div className="flex items-center justify-between mb-4">
277
270
  <h2 className="text-xl font-bold text-gray-900">{offer.name}</h2>
278
- <StatusBadge status={subscription.status} endsOn={endsOn} />
271
+ <StatusBadge status={subscription.status} endsOn={endsOn} copy={copy} />
279
272
  </div>
280
273
 
281
274
  <div className="mb-4">
282
275
  {offer.isFree ? (
283
- <span aria-label="Free">
284
- <span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">Free</span>
276
+ <span aria-label={copy.freePrice}>
277
+ <span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">{copy.freePrice}</span>
285
278
  </span>
286
279
  ) : (
287
- <span aria-label={`${priceLabel(offer)} ${offer.intervalDisplay}`}>
288
- <span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">{priceLabel(offer)}</span>
280
+ <span aria-label={`${priceLabel(offer, copy.freePrice)} ${offer.intervalDisplay}`}>
281
+ <span aria-hidden="true" className="text-3xl font-extrabold text-gray-900">{priceLabel(offer, copy.freePrice)}</span>
289
282
  {" "}
290
283
  <span aria-hidden="true" className="text-base text-gray-500">{offer.intervalDisplay}</span>
291
284
  </span>
@@ -300,16 +293,15 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
300
293
  One date format on the card, the banners' (crochet_patterns-rjg2e). */}
301
294
  {subscription.status === "trialing" && subscription.trialEnd && !offer.isFree && !ending ? (
302
295
  <p data-testid="first-charge" className="text-sm text-gray-600 mb-6">
303
- {`First charge ${priceLabel(offer)} on ${prettyDate(subscription.trialEnd)}`}
296
+ {copy.firstCharge(priceLabel(offer, copy.freePrice), prettyDate(subscription.trialEnd))}
304
297
  </p>
305
298
  ) : subscription.currentPeriodEnd && !offer.isFree && subscription.status === "active" && !subscription.cancelAtPeriodEnd ? (
306
299
  <p data-testid="next-billing-date" className="text-sm text-gray-600 mb-6">
307
- Next billing date: {prettyDate(subscription.currentPeriodEnd)}
300
+ {copy.nextBilling(prettyDate(subscription.currentPeriodEnd))}
308
301
  </p>
309
302
  ) : subscription.currentPeriodStart && subscription.currentPeriodEnd && !ending ? (
310
303
  <p className="text-sm text-gray-500 mb-6">
311
- Billing period: {prettyDate(subscription.currentPeriodStart)} to{" "}
312
- {prettyDate(subscription.currentPeriodEnd)}
304
+ {copy.billingPeriod(prettyDate(subscription.currentPeriodStart), prettyDate(subscription.currentPeriodEnd))}
313
305
  </p>
314
306
  ) : null}
315
307
 
@@ -322,6 +314,7 @@ export function CurrentPlanCard({ subscription, usage, upgradeCta, onPortalClick
322
314
  key={f.key}
323
315
  feature={f}
324
316
  used={u && typeof u.used === "number" ? u.used : null}
317
+ featureCopy={featureCopy}
325
318
  />
326
319
  );
327
320
  })}
@@ -0,0 +1,18 @@
1
+ import React from "react";
2
+ import type { OfferFeature } from "../types";
3
+ import { featureText, type FeatureCopy } from "../copy";
4
+
5
+ /**
6
+ * The text of one feature row. Without an override it is the bare
7
+ * `<span>{label}</span>` every card always rendered; an override may add a
8
+ * hint under the label.
9
+ */
10
+ export function FeatureLabel({ feature, featureCopy, className }: { feature: OfferFeature; featureCopy?: FeatureCopy; className?: string }) {
11
+ const { label, hint } = featureText(feature, featureCopy);
12
+ return (
13
+ <span className={className}>
14
+ {label}
15
+ {hint && <span data-testid={`feature-hint-${feature.key}`} className="block text-xs text-gray-400">{hint}</span>}
16
+ </span>
17
+ );
18
+ }
@@ -16,6 +16,7 @@ import {
16
16
  type OfferTier,
17
17
  } from "../utils/tiers";
18
18
  import { formatPrice } from "../utils/format";
19
+ import { withBillingCopy, type BillingCopyInput } from "../copy";
19
20
 
20
21
  export interface IntervalEntry {
21
22
  tier: OfferTier;
@@ -38,12 +39,15 @@ export function IntervalToggle({
38
39
  interval,
39
40
  onChange,
40
41
  disabled = false,
42
+ copy,
41
43
  }: {
42
44
  interval: BillingInterval;
43
45
  onChange: (interval: BillingInterval) => void;
44
46
  /** A plan change or checkout is in flight. */
45
47
  disabled?: boolean;
48
+ copy?: BillingCopyInput;
46
49
  }) {
50
+ const words = withBillingCopy(copy);
47
51
  const option = (value: BillingInterval, label: string) => {
48
52
  const selected = interval === value;
49
53
  return (
@@ -65,12 +69,12 @@ export function IntervalToggle({
65
69
  <div className="flex justify-center">
66
70
  <div
67
71
  role="radiogroup"
68
- aria-label="Billing interval"
72
+ aria-label={words.billingInterval}
69
73
  data-testid="interval-toggle"
70
74
  className="inline-flex items-center gap-1 rounded-full bg-gray-100 p-1"
71
75
  >
72
- {option("monthly", "Monthly")}
73
- {option("annual", "Annual")}
76
+ {option("monthly", words.monthly)}
77
+ {option("annual", words.annual)}
74
78
  </div>
75
79
  </div>
76
80
  );
@@ -10,6 +10,7 @@ import React, { useEffect, useRef } from "react";
10
10
  import type { ProductOffer } from "../types";
11
11
  import type { PlanChangeConfirmState } from "../hooks/usePlanChange";
12
12
  import { formatPrice } from "../utils/format";
13
+ import { defaultBillingCopy, withBillingCopy, type BillingCopyInput, type PlanChangeCopy } from "../copy";
13
14
 
14
15
  export interface PlanChangeConfirmProps {
15
16
  state: PlanChangeConfirmState | null;
@@ -17,6 +18,8 @@ export interface PlanChangeConfirmProps {
17
18
  error: string | null;
18
19
  onConfirm: () => void;
19
20
  onCancel: () => void;
21
+ /** Wording overrides; defaults are the original strings. */
22
+ copy?: BillingCopyInput;
20
23
  }
21
24
 
22
25
  function formatDay(iso: string | null | undefined): string | null {
@@ -38,91 +41,72 @@ interface Copy {
38
41
  }
39
42
 
40
43
  /** The dialog's words. Exported for tests. */
41
- export function planChangeCopy(state: PlanChangeConfirmState): Copy {
44
+ export function planChangeCopy(state: PlanChangeConfirmState, copy: PlanChangeCopy = defaultBillingCopy): Copy {
42
45
  const { offer, preview, currentName, leavingUnlisted } = state;
43
- const current = currentName ?? "your current plan";
46
+ const current = currentName ?? copy.confirmYourCurrentPlan;
44
47
  const date = formatDay(preview?.effectiveAt);
45
48
  const mode = preview?.mode ?? null;
46
49
 
47
50
  if (offer.isFree) {
48
- const title = `Downgrade to ${offer.name}?`;
51
+ const title = copy.confirmDowngradeTitle(offer.name);
52
+ const action = copy.confirmDowngradeAction(offer.name);
49
53
  if (mode === "at_period_end") {
50
54
  return {
51
55
  title,
52
56
  body: [
53
- date
54
- ? `Your ${current} plan ends on ${date}. You keep everything in it until then, and you won't be charged again.`
55
- : `Your ${current} plan ends at the end of this billing period. You keep everything in it until then, and you won't be charged again.`,
56
- `After that you're on ${offer.name}, and lose what ${current} includes.`,
57
+ date ? copy.confirmDowngradeAtDate(current, date) : copy.confirmDowngradeAtPeriodEnd(current),
58
+ copy.confirmDowngradeAfter(offer.name, current),
57
59
  ],
58
- action: `Downgrade to ${offer.name}`,
60
+ action,
59
61
  };
60
62
  }
61
63
  if (leavingUnlisted) {
62
64
  return {
63
65
  title,
64
- body: [
65
- `Your ${current} plan ends now, and you lose what it includes.`,
66
- `${current} isn't a plan you can buy, so once it ends you can't get it back.`,
67
- ],
68
- action: `End ${current}`,
66
+ body: [copy.confirmEndsNow(current), copy.confirmUnlistedGone(current)],
67
+ action: copy.confirmEndAction(current),
69
68
  };
70
69
  }
71
70
  if (mode === "immediate") {
72
- return {
73
- title,
74
- body: [`Your ${current} plan ends now, and you lose what it includes.`],
75
- action: `Downgrade to ${offer.name}`,
76
- };
71
+ return { title, body: [copy.confirmEndsNow(current)], action };
77
72
  }
78
- return {
79
- title,
80
- body: [
81
- `Your ${current} plan will end. If it is a paid plan you keep it until the end of the period you've paid for.`,
82
- ],
83
- action: `Downgrade to ${offer.name}`,
84
- };
73
+ return { title, body: [copy.confirmDowngradeGeneric(current)], action };
85
74
  }
86
75
 
87
76
  if (mode === "at_period_end") {
88
77
  return {
89
- title: `Switch to ${offer.name}?`,
78
+ title: copy.confirmSwitchTitle(offer.name),
90
79
  body: [
91
80
  date
92
- ? `You keep ${current} until ${date}. Then your plan changes to ${offer.name} at ${recurring(offer)}.`
93
- : `You keep ${current} until the end of this billing period. Then your plan changes to ${offer.name} at ${recurring(offer)}.`,
94
- "Nothing is charged today.",
81
+ ? copy.confirmSwitchAtDate(current, offer.name, date, recurring(offer))
82
+ : copy.confirmSwitchAtPeriodEnd(current, offer.name, recurring(offer)),
83
+ copy.confirmNothingToday,
95
84
  ],
96
- action: `Switch to ${offer.name}`,
85
+ action: copy.confirmSwitchAction(offer.name),
97
86
  };
98
87
  }
99
88
 
100
89
  if (!preview) {
101
90
  // The preview could not be had: say only what the rules guarantee.
102
91
  return {
103
- title: `Change to ${offer.name}?`,
104
- body: [
105
- `${offer.name} is ${recurring(offer)}. 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.`,
106
- "There's no free trial when you're already on a paid plan.",
107
- ],
108
- action: `Change to ${offer.name}`,
92
+ title: copy.confirmChangeTitle(offer.name),
93
+ body: [copy.confirmChangeRules(offer.name, recurring(offer)), copy.confirmNoTrial],
94
+ action: copy.confirmChangeAction(offer.name),
109
95
  };
110
96
  }
111
97
 
112
98
  const amount = preview?.amountDue != null ? formatPrice(preview.amountDue, preview.currency.toUpperCase()) : null;
113
99
  return {
114
- title: `Upgrade to ${offer.name}?`,
100
+ title: copy.confirmUpgradeTitle(offer.name),
115
101
  body: [
116
- amount
117
- ? `You'll be charged ${amount} today for the rest of this billing period, then ${recurring(offer)}.`
118
- : `You'll be charged today for the rest of this billing period (prorated), then ${recurring(offer)}.`,
119
- `Your plan changes from ${current} to ${offer.name} now. There's no free trial when you're already on a paid plan.`,
102
+ amount ? copy.confirmChargedAmount(amount, recurring(offer)) : copy.confirmChargedProrated(recurring(offer)),
103
+ `${copy.confirmChangesNow(current, offer.name)} ${copy.confirmNoTrial}`,
120
104
  ],
121
- action: amount ? `Upgrade and pay ${amount}` : `Upgrade to ${offer.name}`,
105
+ action: amount ? copy.confirmUpgradeAndPay(amount) : copy.confirmUpgradeAction(offer.name),
122
106
  };
123
107
  }
124
108
 
125
- export function PlanChangeConfirm({ state, confirming, error, onConfirm, onCancel }: PlanChangeConfirmProps) {
109
+ export function PlanChangeConfirm({ state, confirming, error, onConfirm, onCancel, copy: copyOverride }: PlanChangeConfirmProps) {
126
110
  const cancelRef = useRef<HTMLButtonElement>(null);
127
111
  useEffect(() => {
128
112
  if (state) cancelRef.current?.focus();
@@ -137,7 +121,8 @@ export function PlanChangeConfirm({ state, confirming, error, onConfirm, onCance
137
121
  }, [state, onCancel]);
138
122
 
139
123
  if (!state) return null;
140
- const copy = planChangeCopy(state);
124
+ const words = withBillingCopy(copyOverride);
125
+ const copy = planChangeCopy(state, words);
141
126
  const destructive = state.offer.isFree;
142
127
 
143
128
  return (
@@ -172,7 +157,7 @@ export function PlanChangeConfirm({ state, confirming, error, onConfirm, onCance
172
157
  disabled={confirming}
173
158
  className="px-4 py-2.5 text-sm font-semibold rounded-xl border border-gray-300 bg-white text-gray-700 hover:bg-gray-50 cursor-pointer disabled:cursor-not-allowed disabled:opacity-60"
174
159
  >
175
- {destructive ? "Keep my plan" : "Cancel"}
160
+ {destructive ? words.confirmKeepPlan : words.confirmCancel}
176
161
  </button>
177
162
  <button
178
163
  type="button"
@@ -184,7 +169,7 @@ export function PlanChangeConfirm({ state, confirming, error, onConfirm, onCance
184
169
  destructive ? "bg-red-600 hover:bg-red-700" : "bg-blue-600 hover:bg-blue-700"
185
170
  }`}
186
171
  >
187
- {confirming ? "Working…" : copy.action}
172
+ {confirming ? words.confirmWorking : copy.action}
188
173
  </button>
189
174
  </div>
190
175
  </div>