@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.
@@ -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) carries the badge; the others do not.
352
- expect(screen.getByTestId("current-plan-badge-pro-monthly")).toBeDefined();
353
- expect(screen.queryByTestId("current-plan-badge-starter-monthly")).toBeNull();
354
- expect(screen.queryByTestId("current-plan-badge-enterprise-monthly")).toBeNull();
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
- (await screen.findByTestId("upgrade-cta-starter-monthly")).click();
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
- { label: "pro · Enterprise card → upgrade CTA, trial copy if applicable",
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: /contact sales/i, shouldBeDisabled: false,
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 badge = screen.queryByTestId(`current-plan-badge-${cardSlug}`);
896
+ const current = screen.getByTestId(`plan-card-${cardSlug}`).getAttribute("data-current");
888
897
  if (shouldShowCurrentBadge) {
889
- expect(badge).not.toBeNull();
898
+ expect(current).toBe("true");
890
899
  } else {
891
- expect(badge).toBeNull();
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
- // Banner renders
969
- await waitFor(() => expect(screen.getByTestId("status-banner-success")).toBeDefined());
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(() => expect(screen.getByTestId("status-banner-success")).toBeDefined());
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 type { OfferFeature, ProductOffer } from "../types";
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
- /** Called when user clicks an available plan's CTA button */
35
- onPlanChange?: (offer: ProductOffer) => void;
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
- statusMessage?: "success" | "cancelled" | "activating" | null;
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
- {features.map((f) => <FeatureRow key={f.key} feature={f} />)}
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
- } finally {
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" badge + disabled CTA. startsim-1f1, crochet_patterns-34n7.
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
- /** 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
- }
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
- // Branch the offer CTA internally so apps don't reinvent it:
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
- if (offer.isFree) {
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 === "activating" && (
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&apos;t received the payment confirmation yet. It can take a minute; refresh this page to check again. Please don&apos;t pay a second time.
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&apos;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 = ctaLabelFor(offer, direction);
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
- {direction === "upgrade" && <TrialCopy trialDays={offer.trialDays} />}
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 { resolveCurrentOfferId, type CurrentPlanState } from "../utils/plan";
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
- onSelectOffer?: (offer: ProductOffer) => void;
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
- resolveCurrentOfferId(product?.offers ?? [], currentPlan),
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 isCurrent = offer.id === resolveCurrentOfferId(product.offers, currentPlan);
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
- {offer.trialDays > 0 && (
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
- {offer.features.length > 0 && (
208
+ {features.length > 0 && (
177
209
  <ul className="list-none p-0 m-0 mb-4 flex-1 space-y-0">
178
- {offer.features.map((f) => (
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 && onSelectOffer?.(offer)}
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
  }
@@ -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";
@@ -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";