@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.
@@ -12,7 +12,8 @@ import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { PlanChangePreview, ProductOffer, OfferFeature } from "../types";
14
14
  import { cardFeatures, cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
15
- import { formatFeatureLabel } from "../utils/format";
15
+ import { resolveFeatureCopy, withBillingCopy, type BillingCopy, type BillingCopyInput, type FeatureCopy } from "../copy";
16
+ import { FeatureLabel } from "./FeatureLabel";
16
17
  import {
17
18
  isPayingSubscription,
18
19
  isUnlistedOffer,
@@ -55,6 +56,12 @@ export interface PricingDetailPageProps {
55
56
 
56
57
  /** Line under the heading; defaults to the product description. */
57
58
  subtitle?: React.ReactNode;
59
+
60
+ /** Wording overrides (trial phrasing, buttons, headings…); defaults are the original strings. */
61
+ copy?: BillingCopyInput;
62
+
63
+ /** Feature-row overrides by feature key: a label string or `{ label, hint }`. */
64
+ featureCopy?: FeatureCopy;
58
65
  }
59
66
 
60
67
  export function PricingDetailPage({
@@ -65,7 +72,10 @@ export function PricingDetailPage({
65
72
  busy: hostBusy = false,
66
73
  title,
67
74
  subtitle,
75
+ copy: copyOverride,
76
+ featureCopy,
68
77
  }: PricingDetailPageProps) {
78
+ const copy = withBillingCopy(copyOverride);
69
79
  const { product, loading, error, refetch } = useProduct(productId);
70
80
  const currentOffer = resolveCurrentOffer(product?.offers ?? [], currentPlan);
71
81
  const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
@@ -82,7 +92,7 @@ export function PricingDetailPage({
82
92
  if (loading) {
83
93
  return (
84
94
  <div className="text-center py-16 text-gray-500">
85
- Loading pricing...
95
+ {copy.pricingLoading}
86
96
  </div>
87
97
  );
88
98
  }
@@ -90,14 +100,14 @@ export function PricingDetailPage({
90
100
  if (error || !product) {
91
101
  return (
92
102
  <div role="alert" className="text-center py-16">
93
- <p className="text-red-600">Unable to load pricing.</p>
103
+ <p className="text-red-600">{copy.pricingError}</p>
94
104
  <button
95
105
  type="button"
96
106
  data-testid="pricing-retry"
97
107
  onClick={refetch}
98
108
  className="mt-4 py-2 px-5 text-sm font-semibold rounded-lg bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
99
109
  >
100
- Try again
110
+ {copy.tryAgain}
101
111
  </button>
102
112
  </div>
103
113
  );
@@ -106,7 +116,7 @@ export function PricingDetailPage({
106
116
  if (product.offers.length === 0) {
107
117
  return (
108
118
  <div className="text-center py-16 text-gray-500">
109
- No pricing plans available at this time.
119
+ {copy.noPlans}
110
120
  </div>
111
121
  );
112
122
  }
@@ -150,11 +160,14 @@ export function PricingDetailPage({
150
160
  // Extract all unique feature keys across all offers (preserving order)
151
161
  const allFeatureKeys: string[] = [];
152
162
  const featureNameMap: Record<string, string> = {};
163
+ const featureHintMap: Record<string, string | undefined> = {};
153
164
  for (const offer of visibleOffers) {
154
165
  for (const feature of offer.features) {
155
166
  if (!allFeatureKeys.includes(feature.key)) {
156
167
  allFeatureKeys.push(feature.key);
157
- featureNameMap[feature.key] = feature.name;
168
+ const override = resolveFeatureCopy(feature, featureCopy, { generic: true });
169
+ featureNameMap[feature.key] = override?.label ?? feature.name;
170
+ featureHintMap[feature.key] = override?.hint;
158
171
  }
159
172
  }
160
173
  }
@@ -176,9 +189,9 @@ export function PricingDetailPage({
176
189
  role="status"
177
190
  className="mb-8 mx-auto max-w-2xl rounded-xl border border-blue-200 bg-blue-50 p-4 text-center"
178
191
  >
179
- <p className="text-sm font-semibold text-blue-900">You&apos;re on {currentOffer.name}.</p>
192
+ <p className="text-sm font-semibold text-blue-900">{copy.unlistedYoureOn(currentOffer.name)}</p>
180
193
  <p className="mt-1 text-sm text-blue-800">
181
- It isn&apos;t one of the plans below. If you choose another plan, {currentOffer.name} ends and can&apos;t be chosen again.
194
+ {copy.unlistedExplain(currentOffer.name)}
182
195
  </p>
183
196
  </div>
184
197
  )}
@@ -196,10 +209,10 @@ export function PricingDetailPage({
196
209
  className="mb-8 mx-auto max-w-2xl rounded-xl border border-gray-200 bg-gray-50 p-4 text-center"
197
210
  >
198
211
  {currentPlan?.loading ? (
199
- <p className="text-sm text-gray-700">Checking your plan…</p>
212
+ <p className="text-sm text-gray-700">{copy.checkingPlan}</p>
200
213
  ) : (
201
214
  <>
202
- <p className="text-sm text-gray-700">We couldn&apos;t check your plan, so plan changes are paused.</p>
215
+ <p className="text-sm text-gray-700">{copy.planCheckFailed}</p>
203
216
  {currentPlan?.retry && (
204
217
  <button
205
218
  type="button"
@@ -207,7 +220,7 @@ export function PricingDetailPage({
207
220
  onClick={currentPlan.retry}
208
221
  className="mt-2 text-sm font-semibold text-blue-700 hover:underline cursor-pointer"
209
222
  >
210
- Try again
223
+ {copy.tryAgain}
211
224
  </button>
212
225
  )}
213
226
  </>
@@ -217,7 +230,7 @@ export function PricingDetailPage({
217
230
 
218
231
  {hasChoice && (
219
232
  <div className="mb-10">
220
- <IntervalToggle interval={interval} onChange={setInterval} disabled={busy} />
233
+ <IntervalToggle interval={interval} onChange={setInterval} disabled={busy} copy={copyOverride} />
221
234
  </div>
222
235
  )}
223
236
 
@@ -233,10 +246,12 @@ export function PricingDetailPage({
233
246
  buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
234
247
  inherited={below ? cumulative[below.slug] ?? null : null}
235
248
  isCurrent={direction === "current"}
236
- yourPlanNote={direction === "switch" && currentOffer ? `Your plan · billed ${currentOffer.intervalUnit === "year" ? "yearly" : "monthly"}` : null}
237
- ctaLabel={direction ? planCtaLabel(entry.offer, direction, paying) : entry.offer.ctaText}
249
+ yourPlanNote={direction === "switch" && currentOffer ? copy.yourPlanBilled(currentOffer.intervalUnit === "year" ? "yearly" : "monthly") : null}
250
+ ctaLabel={direction ? planCtaLabel(entry.offer, direction, paying, copy) : entry.offer.ctaText}
238
251
  showTrial={direction ? trialApplies(entry.offer, direction, paying) : entry.offer.trialDays > 0}
239
252
  busy={busy || planUnknown}
253
+ copy={copy}
254
+ featureCopy={featureCopy}
240
255
  spinning={spinningId === entry.offer.id}
241
256
  />
242
257
  );
@@ -248,6 +263,8 @@ export function PricingDetailPage({
248
263
  entries={entries}
249
264
  featureKeys={allFeatureKeys}
250
265
  featureNameMap={featureNameMap}
266
+ featureHintMap={featureHintMap}
267
+ copy={copy}
251
268
  />
252
269
  )}
253
270
 
@@ -257,6 +274,7 @@ export function PricingDetailPage({
257
274
  error={planChange.pending ? planChange.error : null}
258
275
  onConfirm={planChange.confirm}
259
276
  onCancel={planChange.cancel}
277
+ copy={copyOverride}
260
278
  />
261
279
  </div>
262
280
  );
@@ -281,10 +299,12 @@ interface DetailCardProps {
281
299
  showTrial?: boolean;
282
300
  /** A plan change is in flight: every button waits. */
283
301
  busy?: boolean;
302
+ copy: BillingCopy;
303
+ featureCopy?: FeatureCopy;
284
304
  }
285
305
 
286
306
  function DetailCard({
287
- entry, onSelect, buildsOn, inherited, yourPlanNote, isCurrent = false, ctaLabel, showTrial, busy = false, spinning = false,
307
+ entry, onSelect, buildsOn, inherited, yourPlanNote, isCurrent = false, ctaLabel, showTrial, busy = false, spinning = false, copy, featureCopy,
288
308
  }: DetailCardProps) {
289
309
  const { offer, tier } = entry;
290
310
  const trial = showTrial ?? offer.trialDays > 0;
@@ -309,7 +329,7 @@ function DetailCard({
309
329
  >
310
330
  {isFeatured && (
311
331
  <span className="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-[var(--billing-accent,#2563eb)] text-white text-xs font-bold px-4 py-1 rounded-full uppercase tracking-wide whitespace-nowrap">
312
- Most Popular
332
+ {copy.mostPopular}
313
333
  </span>
314
334
  )}
315
335
 
@@ -331,7 +351,7 @@ function DetailCard({
331
351
  <div className="mb-5 min-h-8">
332
352
  {trial ? (
333
353
  <span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-sm font-semibold px-3 py-1 rounded-lg w-fit">
334
- {trialLabel(offer)}
354
+ {trialLabel(offer, copy)}
335
355
  </span>
336
356
  ) : yourPlanNote ? (
337
357
  <span data-testid={`your-plan-note-${offer.slug}`} className="inline-block bg-gray-100 text-gray-700 text-sm font-semibold px-3 py-1 rounded-lg w-fit">
@@ -341,7 +361,7 @@ function DetailCard({
341
361
  </div>
342
362
 
343
363
  <p data-testid={`card-lead-${offer.slug}`} className="text-sm font-semibold text-gray-900 mb-1 min-h-5">
344
- {buildsOn ? `Everything in ${buildsOn}, plus:` : null}
364
+ {buildsOn ? copy.everythingIn(buildsOn) : null}
345
365
  </p>
346
366
 
347
367
  <ul className="list-none p-0 m-0 mb-7 flex-1 space-y-0">
@@ -351,7 +371,7 @@ function DetailCard({
351
371
  className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
352
372
  >
353
373
  <FeatureIcon value={feature.value} />
354
- <span>{formatFeatureLabel(feature)}</span>
374
+ <FeatureLabel feature={feature} featureCopy={featureCopy} />
355
375
  {typeof feature.value === "string" && (
356
376
  <span className="ml-auto text-gray-900 font-medium">{feature.value}</span>
357
377
  )}
@@ -378,7 +398,7 @@ function DetailCard({
378
398
  {spinning && (
379
399
  <span aria-hidden="true" className="inline-block align-[-2px] mr-2 h-4 w-4 animate-spin rounded-full border-2 border-current border-b-transparent" />
380
400
  )}
381
- {isCurrent ? "Current plan" : ctaLabel ?? offer.ctaText}
401
+ {isCurrent ? copy.currentPlan : ctaLabel ?? offer.ctaText}
382
402
  </button>
383
403
  </div>
384
404
  );
@@ -388,9 +408,11 @@ interface ComparisonTableProps {
388
408
  entries: IntervalEntry[];
389
409
  featureKeys: string[];
390
410
  featureNameMap: Record<string, string>;
411
+ featureHintMap: Record<string, string | undefined>;
412
+ copy: BillingCopy;
391
413
  }
392
414
 
393
- function ComparisonTable({ entries, featureKeys, featureNameMap }: ComparisonTableProps) {
415
+ function ComparisonTable({ entries, featureKeys, featureNameMap, featureHintMap, copy }: ComparisonTableProps) {
394
416
  const offers = entries.map((e) => e.offer);
395
417
  // offer slug -> feature key -> feature, with cheaper tiers' capabilities
396
418
  // carried upward so a paid plan never shows a dash where free shows a tick.
@@ -402,7 +424,7 @@ function ComparisonTable({ entries, featureKeys, featureNameMap }: ComparisonTab
402
424
  <thead>
403
425
  <tr className="border-b-2 border-gray-200">
404
426
  <th scope="col" className="text-left py-4 px-4 text-sm font-semibold text-gray-500 uppercase tracking-wider">
405
- Feature
427
+ {copy.featureColumn}
406
428
  </th>
407
429
  {entries.map(({ offer, tier }) => (
408
430
  <th
@@ -425,6 +447,7 @@ function ComparisonTable({ entries, featureKeys, featureNameMap }: ComparisonTab
425
447
  >
426
448
  <td scope="row" className="py-3 px-4 text-sm text-gray-700">
427
449
  {featureNameMap[key]}
450
+ {featureHintMap[key] && <span className="block text-xs text-gray-400">{featureHintMap[key]}</span>}
428
451
  </td>
429
452
  {offers.map((offer) => {
430
453
  const feature = featureLookup[offer.slug]?.[key];
@@ -433,7 +456,7 @@ function ComparisonTable({ entries, featureKeys, featureNameMap }: ComparisonTab
433
456
  key={offer.slug}
434
457
  className="text-center py-3 px-4 text-sm"
435
458
  >
436
- <ComparisonCellValue feature={feature} />
459
+ <ComparisonCellValue feature={feature} copy={copy} />
437
460
  </td>
438
461
  );
439
462
  })}
@@ -445,7 +468,7 @@ function ComparisonTable({ entries, featureKeys, featureNameMap }: ComparisonTab
445
468
  );
446
469
  }
447
470
 
448
- function ComparisonCellValue({ feature }: { feature?: OfferFeature }) {
471
+ function ComparisonCellValue({ feature, copy }: { feature?: OfferFeature; copy: BillingCopy }) {
449
472
  if (!feature) {
450
473
  return <span className="text-gray-300">&mdash;</span>;
451
474
  }
@@ -454,7 +477,7 @@ function ComparisonCellValue({ feature }: { feature?: OfferFeature }) {
454
477
 
455
478
  if (value === true) {
456
479
  return (
457
- <svg role="img" aria-label="Included" className="w-5 h-5 text-green-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
480
+ <svg role="img" aria-label={copy.included} className="w-5 h-5 text-green-500 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
458
481
  <path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
459
482
  </svg>
460
483
  );
@@ -462,7 +485,7 @@ function ComparisonCellValue({ feature }: { feature?: OfferFeature }) {
462
485
 
463
486
  if (value === false) {
464
487
  return (
465
- <svg role="img" aria-label="Not included" className="w-5 h-5 text-gray-300 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
488
+ <svg role="img" aria-label={copy.notIncluded} className="w-5 h-5 text-gray-300 mx-auto" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={3}>
466
489
  <path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
467
490
  </svg>
468
491
  );
@@ -473,7 +496,7 @@ function ComparisonCellValue({ feature }: { feature?: OfferFeature }) {
473
496
  }
474
497
 
475
498
  if (typeof value === "number") {
476
- return <span className="text-gray-900 font-medium">{value === -1 ? "Unlimited" : value}</span>;
499
+ return <span className="text-gray-900 font-medium">{value === -1 ? copy.unlimited : value}</span>;
477
500
  }
478
501
 
479
502
  return <span className="text-gray-500">{String(value)}</span>;
@@ -12,7 +12,8 @@ import React from "react";
12
12
  import { useProduct } from "../hooks/useProduct";
13
13
  import type { ProductOffer } from "../types";
14
14
  import { FeatureIcon } from "./FeatureIcon";
15
- import { formatFeatureLabel } from "../utils/format";
15
+ import { withBillingCopy, type BillingCopy, type BillingCopyInput, type FeatureCopy } from "../copy";
16
+ import { FeatureLabel } from "./FeatureLabel";
16
17
  import { resolveCurrentOfferId, trialLabel, type CurrentPlanState } from "../utils/plan";
17
18
  import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
18
19
 
@@ -26,9 +27,16 @@ export interface PricingPageProps {
26
27
  /** The viewer's plan, so their card reads "Current plan" and is disabled.
27
28
  * Omit on signed-out pages. crochet_patterns-34n7. */
28
29
  currentPlan?: CurrentPlanState | null;
30
+
31
+ /** Wording overrides (trial phrasing, buttons, headings…); defaults are the original strings. */
32
+ copy?: BillingCopyInput;
33
+
34
+ /** Feature-row overrides by feature key: a label string or `{ label, hint }`. */
35
+ featureCopy?: FeatureCopy;
29
36
  }
30
37
 
31
- export function PricingPage({ productId, onSelectOffer, currentPlan }: PricingPageProps) {
38
+ export function PricingPage({ productId, onSelectOffer, currentPlan, copy: copyOverride, featureCopy }: PricingPageProps) {
39
+ const copy = withBillingCopy(copyOverride);
32
40
  const { product, loading, error } = useProduct(productId);
33
41
  const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
34
42
  product?.offers ?? [],
@@ -38,7 +46,7 @@ export function PricingPage({ productId, onSelectOffer, currentPlan }: PricingPa
38
46
  if (loading) {
39
47
  return (
40
48
  <div data-testid="pricing-loading" className="text-center py-16 text-gray-500">
41
- Loading pricing...
49
+ {copy.pricingLoading}
42
50
  </div>
43
51
  );
44
52
  }
@@ -46,7 +54,7 @@ export function PricingPage({ productId, onSelectOffer, currentPlan }: PricingPa
46
54
  if (error || !product) {
47
55
  return (
48
56
  <div data-testid="pricing-error" className="text-center py-16 text-red-600">
49
- Unable to load pricing. Please try again.
57
+ {copy.pricingErrorTryAgain}
50
58
  </div>
51
59
  );
52
60
  }
@@ -54,7 +62,7 @@ export function PricingPage({ productId, onSelectOffer, currentPlan }: PricingPa
54
62
  if (product.offers.length === 0) {
55
63
  return (
56
64
  <div className="text-center py-16 text-gray-500">
57
- No pricing plans available at this time.
65
+ {copy.noPlans}
58
66
  </div>
59
67
  );
60
68
  }
@@ -74,7 +82,7 @@ export function PricingPage({ productId, onSelectOffer, currentPlan }: PricingPa
74
82
 
75
83
  {hasChoice && (
76
84
  <div className="mb-10">
77
- <IntervalToggle interval={interval} onChange={setInterval} />
85
+ <IntervalToggle interval={interval} onChange={setInterval} copy={copyOverride} />
78
86
  </div>
79
87
  )}
80
88
 
@@ -85,6 +93,8 @@ export function PricingPage({ productId, onSelectOffer, currentPlan }: PricingPa
85
93
  entry={entry}
86
94
  onSelect={onSelectOffer}
87
95
  isCurrent={entry.offer.id === currentOfferId}
96
+ copy={copy}
97
+ featureCopy={featureCopy}
88
98
  />
89
99
  ))}
90
100
  </div>
@@ -96,9 +106,11 @@ interface PricingCardProps {
96
106
  entry: IntervalEntry;
97
107
  onSelect?: (offer: ProductOffer) => void;
98
108
  isCurrent?: boolean;
109
+ copy: BillingCopy;
110
+ featureCopy?: FeatureCopy;
99
111
  }
100
112
 
101
- function PricingCard({ entry, onSelect, isCurrent = false }: PricingCardProps) {
113
+ function PricingCard({ entry, onSelect, isCurrent = false, copy, featureCopy }: PricingCardProps) {
102
114
  const { offer, tier } = entry;
103
115
  const isFeatured = offer.isFeatured;
104
116
 
@@ -116,7 +128,7 @@ function PricingCard({ entry, onSelect, isCurrent = false }: PricingCardProps) {
116
128
  >
117
129
  {isFeatured && (
118
130
  <span className="absolute -top-3.5 left-1/2 -translate-x-1/2 bg-blue-600 text-white text-xs font-bold px-4 py-1 rounded-full uppercase tracking-wide whitespace-nowrap">
119
- Most Popular
131
+ {copy.mostPopular}
120
132
  </span>
121
133
  )}
122
134
 
@@ -136,7 +148,7 @@ function PricingCard({ entry, onSelect, isCurrent = false }: PricingCardProps) {
136
148
  data-testid="trial-badge"
137
149
  className="inline-block bg-blue-50 text-blue-700 text-sm font-semibold px-3 py-1 rounded-lg mb-5 w-fit"
138
150
  >
139
- {trialLabel(offer)}
151
+ {trialLabel(offer, copy)}
140
152
  </span>
141
153
  )}
142
154
 
@@ -147,7 +159,7 @@ function PricingCard({ entry, onSelect, isCurrent = false }: PricingCardProps) {
147
159
  className="flex items-center gap-2.5 py-2.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
148
160
  >
149
161
  <FeatureIcon value={feature.value} />
150
- <span>{formatFeatureLabel(feature)}</span>
162
+ <FeatureLabel feature={feature} featureCopy={featureCopy} />
151
163
  </li>
152
164
  ))}
153
165
  </ul>
@@ -167,7 +179,7 @@ function PricingCard({ entry, onSelect, isCurrent = false }: PricingCardProps) {
167
179
  }
168
180
  `}
169
181
  >
170
- {isCurrent ? "Current plan" : offer.ctaText}
182
+ {isCurrent ? copy.currentPlan : offer.ctaText}
171
183
  </button>
172
184
  </div>
173
185
  );
@@ -13,7 +13,8 @@ import { useProduct } from "../hooks/useProduct";
13
13
  import type { OfferFeature, ProductOffer } from "../types";
14
14
  import { cardFeatures, cumulativeFeatureLookup, tierBelow } from "../utils/tiers";
15
15
  import { FeatureIcon } from "./FeatureIcon";
16
- import { formatFeatureLabel } from "../utils/format";
16
+ import { withBillingCopy, type BillingCopy, type BillingCopyInput, type FeatureCopy } from "../copy";
17
+ import { FeatureLabel } from "./FeatureLabel";
17
18
  import { resolveCurrentOfferId, trialLabel, type CurrentPlanState } from "../utils/plan";
18
19
  import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers, type IntervalEntry } from "./IntervalToggle";
19
20
 
@@ -30,6 +31,12 @@ export interface PricingSectionProps {
30
31
  /** The viewer's plan, so their card reads "Current plan" and is disabled.
31
32
  * Omit on signed-out pages. crochet_patterns-34n7. */
32
33
  currentPlan?: CurrentPlanState | null;
34
+
35
+ /** Wording overrides (trial phrasing, buttons, headings…); defaults are the original strings. */
36
+ copy?: BillingCopyInput;
37
+
38
+ /** Feature-row overrides by feature key: a label string or `{ label, hint }`. */
39
+ featureCopy?: FeatureCopy;
33
40
  }
34
41
 
35
42
  export function PricingSection({
@@ -37,7 +44,10 @@ export function PricingSection({
37
44
  onSelectOffer,
38
45
  maxFeatures = 4,
39
46
  currentPlan,
47
+ copy: copyOverride,
48
+ featureCopy,
40
49
  }: PricingSectionProps) {
50
+ const copy = withBillingCopy(copyOverride);
41
51
  const { product, loading, error } = useProduct(productId);
42
52
  const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
43
53
  product?.offers ?? [],
@@ -47,7 +57,7 @@ export function PricingSection({
47
57
  if (loading) {
48
58
  return (
49
59
  <div className="text-center py-10 text-gray-500">
50
- Loading pricing...
60
+ {copy.pricingLoading}
51
61
  </div>
52
62
  );
53
63
  }
@@ -55,7 +65,7 @@ export function PricingSection({
55
65
  if (error || !product) {
56
66
  return (
57
67
  <div className="text-center py-10 text-red-600">
58
- Unable to load pricing. Please try again.
68
+ {copy.pricingErrorTryAgain}
59
69
  </div>
60
70
  );
61
71
  }
@@ -63,7 +73,7 @@ export function PricingSection({
63
73
  if (product.offers.length === 0) {
64
74
  return (
65
75
  <div className="text-center py-16 text-gray-500">
66
- No pricing plans available at this time.
76
+ {copy.noPlans}
67
77
  </div>
68
78
  );
69
79
  }
@@ -79,7 +89,7 @@ export function PricingSection({
79
89
  <div data-testid="pricing-section" className="w-full px-4 py-8">
80
90
  {hasChoice && (
81
91
  <div className="mb-6">
82
- <IntervalToggle interval={interval} onChange={setInterval} />
92
+ <IntervalToggle interval={interval} onChange={setInterval} copy={copyOverride} />
83
93
  </div>
84
94
  )}
85
95
  <div className="flex flex-row flex-wrap justify-center gap-4 items-stretch">
@@ -94,6 +104,8 @@ export function PricingSection({
94
104
  buildsOn={below ? entries.find((e) => e.offer.id === below.id)?.tier.name ?? below.name : null}
95
105
  inherited={below ? cumulative[below.slug] ?? null : null}
96
106
  isCurrent={entry.offer.id === currentOfferId}
107
+ copy={copy}
108
+ featureCopy={featureCopy}
97
109
  />
98
110
  );
99
111
  })}
@@ -111,9 +123,11 @@ interface SectionCardProps {
111
123
  buildsOn?: string | null;
112
124
  /** What the tier below already has (cumulative); rows it matches are not repeated. */
113
125
  inherited?: Record<string, OfferFeature> | null;
126
+ copy: BillingCopy;
127
+ featureCopy?: FeatureCopy;
114
128
  }
115
129
 
116
- function SectionCard({ entry, onSelect, maxFeatures, buildsOn, inherited, isCurrent = false }: SectionCardProps) {
130
+ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, inherited, isCurrent = false, copy, featureCopy }: SectionCardProps) {
117
131
  const { offer, tier } = entry;
118
132
  // Featured is the tier's, so the highlight survives the Annual toggle.
119
133
  const isFeatured = !!(tier.monthly?.isFeatured || tier.annual?.isFeatured);
@@ -152,13 +166,13 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, inherited, isCurr
152
166
  data-testid="trial-badge"
153
167
  className="inline-block bg-[var(--billing-accent-soft,#eff6ff)] text-[color:var(--billing-accent-text,#1d4ed8)] text-xs font-semibold px-2 py-0.5 rounded-md w-fit"
154
168
  >
155
- {trialLabel(offer)}
169
+ {trialLabel(offer, copy)}
156
170
  </span>
157
171
  )}
158
172
  </div>
159
173
 
160
174
  <p data-testid={`section-lead-${offer.slug}`} className="text-sm font-semibold text-gray-900 mb-1 min-h-5">
161
- {buildsOn ? `Everything in ${buildsOn}, plus:` : null}
175
+ {buildsOn ? copy.everythingIn(buildsOn) : null}
162
176
  </p>
163
177
 
164
178
  <ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
@@ -168,7 +182,7 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, inherited, isCurr
168
182
  className="flex items-center gap-2 py-1.5 text-sm text-gray-600 border-b border-gray-100 last:border-0"
169
183
  >
170
184
  <FeatureIcon value={feature.value} size="w-3.5 h-3.5" />
171
- <span>{formatFeatureLabel(feature)}</span>
185
+ <FeatureLabel feature={feature} featureCopy={featureCopy} />
172
186
  </li>
173
187
  ))}
174
188
  </ul>
@@ -188,7 +202,7 @@ function SectionCard({ entry, onSelect, maxFeatures, buildsOn, inherited, isCurr
188
202
  }
189
203
  `}
190
204
  >
191
- {isCurrent ? "Current plan" : offer.ctaText}
205
+ {isCurrent ? copy.currentPlan : offer.ctaText}
192
206
  </button>
193
207
  </div>
194
208
  );