@startsimpli/billing 0.4.24 → 0.4.26
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/IntervalToggle.tsx +6 -2
- package/src/components/PlanChange.crochet.test.tsx +274 -0
- package/src/components/PlanChangeConfirm.tsx +193 -0
- package/src/components/PlanPicker.crochet.test.tsx +5 -1
- package/src/components/PricingDetailPage.review.test.tsx +256 -0
- package/src/components/PricingDetailPage.tsx +208 -36
- package/src/components/SubscriptionManager.test.tsx +30 -16
- package/src/components/SubscriptionManager.tsx +128 -74
- 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 +86 -0
|
@@ -348,10 +348,12 @@ describe("SubscriptionManager", () => {
|
|
|
348
348
|
expect(screen.getByTestId("plan-card-pro-monthly")).toBeDefined();
|
|
349
349
|
expect(screen.getByTestId("plan-card-enterprise-monthly")).toBeDefined();
|
|
350
350
|
|
|
351
|
-
// The matching one (Pro)
|
|
352
|
-
expect(screen.getByTestId("
|
|
353
|
-
expect(screen.
|
|
354
|
-
expect(screen.
|
|
351
|
+
// The matching one (Pro) is marked current; the others are not.
|
|
352
|
+
expect(screen.getByTestId("plan-card-pro-monthly").getAttribute("data-current")).toBe("true");
|
|
353
|
+
expect(screen.getByTestId("plan-card-starter-monthly").getAttribute("data-current")).toBeNull();
|
|
354
|
+
expect(screen.getByTestId("plan-card-enterprise-monthly").getAttribute("data-current")).toBeNull();
|
|
355
|
+
// One marker: the disabled "Current plan" button, no badge beside it (crochet_patterns-n8iib).
|
|
356
|
+
expect(within(screen.getByTestId("plan-card-pro-monthly")).getAllByText(/current plan/i)).toHaveLength(1);
|
|
355
357
|
});
|
|
356
358
|
|
|
357
359
|
it("calls onPlanChange when upgrade option clicked", async () => {
|
|
@@ -376,12 +378,15 @@ describe("SubscriptionManager", () => {
|
|
|
376
378
|
expect(screen.getByTestId("available-plans")).toBeDefined();
|
|
377
379
|
});
|
|
378
380
|
|
|
379
|
-
// Click Enterprise upgrade option
|
|
381
|
+
// Click Enterprise upgrade option: already paying, so it asks first.
|
|
380
382
|
const enterpriseButton = screen.getByTestId("upgrade-cta-enterprise-monthly");
|
|
381
383
|
await userEvent.click(enterpriseButton);
|
|
384
|
+
expect(onPlanChange).not.toHaveBeenCalled();
|
|
385
|
+
await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
|
|
382
386
|
|
|
383
387
|
expect(onPlanChange).toHaveBeenCalledWith(
|
|
384
|
-
expect.objectContaining({ slug: "enterprise-monthly" })
|
|
388
|
+
expect.objectContaining({ slug: "enterprise-monthly" }),
|
|
389
|
+
null,
|
|
385
390
|
);
|
|
386
391
|
});
|
|
387
392
|
|
|
@@ -655,8 +660,10 @@ describe("SubscriptionManager", () => {
|
|
|
655
660
|
|
|
656
661
|
await waitFor(() => expect(screen.getByTestId("available-plans")).toBeDefined());
|
|
657
662
|
|
|
658
|
-
// Click the Free card's CTA (slug "starter-monthly", isFree=true)
|
|
659
|
-
|
|
663
|
+
// Click the Free card's CTA (slug "starter-monthly", isFree=true), then
|
|
664
|
+
// confirm: leaving a paid plan is never one click (crochet_patterns-lr7mj).
|
|
665
|
+
await userEvent.click(await screen.findByTestId("upgrade-cta-starter-monthly"));
|
|
666
|
+
await userEvent.click(await screen.findByTestId("plan-change-confirm-button"));
|
|
660
667
|
|
|
661
668
|
// subscribe-free is hit (server fetcher saw the POST)
|
|
662
669
|
await waitFor(() => {
|
|
@@ -830,10 +837,12 @@ describe("SubscriptionManager", () => {
|
|
|
830
837
|
cardSlug: "pro-monthly",
|
|
831
838
|
expectedCta: /current plan/i, shouldBeDisabled: true,
|
|
832
839
|
shouldShowCurrentBadge: true, shouldShowTrialCopy: false },
|
|
833
|
-
|
|
840
|
+
// Already paying: an upgrade changes the plan in place, so it says so
|
|
841
|
+
// instead of the new-buyer pitch (crochet_patterns-anw61).
|
|
842
|
+
{ label: "pro · Enterprise card → 'Upgrade to Enterprise', no trial copy",
|
|
834
843
|
subscription: mockSubscriptionActive,
|
|
835
844
|
cardSlug: "enterprise-monthly",
|
|
836
|
-
expectedCta: /
|
|
845
|
+
expectedCta: /upgrade to enterprise/i, shouldBeDisabled: false,
|
|
837
846
|
shouldShowCurrentBadge: false, shouldShowTrialCopy: false },
|
|
838
847
|
|
|
839
848
|
// TRIALING Pro current sub
|
|
@@ -884,11 +893,11 @@ describe("SubscriptionManager", () => {
|
|
|
884
893
|
expect((cta as HTMLButtonElement).disabled).toBe(false);
|
|
885
894
|
}
|
|
886
895
|
|
|
887
|
-
const
|
|
896
|
+
const current = screen.getByTestId(`plan-card-${cardSlug}`).getAttribute("data-current");
|
|
888
897
|
if (shouldShowCurrentBadge) {
|
|
889
|
-
expect(
|
|
898
|
+
expect(current).toBe("true");
|
|
890
899
|
} else {
|
|
891
|
-
expect(
|
|
900
|
+
expect(current).toBeNull();
|
|
892
901
|
}
|
|
893
902
|
|
|
894
903
|
// Trial copy: only show on the upgrade direction for trialDays > 0 offers
|
|
@@ -965,8 +974,10 @@ describe("SubscriptionManager", () => {
|
|
|
965
974
|
</Wrapper>
|
|
966
975
|
);
|
|
967
976
|
|
|
968
|
-
//
|
|
969
|
-
|
|
977
|
+
// No subscription found yet: "Activating…", never "activated" beside
|
|
978
|
+
// Free (crochet_patterns-nag60).
|
|
979
|
+
await waitFor(() => expect(screen.getByTestId("status-banner-activating")).toBeDefined());
|
|
980
|
+
expect(screen.queryByTestId("status-banner-success")).toBeNull();
|
|
970
981
|
|
|
971
982
|
// success-sync was hit
|
|
972
983
|
await waitFor(() => {
|
|
@@ -993,7 +1004,10 @@ describe("SubscriptionManager", () => {
|
|
|
993
1004
|
</Wrapper>
|
|
994
1005
|
);
|
|
995
1006
|
|
|
996
|
-
await waitFor(() =>
|
|
1007
|
+
await waitFor(() => {
|
|
1008
|
+
const calls = (fetcher as ReturnType<typeof vi.fn>).mock.calls.map((c) => c[0] as string);
|
|
1009
|
+
expect(calls.some((u) => u.includes("/billing/success-sync/"))).toBe(true);
|
|
1010
|
+
});
|
|
997
1011
|
rerender(
|
|
998
1012
|
<Wrapper>
|
|
999
1013
|
<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.
|
|
@@ -244,55 +304,20 @@ export function SubscriptionManager({
|
|
|
244
304
|
// Show every tier in the Change Plan grid (not just non-current ones) so
|
|
245
305
|
// users can compare and see what they have; `entries` is one offer per tier
|
|
246
306
|
// for the chosen interval. The card matching their current plan gets a
|
|
247
|
-
// "Current plan"
|
|
248
|
-
|
|
249
|
-
/** Per-card direction relative to the current subscription:
|
|
250
|
-
* - "current" → user is on this exact offer → "Current Plan" badge, no action
|
|
251
|
-
* - "upgrade" → card is a higher tier (paid vs current free, or higher sortOrder)
|
|
252
|
-
* - "downgrade" → card is a lower tier (free when current is paid, or lower sortOrder)
|
|
253
|
-
* startsim-21f. */
|
|
254
|
-
type Direction = "current" | "upgrade" | "downgrade" | "switch";
|
|
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
|
-
}
|
|
307
|
+
// a ring and a disabled "Current plan" CTA — one marker, not a badge as well
|
|
308
|
+
// (crochet_patterns-n8iib). startsim-1f1, crochet_patterns-34n7.
|
|
267
309
|
|
|
268
|
-
|
|
269
|
-
|
|
270
|
-
|
|
271
|
-
|
|
272
|
-
|
|
273
|
-
|
|
274
|
-
if (direction === "downgrade") return `Downgrade to ${cardOffer.name}`;
|
|
275
|
-
return cardOffer.ctaText || `Upgrade to ${cardOffer.name}`;
|
|
276
|
-
}
|
|
310
|
+
// Direction and label per card, shared with /pricing (utils/plan):
|
|
311
|
+
// startsim-21f, crochet_patterns-anw61.
|
|
312
|
+
const paying = isPayingSubscription(usableSubscription);
|
|
313
|
+
const onUnlistedPlan = !!product && isUnlistedOffer(product.offers, usableSubscription?.offer);
|
|
314
|
+
const directionFor = (cardOffer: ProductOffer): PlanDirection =>
|
|
315
|
+
planDirection(cardOffer, currentOffer, tierKeyOf);
|
|
277
316
|
|
|
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) => {
|
|
317
|
+
const handleOfferClick = (offer: ProductOffer) => {
|
|
284
318
|
// Non-owner viewers can SEE the plans but can't change them — startsim-wo9.
|
|
285
319
|
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);
|
|
320
|
+
void planChange.choose(offer, { paying, leavingUnlisted: onUnlistedPlan, currentOffer });
|
|
296
321
|
};
|
|
297
322
|
|
|
298
323
|
// The "Upgrade" CTA on the current-plan card points at the NEXT tier above
|
|
@@ -326,7 +351,7 @@ export function SubscriptionManager({
|
|
|
326
351
|
|
|
327
352
|
return (
|
|
328
353
|
<div data-testid="subscription-manager" className="max-w-3xl mx-auto px-6 py-12 space-y-8">
|
|
329
|
-
{statusMessage === "success" && (
|
|
354
|
+
{statusMessage === "success" && activation === "done" && planFoundRef.current && (
|
|
330
355
|
<div data-testid="status-banner-success" className="rounded-xl bg-green-50 border border-green-200 p-4 flex items-center gap-3">
|
|
331
356
|
<svg className="w-5 h-5 text-green-600 shrink-0" fill="none" viewBox="0 0 24 24" stroke="currentColor" strokeWidth={2}>
|
|
332
357
|
<path strokeLinecap="round" strokeLinejoin="round" d="M5 13l4 4L19 7" />
|
|
@@ -344,7 +369,32 @@ export function SubscriptionManager({
|
|
|
344
369
|
</div>
|
|
345
370
|
)}
|
|
346
371
|
|
|
347
|
-
{statusMessage === "
|
|
372
|
+
{statusMessage === "success" && activation === "done" && !planFoundRef.current && (
|
|
373
|
+
<div data-testid="status-banner-pending" role="status" className="rounded-xl bg-amber-50 border border-amber-200 p-4">
|
|
374
|
+
<p className="text-sm font-medium text-amber-900">
|
|
375
|
+
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.
|
|
376
|
+
</p>
|
|
377
|
+
</div>
|
|
378
|
+
)}
|
|
379
|
+
|
|
380
|
+
{statusMessage === "scheduled" && (
|
|
381
|
+
<div data-testid="status-banner-scheduled" role="status" className="rounded-xl bg-blue-50 border border-blue-200 p-4">
|
|
382
|
+
<p className="text-sm font-medium text-blue-900">
|
|
383
|
+
Your plan change is scheduled.
|
|
384
|
+
{usableSubscription?.currentPeriodEnd
|
|
385
|
+
? <> You keep your current plan until {new Date(usableSubscription.currentPeriodEnd).toLocaleDateString("en-US", { month: "long", day: "numeric", year: "numeric", timeZone: "UTC" })}.</>
|
|
386
|
+
: <> You keep your current plan until the end of this billing period.</>}
|
|
387
|
+
</p>
|
|
388
|
+
</div>
|
|
389
|
+
)}
|
|
390
|
+
|
|
391
|
+
{planChange.error && !planChange.pending && (
|
|
392
|
+
<div data-testid="plan-change-error" role="alert" className="rounded-xl bg-red-50 border border-red-200 p-4">
|
|
393
|
+
<p className="text-sm text-red-700">{planChange.error}</p>
|
|
394
|
+
</div>
|
|
395
|
+
)}
|
|
396
|
+
|
|
397
|
+
{(statusMessage === "activating" || (statusMessage === "success" && activation !== "done")) && (
|
|
348
398
|
<div data-testid="status-banner-activating" className="rounded-xl bg-blue-50 border border-blue-200 p-4 flex items-center gap-3">
|
|
349
399
|
<svg className="w-5 h-5 text-blue-600 shrink-0 animate-spin" fill="none" viewBox="0 0 24 24">
|
|
350
400
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4"></circle>
|
|
@@ -380,7 +430,8 @@ export function SubscriptionManager({
|
|
|
380
430
|
usableSubscription.viewerIsOwner !== false && usableSubscription.offer.isFree && featuredPaidOffer ? (
|
|
381
431
|
<button
|
|
382
432
|
data-testid="upgrade-cta-current-plan"
|
|
383
|
-
onClick={() => handleOfferClick(featuredPaidOffer)}
|
|
433
|
+
onClick={() => !planChange.busy && handleOfferClick(featuredPaidOffer)}
|
|
434
|
+
disabled={planChange.busy}
|
|
384
435
|
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
436
|
>
|
|
386
437
|
Upgrade to {featuredPaidOffer.name} →
|
|
@@ -389,7 +440,7 @@ export function SubscriptionManager({
|
|
|
389
440
|
}
|
|
390
441
|
/>
|
|
391
442
|
</>
|
|
392
|
-
) : onFreeByDefault && freeOffer ? (
|
|
443
|
+
) : statusMessage === "success" && activation !== "done" ? null : onFreeByDefault && freeOffer ? (
|
|
393
444
|
<div data-testid="free-by-default" className="text-center py-8">
|
|
394
445
|
<p className="text-lg text-gray-600">You're on the {freeOffer.name} plan</p>
|
|
395
446
|
<p className="text-sm text-gray-400 mt-1">Choose a plan below to upgrade.</p>
|
|
@@ -446,7 +497,8 @@ export function SubscriptionManager({
|
|
|
446
497
|
const direction = directionFor(offer);
|
|
447
498
|
const isCurrent = direction === "current";
|
|
448
499
|
const isDowngrade = direction === "downgrade" || direction === "switch";
|
|
449
|
-
const ctaLabel =
|
|
500
|
+
const ctaLabel = planCtaLabel(offer, direction, paying);
|
|
501
|
+
const showTrial = trialApplies(offer, direction, paying);
|
|
450
502
|
|
|
451
503
|
// Border: current = solid ring; featured upgrade = blue; else gray.
|
|
452
504
|
const cardBorder =
|
|
@@ -469,16 +521,9 @@ export function SubscriptionManager({
|
|
|
469
521
|
key={offer.id}
|
|
470
522
|
data-testid={`plan-card-${offer.slug}`}
|
|
471
523
|
data-direction={direction}
|
|
524
|
+
data-current={isCurrent || undefined}
|
|
472
525
|
className={`relative rounded-xl p-6 transition-colors ${cardBorder}`}
|
|
473
526
|
>
|
|
474
|
-
{isCurrent && (
|
|
475
|
-
<span
|
|
476
|
-
data-testid={`current-plan-badge-${offer.slug}`}
|
|
477
|
-
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"
|
|
478
|
-
>
|
|
479
|
-
Current plan
|
|
480
|
-
</span>
|
|
481
|
-
)}
|
|
482
527
|
<h4 className="text-lg font-bold text-gray-900 mb-2">{tier.name}</h4>
|
|
483
528
|
<div className="mb-3">
|
|
484
529
|
<OfferPrice
|
|
@@ -491,13 +536,14 @@ export function SubscriptionManager({
|
|
|
491
536
|
{/* Trial copy is for prospective trialers only — once the
|
|
492
537
|
user is on this offer or moving downward, drop the
|
|
493
538
|
"14-day free trial" sales pitch. */}
|
|
494
|
-
{
|
|
495
|
-
<PlanFeatureList features={offer.features} />
|
|
539
|
+
{showTrial && <TrialCopy trialDays={offer.trialDays} />}
|
|
540
|
+
<PlanFeatureList features={offer.features} showTrialNote={showTrial || !(offer.trialDays > 0)} />
|
|
496
541
|
<button
|
|
497
542
|
data-testid={`upgrade-cta-${offer.slug}`}
|
|
498
|
-
onClick={() => !isCurrent && !isViewerNonOwner && handleOfferClick(offer)}
|
|
499
|
-
disabled={isCurrent || isViewerNonOwner}
|
|
500
|
-
aria-disabled={isCurrent || isViewerNonOwner || undefined}
|
|
543
|
+
onClick={() => !isCurrent && !isViewerNonOwner && !planChange.busy && handleOfferClick(offer)}
|
|
544
|
+
disabled={isCurrent || isViewerNonOwner || planChange.busy}
|
|
545
|
+
aria-disabled={isCurrent || isViewerNonOwner || planChange.busy || undefined}
|
|
546
|
+
aria-busy={planChange.busy && planChange.pending?.offer.id === offer.id ? true : undefined}
|
|
501
547
|
title={isViewerNonOwner ? "Only the team owner can change the plan" : undefined}
|
|
502
548
|
className={`w-full py-2.5 px-4 text-sm font-bold rounded-xl transition-colors ${ctaClass} ${isViewerNonOwner ? "opacity-60 cursor-not-allowed" : ""}`}
|
|
503
549
|
>
|
|
@@ -532,6 +578,14 @@ export function SubscriptionManager({
|
|
|
532
578
|
</button>
|
|
533
579
|
</div>
|
|
534
580
|
)}
|
|
581
|
+
|
|
582
|
+
<PlanChangeConfirm
|
|
583
|
+
state={planChange.pending}
|
|
584
|
+
confirming={planChange.confirming}
|
|
585
|
+
error={planChange.pending ? planChange.error : null}
|
|
586
|
+
onConfirm={planChange.confirm}
|
|
587
|
+
onCancel={planChange.cancel}
|
|
588
|
+
/>
|
|
535
589
|
</div>
|
|
536
590
|
);
|
|
537
591
|
}
|
|
@@ -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";
|