@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.
@@ -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 { formatFeatureLabel } from "../utils/format";
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
- <span>{label}</span>
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: Record<string, string> = {
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={`Subscription status: ${readableStatus}`} className={className}>
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
- ? `Your ${usableSubscription.offer.name} trial has started.`
383
- : "Subscription activated successfully!"}
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">Checkout was cancelled. You can try again whenever you&apos;re ready.</p>
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
- We haven&apos;t received the payment confirmation yet. It can take a minute; refresh this page to check again. Please don&apos;t pay a second time.
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
- Your plan change is scheduled.
411
+ {copy.scheduled}
409
412
  {usableSubscription?.currentPeriodEnd
410
- ? <> You keep your current plan until {new Date(usableSubscription.currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })}.</>
411
- : <> You keep your current plan until the end of this billing period.</>}
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">Activating your subscription...</p>
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
- Upgrade to {featuredPaidOffer.name} →
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">You&apos;re on the {freeOffer.name} plan</p>
471
- <p className="text-sm text-gray-400 mt-1">Choose a plan below to upgrade.</p>
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">No active subscription</p>
476
- <p className="text-sm text-gray-400 mt-1">Choose a plan below to get started.</p>
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 ? "Change Plan" : "Available Plans"}
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 ? "Change Plan" : "Available Plans"}
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
- ? `Starts ${new Date(endingOn).toLocaleDateString("en-US", { year: "numeric", month: "short", day: "numeric", timeZone: "UTC" })}`
530
- : opening ? "Opening checkout\u2026"
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 ? "Only the team owner can change the plan" : undefined}
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 ? "Loading..." : "Manage Billing"}
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 { formatFeatureLabel } from "../utils/format";
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
- Choose a plan
160
+ {copy.chooseAPlan}
151
161
  </h2>
152
162
 
153
163
  {loading && (
154
- <div className="text-center py-8 text-gray-500">Loading...</div>
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
- Unable to load pricing. Please try again.
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
- <span>{formatFeatureLabel(f)}</span>
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 ? "Current plan" : direction ? planCtaLabel(offer, direction, paying) : offer.ctaText}
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
+ });