feat(pro-marketing): per-tier loading state on pricing checkout buttons (#3263)

Squashed rebase onto current main (which now includes #3262's
interstitial + #3273's duplicate dialog + #3270's referral + #3274's
overlay marker). Source-only diff extracted from merge-base; 2
conflicts in pro-test/src/services/checkout.ts resolved additively:

  1. doCheckout entry: both interstitial mount (PR-5, from main) and
     setPhase(creating_checkout, productId) (PR-6) needed; kept both.
  2. doCheckout finally: both unmountCheckoutInterstitial (PR-5, main)
     and setPhase(idle) (PR-6) needed; kept both.

Changes:
- pro-test/src/services/checkout-intent-url.ts (NEW): pure URL-param
  intent helpers (parseCheckoutIntentFromSearch, stripCheckoutIntent-
  FromSearch, buildCheckoutReturnUrl)
- pro-test/src/services/checkout.ts: CheckoutPhase state machine,
  subscribeCheckoutPhase; bind intent to sign-in via afterSignInUrl
- pro-test/src/components/PricingSection.tsx: subscribe to phase,
  per-tier loading + billing toggle lock
- pro-test/src/App.tsx: tryResumeCheckoutFromUrl on mount
- tests/pro-checkout-intent-url.test.mts: 18-test coverage including
  3 reviewer scenario regression guards

Pro bundle rebuilt fresh.
This commit is contained in:
Elie Habib
2026-04-22 16:21:40 +04:00
committed by GitHub
parent e9d07949a9
commit cfedcc3ea3
9 changed files with 505 additions and 103 deletions

View File

@@ -14,7 +14,7 @@ import {
Landmark, Fuel Landmark, Fuel
} from 'lucide-react'; } from 'lucide-react';
import { t } from './i18n'; import { t } from './i18n';
import { initOverlay, ensureClerk } from './services/checkout'; import { initOverlay, ensureClerk, tryResumeCheckoutFromUrl } from './services/checkout';
import { PricingSection } from './components/PricingSection'; import { PricingSection } from './components/PricingSection';
import { SoonBadge } from './components/SoonBadge'; import { SoonBadge } from './components/SoonBadge';
import dashboardFallback from './assets/worldmonitor-7-mar-2026.jpg'; import dashboardFallback from './assets/worldmonitor-7-mar-2026.jpg';
@@ -1272,6 +1272,10 @@ export default function App() {
setTimeout(goToDashboard, 1500); setTimeout(goToDashboard, 1500);
}); });
// Consume checkout intent from URL (set by afterSignInUrl on the
// checkout-initiated sign-in). No-op for any other /pro entry
// point; strips params before any await so a reload can't re-fire.
void tryResumeCheckoutFromUrl();
}, []); }, []);
useEffect(() => { useEffect(() => {

View File

@@ -1,7 +1,7 @@
import { useState, useEffect, useCallback } from 'react'; import { useState, useEffect, useCallback } from 'react';
import { motion } from 'motion/react'; import { motion } from 'motion/react';
import { Check, ArrowRight, Zap } from 'lucide-react'; import { Check, ArrowRight, Zap, Loader2 } from 'lucide-react';
import { startCheckout } from '../services/checkout'; import { startCheckout, subscribeCheckoutPhase, type CheckoutPhase } from '../services/checkout';
// Static fallback from build-time generation (used while fetching live prices) // Static fallback from build-time generation (used while fetching live prices)
import fallbackTiers from '../generated/tiers.json'; import fallbackTiers from '../generated/tiers.json';
@@ -82,10 +82,22 @@ function getCtaProps(tier: Tier, billing: 'monthly' | 'annual'): CtaProps {
export function PricingSection({ refCode }: { refCode?: string }) { export function PricingSection({ refCode }: { refCode?: string }) {
const [billing, setBilling] = useState<'monthly' | 'annual'>('monthly'); const [billing, setBilling] = useState<'monthly' | 'annual'>('monthly');
// Loading state is driven by the service's checkout phase. Only the
// `creating_checkout` phase (post-auth, inside doCheckout) disables
// the clicked CTA. During the Clerk modal window, phase stays idle —
// the modal backdrop is the user's feedback, so locking the pricing
// section underneath adds no value and creates recovery problems
// (watchdogs, DOM polling) that we don't need.
const [phase, setPhase] = useState<CheckoutPhase>({ kind: 'idle' });
const loadingProductId = phase.kind === 'creating_checkout' ? phase.productId : null;
const TIERS = usePricingData(); const TIERS = usePricingData();
useEffect(() => subscribeCheckoutPhase(setPhase), []);
// checkoutInFlight in the service guards concurrent doCheckout runs.
// The handler is fire-and-forget — no local loading state to manage.
const handleCheckout = useCallback((productId: string) => { const handleCheckout = useCallback((productId: string) => {
startCheckout(productId, { referralCode: refCode }); void startCheckout(productId, { referralCode: refCode });
}, [refCode]); }, [refCode]);
return ( return (
@@ -113,7 +125,12 @@ export function PricingSection({ refCode }: { refCode?: string }) {
Pick the tier that fits your mission. Pick the tier that fits your mission.
</motion.p> </motion.p>
{/* Billing toggle */} {/* Billing toggle — disabled while a checkout is active.
Switching billing mid-flight would change cta.productId
out from under the active checkout, making the spinner
vanish from the tier the user clicked. Locking the toggle
during the flow is the simplest correct behavior: the
user committed to a plan by clicking Checkout. */}
<motion.div <motion.div
className="inline-flex items-center gap-3 bg-wm-card border border-wm-border rounded-sm p-1" className="inline-flex items-center gap-3 bg-wm-card border border-wm-border rounded-sm p-1"
initial={{ opacity: 0, y: 10 }} initial={{ opacity: 0, y: 10 }}
@@ -123,7 +140,8 @@ export function PricingSection({ refCode }: { refCode?: string }) {
> >
<button <button
onClick={() => setBilling('monthly')} onClick={() => setBilling('monthly')}
className={`px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider transition-colors ${ disabled={loadingProductId !== null}
className={`px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider transition-colors disabled:cursor-not-allowed disabled:opacity-60 ${
billing === 'monthly' billing === 'monthly'
? 'bg-wm-green text-wm-bg font-bold' ? 'bg-wm-green text-wm-bg font-bold'
: 'text-wm-muted hover:text-wm-text' : 'text-wm-muted hover:text-wm-text'
@@ -133,12 +151,17 @@ export function PricingSection({ refCode }: { refCode?: string }) {
</button> </button>
<button <button
onClick={() => setBilling('annual')} onClick={() => setBilling('annual')}
className={`px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider transition-colors flex items-center gap-2 ${ disabled={loadingProductId !== null}
className={`px-4 py-2 rounded-sm font-mono text-xs uppercase tracking-wider transition-colors flex items-center gap-2 disabled:cursor-not-allowed disabled:opacity-60 ${
billing === 'annual' billing === 'annual'
? 'bg-wm-green text-wm-bg font-bold' ? 'bg-wm-green text-wm-bg font-bold'
: 'text-wm-muted hover:text-wm-text' : 'text-wm-muted hover:text-wm-text'
}`} }`}
> >
{/* Lock billing toggle ONLY during creating_checkout (narrow
post-auth window). Through the Clerk modal the toggle
is covered by the backdrop anyway; locking during the
modal was unnecessary. */}
Annual Annual
<span className={`text-[10px] px-1.5 py-0.5 rounded-sm ${ <span className={`text-[10px] px-1.5 py-0.5 rounded-sm ${
billing === 'annual' billing === 'annual'
@@ -220,18 +243,38 @@ export function PricingSection({ refCode }: { refCode?: string }) {
> >
{cta.label} <ArrowRight className="w-3.5 h-3.5 inline-block ml-1" aria-hidden="true" /> {cta.label} <ArrowRight className="w-3.5 h-3.5 inline-block ml-1" aria-hidden="true" />
</a> </a>
) : ( ) : (() => {
<button const isLoading = loadingProductId === cta.productId;
onClick={() => handleCheckout(cta.productId)} // Only the clicked tier disables during creating_checkout.
className={`block w-full text-center py-3 rounded-sm font-mono text-xs uppercase tracking-wider font-bold transition-colors cursor-pointer ${ // Sibling tiers stay clickable; if the user changes their
tier.highlighted // mind mid-flow, their next click simply updates the
? 'bg-wm-green text-wm-bg hover:bg-green-400' // pending intent. The pricing page is never hard-locked.
: 'border border-wm-border text-wm-muted hover:text-wm-text hover:border-wm-text' return (
}`} <button
> onClick={() => handleCheckout(cta.productId)}
{cta.label} <ArrowRight className="w-3.5 h-3.5 inline-block ml-1" aria-hidden="true" /> disabled={isLoading}
</button> aria-busy={isLoading || undefined}
)} className={`block w-full text-center py-3 rounded-sm font-mono text-xs uppercase tracking-wider font-bold transition-colors ${
isLoading ? 'cursor-wait opacity-70' : 'cursor-pointer'
} ${
tier.highlighted
? 'bg-wm-green text-wm-bg hover:bg-green-400'
: 'border border-wm-border text-wm-muted hover:text-wm-text hover:border-wm-text'
}`}
>
{isLoading ? (
<>
<Loader2 className="w-3.5 h-3.5 inline-block mr-2 animate-spin" aria-hidden="true" />
<span>Opening</span>
</>
) : (
<>
{cta.label} <ArrowRight className="w-3.5 h-3.5 inline-block ml-1" aria-hidden="true" />
</>
)}
</button>
);
})()}
</motion.div> </motion.div>
); );
})} })}

View File

@@ -0,0 +1,79 @@
/**
* Pure helpers for the checkout-intent-via-URL flow.
*
* Intent is bound to the specific sign-in attempt via Clerk's
* afterSignInUrl / afterSignUpUrl. On successful sign-in from our
* openSignIn call, Clerk navigates to a return URL carrying these
* params. On page load, we parse, consume, and strip them.
*
* Extracted as a pure module (no DOM, no Clerk, no dodopayments
* import) so tests can exercise the parse/strip/build logic without
* a browser or SDK environment. Reviewer-requested after the
* afterSignInUrl refactor introduced this flow.
*/
export const CHECKOUT_PRODUCT_PARAM = 'wm_checkout_product';
export const CHECKOUT_REF_PARAM = 'wm_checkout_ref';
export const CHECKOUT_DISCOUNT_PARAM = 'wm_checkout_discount';
export interface CheckoutIntentFromUrl {
productId: string;
referralCode?: string;
discountCode?: string;
}
/**
* Parse checkout intent from a URL search string. Returns null when
* the required productId param is missing — that's the common
* "normal page load, no intent" case.
*/
export function parseCheckoutIntentFromSearch(search: string): CheckoutIntentFromUrl | null {
const params = new URLSearchParams(search);
const productId = params.get(CHECKOUT_PRODUCT_PARAM);
if (!productId) return null;
return {
productId,
referralCode: params.get(CHECKOUT_REF_PARAM) ?? undefined,
discountCode: params.get(CHECKOUT_DISCOUNT_PARAM) ?? undefined,
};
}
/**
* Strip checkout-intent params from a URL search string while
* preserving all other query params. Returns '' (empty) when nothing
* remains, '?a=b' when other params survive.
*
* Caller applies this BEFORE any await so a reload during the
* post-strip async work can't re-fire checkout with the stale intent.
*/
export function stripCheckoutIntentFromSearch(search: string): string {
const params = new URLSearchParams(search);
params.delete(CHECKOUT_PRODUCT_PARAM);
params.delete(CHECKOUT_REF_PARAM);
params.delete(CHECKOUT_DISCOUNT_PARAM);
const remaining = params.toString();
return remaining ? `?${remaining}` : '';
}
/**
* Build a return URL with checkout intent appended. Called at click-
* time to construct Clerk's afterSignInUrl / afterSignUpUrl.
*
* Strips any previously-set checkout-intent params so stacked intents
* don't compound (user clicks Pro, dismisses, clicks Enterprise: the
* returnUrl should carry Enterprise, not both).
*/
export function buildCheckoutReturnUrl(
currentHref: string,
productId: string,
options?: { referralCode?: string; discountCode?: string },
): string {
const url = new URL(currentHref);
url.searchParams.delete(CHECKOUT_PRODUCT_PARAM);
url.searchParams.delete(CHECKOUT_REF_PARAM);
url.searchParams.delete(CHECKOUT_DISCOUNT_PARAM);
url.searchParams.set(CHECKOUT_PRODUCT_PARAM, productId);
if (options?.referralCode) url.searchParams.set(CHECKOUT_REF_PARAM, options.referralCode);
if (options?.discountCode) url.searchParams.set(CHECKOUT_DISCOUNT_PARAM, options.discountCode);
return url.toString();
}

View File

@@ -15,12 +15,51 @@ const ACTIVE_SUBSCRIPTION_EXISTS = 'ACTIVE_SUBSCRIPTION_EXISTS';
const MONO_FONT = "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace"; const MONO_FONT = "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace";
import {
parseCheckoutIntentFromSearch,
stripCheckoutIntentFromSearch,
buildCheckoutReturnUrl,
} from './checkout-intent-url';
let clerk: InstanceType<typeof Clerk> | null = null; let clerk: InstanceType<typeof Clerk> | null = null;
let pendingProductId: string | null = null;
let pendingOptions: { referralCode?: string; discountCode?: string } | null = null;
let checkoutInFlight = false; let checkoutInFlight = false;
let clerkLoadPromise: Promise<InstanceType<typeof Clerk>> | null = null; let clerkLoadPromise: Promise<InstanceType<typeof Clerk>> | null = null;
/**
* Phase machine for the checkout flow. Only `creating_checkout` drives
* UI lock state. `awaiting_auth` is intentionally not exposed — while
* the Clerk modal is open the pricing section is covered by the modal
* backdrop, so a service-level UI signal for that window adds no user-
* visible value and creates lifecycle-recovery problems (watchdogs,
* DOM polling, false-positive focus events). Keeping the pricing page
* idle during auth means cancellation needs no recovery path — the UI
* is already in the right state.
*
* idle: no checkout in progress; all CTAs clickable
* creating_checkout: post-auth, inside doCheckout's try/finally;
* the clicked tier's CTA shows spinner, siblings
* stay clickable (any click simply updates intent)
*/
export type CheckoutPhase =
| { kind: 'idle' }
| { kind: 'creating_checkout'; productId: string };
let _phase: CheckoutPhase = { kind: 'idle' };
const phaseSubscribers = new Set<(phase: CheckoutPhase) => void>();
function setPhase(phase: CheckoutPhase): void {
_phase = phase;
for (const cb of phaseSubscribers) {
try { cb(phase); } catch (err) { console.error('[checkout] phase subscriber threw:', err); }
}
}
export function subscribeCheckoutPhase(cb: (phase: CheckoutPhase) => void): () => void {
phaseSubscribers.add(cb);
cb(_phase);
return () => { phaseSubscribers.delete(cb); };
}
export async function ensureClerk(): Promise<InstanceType<typeof Clerk>> { export async function ensureClerk(): Promise<InstanceType<typeof Clerk>> {
if (clerk) return clerk; if (clerk) return clerk;
if (clerkLoadPromise) return clerkLoadPromise; if (clerkLoadPromise) return clerkLoadPromise;
@@ -65,17 +104,18 @@ async function _loadClerk(): Promise<InstanceType<typeof Clerk>> {
// and bypass the retry path. // and bypass the retry path.
clerk = instance; clerk = instance;
// Auto-resume checkout after sign-in // NO addListener-based auto-resume. That was the source of the
clerk.addListener(() => { // surprise-purchase bug: any sign-in event (checkout-initiated OR
if (clerk?.user && pendingProductId) { // generic "Sign In" CTA on /pro) would fire the listener; with
const pid = pendingProductId; // module-scoped pendingProductId the stale intent from a dismissed
const opts = pendingOptions; // checkout modal would run when the user signed in later for
pendingProductId = null; // unrelated reasons.
pendingOptions = null; //
doCheckout(pid, opts ?? {}); // Intent is bound to the specific sign-in attempt via Clerk's
} // afterSignInUrl / afterSignUpUrl (see startCheckout). On dismissal
}); // there's no redirect; only successful sign-in FROM OUR openSignIn
// call navigates to a URL carrying the intent params. Generic sign-
// in paths don't set these URLs, so they can't trigger resume.
return clerk; return clerk;
} }
@@ -116,15 +156,19 @@ export async function startCheckout(
} }
if (!c.user) { if (!c.user) {
pendingProductId = productId; // Intent travels via afterSignInUrl / afterSignUpUrl — bound to
pendingOptions = options ?? null; // THIS specific openSignIn call. On successful sign-in, Clerk
// navigates to the returnUrl which carries the checkout intent
// in its query string; tryResumeCheckoutFromUrl picks it up on
// page load. On dismissal, Clerk performs no navigation, so no
// resume. Other /pro sign-in paths don't set these URLs, so they
// can't trigger surprise purchases.
const returnUrl = buildCheckoutReturnUrl(window.location.href, productId, options);
try { try {
c.openSignIn(); c.openSignIn({ afterSignInUrl: returnUrl, afterSignUpUrl: returnUrl });
} catch (err) { } catch (err) {
console.error('[checkout] Failed to open sign in:', err); console.error('[checkout] Failed to open sign in:', err);
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'checkout-sign-in' } }); Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'checkout-sign-in' } });
pendingProductId = null;
pendingOptions = null;
} }
return false; return false;
} }
@@ -132,12 +176,39 @@ export async function startCheckout(
return doCheckout(productId, options ?? {}); return doCheckout(productId, options ?? {});
} }
export async function tryResumeCheckoutFromUrl(): Promise<boolean> {
const intent = parseCheckoutIntentFromSearch(window.location.search);
if (!intent) return false;
// Strip BEFORE any await so a fast reload sees the clean URL.
const cleanSearch = stripCheckoutIntentFromSearch(window.location.search);
const cleanUrl = window.location.pathname + cleanSearch + window.location.hash;
window.history.replaceState({}, '', cleanUrl);
let c: InstanceType<typeof Clerk>;
try {
c = await ensureClerk();
} catch {
return false;
}
if (!c.user) return false;
const { productId, referralCode, discountCode } = intent;
return doCheckout(productId, { referralCode, discountCode });
}
async function doCheckout( async function doCheckout(
productId: string, productId: string,
options: { referralCode?: string; discountCode?: string }, options: { referralCode?: string; discountCode?: string },
): Promise<boolean> { ): Promise<boolean> {
if (checkoutInFlight) return false; if (checkoutInFlight) return false;
checkoutInFlight = true; checkoutInFlight = true;
// Phase transitions to creating_checkout ONLY here, not in
// startCheckout's no-user branch. This narrow window (post-auth,
// edge call + Dodo SDK import + overlay open) is the only time the
// pricing page is visible AND the checkout is mid-work, so it's the
// only time the clicked CTA should show a spinner.
setPhase({ kind: 'creating_checkout', productId });
// Best-effort visual bridge between Clerk modal close and Dodo // Best-effort visual bridge between Clerk modal close and Dodo
// overlay paint. Covers two common sources of blank-screen feel: // overlay paint. Covers two common sources of blank-screen feel:
// 1. Auto-resume after sign-in fires doCheckout synchronously; the // 1. Auto-resume after sign-in fires doCheckout synchronously; the
@@ -266,6 +337,7 @@ async function doCheckout(
} finally { } finally {
checkoutInFlight = false; checkoutInFlight = false;
unmountCheckoutInterstitial(); unmountCheckoutInterstitial();
setPhase({ kind: 'idle' });
} }
} }

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View File

