@startsimpli/billing 0.4.23 → 0.4.25
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 +1 -1
- package/src/components/CurrentPlanCard.tsx +5 -10
- package/src/components/IntervalToggle.tsx +113 -0
- package/src/components/PlanChange.crochet.test.tsx +274 -0
- package/src/components/PlanChangeConfirm.tsx +193 -0
- package/src/components/PlanPicker.crochet.test.tsx +322 -0
- package/src/components/PricingDetailPage.tsx +144 -47
- package/src/components/PricingPage.test.tsx +2 -2
- package/src/components/PricingPage.tsx +46 -24
- package/src/components/PricingSection.test.tsx +6 -5
- package/src/components/PricingSection.tsx +58 -38
- package/src/components/SubscriptionManager.test.tsx +38 -19
- package/src/components/SubscriptionManager.tsx +168 -99
- package/src/components/UpgradeModal.tsx +89 -27
- package/src/components/__fixtures__/crochetProduct.ts +172 -0
- package/src/components/index.ts +2 -0
- package/src/hooks/index.ts +2 -0
- package/src/hooks/usePlanChange.ts +144 -0
- package/src/hooks/useSuccessSync.ts +3 -3
- package/src/index.ts +23 -4
- package/src/types/index.ts +33 -0
- package/src/utils/api.ts +38 -1
- package/src/utils/format.test.ts +57 -0
- package/src/utils/format.ts +54 -0
- package/src/utils/plan.ts +122 -0
- package/src/utils/tiers.interval.test.ts +76 -0
- package/src/utils/tiers.ts +105 -0
|
@@ -12,15 +12,28 @@
|
|
|
12
12
|
* />
|
|
13
13
|
*/
|
|
14
14
|
|
|
15
|
-
import React, { useEffect, useRef } from "react";
|
|
15
|
+
import React, { useCallback, useEffect, useRef, useState } from "react";
|
|
16
16
|
import { useSubscription } from "../hooks/useSubscription";
|
|
17
17
|
import { useProduct } from "../hooks/useProduct";
|
|
18
18
|
import { usePortal } from "../hooks/usePortal";
|
|
19
19
|
import { useCheckout } from "../hooks/useCheckout";
|
|
20
20
|
import { useUsage } from "../hooks/useUsage";
|
|
21
21
|
import { useSuccessSync } from "../hooks/useSuccessSync";
|
|
22
|
-
import
|
|
22
|
+
import { usePlanChange } from "../hooks/usePlanChange";
|
|
23
|
+
import type { OfferFeature, PlanChangePreview, ProductOffer } from "../types";
|
|
23
24
|
import { CurrentPlanCard } from "./CurrentPlanCard";
|
|
25
|
+
import { PlanChangeConfirm } from "./PlanChangeConfirm";
|
|
26
|
+
import {
|
|
27
|
+
isPayingSubscription,
|
|
28
|
+
isUnlistedOffer,
|
|
29
|
+
planCtaLabel,
|
|
30
|
+
planDirection,
|
|
31
|
+
trialApplies,
|
|
32
|
+
TRIAL_NOTE_FEATURE_KEY,
|
|
33
|
+
type PlanDirection,
|
|
34
|
+
} from "../utils/plan";
|
|
35
|
+
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
|
|
36
|
+
import { formatFeatureLabel } from "../utils/format";
|
|
24
37
|
|
|
25
38
|
export interface SubscriptionManagerProps {
|
|
26
39
|
/** Product slug (e.g., "raise-simpli") */
|
|
@@ -29,28 +42,33 @@ export interface SubscriptionManagerProps {
|
|
|
29
42
|
/** URL to return to after portal session */
|
|
30
43
|
returnUrl: string;
|
|
31
44
|
|
|
32
|
-
/**
|
|
33
|
-
|
|
45
|
+
/** Does a paid plan change once the viewer has chosen (and, when they
|
|
46
|
+
* already pay, confirmed) it — typically a checkout via useCheckout. Gets
|
|
47
|
+
* the backend's preview when one was made. Return "navigating" after
|
|
48
|
+
* sending the browser away; throw to show an error. */
|
|
49
|
+
onPlanChange?: (offer: ProductOffer, preview?: PlanChangePreview | null) => unknown;
|
|
34
50
|
|
|
35
|
-
/** Optional status message to display (e.g., after checkout redirect)
|
|
36
|
-
|
|
51
|
+
/** Optional status message to display (e.g., after checkout redirect).
|
|
52
|
+
* "success" shows "Activating…" until the paid plan is actually found;
|
|
53
|
+
* "scheduled" is a change that takes effect at period end. */
|
|
54
|
+
statusMessage?: "success" | "cancelled" | "activating" | "scheduled" | null;
|
|
55
|
+
|
|
56
|
+
/** The checkout session id from the success URL ({CHECKOUT_SESSION_ID}),
|
|
57
|
+
* passed to success-sync so it reads the checkout that was paid. */
|
|
58
|
+
checkoutSessionId?: string | null;
|
|
37
59
|
}
|
|
38
60
|
|
|
61
|
+
/** How long to keep looking for a just-paid subscription before saying so. */
|
|
62
|
+
const ACTIVATION_POLLS = 5;
|
|
63
|
+
const ACTIVATION_POLL_MS = 2000;
|
|
64
|
+
|
|
39
65
|
/** Render one OfferFeature row. Skips boolean-false (the plan _doesn't_ have
|
|
40
66
|
* that feature so we don't pretend it does). Numeric limits surface as
|
|
41
67
|
* "Unlimited X" (-1) / "Up to N X" / "X" (no limit attribute).
|
|
42
68
|
* startsim-o7u — kills the blank gap between price and CTA on plan cards. */
|
|
43
69
|
function FeatureRow({ feature }: { feature: OfferFeature }) {
|
|
44
|
-
|
|
45
|
-
|
|
46
|
-
|
|
47
|
-
let label = feature.name;
|
|
48
|
-
if (typeof v === "number" || typeof feature.limit === "number") {
|
|
49
|
-
const limit = typeof feature.limit === "number" ? feature.limit
|
|
50
|
-
: typeof v === "number" ? v : null;
|
|
51
|
-
if (limit === -1) label = `Unlimited ${feature.name.toLowerCase()}`;
|
|
52
|
-
else if (limit !== null && limit > 0) label = `Up to ${limit} ${feature.name.toLowerCase()}`;
|
|
53
|
-
}
|
|
70
|
+
if (feature.value === false) return null;
|
|
71
|
+
const label = formatFeatureLabel(feature);
|
|
54
72
|
|
|
55
73
|
return (
|
|
56
74
|
<li className="flex items-start gap-2 text-sm text-gray-700">
|
|
@@ -63,22 +81,25 @@ function FeatureRow({ feature }: { feature: OfferFeature }) {
|
|
|
63
81
|
);
|
|
64
82
|
}
|
|
65
83
|
|
|
66
|
-
function PlanFeatureList({ features }: { features: OfferFeature[] }) {
|
|
84
|
+
function PlanFeatureList({ features, showTrialNote = true }: { features: OfferFeature[]; showTrialNote?: boolean }) {
|
|
67
85
|
if (!features || features.length === 0) return null;
|
|
86
|
+
const shown = showTrialNote ? features : features.filter((f) => f.key !== TRIAL_NOTE_FEATURE_KEY);
|
|
68
87
|
return (
|
|
69
88
|
<ul data-testid="plan-features" className="space-y-1.5 mb-4">
|
|
70
|
-
{
|
|
89
|
+
{shown.map((f) => <FeatureRow key={f.key} feature={f} />)}
|
|
71
90
|
</ul>
|
|
72
91
|
);
|
|
73
92
|
}
|
|
74
93
|
|
|
75
94
|
/** Subtle trial copy below the price block on offers that ship a trial.
|
|
76
|
-
* startsim-pih — so users know "Start Free Trial" actually IS a free trial.
|
|
95
|
+
* startsim-pih — so users know "Start Free Trial" actually IS a free trial.
|
|
96
|
+
* No "no card required": checkout collects a card up front and the offer
|
|
97
|
+
* carries nothing that says otherwise (crochet_patterns-34n7). */
|
|
77
98
|
function TrialCopy({ trialDays }: { trialDays: number }) {
|
|
78
99
|
if (!trialDays || trialDays <= 0) return null;
|
|
79
100
|
return (
|
|
80
101
|
<p data-testid="trial-copy" className="-mt-1 mb-3 text-xs text-gray-500">
|
|
81
|
-
{trialDays}-day free trial
|
|
102
|
+
{trialDays}-day free trial
|
|
82
103
|
</p>
|
|
83
104
|
);
|
|
84
105
|
}
|
|
@@ -171,6 +192,7 @@ export function SubscriptionManager({
|
|
|
171
192
|
returnUrl,
|
|
172
193
|
onPlanChange,
|
|
173
194
|
statusMessage,
|
|
195
|
+
checkoutSessionId = null,
|
|
174
196
|
}: SubscriptionManagerProps) {
|
|
175
197
|
const { subscription, loading: subLoading, error: subError, refetch: refetchSubscription } = useSubscription(productId);
|
|
176
198
|
const { product, loading: productLoading, error: productError, refetch: refetchProduct } = useProduct(productId);
|
|
@@ -178,6 +200,27 @@ export function SubscriptionManager({
|
|
|
178
200
|
const { subscribeFree } = useCheckout();
|
|
179
201
|
const { usage } = useUsage(productId);
|
|
180
202
|
const { sync: successSync } = useSuccessSync();
|
|
203
|
+
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
204
|
+
product?.offers ?? [],
|
|
205
|
+
subscription?.offer?.id ?? null,
|
|
206
|
+
);
|
|
207
|
+
|
|
208
|
+
// Free offers → POST /billing/subscribe-free/ then refetch (no Stripe
|
|
209
|
+
// round-trip for $0 plans — startsim-6vk). Paid offers → the app's
|
|
210
|
+
// onPlanChange (a checkout). Either way only after the viewer has confirmed
|
|
211
|
+
// a change to a plan they already have (crochet_patterns-lr7mj).
|
|
212
|
+
const proceed = useCallback(
|
|
213
|
+
async (offer: ProductOffer, preview: PlanChangePreview | null) => {
|
|
214
|
+
if (offer.isFree) {
|
|
215
|
+
await subscribeFree(offer.id);
|
|
216
|
+
await refetchSubscription();
|
|
217
|
+
return;
|
|
218
|
+
}
|
|
219
|
+
return onPlanChange?.(offer, preview);
|
|
220
|
+
},
|
|
221
|
+
[subscribeFree, refetchSubscription, onPlanChange],
|
|
222
|
+
);
|
|
223
|
+
const planChange = usePlanChange(proceed);
|
|
181
224
|
|
|
182
225
|
// startsim-ngw — when the user lands on ?status=success (just back from
|
|
183
226
|
// Stripe checkout), POST /billing/success-sync/ ONCE to populate the cache
|
|
@@ -185,18 +228,35 @@ export function SubscriptionManager({
|
|
|
185
228
|
// webhook, then refetch the subscription. The ref guard prevents the effect
|
|
186
229
|
// re-firing on React StrictMode double-invokes.
|
|
187
230
|
const successSyncedRef = useRef(false);
|
|
231
|
+
// "syncing" until the paid plan is found or we have looked long enough.
|
|
232
|
+
// The banner said "activated" from the URL alone, beside "You're on the
|
|
233
|
+
// Free plan" when the sync had not found it (crochet_patterns-nag60).
|
|
234
|
+
const [activation, setActivation] = useState<"idle" | "syncing" | "done">("idle");
|
|
235
|
+
const planFoundRef = useRef(false);
|
|
236
|
+
// Back from a checkout (session id in the URL): only a paid row is the
|
|
237
|
+
// plan just bought. Otherwise any row the change produced counts.
|
|
238
|
+
planFoundRef.current = !!subscription?.offer && (!checkoutSessionId || !subscription.offer.isFree);
|
|
188
239
|
useEffect(() => {
|
|
189
240
|
if (statusMessage === "success" && !successSyncedRef.current) {
|
|
190
241
|
successSyncedRef.current = true;
|
|
242
|
+
setActivation("syncing");
|
|
191
243
|
(async () => {
|
|
192
244
|
try {
|
|
193
|
-
await successSync();
|
|
194
|
-
}
|
|
245
|
+
await successSync({ sessionId: checkoutSessionId, productSlug: productId });
|
|
246
|
+
} catch {
|
|
247
|
+
/* the refetch below still tells us what the backend has */
|
|
248
|
+
}
|
|
249
|
+
const sleep = (ms: number) => new Promise((r) => setTimeout(r, ms));
|
|
250
|
+
for (let i = 0; i < ACTIVATION_POLLS; i++) {
|
|
195
251
|
await refetchSubscription();
|
|
252
|
+
await sleep(50); // let the refetched row render before reading it
|
|
253
|
+
if (planFoundRef.current) break;
|
|
254
|
+
if (i < ACTIVATION_POLLS - 1) await sleep(ACTIVATION_POLL_MS);
|
|
196
255
|
}
|
|
256
|
+
setActivation("done");
|
|
197
257
|
})();
|
|
198
258
|
}
|
|
199
|
-
}, [statusMessage, successSync, refetchSubscription]);
|
|
259
|
+
}, [statusMessage, successSync, refetchSubscription, checkoutSessionId, productId]);
|
|
200
260
|
|
|
201
261
|
// startsim-z6f: skeleton matching the final layout (current-plan box +
|
|
202
262
|
// 2-card grid) so the transition from loading → loaded doesn't reflow.
|
|
@@ -233,56 +293,30 @@ export function SubscriptionManager({
|
|
|
233
293
|
// BillingApiClient, but guard here too so CurrentPlanCard can never deref
|
|
234
294
|
// `subscription.offer.*` on undefined and white-screen the page.
|
|
235
295
|
const usableSubscription = subscription?.offer ? subscription : null;
|
|
236
|
-
|
|
237
|
-
|
|
238
|
-
//
|
|
239
|
-
|
|
240
|
-
|
|
241
|
-
const
|
|
242
|
-
|
|
243
|
-
|
|
244
|
-
|
|
245
|
-
|
|
246
|
-
|
|
247
|
-
|
|
248
|
-
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
|
|
256
|
-
|
|
257
|
-
}
|
|
258
|
-
|
|
259
|
-
/** Visible CTA label for the card, branching on direction. The default
|
|
260
|
-
* `offer.ctaText` (e.g. "Start Free Trial") is for UPGRADE only — using it
|
|
261
|
-
* on a Free card when a Pro user is looking is the bug startsim-21f closes. */
|
|
262
|
-
function ctaLabelFor(cardOffer: ProductOffer, direction: Direction): string {
|
|
263
|
-
if (direction === "current") return "Current Plan";
|
|
264
|
-
if (direction === "downgrade") return `Downgrade to ${cardOffer.name}`;
|
|
265
|
-
return cardOffer.ctaText || `Upgrade to ${cardOffer.name}`;
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
// Branch the offer CTA internally so apps don't reinvent it:
|
|
269
|
-
// - Free offers → POST /billing/subscribe-free/ then refetch subscription
|
|
270
|
-
// (no Stripe round-trip needed for $0 plans — startsim-6vk).
|
|
271
|
-
// - Paid offers → app-supplied onPlanChange (typically a Stripe checkout
|
|
272
|
-
// redirect via useCheckout).
|
|
273
|
-
const handleOfferClick = async (offer: ProductOffer) => {
|
|
296
|
+
// No subscription row once the fetch has settled (404 → null; a signed-out
|
|
297
|
+
// viewer gets a 401 and the error branch above) means the free plan:
|
|
298
|
+
// that card reads "Current plan" and is disabled (crochet_patterns-34n7).
|
|
299
|
+
const freeOffer = product?.offers.find((o) => o.isFree) ?? null;
|
|
300
|
+
const onFreeByDefault = !usableSubscription && !subLoading && !subError && freeOffer !== null;
|
|
301
|
+
const currentOffer = usableSubscription?.offer ?? (onFreeByDefault ? freeOffer : null);
|
|
302
|
+
const tierKeyOf = (offerId: string) =>
|
|
303
|
+
entries.find(({ tier }) => tier.monthly?.id === offerId || tier.annual?.id === offerId)?.tier.key ?? null;
|
|
304
|
+
// Show every tier in the Change Plan grid (not just non-current ones) so
|
|
305
|
+
// users can compare and see what they have; `entries` is one offer per tier
|
|
306
|
+
// for the chosen interval. The card matching their current plan gets a
|
|
307
|
+
// "Current plan" badge + disabled CTA. startsim-1f1, crochet_patterns-34n7.
|
|
308
|
+
|
|
309
|
+
// Direction and label per card, shared with /pricing (utils/plan):
|
|
310
|
+
// startsim-21f, crochet_patterns-anw61.
|
|
311
|
+
const paying = isPayingSubscription(usableSubscription);
|
|
312
|
+
const onUnlistedPlan = !!product && isUnlistedOffer(product.offers, usableSubscription?.offer);
|
|
313
|
+
const directionFor = (cardOffer: ProductOffer): PlanDirection =>
|
|
314
|
+
planDirection(cardOffer, currentOffer, tierKeyOf);
|
|
315
|
+
|
|
316
|
+
const handleOfferClick = (offer: ProductOffer) => {
|
|
274
317
|
// Non-owner viewers can SEE the plans but can't change them — startsim-wo9.
|
|
275
318
|
if (subscription?.viewerIsOwner === false) return;
|
|
276
|
-
|
|
277
|
-
try {
|
|
278
|
-
await subscribeFree(offer.id);
|
|
279
|
-
await refetchSubscription();
|
|
280
|
-
} catch {
|
|
281
|
-
/* Error captured by hook state — banner not added here yet. */
|
|
282
|
-
}
|
|
283
|
-
return;
|
|
284
|
-
}
|
|
285
|
-
onPlanChange?.(offer);
|
|
319
|
+
void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
|
|
286
320
|
};
|
|
287
321
|
|
|
288
322
|
// The "Upgrade" CTA on the current-plan card points at the NEXT tier above
|
|
@@ -316,7 +350,7 @@ export function SubscriptionManager({
|
|
|
316
350
|
|
|
317
351
|
return (
|
|
318
352
|
<div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12 space-y-8">
|
|
319
|
-
{statusMessage === "success" && (
|
|
353
|
+
{statusMessage === "success" && activation === "done" && planFoundRef.current && (
|
|
320
354
|
<div data-testid="status-banner-success" className="rounded-xl bg-green-50 border border-green-200 p-4 flex items-center gap-3">
|
|
321
355
|
<svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
322
356
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
@@ -334,7 +368,32 @@ export function SubscriptionManager({
|
|
|
334
368
|
</div>
|
|
335
369
|
)}
|
|
336
370
|
|
|
337
|
-
{statusMessage === "
|
|
371
|
+
{statusMessage === "success" && activation === "done" && !planFoundRef.current && (
|
|
372
|
+
<div data-testid="status-banner-pending" role="status" className="rounded-xl bg-amber-50 border border-amber-200 p-4">
|
|
373
|
+
<p className="text-sm font-medium text-amber-900">
|
|
374
|
+
We haven't received the payment confirmation yet. It can take a minute; refresh this page to check again. Please don't pay a second time.
|
|
375
|
+
</p>
|
|
376
|
+
</div>
|
|
377
|
+
)}
|
|
378
|
+
|
|
379
|
+
{statusMessage === "scheduled" && (
|
|
380
|
+
<div data-testid="status-banner-scheduled" role="status" className="rounded-xl bg-blue-50 border border-blue-200 p-4">
|
|
381
|
+
<p className="text-sm font-medium text-blue-900">
|
|
382
|
+
Your plan change is scheduled.
|
|
383
|
+
{usableSubscription?.currentPeriodEnd
|
|
384
|
+
? <> You keep your current plan until {new Date(usableSubscription.currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })}.</>
|
|
385
|
+
: <> You keep your current plan until the end of this billing period.</>}
|
|
386
|
+
</p>
|
|
387
|
+
</div>
|
|
388
|
+
)}
|
|
389
|
+
|
|
390
|
+
{planChange.error && !planChange.pending && (
|
|
391
|
+
<div data-testid="plan-change-error" role="alert" className="rounded-xl bg-red-50 border border-red-200 p-4">
|
|
392
|
+
<p className="text-sm text-red-700">{planChange.error}</p>
|
|
393
|
+
</div>
|
|
394
|
+
)}
|
|
395
|
+
|
|
396
|
+
{(statusMessage === "activating" || (statusMessage === "success" && activation !== "done")) && (
|
|
338
397
|
<div data-testid="status-banner-activating" className="rounded-xl bg-blue-50 border border-blue-200 p-4 flex items-center gap-3">
|
|
339
398
|
<svg className="w-5 h-5 text-blue-600 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
340
399
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
@@ -370,7 +429,8 @@ export function SubscriptionManager({
|
|
|
370
429
|
usableSubscription.viewerIsOwner !== false && usableSubscription.offer.isFree && featuredPaidOffer ? (
|
|
371
430
|
<button
|
|
372
431
|
data-testid="upgrade-cta-current-plan"
|
|
373
|
-
onClick={() => handleOfferClick(featuredPaidOffer)}
|
|
432
|
+
onClick={() => !planChange.busy && handleOfferClick(featuredPaidOffer)}
|
|
433
|
+
disabled={planChange.busy}
|
|
374
434
|
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"
|
|
375
435
|
>
|
|
376
436
|
Upgrade to {featuredPaidOffer.name} →
|
|
@@ -379,6 +439,11 @@ export function SubscriptionManager({
|
|
|
379
439
|
}
|
|
380
440
|
/>
|
|
381
441
|
</>
|
|
442
|
+
) : statusMessage === "success" && activation !== "done" ? null : onFreeByDefault && freeOffer ? (
|
|
443
|
+
<div data-testid="free-by-default" className="text-center py-8">
|
|
444
|
+
<p className="text-lg text-gray-600">You're on the {freeOffer.name} plan</p>
|
|
445
|
+
<p className="text-sm text-gray-400 mt-1">Choose a plan below to upgrade.</p>
|
|
446
|
+
</div>
|
|
382
447
|
) : (
|
|
383
448
|
<div className="text-center py-8">
|
|
384
449
|
<p className="text-lg text-gray-600">No active subscription</p>
|
|
@@ -419,17 +484,20 @@ export function SubscriptionManager({
|
|
|
419
484
|
|
|
420
485
|
{/* Only render the Change Plan section when there are at least 2 offers
|
|
421
486
|
to show. Single-tier apps (just Free) skip it entirely — startsim-ft2. */}
|
|
422
|
-
{!productLoading && !productError &&
|
|
487
|
+
{!productLoading && !productError && entries.length >= 2 && (
|
|
423
488
|
<div data-testid="available-plans" className="space-y-4">
|
|
424
489
|
<h3 className="text-lg font-semibold text-gray-900">
|
|
425
490
|
{subscription ? "Change Plan" : "Available Plans"}
|
|
426
491
|
</h3>
|
|
492
|
+
{hasChoice && <IntervalToggle interval={interval} onChange={setInterval} />}
|
|
427
493
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
|
428
|
-
{
|
|
494
|
+
{entries.map((entry) => {
|
|
495
|
+
const { offer, tier } = entry;
|
|
429
496
|
const direction = directionFor(offer);
|
|
430
497
|
const isCurrent = direction === "current";
|
|
431
|
-
const isDowngrade = direction === "downgrade";
|
|
432
|
-
const ctaLabel =
|
|
498
|
+
const isDowngrade = direction === "downgrade" || direction === "switch";
|
|
499
|
+
const ctaLabel = planCtaLabel(offer, direction, paying);
|
|
500
|
+
const showTrial = trialApplies(offer, direction, paying);
|
|
433
501
|
|
|
434
502
|
// Border: current = solid ring; featured upgrade = blue; else gray.
|
|
435
503
|
const cardBorder =
|
|
@@ -459,36 +527,29 @@ export function SubscriptionManager({
|
|
|
459
527
|
data-testid={`current-plan-badge-${offer.slug}`}
|
|
460
528
|
className="absolute -top-2.5 right-4 inline-block bg-primary text-primary-foreground text-xs font-bold uppercase tracking-wide px-2.5 py-0.5 rounded-full"
|
|
461
529
|
>
|
|
462
|
-
Current
|
|
530
|
+
Current plan
|
|
463
531
|
</span>
|
|
464
532
|
)}
|
|
465
|
-
<h4 className="text-lg font-bold text-gray-900 mb-2">{
|
|
533
|
+
<h4 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h4>
|
|
466
534
|
<div className="mb-3">
|
|
467
|
-
|
|
468
|
-
|
|
469
|
-
|
|
470
|
-
|
|
471
|
-
|
|
472
|
-
|
|
473
|
-
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
|
|
474
|
-
${Math.floor(parseFloat(offer.unitPrice) || 0)}
|
|
475
|
-
</span>
|
|
476
|
-
<span aria-hidden="true" className="text-sm text-gray-500 ml-1">
|
|
477
|
-
{offer.intervalDisplay}
|
|
478
|
-
</span>
|
|
479
|
-
</span>
|
|
480
|
-
)}
|
|
535
|
+
<OfferPrice
|
|
536
|
+
offer={offer}
|
|
537
|
+
amountClassName="text-2xl font-extrabold text-gray-900"
|
|
538
|
+
intervalClassName="text-sm text-gray-500"
|
|
539
|
+
/>
|
|
540
|
+
<AnnualNote entry={entry} className="mt-1" />
|
|
481
541
|
</div>
|
|
482
542
|
{/* Trial copy is for prospective trialers only — once the
|
|
483
543
|
user is on this offer or moving downward, drop the
|
|
484
544
|
"14-day free trial" sales pitch. */}
|
|
485
|
-
{
|
|
486
|
-
<PlanFeatureList features={offer.features} />
|
|
545
|
+
{showTrial && <TrialCopy trialDays={offer.trialDays} />}
|
|
546
|
+
<PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
|
|
487
547
|
<button
|
|
488
548
|
data-testid={`upgrade-cta-${offer.slug}`}
|
|
489
|
-
onClick={() => !isCurrent && !isViewerNonOwner && handleOfferClick(offer)}
|
|
490
|
-
disabled={isCurrent || isViewerNonOwner}
|
|
491
|
-
aria-disabled={isCurrent || isViewerNonOwner || undefined}
|
|
549
|
+
onClick={() => !isCurrent && !isViewerNonOwner && !planChange.busy && handleOfferClick(offer)}
|
|
550
|
+
disabled={isCurrent || isViewerNonOwner || planChange.busy}
|
|
551
|
+
aria-disabled={isCurrent || isViewerNonOwner || planChange.busy || undefined}
|
|
552
|
+
aria-busy={planChange.busy && planChange.pending?.offer.id === offer.id ? true : undefined}
|
|
492
553
|
title={isViewerNonOwner ? "Only the team owner can change the plan" : undefined}
|
|
493
554
|
className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
|
|
494
555
|
>
|
|
@@ -523,6 +584,14 @@ export function SubscriptionManager({
|
|
|
523
584
|
</button>
|
|
524
585
|
</div>
|
|
525
586
|
)}
|
|
587
|
+
|
|
588
|
+
<PlanChangeConfirm
|
|
589
|
+
state={planChange.pending}
|
|
590
|
+
confirming={planChange.confirming}
|
|
591
|
+
error={planChange.pending ? planChange.error : null}
|
|
592
|
+
onConfirm={planChange.confirm}
|
|
593
|
+
onCancel={planChange.cancel}
|
|
594
|
+
/>
|
|
526
595
|
</div>
|
|
527
596
|
);
|
|
528
597
|
}
|
|
@@ -11,14 +11,35 @@
|
|
|
11
11
|
|
|
12
12
|
import React, { useEffect, useRef, useCallback } from "react";
|
|
13
13
|
import { useProduct } from "../hooks/useProduct";
|
|
14
|
-
import type { ProductOffer } from "../types";
|
|
14
|
+
import type { PlanChangePreview, ProductOffer } from "../types";
|
|
15
15
|
import { FeatureIcon } from "./FeatureIcon";
|
|
16
|
+
import { formatFeatureLabel } from "../utils/format";
|
|
17
|
+
import {
|
|
18
|
+
isPayingSubscription,
|
|
19
|
+
isUnlistedOffer,
|
|
20
|
+
planCtaLabel,
|
|
21
|
+
planDirection,
|
|
22
|
+
resolveCurrentOffer,
|
|
23
|
+
trialApplies,
|
|
24
|
+
TRIAL_NOTE_FEATURE_KEY,
|
|
25
|
+
type CurrentPlanState,
|
|
26
|
+
} from "../utils/plan";
|
|
27
|
+
import { usePlanChange } from "../hooks/usePlanChange";
|
|
28
|
+
import { PlanChangeConfirm } from "./PlanChangeConfirm";
|
|
29
|
+
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
|
|
16
30
|
|
|
17
31
|
export interface UpgradeModalProps {
|
|
18
32
|
productId: string;
|
|
19
33
|
open: boolean;
|
|
20
34
|
onClose: () => void;
|
|
21
|
-
|
|
35
|
+
/** Does the purchase / change. For a viewer who already pays it runs only
|
|
36
|
+
* after they confirm the charge (crochet_patterns-anw61). Return
|
|
37
|
+
* "navigating" after sending the browser away; throw to show an error. */
|
|
38
|
+
onSelectOffer?: (offer: ProductOffer, preview?: PlanChangePreview | null) => unknown;
|
|
39
|
+
|
|
40
|
+
/** The viewer's plan, so their card reads "Current plan" and is disabled.
|
|
41
|
+
* Omit on signed-out pages. crochet_patterns-34n7. */
|
|
42
|
+
currentPlan?: CurrentPlanState | null;
|
|
22
43
|
}
|
|
23
44
|
|
|
24
45
|
export function UpgradeModal({
|
|
@@ -26,8 +47,26 @@ export function UpgradeModal({
|
|
|
26
47
|
open,
|
|
27
48
|
onClose,
|
|
28
49
|
onSelectOffer,
|
|
50
|
+
currentPlan,
|
|
29
51
|
}: UpgradeModalProps) {
|
|
30
52
|
const { product, loading, error } = useProduct(productId);
|
|
53
|
+
const currentOffer = resolveCurrentOffer(product?.offers ?? [], currentPlan);
|
|
54
|
+
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
55
|
+
product?.offers ?? [],
|
|
56
|
+
currentOffer?.id ?? null,
|
|
57
|
+
);
|
|
58
|
+
const planChange = usePlanChange(async (offer, preview) => onSelectOffer?.(offer, preview));
|
|
59
|
+
const paying = !!currentOffer && isPayingSubscription(currentPlan?.subscription);
|
|
60
|
+
const onUnlistedPlan = isUnlistedOffer(product?.offers ?? [], currentOffer);
|
|
61
|
+
const tierKeyOf = (offerId: string) =>
|
|
62
|
+
entries.find(({ tier }) => tier.monthly?.id === offerId || tier.annual?.id === offerId)?.tier.key ?? null;
|
|
63
|
+
const select = (offer: ProductOffer) => {
|
|
64
|
+
if (!currentOffer) {
|
|
65
|
+
onSelectOffer?.(offer);
|
|
66
|
+
return;
|
|
67
|
+
}
|
|
68
|
+
void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
|
|
69
|
+
};
|
|
31
70
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
32
71
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
33
72
|
|
|
@@ -120,10 +159,23 @@ export function UpgradeModal({
|
|
|
120
159
|
</div>
|
|
121
160
|
)}
|
|
122
161
|
|
|
162
|
+
{product && hasChoice && (
|
|
163
|
+
<div className="mb-6">
|
|
164
|
+
<IntervalToggle interval={interval} onChange={setInterval} />
|
|
165
|
+
</div>
|
|
166
|
+
)}
|
|
167
|
+
|
|
123
168
|
{product && (
|
|
124
169
|
<div data-testid="upgrade-offers" className="flex flex-wrap justify-center gap-5">
|
|
125
|
-
{
|
|
170
|
+
{entries.map((entry) => {
|
|
171
|
+
const { offer, tier } = entry;
|
|
126
172
|
const isFeatured = offer.isFeatured;
|
|
173
|
+
const direction = currentOffer ? planDirection(offer, currentOffer, tierKeyOf) : null;
|
|
174
|
+
const isCurrent = direction === "current";
|
|
175
|
+
const trial = direction ? trialApplies(offer, direction, paying) : offer.trialDays > 0;
|
|
176
|
+
const features = trial || !(offer.trialDays > 0)
|
|
177
|
+
? offer.features
|
|
178
|
+
: offer.features.filter((f) => f.key !== TRIAL_NOTE_FEATURE_KEY);
|
|
127
179
|
return (
|
|
128
180
|
<div
|
|
129
181
|
key={offer.id}
|
|
@@ -136,59 +188,69 @@ export function UpgradeModal({
|
|
|
136
188
|
}
|
|
137
189
|
`}
|
|
138
190
|
>
|
|
139
|
-
<h3 className="text-lg font-bold text-gray-900 mb-2">{
|
|
191
|
+
<h3 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h3>
|
|
140
192
|
|
|
141
193
|
<div className="mb-2">
|
|
142
|
-
|
|
143
|
-
|
|
144
|
-
|
|
145
|
-
|
|
146
|
-
|
|
147
|
-
|
|
148
|
-
<span aria-hidden="true" className="text-2xl font-extrabold text-gray-900">
|
|
149
|
-
${Math.floor(parseFloat(offer.unitPrice) || 0)}
|
|
150
|
-
</span>
|
|
151
|
-
<span aria-hidden="true" className="text-sm text-gray-500"> {offer.intervalDisplay}</span>
|
|
152
|
-
</span>
|
|
153
|
-
)}
|
|
194
|
+
<OfferPrice
|
|
195
|
+
offer={offer}
|
|
196
|
+
amountClassName="text-2xl font-extrabold text-gray-900"
|
|
197
|
+
intervalClassName="text-sm text-gray-500"
|
|
198
|
+
/>
|
|
199
|
+
<AnnualNote entry={entry} className="mt-1" />
|
|
154
200
|
</div>
|
|
155
201
|
|
|
156
|
-
{
|
|
157
|
-
<span 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">
|
|
202
|
+
{trial && (
|
|
203
|
+
<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">
|
|
158
204
|
{offer.trialDays}-day free trial
|
|
159
205
|
</span>
|
|
160
206
|
)}
|
|
161
207
|
|
|
162
|
-
{
|
|
208
|
+
{features.length > 0 && (
|
|
163
209
|
<ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
|
|
164
|
-
{
|
|
210
|
+
{features.map((f) => (
|
|
165
211
|
<li key={f.key} className="flex items-center gap-1.5 py-1 text-sm text-gray-600">
|
|
166
212
|
<FeatureIcon value={f.value} size="w-3.5 h-3.5" />
|
|
167
|
-
{f
|
|
213
|
+
<span>{formatFeatureLabel(f)}</span>
|
|
168
214
|
</li>
|
|
169
215
|
))}
|
|
170
216
|
</ul>
|
|
171
217
|
)}
|
|
172
218
|
|
|
173
219
|
<button
|
|
174
|
-
onClick={() =>
|
|
220
|
+
onClick={() => !isCurrent && !planChange.busy && select(offer)}
|
|
175
221
|
data-testid={`upgrade-cta-${offer.slug}`}
|
|
222
|
+
disabled={isCurrent || planChange.busy}
|
|
223
|
+
aria-disabled={isCurrent || planChange.busy || undefined}
|
|
176
224
|
className={`
|
|
177
|
-
w-full py-2.5 px-4 text-sm font-bold rounded-lg
|
|
178
|
-
${
|
|
179
|
-
? "bg-
|
|
180
|
-
:
|
|
225
|
+
w-full py-2.5 px-4 text-sm font-bold rounded-lg transition-colors
|
|
226
|
+
${isCurrent
|
|
227
|
+
? "bg-gray-100 text-gray-500 cursor-not-allowed"
|
|
228
|
+
: isFeatured
|
|
229
|
+
? "bg-blue-600 text-white hover:bg-blue-700 cursor-pointer"
|
|
230
|
+
: "bg-gray-100 text-gray-800 hover:bg-gray-200 cursor-pointer"
|
|
181
231
|
}
|
|
182
232
|
`}
|
|
183
233
|
>
|
|
184
|
-
{offer.ctaText}
|
|
234
|
+
{isCurrent ? "Current plan" : direction ? planCtaLabel(offer, direction, paying) : offer.ctaText}
|
|
185
235
|
</button>
|
|
186
236
|
</div>
|
|
187
237
|
);
|
|
188
238
|
})}
|
|
189
239
|
</div>
|
|
190
240
|
)}
|
|
241
|
+
{planChange.error && !planChange.pending && (
|
|
242
|
+
<p data-testid="plan-change-error" role="alert" className="mt-4 rounded-lg bg-red-50 border border-red-200 p-3 text-sm text-red-700">
|
|
243
|
+
{planChange.error}
|
|
244
|
+
</p>
|
|
245
|
+
)}
|
|
191
246
|
</div>
|
|
247
|
+
<PlanChangeConfirm
|
|
248
|
+
state={planChange.pending}
|
|
249
|
+
confirming={planChange.confirming}
|
|
250
|
+
error={planChange.pending ? planChange.error : null}
|
|
251
|
+
onConfirm={planChange.confirm}
|
|
252
|
+
onCancel={planChange.cancel}
|
|
253
|
+
/>
|
|
192
254
|
</div>
|
|
193
255
|
);
|
|
194
256
|
}
|