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
|
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(() => {
|
||||||
|
|||||||
@@ -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>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
|||||||
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";
|
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
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>
|
||||||
<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">
|
||||||
|
|||||||
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