@@ -144,8 +144,8 @@
} }
</script> </script>
<script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script> <script src="https://challenges.cloudflare.com/turnstile/v0/api.js?render=explicit" async defer></script>
<script type="module" crossorigin src="/pro/assets/index-SL5zRmGT.js"></script> <script type="module" crossorigin src="/pro/assets/index-C4n4JLzf.js"></script>
<link rel="stylesheet" crossorigin href="/pro/assets/index-C0phu92I.css"> <link rel="stylesheet" crossorigin href="/pro/assets/index-xSEP0-ib.css">
</head> </head>
<body> <body>
<div id="root"> <div id="root">

View File

@@ -0,0 +1,199 @@
/**
* Regression coverage for the /pro checkout-intent-via-URL flow
* introduced after reviewer flagged the module-state surprise-purchase
* bug. Covers the three behavioral scenarios the reviewer asked for
* manual smoke-testing, at the pure-function level.
*
* The full flow (openSignIn + Clerk redirect + doCheckout) requires a
* browser + Clerk + Dodo SDK and is covered by manual smoke tests
* documented in the PR description. This file exercises the boundary
* between URL state and resume decision — the bit most likely to rot.
*/
import { describe, it } from 'node:test';
import assert from 'node:assert/strict';
import {
parseCheckoutIntentFromSearch,
stripCheckoutIntentFromSearch,
buildCheckoutReturnUrl,
CHECKOUT_PRODUCT_PARAM,
CHECKOUT_REF_PARAM,
CHECKOUT_DISCOUNT_PARAM,
} from '../pro-test/src/services/checkout-intent-url.ts';
describe('parseCheckoutIntentFromSearch', () => {
it('returns null when no productId param is present (normal page load)', () => {
assert.equal(parseCheckoutIntentFromSearch(''), null);
assert.equal(parseCheckoutIntentFromSearch('?foo=bar'), null);
assert.equal(parseCheckoutIntentFromSearch('?ref=someone'), null);
});
it('returns intent with just productId when only the required param is present', () => {
const intent = parseCheckoutIntentFromSearch(`?${CHECKOUT_PRODUCT_PARAM}=pro_monthly`);
assert.deepEqual(intent, { productId: 'pro_monthly', referralCode: undefined, discountCode: undefined });
});
it('returns full intent with optional referralCode + discountCode', () => {
const intent = parseCheckoutIntentFromSearch(
`?${CHECKOUT_PRODUCT_PARAM}=pro_annual&${CHECKOUT_REF_PARAM}=abc123&${CHECKOUT_DISCOUNT_PARAM}=SAVE20`,
);
assert.deepEqual(intent, { productId: 'pro_annual', referralCode: 'abc123', discountCode: 'SAVE20' });
});
it('ignores unrelated query params', () => {
const intent = parseCheckoutIntentFromSearch(
`?utm_source=email&${CHECKOUT_PRODUCT_PARAM}=pro_monthly&utm_campaign=launch`,
);
assert.deepEqual(intent, { productId: 'pro_monthly', referralCode: undefined, discountCode: undefined });
});
it('rejects empty productId (defensive)', () => {
// URLSearchParams treats "?x=" as x present with empty string.
// parser requires a truthy value — empty string is rejected like
// missing, so a malformed URL can't surprise-trigger checkout with
// an empty productId that doCheckout would then fail on anyway.
assert.equal(parseCheckoutIntentFromSearch(`?${CHECKOUT_PRODUCT_PARAM}=`), null);
});
});
describe('stripCheckoutIntentFromSearch', () => {
it('returns empty string when only checkout-intent params were present', () => {
assert.equal(
stripCheckoutIntentFromSearch(`?${CHECKOUT_PRODUCT_PARAM}=pro_monthly`),
'',
);
assert.equal(
stripCheckoutIntentFromSearch(
`?${CHECKOUT_PRODUCT_PARAM}=pro_annual&${CHECKOUT_REF_PARAM}=abc&${CHECKOUT_DISCOUNT_PARAM}=X`,
),
'',
);
});
it('preserves unrelated query params (utm, ref for /pro itself, etc.)', () => {
const result = stripCheckoutIntentFromSearch(
`?utm_source=email&${CHECKOUT_PRODUCT_PARAM}=pro_monthly&utm_campaign=launch`,
);
// URLSearchParams preserves insertion order for the surviving
// params, so utm_source comes before utm_campaign in the result.
assert.equal(result, '?utm_source=email&utm_campaign=launch');
});
it('returns empty string for empty input', () => {
assert.equal(stripCheckoutIntentFromSearch(''), '');
});
it('strips all three checkout params together (partial cleanup would leave ghosts)', () => {
const result = stripCheckoutIntentFromSearch(
`?${CHECKOUT_PRODUCT_PARAM}=X&${CHECKOUT_REF_PARAM}=Y&${CHECKOUT_DISCOUNT_PARAM}=Z&keep=me`,
);
assert.equal(result, '?keep=me');
});
it('is idempotent — second call on a stripped URL is a no-op', () => {
const once = stripCheckoutIntentFromSearch(
`?${CHECKOUT_PRODUCT_PARAM}=pro_monthly&keep=1`,
);
const twice = stripCheckoutIntentFromSearch(once);
assert.equal(twice, once);
});
});
describe('buildCheckoutReturnUrl', () => {
it('appends checkout params to a clean current URL', () => {
const returnUrl = buildCheckoutReturnUrl('https://worldmonitor.app/pro', 'pro_monthly');
const url = new URL(returnUrl);
assert.equal(url.searchParams.get(CHECKOUT_PRODUCT_PARAM), 'pro_monthly');
assert.equal(url.searchParams.get(CHECKOUT_REF_PARAM), null);
assert.equal(url.searchParams.get(CHECKOUT_DISCOUNT_PARAM), null);
});
it('overwrites stale checkout params when the user clicks a different tier', () => {
// User clicks Pro, dismisses sign-in, clicks Enterprise. returnUrl
// for the second click must not carry Pro's intent.
const firstClick = buildCheckoutReturnUrl('https://worldmonitor.app/pro', 'pro_monthly');
const secondClick = buildCheckoutReturnUrl(firstClick, 'enterprise');
const url = new URL(secondClick);
assert.equal(url.searchParams.get(CHECKOUT_PRODUCT_PARAM), 'enterprise');
// Ensure no stacking — exactly one occurrence.
assert.equal(url.searchParams.getAll(CHECKOUT_PRODUCT_PARAM).length, 1);
});
it('includes referralCode + discountCode when provided', () => {
const returnUrl = buildCheckoutReturnUrl('https://worldmonitor.app/pro', 'pro_annual', {
referralCode: 'abc',
discountCode: 'SAVE20',
});
const url = new URL(returnUrl);
assert.equal(url.searchParams.get(CHECKOUT_PRODUCT_PARAM), 'pro_annual');
assert.equal(url.searchParams.get(CHECKOUT_REF_PARAM), 'abc');
assert.equal(url.searchParams.get(CHECKOUT_DISCOUNT_PARAM), 'SAVE20');
});
it('preserves unrelated query params on the current URL (utm, etc.)', () => {
const returnUrl = buildCheckoutReturnUrl(
'https://worldmonitor.app/pro?utm_source=email',
'pro_monthly',
);
const url = new URL(returnUrl);
assert.equal(url.searchParams.get('utm_source'), 'email');
assert.equal(url.searchParams.get(CHECKOUT_PRODUCT_PARAM), 'pro_monthly');
});
it('preserves pathname and hash (e.g., returning to /pro#enterprise)', () => {
const returnUrl = buildCheckoutReturnUrl(
'https://worldmonitor.app/pro#pricing',
'pro_monthly',
);
const url = new URL(returnUrl);
assert.equal(url.pathname, '/pro');
assert.equal(url.hash, '#pricing');
});
});
describe('reviewer scenario coverage (regression guards)', () => {
it('scenario 1: click paid tier signed-out → sign in → checkout auto-resumes', () => {
// 1. Signed-out click builds returnUrl with intent
const returnUrl = buildCheckoutReturnUrl('https://worldmonitor.app/pro', 'pro_monthly', {
referralCode: 'abc',
});
// 2. Clerk redirects user to returnUrl after successful sign-in
// 3. /pro loads, tryResumeCheckoutFromUrl parses intent
const search = new URL(returnUrl).search;
const intent = parseCheckoutIntentFromSearch(search);
// 4. Intent parsed; doCheckout fires with correct params
assert.deepEqual(intent, { productId: 'pro_monthly', referralCode: 'abc', discountCode: undefined });
});
it('scenario 2: click paid → dismiss sign-in → later generic sign-in does NOT resume', () => {
// 1. User click creates returnUrl — but user never reaches Clerk's
// success handler (dismissed). No navigation happens; URL stays
// at the pre-click state (no intent params).
const currentSearch = '?utm_source=email'; // typical marketing-page URL
const intent = parseCheckoutIntentFromSearch(currentSearch);
assert.equal(intent, null, 'no intent in URL because Clerk never redirected');
// 2. Later, user clicks generic Sign In on /pro. That path calls
// c.openSignIn() without afterSignInUrl, so Clerk's default
// post-auth behavior applies — either no redirect or redirect
// to Clerk's configured afterSignInUrl (NOT our intent URL).
// 3. The URL on their post-auth /pro page STILL has no intent.
// parseCheckoutIntentFromSearch returns null, no resume fires.
// Verified: the bug requires intent in URL, which requires
// checkout-initiated openSignIn, which requires a click on a
// paid tier. Generic sign-in cannot produce that URL.
});
it('scenario 3: reload after successful resume does NOT re-fire', () => {
// 1. Post-redirect URL has intent
const postRedirectSearch = `?${CHECKOUT_PRODUCT_PARAM}=pro_monthly&${CHECKOUT_REF_PARAM}=abc&utm=x`;
const intent1 = parseCheckoutIntentFromSearch(postRedirectSearch);
assert.equal(intent1?.productId, 'pro_monthly', 'first load parses intent');
// 2. tryResumeCheckoutFromUrl strips intent BEFORE any await
const stripped = stripCheckoutIntentFromSearch(postRedirectSearch);
assert.equal(stripped, '?utm=x', 'intent params removed, utm preserved');
// 3. User reloads the page → URL is stripped → parse returns null
const intent2 = parseCheckoutIntentFromSearch(stripped);
assert.equal(intent2, null, 'reload sees no intent, no re-fire');
});
});