@startsimpli/billing 0.4.24 → 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/PlanChange.crochet.test.tsx +274 -0
- package/src/components/PlanChangeConfirm.tsx +193 -0
- package/src/components/PricingDetailPage.tsx +85 -16
- package/src/components/SubscriptionManager.test.tsx +21 -9
- package/src/components/SubscriptionManager.tsx +125 -65
- package/src/components/UpgradeModal.tsx +57 -13
- 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 +20 -4
- package/src/types/index.ts +29 -0
- package/src/utils/api.ts +38 -1
- package/src/utils/plan.ts +84 -0
|
@@ -376,12 +376,15 @@ describe("SubscriptionManager", () => {
|
|
|
376
376
|
expect(screen.getByTestId("available-plans")).toBeDefined();
|
|
377
377
|
});
|
|
378
378
|
|
|
379
|
-
// Click Enterprise upgrade option
|
|
379
|
+
// Click Enterprise upgrade option: already paying, so it asks first.
|
|
380
380
|
const enterpriseButton = screen.getByTestId("upgrade-cta-enterprise-monthly");
|
|
381
381
|
await userEvent.click(enterpriseButton);
|
|
382
|
+
expect(onPlanChange).not.toHaveBeenCalled();
|
|
383
|
+
await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
|
|
382
384
|
|
|
383
385
|
expect(onPlanChange).toHaveBeenCalledWith(
|
|
384
|
-
expect.objectContaining({ slug: "enterprise-monthly" })
|
|
386
|
+
expect.objectContaining({ slug: "enterprise-monthly" }),
|
|
387
|
+
null,
|
|
385
388
|
);
|
|
386
389
|
});
|
|
387
390
|
|
|
@@ -655,8 +658,10 @@ describe("SubscriptionManager", () => {
|
|
|
655
658
|
|
|
656
659
|
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
657
660
|
|
|
658
|
-
// Click the Free card's CTA (slug "starter-monthly", isFree=true)
|
|
659
|
-
|
|
661
|
+
// Click the Free card's CTA (slug "starter-monthly", isFree=true), then
|
|
662
|
+
// confirm: leaving a paid plan is never one click (crochet_patterns-lr7mj).
|
|
663
|
+
await userEvent.click(await screen.findByTestId("upgrade-cta-starter-monthly"));
|
|
664
|
+
await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
|
|
660
665
|
|
|
661
666
|
// subscribe-free is hit (server fetcher saw the POST)
|
|
662
667
|
await waitFor(() => {
|
|
@@ -830,10 +835,12 @@ describe("SubscriptionManager", () => {
|
|
|
830
835
|
cardSlug: "pro-monthly",
|
|
831
836
|
expectedCta: /current plan/i, shouldBeDisabled: true,
|
|
832
837
|
shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
|
|
833
|
-
|
|
838
|
+
// Already paying: an upgrade changes the plan in place, so it says so
|
|
839
|
+
// instead of the new-buyer pitch (crochet_patterns-anw61).
|
|
840
|
+
{ label: "pro · Enterprise card → 'Upgrade to Enterprise', no trial copy",
|
|
834
841
|
subscription: mockSubscriptionActive,
|
|
835
842
|
cardSlug: "enterprise-monthly",
|
|
836
|
-
expectedCta: /
|
|
843
|
+
expectedCta: /upgrade to enterprise/i, shouldBeDisabled: false,
|
|
837
844
|
shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
|
|
838
845
|
|
|
839
846
|
// TRIALING Pro current sub
|
|
@@ -965,8 +972,10 @@ describe("SubscriptionManager", () => {
|
|
|
965
972
|
</Wrapper>
|
|
966
973
|
);
|
|
967
974
|
|
|
968
|
-
//
|
|
969
|
-
|
|
975
|
+
// No subscription found yet: "Activating…", never "activated" beside
|
|
976
|
+
// Free (crochet_patterns-nag60).
|
|
977
|
+
await waitFor(() => expect(screen.getByTestId("status-banner-activating")).toBeDefined());
|
|
978
|
+
expect(screen.queryByTestId("status-banner-success")).toBeNull();
|
|
970
979
|
|
|
971
980
|
// success-sync was hit
|
|
972
981
|
await waitFor(() => {
|
|
@@ -993,7 +1002,10 @@ describe("SubscriptionManager", () => {
|
|
|
993
1002
|
</Wrapper>
|
|
994
1003
|
);
|
|
995
1004
|
|
|
996
|
-
await waitFor(() =>
|
|
1005
|
+
await waitFor(() => {
|
|
1006
|
+
const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
|
|
1007
|
+
expect(calls.some((u) => u.includes("/billing/success-sync/"))).toBe(true);
|
|
1008
|
+
});
|
|
997
1009
|
rerender(
|
|
998
1010
|
<Wrapper>
|
|
999
1011
|
<SubscriptionManager productId="raise-simpli" returnUrl="https://t/s" statusMessage="success" />
|
|
@@ -12,15 +12,26 @@
|
|
|
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";
|
|
24
35
|
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
|
|
25
36
|
import { formatFeatureLabel } from "../utils/format";
|
|
26
37
|
|
|
@@ -31,13 +42,26 @@ export interface SubscriptionManagerProps {
|
|
|
31
42
|
/** URL to return to after portal session */
|
|
32
43
|
returnUrl: string;
|
|
33
44
|
|
|
34
|
-
/**
|
|
35
|
-
|
|
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;
|
|
36
50
|
|
|
37
|
-
/** Optional status message to display (e.g., after checkout redirect)
|
|
38
|
-
|
|
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;
|
|
39
59
|
}
|
|
40
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
|
+
|
|
41
65
|
/** Render one OfferFeature row. Skips boolean-false (the plan _doesn't_ have
|
|
42
66
|
* that feature so we don't pretend it does). Numeric limits surface as
|
|
43
67
|
* "Unlimited X" (-1) / "Up to N X" / "X" (no limit attribute).
|
|
@@ -57,11 +81,12 @@ function FeatureRow({ feature }: { feature: OfferFeature }) {
|
|
|
57
81
|
);
|
|
58
82
|
}
|
|
59
83
|
|
|
60
|
-
function PlanFeatureList({ features }: { features: OfferFeature[] }) {
|
|
84
|
+
function PlanFeatureList({ features, showTrialNote = true }: { features: OfferFeature[]; showTrialNote?: boolean }) {
|
|
61
85
|
if (!features || features.length === 0) return null;
|
|
86
|
+
const shown = showTrialNote ? features : features.filter((f) => f.key !== TRIAL_NOTE_FEATURE_KEY);
|
|
62
87
|
return (
|
|
63
88
|
<ul data-testid="plan-features" className="space-y-1.5 mb-4">
|
|
64
|
-
{
|
|
89
|
+
{shown.map((f) => <FeatureRow key={f.key} feature={f} />)}
|
|
65
90
|
</ul>
|
|
66
91
|
);
|
|
67
92
|
}
|
|
@@ -167,6 +192,7 @@ export function SubscriptionManager({
|
|
|
167
192
|
returnUrl,
|
|
168
193
|
onPlanChange,
|
|
169
194
|
statusMessage,
|
|
195
|
+
checkoutSessionId = null,
|
|
170
196
|
}: SubscriptionManagerProps) {
|
|
171
197
|
const { subscription, loading: subLoading, error: subError, refetch: refetchSubscription } = useSubscription(productId);
|
|
172
198
|
const { product, loading: productLoading, error: productError, refetch: refetchProduct } = useProduct(productId);
|
|
@@ -179,24 +205,58 @@ export function SubscriptionManager({
|
|
|
179
205
|
subscription?.offer?.id ?? null,
|
|
180
206
|
);
|
|
181
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);
|
|
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
|
|
184
227
|
// from Stripe so the UI flips to Trialing/Active without waiting for the
|
|
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.
|
|
@@ -246,53 +306,17 @@ export function SubscriptionManager({
|
|
|
246
306
|
// for the chosen interval. The card matching their current plan gets a
|
|
247
307
|
// "Current plan" badge + disabled CTA. startsim-1f1, crochet_patterns-34n7.
|
|
248
308
|
|
|
249
|
-
|
|
250
|
-
|
|
251
|
-
|
|
252
|
-
|
|
253
|
-
|
|
254
|
-
|
|
255
|
-
function directionFor(cardOffer: ProductOffer): Direction {
|
|
256
|
-
if (!currentOffer) return "upgrade"; // plan still unknown → every card is an upgrade
|
|
257
|
-
if (cardOffer.id === currentOffer.id) return "current";
|
|
258
|
-
// Same plan, other billing interval (Lite ↔ Lite Annual).
|
|
259
|
-
const tierKey = tierKeyOf(cardOffer.id);
|
|
260
|
-
if (tierKey !== null && tierKey === tierKeyOf(currentOffer.id)) return "switch";
|
|
261
|
-
// Free vs Paid: paid is always an upgrade, free is always a downgrade.
|
|
262
|
-
if (cardOffer.isFree && !currentOffer.isFree) return "downgrade";
|
|
263
|
-
if (!cardOffer.isFree && currentOffer.isFree) return "upgrade";
|
|
264
|
-
// Both paid → compare sortOrder (apps order their tiers low → high).
|
|
265
|
-
return cardOffer.sortOrder > currentOffer.sortOrder ? "upgrade" : "downgrade";
|
|
266
|
-
}
|
|
267
|
-
|
|
268
|
-
/** Visible CTA label for the card, branching on direction. The default
|
|
269
|
-
* `offer.ctaText` (e.g. "Start Free Trial") is for UPGRADE only — using it
|
|
270
|
-
* on a Free card when a Pro user is looking is the bug startsim-21f closes. */
|
|
271
|
-
function ctaLabelFor(cardOffer: ProductOffer, direction: Direction): string {
|
|
272
|
-
if (direction === "current") return "Current plan";
|
|
273
|
-
if (direction === "switch") return cardOffer.intervalUnit === "year" ? "Switch to annual" : "Switch to monthly";
|
|
274
|
-
if (direction === "downgrade") return `Downgrade to ${cardOffer.name}`;
|
|
275
|
-
return cardOffer.ctaText || `Upgrade to ${cardOffer.name}`;
|
|
276
|
-
}
|
|
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);
|
|
277
315
|
|
|
278
|
-
|
|
279
|
-
// - Free offers → POST /billing/subscribe-free/ then refetch subscription
|
|
280
|
-
// (no Stripe round-trip needed for $0 plans — startsim-6vk).
|
|
281
|
-
// - Paid offers → app-supplied onPlanChange (typically a Stripe checkout
|
|
282
|
-
// redirect via useCheckout).
|
|
283
|
-
const handleOfferClick = async (offer: ProductOffer) => {
|
|
316
|
+
const handleOfferClick = (offer: ProductOffer) => {
|
|
284
317
|
// Non-owner viewers can SEE the plans but can't change them — startsim-wo9.
|
|
285
318
|
if (subscription?.viewerIsOwner === false) return;
|
|
286
|
-
|
|
287
|
-
try {
|
|
288
|
-
await subscribeFree(offer.id);
|
|
289
|
-
await refetchSubscription();
|
|
290
|
-
} catch {
|
|
291
|
-
/* Error captured by hook state — banner not added here yet. */
|
|
292
|
-
}
|
|
293
|
-
return;
|
|
294
|
-
}
|
|
295
|
-
onPlanChange?.(offer);
|
|
319
|
+
void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
|
|
296
320
|
};
|
|
297
321
|
|
|
298
322
|
// The "Upgrade" CTA on the current-plan card points at the NEXT tier above
|
|
@@ -326,7 +350,7 @@ export function SubscriptionManager({
|
|
|
326
350
|
|
|
327
351
|
return (
|
|
328
352
|
<div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12 space-y-8">
|
|
329
|
-
{statusMessage === "success" && (
|
|
353
|
+
{statusMessage === "success" && activation === "done" && planFoundRef.current && (
|
|
330
354
|
<div data-testid="status-banner-success" className="rounded-xl bg-green-50 border border-green-200 p-4 flex items-center gap-3">
|
|
331
355
|
<svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
332
356
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
@@ -344,7 +368,32 @@ export function SubscriptionManager({
|
|
|
344
368
|
</div>
|
|
345
369
|
)}
|
|
346
370
|
|
|
347
|
-
{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")) && (
|
|
348
397
|
<div data-testid="status-banner-activating" className="rounded-xl bg-blue-50 border border-blue-200 p-4 flex items-center gap-3">
|
|
349
398
|
<svg className="w-5 h-5 text-blue-600 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
350
399
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
@@ -380,7 +429,8 @@ export function SubscriptionManager({
|
|
|
380
429
|
usableSubscription.viewerIsOwner !== false && usableSubscription.offer.isFree && featuredPaidOffer ? (
|
|
381
430
|
<button
|
|
382
431
|
data-testid="upgrade-cta-current-plan"
|
|
383
|
-
onClick={() => handleOfferClick(featuredPaidOffer)}
|
|
432
|
+
onClick={() => !planChange.busy && handleOfferClick(featuredPaidOffer)}
|
|
433
|
+
disabled={planChange.busy}
|
|
384
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"
|
|
385
435
|
>
|
|
386
436
|
Upgrade to {featuredPaidOffer.name} →
|
|
@@ -389,7 +439,7 @@ export function SubscriptionManager({
|
|
|
389
439
|
}
|
|
390
440
|
/>
|
|
391
441
|
</>
|
|
392
|
-
) : onFreeByDefault && freeOffer ? (
|
|
442
|
+
) : statusMessage === "success" && activation !== "done" ? null : onFreeByDefault && freeOffer ? (
|
|
393
443
|
<div data-testid="free-by-default" className="text-center py-8">
|
|
394
444
|
<p className="text-lg text-gray-600">You're on the {freeOffer.name} plan</p>
|
|
395
445
|
<p className="text-sm text-gray-400 mt-1">Choose a plan below to upgrade.</p>
|
|
@@ -446,7 +496,8 @@ export function SubscriptionManager({
|
|
|
446
496
|
const direction = directionFor(offer);
|
|
447
497
|
const isCurrent = direction === "current";
|
|
448
498
|
const isDowngrade = direction === "downgrade" || direction === "switch";
|
|
449
|
-
const ctaLabel =
|
|
499
|
+
const ctaLabel = planCtaLabel(offer, direction, paying);
|
|
500
|
+
const showTrial = trialApplies(offer, direction, paying);
|
|
450
501
|
|
|
451
502
|
// Border: current = solid ring; featured upgrade = blue; else gray.
|
|
452
503
|
const cardBorder =
|
|
@@ -491,13 +542,14 @@ export function SubscriptionManager({
|
|
|
491
542
|
{/* Trial copy is for prospective trialers only — once the
|
|
492
543
|
user is on this offer or moving downward, drop the
|
|
493
544
|
"14-day free trial" sales pitch. */}
|
|
494
|
-
{
|
|
495
|
-
<PlanFeatureList features={offer.features} />
|
|
545
|
+
{showTrial && <TrialCopy trialDays={offer.trialDays} />}
|
|
546
|
+
<PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
|
|
496
547
|
<button
|
|
497
548
|
data-testid={`upgrade-cta-${offer.slug}`}
|
|
498
|
-
onClick={() => !isCurrent && !isViewerNonOwner && handleOfferClick(offer)}
|
|
499
|
-
disabled={isCurrent || isViewerNonOwner}
|
|
500
|
-
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}
|
|
501
553
|
title={isViewerNonOwner ? "Only the team owner can change the plan" : undefined}
|
|
502
554
|
className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
|
|
503
555
|
>
|
|
@@ -532,6 +584,14 @@ export function SubscriptionManager({
|
|
|
532
584
|
</button>
|
|
533
585
|
</div>
|
|
534
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
|
+
/>
|
|
535
595
|
</div>
|
|
536
596
|
);
|
|
537
597
|
}
|
|
@@ -11,17 +11,31 @@
|
|
|
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
16
|
import { formatFeatureLabel } from "../utils/format";
|
|
17
|
-
import {
|
|
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";
|
|
18
29
|
import { AnnualNote, IntervalToggle, OfferPrice, useIntervalOffers } from "./IntervalToggle";
|
|
19
30
|
|
|
20
31
|
export interface UpgradeModalProps {
|
|
21
32
|
productId: string;
|
|
22
33
|
open: boolean;
|
|
23
34
|
onClose: () => void;
|
|
24
|
-
|
|
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;
|
|
25
39
|
|
|
26
40
|
/** The viewer's plan, so their card reads "Current plan" and is disabled.
|
|
27
41
|
* Omit on signed-out pages. crochet_patterns-34n7. */
|
|
@@ -36,10 +50,23 @@ export function UpgradeModal({
|
|
|
36
50
|
currentPlan,
|
|
37
51
|
}: UpgradeModalProps) {
|
|
38
52
|
const { product, loading, error } = useProduct(productId);
|
|
53
|
+
const currentOffer = resolveCurrentOffer(product?.offers ?? [], currentPlan);
|
|
39
54
|
const { interval, setInterval, hasChoice, entries } = useIntervalOffers(
|
|
40
55
|
product?.offers ?? [],
|
|
41
|
-
|
|
56
|
+
currentOffer?.id ?? null,
|
|
42
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
|
+
};
|
|
43
70
|
const modalRef = useRef<HTMLDivElement>(null);
|
|
44
71
|
const previousFocusRef = useRef<HTMLElement | null>(null);
|
|
45
72
|
|
|
@@ -143,7 +170,12 @@ export function UpgradeModal({
|
|
|
143
170
|
{entries.map((entry) => {
|
|
144
171
|
const { offer, tier } = entry;
|
|
145
172
|
const isFeatured = offer.isFeatured;
|
|
146
|
-
const
|
|
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);
|
|
147
179
|
return (
|
|
148
180
|
<div
|
|
149
181
|
key={offer.id}
|
|
@@ -167,15 +199,15 @@ export function UpgradeModal({
|
|
|
167
199
|
<AnnualNote entry={entry} className="mt-1" />
|
|
168
200
|
</div>
|
|
169
201
|
|
|
170
|
-
{
|
|
171
|
-
<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">
|
|
172
204
|
{offer.trialDays}-day free trial
|
|
173
205
|
</span>
|
|
174
206
|
)}
|
|
175
207
|
|
|
176
|
-
{
|
|
208
|
+
{features.length > 0 && (
|
|
177
209
|
<ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
|
|
178
|
-
{
|
|
210
|
+
{features.map((f) => (
|
|
179
211
|
<li key={f.key} className="flex items-center gap-1.5 py-1 text-sm text-gray-600">
|
|
180
212
|
<FeatureIcon value={f.value} size="w-3.5 h-3.5" />
|
|
181
213
|
<span>{formatFeatureLabel(f)}</span>
|
|
@@ -185,10 +217,10 @@ export function UpgradeModal({
|
|
|
185
217
|
)}
|
|
186
218
|
|
|
187
219
|
<button
|
|
188
|
-
onClick={() => !isCurrent &&
|
|
220
|
+
onClick={() => !isCurrent && !planChange.busy && select(offer)}
|
|
189
221
|
data-testid={`upgrade-cta-${offer.slug}`}
|
|
190
|
-
disabled={isCurrent}
|
|
191
|
-
aria-disabled={isCurrent || undefined}
|
|
222
|
+
disabled={isCurrent || planChange.busy}
|
|
223
|
+
aria-disabled={isCurrent || planChange.busy || undefined}
|
|
192
224
|
className={`
|
|
193
225
|
w-full py-2.5 px-4 text-sm font-bold rounded-lg transition-colors
|
|
194
226
|
${isCurrent
|
|
@@ -199,14 +231,26 @@ export function UpgradeModal({
|
|
|
199
231
|
}
|
|
200
232
|
`}
|
|
201
233
|
>
|
|
202
|
-
{isCurrent ? "Current plan" : offer.ctaText}
|
|
234
|
+
{isCurrent ? "Current plan" : direction ? planCtaLabel(offer, direction, paying) : offer.ctaText}
|
|
203
235
|
</button>
|
|
204
236
|
</div>
|
|
205
237
|
);
|
|
206
238
|
})}
|
|
207
239
|
</div>
|
|
208
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
|
+
)}
|
|
209
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
|
+
/>
|
|
210
254
|
</div>
|
|
211
255
|
);
|
|
212
256
|
}
|
package/src/components/index.ts
CHANGED
|
@@ -16,3 +16,5 @@ export { CurrentPlanCard } from "./CurrentPlanCard";
|
|
|
16
16
|
export type { CurrentPlanCardProps } from "./CurrentPlanCard";
|
|
17
17
|
export { LimitNudge } from "./LimitNudge";
|
|
18
18
|
export type { LimitNudgeProps } from "./LimitNudge";
|
|
19
|
+
export { PlanChangeConfirm, planChangeCopy } from "./PlanChangeConfirm";
|
|
20
|
+
export type { PlanChangeConfirmProps } from "./PlanChangeConfirm";
|
package/src/hooks/index.ts
CHANGED
|
@@ -6,3 +6,5 @@ export { usePortal } from "./usePortal";
|
|
|
6
6
|
export { useSubscription } from "./useSubscription";
|
|
7
7
|
export { useSuccessSync } from "./useSuccessSync";
|
|
8
8
|
export { useUsage, notifyUsageChanged, USAGE_CHANGED_EVENT } from "./useUsage";
|
|
9
|
+
export { usePlanChange } from "./usePlanChange";
|
|
10
|
+
export type { PlanChangeContext, PlanChangeConfirmState, PlanChangeProceed, UsePlanChangeResult } from "./usePlanChange";
|