@startsimpli/billing 0.4.23 → 0.4.25

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