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