mirror of
https://github.com/koala73/worldmonitor.git
synced 2026-04-25 17:14:57 +02:00
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:
@@ -14,7 +14,7 @@ import {
|
||||
Landmark, Fuel
|
||||
} from 'lucide-react';
|
||||
import { t } from './i18n';
|
||||
import { initOverlay, ensureClerk } from './services/checkout';
|
||||
import { initOverlay, ensureClerk, tryResumeCheckoutFromUrl } from './services/checkout';
|
||||
import { PricingSection } from './components/PricingSection';
|
||||
import { SoonBadge } from './components/SoonBadge';
|
||||
import dashboardFallback from './assets/worldmonitor-7-mar-2026.jpg';
|
||||
@@ -1272,6 +1272,10 @@ export default function App() {
|
||||
|
||||
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(() => {
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import { motion } from 'motion/react';
|
||||
import { Check, ArrowRight, Zap } from 'lucide-react';
|
||||
import { startCheckout } from '../services/checkout';
|
||||
import { Check, ArrowRight, Zap, Loader2 } from 'lucide-react';
|
||||
import { startCheckout, subscribeCheckoutPhase, type CheckoutPhase } from '../services/checkout';
|
||||
|
||||
// Static fallback from build-time generation (used while fetching live prices)
|
||||
import fallbackTiers from '../generated/tiers.json';
|
||||
@@ -82,10 +82,22 @@ function getCtaProps(tier: Tier, billing: 'monthly' | 'annual'): CtaProps {
|
||||
|
||||
export function PricingSection({ refCode }: { refCode?: string }) {
|
||||
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();
|
||||
|
||||
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) => {
|
||||
startCheckout(productId, { referralCode: refCode });
|
||||
void startCheckout(productId, { referralCode: refCode });
|
||||
}, [refCode]);
|
||||
|
||||
return (
|
||||
@@ -113,7 +125,12 @@ export function PricingSection({ refCode }: { refCode?: string }) {
|
||||
Pick the tier that fits your mission.
|
||||
</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
|
||||
className="inline-flex items-center gap-3 bg-wm-card border border-wm-border rounded-sm p-1"
|
||||
initial={{ opacity: 0, y: 10 }}
|
||||
@@ -123,7 +140,8 @@ export function PricingSection({ refCode }: { refCode?: string }) {
|
||||
>
|
||||
<button
|
||||
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'
|
||||
? 'bg-wm-green text-wm-bg font-bold'
|
||||
: 'text-wm-muted hover:text-wm-text'
|
||||
@@ -133,12 +151,17 @@ export function PricingSection({ refCode }: { refCode?: string }) {
|
||||
</button>
|
||||
<button
|
||||
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'
|
||||
? 'bg-wm-green text-wm-bg font-bold'
|
||||
: '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
|
||||
<span className={`text-[10px] px-1.5 py-0.5 rounded-sm ${
|
||||
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" />
|
||||
</a>
|
||||
) : (
|
||||
<button
|
||||
onClick={() => handleCheckout(cta.productId)}
|
||||
className={`block w-full text-center py-3 rounded-sm font-mono text-xs uppercase tracking-wider font-bold transition-colors 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'
|
||||
}`}
|
||||
>
|
||||
{cta.label} <ArrowRight className="w-3.5 h-3.5 inline-block ml-1" aria-hidden="true" />
|
||||
</button>
|
||||
)}
|
||||
) : (() => {
|
||||
const isLoading = loadingProductId === cta.productId;
|
||||
// Only the clicked tier disables during creating_checkout.
|
||||
// Sibling tiers stay clickable; if the user changes their
|
||||
// mind mid-flow, their next click simply updates the
|
||||
// pending intent. The pricing page is never hard-locked.
|
||||
return (
|
||||
<button
|
||||
onClick={() => handleCheckout(cta.productId)}
|
||||
disabled={isLoading}
|
||||
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>
|
||||
);
|
||||
})}
|
||||
|
||||
79
pro-test/src/services/checkout-intent-url.ts
Normal file
79
pro-test/src/services/checkout-intent-url.ts
Normal 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();
|
||||
}
|
||||
@@ -15,12 +15,51 @@ const ACTIVE_SUBSCRIPTION_EXISTS = 'ACTIVE_SUBSCRIPTION_EXISTS';
|
||||
|
||||
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 pendingProductId: string | null = null;
|
||||
let pendingOptions: { referralCode?: string; discountCode?: string } | null = null;
|
||||
let checkoutInFlight = false;
|
||||
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>> {
|
||||
if (clerk) return clerk;
|
||||
if (clerkLoadPromise) return clerkLoadPromise;
|
||||
@@ -65,17 +104,18 @@ async function _loadClerk(): Promise<InstanceType<typeof Clerk>> {
|
||||
// and bypass the retry path.
|
||||
clerk = instance;
|
||||
|
||||
// Auto-resume checkout after sign-in
|
||||
clerk.addListener(() => {
|
||||
if (clerk?.user && pendingProductId) {
|
||||
const pid = pendingProductId;
|
||||
const opts = pendingOptions;
|
||||
pendingProductId = null;
|
||||
pendingOptions = null;
|
||||
doCheckout(pid, opts ?? {});
|
||||
}
|
||||
});
|
||||
|
||||
// NO addListener-based auto-resume. That was the source of the
|
||||
// surprise-purchase bug: any sign-in event (checkout-initiated OR
|
||||
// generic "Sign In" CTA on /pro) would fire the listener; with
|
||||
// module-scoped pendingProductId the stale intent from a dismissed
|
||||
// checkout modal would run when the user signed in later for
|
||||
// unrelated reasons.
|
||||
//
|
||||
// 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;
|
||||
}
|
||||
|
||||
@@ -116,15 +156,19 @@ export async function startCheckout(
|
||||
}
|
||||
|
||||
if (!c.user) {
|
||||
pendingProductId = productId;
|
||||
pendingOptions = options ?? null;
|
||||
// Intent travels via afterSignInUrl / afterSignUpUrl — bound to
|
||||
// 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 {
|
||||
c.openSignIn();
|
||||
c.openSignIn({ afterSignInUrl: returnUrl, afterSignUpUrl: returnUrl });
|
||||
} catch (err) {
|
||||
console.error('[checkout] Failed to open sign in:', err);
|
||||
Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'checkout-sign-in' } });
|
||||
pendingProductId = null;
|
||||
pendingOptions = null;
|
||||
}
|
||||
return false;
|
||||
}
|
||||
@@ -132,12 +176,39 @@ export async function startCheckout(
|
||||
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(
|
||||
productId: string,
|
||||
options: { referralCode?: string; discountCode?: string },
|
||||
): Promise<boolean> {
|
||||
if (checkoutInFlight) return false;
|
||||
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
|
||||
// overlay paint. Covers two common sources of blank-screen feel:
|
||||
// 1. Auto-resume after sign-in fires doCheckout synchronously; the
|
||||
@@ -266,6 +337,7 @@ async function doCheckout(
|
||||
} finally {
|
||||
checkoutInFlight = false;
|
||||
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
1
public/pro/assets/index-xSEP0-ib.css
Normal file
1
public/pro/assets/index-xSEP0-ib.css
Normal file
File diff suppressed because one or more lines are too long
@@ -144,8 +144,8 @@
|
||||
}
|
||||
</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>
|
||||
<link rel="stylesheet" crossorigin href="/pro/assets/index-C0phu92I.css">
|
||||
<script type="module" crossorigin src="/pro/assets/index-C4n4JLzf.js"></script>
|
||||
<link rel="stylesheet" crossorigin href="/pro/assets/index-xSEP0-ib.css">
|
||||
</head>
|
||||
<body>
|
||||
<div id="root">
|
||||
|
||||
199
tests/pro-checkout-intent-url.test.mts
Normal file
199
tests/pro-checkout-intent-url.test.mts
Normal 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');
|
||||
});
|
||||
});
|
||||
Reference in New Issue
Block a user