@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
|
@@ -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 {
|
|
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
|
-
|
|
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">
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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">
|
|
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
|
-
|
|
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">
|
|
212
|
+
<p className="text-sm text-gray-700">{copy.checkingPlan}</p>
|
|
200
213
|
) : (
|
|
201
214
|
<>
|
|
202
|
-
<p className="text-sm text-gray-700">
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
<
|
|
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 ?
|
|
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
|
-
|
|
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">—</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=
|
|
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=
|
|
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 ?
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
<
|
|
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 ?
|
|
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 {
|
|
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
|
-
|
|
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
|
-
|
|
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
|
-
|
|
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 ?
|
|
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
|
-
<
|
|
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 ?
|
|
205
|
+
{isCurrent ? copy.currentPlan : offer.ctaText}
|
|
192
206
|
</button>
|
|
193
207
|
</div>
|
|
194
208
|
);
|