@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
|
@@ -34,7 +34,8 @@ import {
|
|
|
34
34
|
type PlanDirection,
|
|
35
35
|
} from "../utils/plan";
|
|
36
36
|
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
|
|
37
|
-
import {
|
|
37
|
+
import { FeatureLabel } from "./FeatureLabel";
|
|
38
|
+
import { withBillingCopy, type BillingCopy, type BillingCopyInput, type FeatureCopy } from "../copy";
|
|
38
39
|
|
|
39
40
|
export interface SubscriptionManagerProps {
|
|
40
41
|
/** Product slug (e.g., "raise-simpli") */
|
|
@@ -57,6 +58,14 @@ export interface SubscriptionManagerProps {
|
|
|
57
58
|
/** The checkout session id from the success URL ({CHECKOUT_SESSION_ID}),
|
|
58
59
|
* passed to success-sync so it reads the checkout that was paid. */
|
|
59
60
|
checkoutSessionId?: string | null;
|
|
61
|
+
|
|
62
|
+
/** Wording overrides (section titles, buttons, badges, trial phrasing…).
|
|
63
|
+
* Defaults are the original strings. Plan names come from the offers. */
|
|
64
|
+
copy?: BillingCopyInput;
|
|
65
|
+
|
|
66
|
+
/** Feature-row overrides by feature key (`ai_refine`, `trial_note`…):
|
|
67
|
+
* a label string or `{ label, hint }`. */
|
|
68
|
+
featureCopy?: FeatureCopy;
|
|
60
69
|
}
|
|
61
70
|
|
|
62
71
|
/** How long to keep looking for a just-paid subscription before saying so. */
|
|
@@ -67,9 +76,8 @@ const ACTIVATION_POLL_MS = 2000;
|
|
|
67
76
|
* that feature so we don't pretend it does). Numeric limits surface as
|
|
68
77
|
* "Unlimited X" (-1) / "Up to N X" / "X" (no limit attribute).
|
|
69
78
|
* startsim-o7u — kills the blank gap between price and CTA on plan cards. */
|
|
70
|
-
function FeatureRow({ feature }: { feature: OfferFeature }) {
|
|
79
|
+
function FeatureRow({ feature, featureCopy }: { feature: OfferFeature; featureCopy?: FeatureCopy }) {
|
|
71
80
|
if (feature.value === false) return null;
|
|
72
|
-
const label = formatFeatureLabel(feature);
|
|
73
81
|
|
|
74
82
|
return (
|
|
75
83
|
<li className="flex items-start gap-2 text-sm text-gray-700">
|
|
@@ -77,17 +85,17 @@ function FeatureRow({ feature }: { feature: OfferFeature }) {
|
|
|
77
85
|
stroke="currentColor" strokeWidth={2.5} aria-hidden="true">
|
|
78
86
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
79
87
|
</svg>
|
|
80
|
-
<
|
|
88
|
+
<FeatureLabel feature={feature} featureCopy={featureCopy} />
|
|
81
89
|
</li>
|
|
82
90
|
);
|
|
83
91
|
}
|
|
84
92
|
|
|
85
|
-
function PlanFeatureList({ features, showTrialNote = true }: { features: OfferFeature[]; showTrialNote?: boolean }) {
|
|
93
|
+
function PlanFeatureList({ features, showTrialNote = true, featureCopy }: { features: OfferFeature[]; showTrialNote?: boolean; featureCopy?: FeatureCopy }) {
|
|
86
94
|
if (!features || features.length === 0) return null;
|
|
87
95
|
const shown = showTrialNote ? features : features.filter((f) => f.key !== TRIAL_NOTE_FEATURE_KEY);
|
|
88
96
|
return (
|
|
89
97
|
<ul data-testid="plan-features" className="space-y-1.5 mb-4">
|
|
90
|
-
{shown.map((f) => <FeatureRow key={f.key} feature={f} />)}
|
|
98
|
+
{shown.map((f) => <FeatureRow key={f.key} feature={f} featureCopy={featureCopy} />)}
|
|
91
99
|
</ul>
|
|
92
100
|
);
|
|
93
101
|
}
|
|
@@ -96,11 +104,11 @@ function PlanFeatureList({ features, showTrialNote = true }: { features: OfferFe
|
|
|
96
104
|
* startsim-pih — so users know "Start Free Trial" actually IS a free trial.
|
|
97
105
|
* No "no card required": checkout collects a card up front and the offer
|
|
98
106
|
* carries nothing that says otherwise (crochet_patterns-34n7). */
|
|
99
|
-
function TrialCopy({ offer }: { offer: ProductOffer }) {
|
|
107
|
+
function TrialCopy({ offer, copy }: { offer: ProductOffer; copy: BillingCopy }) {
|
|
100
108
|
if (!offer.trialDays || offer.trialDays <= 0) return null;
|
|
101
109
|
return (
|
|
102
110
|
<p data-testid="trial-copy" className="-mt-1 mb-3 text-xs text-gray-500">
|
|
103
|
-
{trialLabel(offer)}
|
|
111
|
+
{trialLabel(offer, copy)}
|
|
104
112
|
</p>
|
|
105
113
|
);
|
|
106
114
|
}
|
|
@@ -138,7 +146,7 @@ function formatDate(iso: string): string {
|
|
|
138
146
|
return `${year}-${month}-${day}`;
|
|
139
147
|
}
|
|
140
148
|
|
|
141
|
-
function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string | null }) {
|
|
149
|
+
function StatusBadge({ status, trialEnd, copy }: { status: string; trialEnd: string | null; copy: BillingCopy }) {
|
|
142
150
|
let className = "inline-block text-xs font-semibold px-3 py-1 rounded-full ";
|
|
143
151
|
switch (status) {
|
|
144
152
|
case "active":
|
|
@@ -166,20 +174,12 @@ function StatusBadge({ status, trialEnd }: { status: string; trialEnd: string |
|
|
|
166
174
|
className += "bg-gray-100 text-gray-600";
|
|
167
175
|
}
|
|
168
176
|
|
|
169
|
-
const labelMap
|
|
170
|
-
active: "Active",
|
|
171
|
-
trialing: "Trialing",
|
|
172
|
-
past_due: "Past due",
|
|
173
|
-
cancelled: "Cancelled",
|
|
174
|
-
incomplete: "Incomplete",
|
|
175
|
-
incomplete_expired: "Expired",
|
|
176
|
-
unpaid: "Unpaid",
|
|
177
|
-
};
|
|
177
|
+
const labelMap = copy.status;
|
|
178
178
|
|
|
179
179
|
const readableStatus = labelMap[status] ?? status;
|
|
180
180
|
|
|
181
181
|
return (
|
|
182
|
-
<span data-testid="plan-status" role="status" aria-label={
|
|
182
|
+
<span data-testid="plan-status" role="status" aria-label={copy.statusAria(readableStatus)} className={className}>
|
|
183
183
|
{labelMap[status] ?? status}
|
|
184
184
|
{status === "trialing" && trialEnd && (
|
|
185
185
|
<span className="ml-1">until {formatDate(trialEnd)}</span>
|
|
@@ -194,7 +194,10 @@ export function SubscriptionManager({
|
|
|
194
194
|
onPlanChange,
|
|
195
195
|
statusMessage,
|
|
196
196
|
checkoutSessionId = null,
|
|
197
|
+
copy: copyOverride,
|
|
198
|
+
featureCopy,
|
|
197
199
|
}: SubscriptionManagerProps) {
|
|
200
|
+
const copy = withBillingCopy(copyOverride);
|
|
198
201
|
const { subscription, loading: subLoading, error: subError, refetch: refetchSubscription } = useSubscription(productId);
|
|
199
202
|
const { product, loading: productLoading, error: productError, refetch: refetchProduct } = useProduct(productId);
|
|
200
203
|
const { openPortal, loading: portalLoading, error: portalError } = usePortal();
|
|
@@ -379,8 +382,8 @@ export function SubscriptionManager({
|
|
|
379
382
|
<p className="text-sm font-medium text-green-800">
|
|
380
383
|
{/* A trial is not yet a paid plan (crochet_patterns-reg6a). */}
|
|
381
384
|
{usableSubscription?.status === "trialing"
|
|
382
|
-
?
|
|
383
|
-
:
|
|
385
|
+
? copy.trialStarted(usableSubscription.offer.name)
|
|
386
|
+
: copy.activated}
|
|
384
387
|
</p>
|
|
385
388
|
</div>
|
|
386
389
|
)}
|
|
@@ -390,14 +393,14 @@ export function SubscriptionManager({
|
|
|
390
393
|
<svg className="w-5 h-5 text-gray-500 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
391
394
|
<path strokeLinecap="round" strokeLinejoin="round" d="M6 18L18 6M6 6l12 12" />
|
|
392
395
|
</svg>
|
|
393
|
-
<p className="text-sm font-medium text-gray-700">
|
|
396
|
+
<p className="text-sm font-medium text-gray-700">{copy.checkoutCancelled}</p>
|
|
394
397
|
</div>
|
|
395
398
|
)}
|
|
396
399
|
|
|
397
400
|
{statusMessage === "success" && activation === "done" && !planFoundRef.current && (
|
|
398
401
|
<div data-testid="status-banner-pending" role="status" className="rounded-xl bg-amber-50 border border-amber-200 p-4">
|
|
399
402
|
<p className="text-sm font-medium text-amber-900">
|
|
400
|
-
|
|
403
|
+
{copy.paymentPending}
|
|
401
404
|
</p>
|
|
402
405
|
</div>
|
|
403
406
|
)}
|
|
@@ -405,10 +408,10 @@ export function SubscriptionManager({
|
|
|
405
408
|
{statusMessage === "scheduled" && (
|
|
406
409
|
<div data-testid="status-banner-scheduled" role="status" className="rounded-xl bg-blue-50 border border-blue-200 p-4">
|
|
407
410
|
<p className="text-sm font-medium text-blue-900">
|
|
408
|
-
|
|
411
|
+
{copy.scheduled}
|
|
409
412
|
{usableSubscription?.currentPeriodEnd
|
|
410
|
-
? <>
|
|
411
|
-
: <>
|
|
413
|
+
? <> {copy.scheduledUntil(new Date(usableSubscription.currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" }))}</>
|
|
414
|
+
: <> {copy.scheduledUntilPeriodEnd}</>}
|
|
412
415
|
</p>
|
|
413
416
|
</div>
|
|
414
417
|
)}
|
|
@@ -425,7 +428,7 @@ export function SubscriptionManager({
|
|
|
425
428
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
426
429
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
|
|
427
430
|
</svg>
|
|
428
|
-
<p className="text-sm font-medium text-blue-800">
|
|
431
|
+
<p className="text-sm font-medium text-blue-800">{copy.activating}</p>
|
|
429
432
|
</div>
|
|
430
433
|
)}
|
|
431
434
|
|
|
@@ -450,6 +453,8 @@ export function SubscriptionManager({
|
|
|
450
453
|
<CurrentPlanCard
|
|
451
454
|
subscription={usableSubscription}
|
|
452
455
|
usage={usage}
|
|
456
|
+
copy={copyOverride}
|
|
457
|
+
featureCopy={featureCopy}
|
|
453
458
|
onPortalClick={usableSubscription.viewerIsOwner === false ? undefined : handlePortalClick}
|
|
454
459
|
upgradeCta={
|
|
455
460
|
usableSubscription.viewerIsOwner !== false && usableSubscription.offer.isFree && featuredPaidOffer ? (
|
|
@@ -459,7 +464,7 @@ export function SubscriptionManager({
|
|
|
459
464
|
disabled={planChange.busy}
|
|
460
465
|
className="inline-flex items-center gap-2 px-5 py-2.5 text-sm font-bold rounded-xl bg-blue-600 text-white hover:bg-blue-700 cursor-pointer transition-colors"
|
|
461
466
|
>
|
|
462
|
-
|
|
467
|
+
{copy.upgradeToArrow(featuredPaidOffer.name)}
|
|
463
468
|
</button>
|
|
464
469
|
) : null
|
|
465
470
|
}
|
|
@@ -467,13 +472,13 @@ export function SubscriptionManager({
|
|
|
467
472
|
</>
|
|
468
473
|
) : statusMessage === "success" && activation !== "done" ? null : onFreeByDefault && freeOffer ? (
|
|
469
474
|
<div data-testid="free-by-default" className="text-center py-8">
|
|
470
|
-
<p className="text-lg text-gray-600">
|
|
471
|
-
<p className="text-sm text-gray-400 mt-1">
|
|
475
|
+
<p className="text-lg text-gray-600">{copy.freeByDefault(freeOffer.name)}</p>
|
|
476
|
+
<p className="text-sm text-gray-400 mt-1">{copy.chooseToUpgrade}</p>
|
|
472
477
|
</div>
|
|
473
478
|
) : (
|
|
474
479
|
<div className="text-center py-8">
|
|
475
|
-
<p className="text-lg text-gray-600">
|
|
476
|
-
<p className="text-sm text-gray-400 mt-1">
|
|
480
|
+
<p className="text-lg text-gray-600">{copy.noSubscription}</p>
|
|
481
|
+
<p className="text-sm text-gray-400 mt-1">{copy.chooseToStart}</p>
|
|
477
482
|
</div>
|
|
478
483
|
)}
|
|
479
484
|
|
|
@@ -482,7 +487,7 @@ export function SubscriptionManager({
|
|
|
482
487
|
{productLoading && (
|
|
483
488
|
<div data-testid="available-plans-loading" className="space-y-4">
|
|
484
489
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
485
|
-
{subscription ?
|
|
490
|
+
{subscription ? copy.changePlan : copy.availablePlans}
|
|
486
491
|
</h3>
|
|
487
492
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
488
493
|
<PlanSkeleton testId="skeleton-plan-card" />
|
|
@@ -513,9 +518,9 @@ export function SubscriptionManager({
|
|
|
513
518
|
{!productLoading && !productError && entries.length >= 2 && (
|
|
514
519
|
<div data-testid="available-plans" className="space-y-4">
|
|
515
520
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
516
|
-
{subscription ?
|
|
521
|
+
{subscription ? copy.changePlan : copy.availablePlans}
|
|
517
522
|
</h3>
|
|
518
|
-
{hasChoice && <IntervalToggle interval={interval} onChange={setInterval} />}
|
|
523
|
+
{hasChoice && <IntervalToggle interval={interval} onChange={setInterval} copy={copyOverride} />}
|
|
519
524
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
520
525
|
{entries.map((entry) => {
|
|
521
526
|
const { offer, tier } = entry;
|
|
@@ -526,9 +531,9 @@ export function SubscriptionManager({
|
|
|
526
531
|
// Straight to checkout: say so until the page goes (crochet_patterns-jnkso).
|
|
527
532
|
const opening = planChange.busy && planChange.choosing?.id === offer.id && !planChange.pending && !offer.isFree && !paying;
|
|
528
533
|
const ctaLabel = fallsBackHere
|
|
529
|
-
?
|
|
530
|
-
: opening ?
|
|
531
|
-
: planCtaLabel(offer, direction, paying);
|
|
534
|
+
? copy.startsOn(new Date(endingOn).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" }))
|
|
535
|
+
: opening ? copy.openingCheckout
|
|
536
|
+
: planCtaLabel(offer, direction, paying, copy);
|
|
532
537
|
const showTrial = trialApplies(offer, direction, paying);
|
|
533
538
|
|
|
534
539
|
// Border: current = solid ring; featured upgrade = blue; else gray.
|
|
@@ -567,15 +572,15 @@ export function SubscriptionManager({
|
|
|
567
572
|
{/* Trial copy is for prospective trialers only — once the
|
|
568
573
|
user is on this offer or moving downward, drop the
|
|
569
574
|
"14-day free trial" sales pitch. */}
|
|
570
|
-
{showTrial && <TrialCopy offer={offer} />}
|
|
571
|
-
<PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
|
|
575
|
+
{showTrial && <TrialCopy offer={offer} copy={copy} />}
|
|
576
|
+
<PlanFeatureList features={offer.features} featureCopy={featureCopy} showTrialNote={showTrial || !(offer.trialDays > 0)} />
|
|
572
577
|
<button
|
|
573
578
|
data-testid={`upgrade-cta-${offer.slug}`}
|
|
574
579
|
onClick={() => !isCurrent && !fallsBackHere && !isViewerNonOwner && !planChange.busy && handleOfferClick(offer)}
|
|
575
580
|
disabled={isCurrent || fallsBackHere || isViewerNonOwner || planChange.busy}
|
|
576
581
|
aria-disabled={isCurrent || fallsBackHere || isViewerNonOwner || planChange.busy || undefined}
|
|
577
582
|
aria-busy={planChange.busy && planChange.choosing?.id === offer.id ? true : undefined}
|
|
578
|
-
title={isViewerNonOwner ?
|
|
583
|
+
title={isViewerNonOwner ? copy.onlyOwnerCanChange : undefined}
|
|
579
584
|
className={`mt-auto w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${fallsBackHere ? "bg-gray-100 text-gray-500 cursor-not-allowed" : ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
|
|
580
585
|
>
|
|
581
586
|
{ctaLabel}
|
|
@@ -606,7 +611,7 @@ export function SubscriptionManager({
|
|
|
606
611
|
: "bg-white text-gray-700 border-gray-200 hover:bg-gray-50 hover:border-gray-300 cursor-pointer"
|
|
607
612
|
}`}
|
|
608
613
|
>
|
|
609
|
-
{portalLoading ?
|
|
614
|
+
{portalLoading ? copy.loadingShort : copy.manageBilling}
|
|
610
615
|
</button>
|
|
611
616
|
{portalError && (
|
|
612
617
|
<p data-testid="portal-error" role="alert" className="mt-3 text-sm text-red-700">
|
|
@@ -622,6 +627,7 @@ export function SubscriptionManager({
|
|
|
622
627
|
error={planChange.pending ? planChange.error : null}
|
|
623
628
|
onConfirm={planChange.confirm}
|
|
624
629
|
onCancel={planChange.cancel}
|
|
630
|
+
copy={copyOverride}
|
|
625
631
|
/>
|
|
626
632
|
</div>
|
|
627
633
|
);
|
|
@@ -13,7 +13,8 @@ import React, { useEffect, useRef, useCallback } from "react";
|
|
|
13
13
|
import { useProduct } from "../hooks/useProduct";
|
|
14
14
|
import type { PlanChangePreview, ProductOffer } from "../types";
|
|
15
15
|
import { FeatureIcon } from "./FeatureIcon";
|
|
16
|
-
import {
|
|
16
|
+
import { withBillingCopy, type BillingCopyInput, type FeatureCopy } from "../copy";
|
|
17
|
+
import { FeatureLabel } from "./FeatureLabel";
|
|
17
18
|
import {
|
|
18
19
|
isPayingSubscription,
|
|
19
20
|
isUnlistedOffer,
|
|
@@ -41,6 +42,12 @@ export interface UpgradeModalProps {
|
|
|
41
42
|
/** The viewer's plan, so their card reads "Current plan" and is disabled.
|
|
42
43
|
* Omit on signed-out pages. crochet_patterns-34n7. */
|
|
43
44
|
currentPlan?: CurrentPlanState | null;
|
|
45
|
+
|
|
46
|
+
/** Wording overrides (trial phrasing, buttons, headings…); defaults are the original strings. */
|
|
47
|
+
copy?: BillingCopyInput;
|
|
48
|
+
|
|
49
|
+
/** Feature-row overrides by feature key: a label string or `{ label, hint }`. */
|
|
50
|
+
featureCopy?: FeatureCopy;
|
|
44
51
|
}
|
|
45
52
|
|
|
46
53
|
export function UpgradeModal({
|
|
@@ -49,7 +56,10 @@ export function UpgradeModal({
|
|
|
49
56
|
onClose,
|
|
50
57
|
onSelectOffer,
|
|
51
58
|
currentPlan,
|
|
59
|
+
copy: copyOverride,
|
|
60
|
+
featureCopy,
|
|
52
61
|
}: UpgradeModalProps) {
|
|
62
|
+
const copy = withBillingCopy(copyOverride);
|
|
53
63
|
const { product, loading, error } = useProduct(productId);
|
|
54
64
|
const currentOffer = resolveCurrentOffer(product?.offers ?? [], currentPlan);
|
|
55
65
|
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
@@ -147,22 +157,22 @@ export function UpgradeModal({
|
|
|
147
157
|
</button>
|
|
148
158
|
|
|
149
159
|
<h2 id="upgrade-modal-title" className="text-2xl font-extrabold text-gray-900 text-center mb-8">
|
|
150
|
-
|
|
160
|
+
{copy.chooseAPlan}
|
|
151
161
|
</h2>
|
|
152
162
|
|
|
153
163
|
{loading && (
|
|
154
|
-
<div className="text-center py-8 text-gray-500">
|
|
164
|
+
<div className="text-center py-8 text-gray-500">{copy.loadingShort}</div>
|
|
155
165
|
)}
|
|
156
166
|
|
|
157
167
|
{error && (
|
|
158
168
|
<div className="text-center py-8 text-red-600">
|
|
159
|
-
|
|
169
|
+
{copy.pricingErrorTryAgain}
|
|
160
170
|
</div>
|
|
161
171
|
)}
|
|
162
172
|
|
|
163
173
|
{product && hasChoice && (
|
|
164
174
|
<div className="mb-6">
|
|
165
|
-
<IntervalToggle interval={interval} onChange={setInterval} />
|
|
175
|
+
<IntervalToggle interval={interval} onChange={setInterval} copy={copyOverride} />
|
|
166
176
|
</div>
|
|
167
177
|
)}
|
|
168
178
|
|
|
@@ -202,7 +212,7 @@ export function UpgradeModal({
|
|
|
202
212
|
|
|
203
213
|
{trial && (
|
|
204
214
|
<span data-testid={`trial-pill-${offer.slug}`} className="inline-block bg-blue-50 text-blue-700 text-xs font-semibold px-2.5 py-0.5 rounded-md mb-3 w-fit">
|
|
205
|
-
{trialLabel(offer)}
|
|
215
|
+
{trialLabel(offer, copy)}
|
|
206
216
|
</span>
|
|
207
217
|
)}
|
|
208
218
|
|
|
@@ -211,7 +221,7 @@ export function UpgradeModal({
|
|
|
211
221
|
{features.map((f) => (
|
|
212
222
|
<li key={f.key} className="flex items-center gap-1.5 py-1 text-sm text-gray-600">
|
|
213
223
|
<FeatureIcon value={f.value} size="w-3.5 h-3.5" />
|
|
214
|
-
<
|
|
224
|
+
<FeatureLabel feature={f} featureCopy={featureCopy} />
|
|
215
225
|
</li>
|
|
216
226
|
))}
|
|
217
227
|
</ul>
|
|
@@ -232,7 +242,7 @@ export function UpgradeModal({
|
|
|
232
242
|
}
|
|
233
243
|
`}
|
|
234
244
|
>
|
|
235
|
-
{isCurrent ?
|
|
245
|
+
{isCurrent ? copy.currentPlan : direction ? planCtaLabel(offer, direction, paying, copy) : offer.ctaText}
|
|
236
246
|
</button>
|
|
237
247
|
</div>
|
|
238
248
|
);
|
|
@@ -251,6 +261,7 @@ export function UpgradeModal({
|
|
|
251
261
|
error={planChange.pending ? planChange.error : null}
|
|
252
262
|
onConfirm={planChange.confirm}
|
|
253
263
|
onCancel={planChange.cancel}
|
|
264
|
+
copy={copyOverride}
|
|
254
265
|
/>
|
|
255
266
|
</div>
|
|
256
267
|
);
|
|
@@ -0,0 +1,261 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Copy / label overrides (`copy`, `featureCopy`) on every plan surface.
|
|
3
|
+
* Per component: the default render is byte-identical with and without empty
|
|
4
|
+
* overrides, and the overrides replace the words. Plan names stay offer data.
|
|
5
|
+
*/
|
|
6
|
+
import { describe, it, expect, vi } from "vitest";
|
|
7
|
+
import { render, screen, waitFor, within } from "@testing-library/react";
|
|
8
|
+
import React from "react";
|
|
9
|
+
import { BillingProvider } from "../hooks/BillingProvider";
|
|
10
|
+
import { SubscriptionManager } from "./SubscriptionManager";
|
|
11
|
+
import { PricingDetailPage } from "./PricingDetailPage";
|
|
12
|
+
import { PricingSection } from "./PricingSection";
|
|
13
|
+
import { PricingPage } from "./PricingPage";
|
|
14
|
+
import { UpgradeModal } from "./UpgradeModal";
|
|
15
|
+
import { CurrentPlanCard } from "./CurrentPlanCard";
|
|
16
|
+
import { PlanChangeConfirm, planChangeCopy } from "./PlanChangeConfirm";
|
|
17
|
+
import { withBillingCopy } from "../copy";
|
|
18
|
+
import type { BillingProduct, SubscriptionInfo } from "../types";
|
|
19
|
+
import {
|
|
20
|
+
crochetProductWithTiers,
|
|
21
|
+
crochetFree,
|
|
22
|
+
crochetLite,
|
|
23
|
+
crochetPro,
|
|
24
|
+
crochetFetcher,
|
|
25
|
+
subscriptionTo,
|
|
26
|
+
} from "./__fixtures__/crochetProduct";
|
|
27
|
+
|
|
28
|
+
const text = (el: Element) => (el.textContent ?? "").replace(/\s+/g, " ");
|
|
29
|
+
|
|
30
|
+
const copy = {
|
|
31
|
+
changePlan: "Pick a different level",
|
|
32
|
+
availablePlans: "Levels you can pick",
|
|
33
|
+
currentPlan: "You are here",
|
|
34
|
+
upgradeTo: (n: string) => `Move up to ${n}`,
|
|
35
|
+
downgradeTo: (n: string) => `Move down to ${n}`,
|
|
36
|
+
trialFree: (d: number) => `${d} days on us`,
|
|
37
|
+
mostPopular: "Crowd favourite",
|
|
38
|
+
status: { active: "All good" },
|
|
39
|
+
} as never;
|
|
40
|
+
|
|
41
|
+
const featureCopy = {
|
|
42
|
+
ai_designs_per_month: { label: "Patterns per month", hint: "Resets on the 1st" },
|
|
43
|
+
patterns_per_month: "Patterns per month",
|
|
44
|
+
ai_refine: "Tweak any pattern by chatting",
|
|
45
|
+
ai_designs: "Pattern making",
|
|
46
|
+
trial_note: "Try Pro free for 7 days",
|
|
47
|
+
};
|
|
48
|
+
|
|
49
|
+
const featuredProduct: BillingProduct = {
|
|
50
|
+
...crochetProductWithTiers,
|
|
51
|
+
offers: crochetProductWithTiers.offers.map((o) => (o.slug.startsWith("crochet-pro") ? { ...o, isFeatured: true } : o)),
|
|
52
|
+
};
|
|
53
|
+
|
|
54
|
+
function ui(el: React.ReactElement, product: BillingProduct = featuredProduct, sub: SubscriptionInfo | null = null) {
|
|
55
|
+
return (
|
|
56
|
+
<BillingProvider apiBaseUrl="https://api.test/api/v1" fetcher={crochetFetcher(product, sub)}>
|
|
57
|
+
{el}
|
|
58
|
+
</BillingProvider>
|
|
59
|
+
);
|
|
60
|
+
}
|
|
61
|
+
|
|
62
|
+
async function html(el: React.ReactElement, ready: string, sub: SubscriptionInfo | null = null) {
|
|
63
|
+
const { container, unmount } = render(ui(el, crochetProductWithTiers, sub));
|
|
64
|
+
await waitFor(() => expect(screen.getByTestId(ready)).toBeDefined());
|
|
65
|
+
await new Promise((r) => setTimeout(r, 20));
|
|
66
|
+
const out = container.innerHTML;
|
|
67
|
+
unmount();
|
|
68
|
+
return out;
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
describe("SubscriptionManager copy", () => {
|
|
72
|
+
const el = (props = {}) => <SubscriptionManager productId="crochet-patterns" returnUrl="https://t/s" {...props} />;
|
|
73
|
+
const free = subscriptionTo(crochetFree);
|
|
74
|
+
|
|
75
|
+
it("default render is unchanged by empty overrides", async () => {
|
|
76
|
+
const a = await html(el(), "available-plans", free);
|
|
77
|
+
const b = await html(el({ copy: {}, featureCopy: {} }), "available-plans", free);
|
|
78
|
+
expect(b).toBe(a);
|
|
79
|
+
expect(a).toContain("Change Plan");
|
|
80
|
+
expect(a).toContain("7-day free trial");
|
|
81
|
+
});
|
|
82
|
+
|
|
83
|
+
it("applies copy and featureCopy", async () => {
|
|
84
|
+
render(ui(el({ copy, featureCopy }), featuredProduct, free));
|
|
85
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
86
|
+
const grid = screen.getByTestId("available-plans");
|
|
87
|
+
expect(within(grid).getByText("Pick a different level")).toBeDefined();
|
|
88
|
+
expect(text(grid)).not.toContain("Change Plan");
|
|
89
|
+
expect(screen.getByTestId("upgrade-cta-crochet-free").textContent).toBe("You are here");
|
|
90
|
+
const pro = screen.getByTestId("plan-card-crochet-pro");
|
|
91
|
+
expect(within(pro).getByTestId("trial-copy").textContent).toBe("7 days on us");
|
|
92
|
+
expect(text(pro)).toContain("Tweak any pattern by chatting");
|
|
93
|
+
expect(text(pro)).toContain("Try Pro free for 7 days");
|
|
94
|
+
expect(text(pro)).not.toContain("AI chat");
|
|
95
|
+
const lite = screen.getByTestId("plan-card-crochet-lite");
|
|
96
|
+
expect(text(lite)).toContain("Patterns per month");
|
|
97
|
+
expect(text(lite)).toContain("Resets on the 1st");
|
|
98
|
+
// plan names are offer data, not copy
|
|
99
|
+
expect(within(pro).getByRole("heading").textContent).toBe("Pro");
|
|
100
|
+
expect(screen.getByTestId("current-plan").textContent).toContain("Free");
|
|
101
|
+
});
|
|
102
|
+
|
|
103
|
+
it("status badge and the upgrade button on the current-plan card use copy", async () => {
|
|
104
|
+
render(ui(el({ copy: withBillingCopy({ status: { active: "All good" } as never, upgradeToArrow: (n: string) => `Go ${n}` }) }), crochetProductWithTiers, free));
|
|
105
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
106
|
+
expect(screen.getByTestId("plan-status").textContent).toBe("All good");
|
|
107
|
+
expect(screen.getByTestId("upgrade-cta-current-plan").textContent).toBe("Go Lite");
|
|
108
|
+
});
|
|
109
|
+
|
|
110
|
+
it("with no subscription row it shows the Available Plans title from copy", async () => {
|
|
111
|
+
render(ui(el({ copy: { availablePlans: "Levels you can pick" } }), { ...crochetProductWithTiers, offers: crochetProductWithTiers.offers.filter((o) => !o.isFree) }, null));
|
|
112
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
113
|
+
expect(within(screen.getByTestId("available-plans")).getByText("Levels you can pick")).toBeDefined();
|
|
114
|
+
});
|
|
115
|
+
});
|
|
116
|
+
|
|
117
|
+
describe("PricingSection copy", () => {
|
|
118
|
+
const el = (props = {}) => <PricingSection productId="crochet-patterns" {...props} />;
|
|
119
|
+
it("default unchanged", async () => {
|
|
120
|
+
const a = await html(el(), "pricing-section");
|
|
121
|
+
const b = await html(el({ copy: {}, featureCopy: {} }), "pricing-section");
|
|
122
|
+
expect(b).toBe(a);
|
|
123
|
+
});
|
|
124
|
+
it("applies overrides", async () => {
|
|
125
|
+
render(ui(el({ copy, featureCopy, maxFeatures: 10, currentPlan: { signedIn: true, loading: false, subscription: subscriptionTo(crochetFree) } })));
|
|
126
|
+
await waitFor(() => expect(screen.getByTestId("pricing-section")).toBeDefined());
|
|
127
|
+
expect(text(screen.getByTestId("section-card-crochet-pro"))).toContain("7 days on us");
|
|
128
|
+
expect(text(screen.getByTestId("section-card-crochet-lite"))).toContain("Patterns per month");
|
|
129
|
+
expect(screen.getByTestId("section-cta-crochet-free").textContent).toBe("You are here");
|
|
130
|
+
expect(text(screen.getByTestId("section-card-crochet-lite"))).toContain("Tweak any pattern by chatting");
|
|
131
|
+
});
|
|
132
|
+
});
|
|
133
|
+
|
|
134
|
+
describe("PricingPage copy", () => {
|
|
135
|
+
const el = (props = {}) => <PricingPage productId="crochet-patterns" {...props} />;
|
|
136
|
+
it("default unchanged", async () => {
|
|
137
|
+
const a = await html(el(), "pricing-page");
|
|
138
|
+
const b = await html(el({ copy: {}, featureCopy: {} }), "pricing-page");
|
|
139
|
+
expect(b).toBe(a);
|
|
140
|
+
});
|
|
141
|
+
it("applies overrides", async () => {
|
|
142
|
+
render(ui(el({ copy, featureCopy })));
|
|
143
|
+
await waitFor(() => expect(screen.getByTestId("pricing-page")).toBeDefined());
|
|
144
|
+
expect(text(screen.getByTestId("pricing-page"))).toContain("7 days on us");
|
|
145
|
+
expect(text(screen.getByTestId("pricing-page"))).toContain("Tweak any pattern by chatting");
|
|
146
|
+
expect(text(screen.getByTestId("pricing-page"))).toContain("Crowd favourite");
|
|
147
|
+
});
|
|
148
|
+
});
|
|
149
|
+
|
|
150
|
+
describe("PricingDetailPage copy", () => {
|
|
151
|
+
const el = (props = {}) => <PricingDetailPage productId="crochet-patterns" {...props} />;
|
|
152
|
+
it("default unchanged", async () => {
|
|
153
|
+
const a = await html(el(), "pricing-detail");
|
|
154
|
+
const b = await html(el({ copy: {}, featureCopy: {} }), "pricing-detail");
|
|
155
|
+
expect(b).toBe(a);
|
|
156
|
+
});
|
|
157
|
+
it("applies overrides to cards and the comparison table", async () => {
|
|
158
|
+
render(ui(el({ copy: { ...copy as object, featureColumn: "What you get" }, featureCopy, currentPlan: { signedIn: true, loading: false, subscription: subscriptionTo(crochetFree) } })));
|
|
159
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
160
|
+
const pro = screen.getByTestId("detail-card-crochet-pro");
|
|
161
|
+
expect(text(pro)).toContain("7 days on us");
|
|
162
|
+
expect(text(pro)).toContain("Crowd favourite");
|
|
163
|
+
expect(text(screen.getByTestId("detail-card-crochet-lite"))).toContain("Tweak any pattern by chatting");
|
|
164
|
+
expect(screen.getByTestId("detail-cta-crochet-free").textContent).toBe("You are here");
|
|
165
|
+
const row = screen.getByTestId("comparison-row-ai_refine");
|
|
166
|
+
expect(row.textContent).toContain("Tweak any pattern by chatting");
|
|
167
|
+
expect(text(screen.getByTestId("comparison-table"))).toContain("What you get");
|
|
168
|
+
expect(within(pro).getByRole("heading").textContent).toBe("Pro");
|
|
169
|
+
});
|
|
170
|
+
});
|
|
171
|
+
|
|
172
|
+
describe("downgrade / upgrade button wording", () => {
|
|
173
|
+
it("SubscriptionManager and PricingDetailPage use copy for Downgrade to X and Upgrade to X", async () => {
|
|
174
|
+
const proSub = subscriptionTo(crochetPro);
|
|
175
|
+
const { unmount } = render(ui(<SubscriptionManager productId="crochet-patterns" returnUrl="https://t/s" copy={copy} />, featuredProduct, proSub));
|
|
176
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
177
|
+
expect(screen.getByTestId("upgrade-cta-crochet-free").textContent).toBe("Move down to Free");
|
|
178
|
+
expect(screen.getByTestId("upgrade-cta-crochet-lite").textContent).toBe("Move down to Lite");
|
|
179
|
+
unmount();
|
|
180
|
+
render(ui(<PricingDetailPage productId="crochet-patterns" copy={copy} currentPlan={{ signedIn: true, loading: false, subscription: proSub }} />, featuredProduct, proSub));
|
|
181
|
+
await waitFor(() => expect(screen.getByTestId("pricing-detail")).toBeDefined());
|
|
182
|
+
expect(screen.getByTestId("detail-cta-crochet-free").textContent).toBe("Move down to Free");
|
|
183
|
+
expect(screen.getByTestId("detail-cta-crochet-pro").textContent).toBe("You are here");
|
|
184
|
+
});
|
|
185
|
+
it("defaults stay Downgrade to / Upgrade to", async () => {
|
|
186
|
+
const liteSub = subscriptionTo(crochetLite);
|
|
187
|
+
render(ui(<SubscriptionManager productId="crochet-patterns" returnUrl="https://t/s" />, featuredProduct, liteSub));
|
|
188
|
+
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
189
|
+
expect(screen.getByTestId("upgrade-cta-crochet-free").textContent).toBe("Downgrade to Free");
|
|
190
|
+
expect(screen.getByTestId("upgrade-cta-crochet-pro").textContent).toBe("Upgrade to Pro");
|
|
191
|
+
});
|
|
192
|
+
});
|
|
193
|
+
|
|
194
|
+
describe("UpgradeModal copy", () => {
|
|
195
|
+
const el = (props = {}) => <UpgradeModal productId="crochet-patterns" open onClose={() => {}} {...props} />;
|
|
196
|
+
it("default unchanged", async () => {
|
|
197
|
+
const a = await html(el(), "upgrade-offers");
|
|
198
|
+
const b = await html(el({ copy: {}, featureCopy: {} }), "upgrade-offers");
|
|
199
|
+
expect(b).toBe(a);
|
|
200
|
+
});
|
|
201
|
+
it("applies overrides", async () => {
|
|
202
|
+
render(ui(el({ copy: { ...copy as object, chooseAPlan: "Pick a level" }, featureCopy })));
|
|
203
|
+
await waitFor(() => expect(screen.getByTestId("upgrade-offers")).toBeDefined());
|
|
204
|
+
expect(screen.getByRole("heading", { name: "Pick a level" })).toBeDefined();
|
|
205
|
+
expect(text(screen.getByTestId("upgrade-offer-crochet-pro"))).toContain("7 days on us");
|
|
206
|
+
expect(text(screen.getByTestId("upgrade-offer-crochet-pro"))).toContain("Tweak any pattern by chatting");
|
|
207
|
+
});
|
|
208
|
+
});
|
|
209
|
+
|
|
210
|
+
describe("CurrentPlanCard copy", () => {
|
|
211
|
+
const usage = {};
|
|
212
|
+
it("default unchanged", () => {
|
|
213
|
+
const sub = subscriptionTo(crochetPro, "past_due");
|
|
214
|
+
const a = render(<CurrentPlanCard subscription={sub} usage={usage} onPortalClick={() => {}} />);
|
|
215
|
+
const ha = a.container.innerHTML; a.unmount();
|
|
216
|
+
const b = render(<CurrentPlanCard subscription={sub} usage={usage} onPortalClick={() => {}} copy={{}} featureCopy={{}} />);
|
|
217
|
+
expect(b.container.innerHTML).toBe(ha);
|
|
218
|
+
expect(ha).toContain("Past due");
|
|
219
|
+
expect(ha).toContain("Update card");
|
|
220
|
+
});
|
|
221
|
+
it("applies overrides", () => {
|
|
222
|
+
const sub = subscriptionTo(crochetPro, "past_due");
|
|
223
|
+
render(<CurrentPlanCard subscription={sub} usage={usage} onPortalClick={() => {}}
|
|
224
|
+
copy={{ status: { past_due: "Needs attention" }, updateCard: "Fix payment", pastDueMessage: "Your last payment did not go through." } as never}
|
|
225
|
+
featureCopy={featureCopy} />);
|
|
226
|
+
expect(screen.getByTestId("plan-status").textContent).toBe("Needs attention");
|
|
227
|
+
expect(screen.getByText("Fix payment")).toBeDefined();
|
|
228
|
+
expect(screen.getByText("Your last payment did not go through.")).toBeDefined();
|
|
229
|
+
expect(screen.getByTestId("current-plan-feature-ai_refine").textContent).toContain("Tweak any pattern by chatting");
|
|
230
|
+
expect(screen.getByRole("heading", { level: 2 }).textContent).toBe("Pro");
|
|
231
|
+
});
|
|
232
|
+
});
|
|
233
|
+
|
|
234
|
+
describe("PlanChangeConfirm copy", () => {
|
|
235
|
+
const state = { offer: crochetFree, preview: null, currentName: "Pro", leavingUnlisted: false } as never;
|
|
236
|
+
it("default text unchanged", () => {
|
|
237
|
+
const c = planChangeCopy(state);
|
|
238
|
+
expect(c.title).toBe("Downgrade to Free?");
|
|
239
|
+
expect(c.action).toBe("Downgrade to Free");
|
|
240
|
+
expect(planChangeCopy(state, withBillingCopy())).toEqual(c);
|
|
241
|
+
});
|
|
242
|
+
it("overrides titles, actions, bodies and buttons", () => {
|
|
243
|
+
const o = withBillingCopy({
|
|
244
|
+
confirmDowngradeTitle: (n) => `Move down to ${n}?`,
|
|
245
|
+
confirmDowngradeAction: (n) => `Yes, ${n}`,
|
|
246
|
+
confirmDowngradeGeneric: (cur) => `${cur} will end.`,
|
|
247
|
+
confirmKeepPlan: "Stay put",
|
|
248
|
+
});
|
|
249
|
+
const c = planChangeCopy(state, o);
|
|
250
|
+
expect(c.title).toBe("Move down to Free?");
|
|
251
|
+
expect(c.action).toBe("Yes, Free");
|
|
252
|
+
expect(c.body).toEqual(["Pro will end."]);
|
|
253
|
+
render(<PlanChangeConfirm state={state} confirming={false} error={null} onConfirm={vi.fn()} onCancel={vi.fn()} copy={o} />);
|
|
254
|
+
expect(screen.getByTestId("plan-change-cancel").textContent).toBe("Stay put");
|
|
255
|
+
expect(screen.getByTestId("plan-change-confirm-button").textContent).toBe("Yes, Free");
|
|
256
|
+
});
|
|
257
|
+
it("lite is not special-cased: names come from the offer", () => {
|
|
258
|
+
const c = planChangeCopy({ ...(state as object), offer: crochetLite, preview: null } as never);
|
|
259
|
+
expect(c.title).toBe("Change to Lite?");
|
|
260
|
+
});
|
|
261
|
+
});
|