| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
|
|
| import * as Sentry from '@sentry/react'; |
| import type { CheckoutEvent } from 'dodopayments-checkout'; |
| import { ensureClerk, type LoadedClerk } from './clerk'; |
| export { ensureClerk } from './clerk'; |
|
|
| const API_BASE = 'https://api.worldmonitor.app/api'; |
| const DODO_PORTAL_FALLBACK_URL = 'https://customer.dodopayments.com'; |
| const ACTIVE_SUBSCRIPTION_EXISTS = 'ACTIVE_SUBSCRIPTION_EXISTS'; |
| const PAYMENT_IN_PROGRESS = 'PAYMENT_IN_PROGRESS'; |
|
|
| import { |
| parseCheckoutIntentFromSearch, |
| stripCheckoutIntentFromSearch, |
| buildCheckoutReturnUrl, |
| } from './checkout-intent-url'; |
| import { createEntitlementWatchdog, type EntitlementWatchdog } from './entitlement-watchdog'; |
| import { |
| createDefaultCheckoutTransportDeps, |
| postCreateCheckout, |
| } from './checkout-transport'; |
| import { |
| checkoutRetryAtMs, |
| checkoutRetryRemainingSeconds, |
| parseCheckoutRetryAfterSeconds, |
| } from './checkout-rate-limit'; |
| import { DASHBOARD_CHECKOUT_SUCCESS_URL, DASHBOARD_CHECKOUT_RETURN_URL } from '../routes'; |
| import fallbackTiers from '../generated/tiers.json'; |
|
|
| let checkoutInFlight = false; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const FUNNEL_QUEUE_LIMIT = 20; |
| const FUNNEL_FLUSH_INTERVAL_MS = 500; |
| const FUNNEL_FLUSH_MAX_ATTEMPTS = 60; |
| const pendingFunnelEvents: Array<{ event: string; data?: Record<string, unknown> }> = []; |
| let funnelFlushTimer: number | null = null; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const PRO_FUNNEL_PENDING_KEY = 'wm-pro-funnel-pending'; |
| const PRO_FUNNEL_PERSIST_LIMIT = 10; |
|
|
| function persistFunnelEventForReplay(event: string, data?: Record<string, unknown>): void { |
| if (event !== 'checkout-start') return; |
| try { |
| const raw = window.sessionStorage.getItem(PRO_FUNNEL_PENDING_KEY); |
| const parsed: unknown = raw ? JSON.parse(raw) : []; |
| const items = Array.isArray(parsed) ? parsed : []; |
| items.push({ event, data }); |
| while (items.length > PRO_FUNNEL_PERSIST_LIMIT) items.shift(); |
| window.sessionStorage.setItem(PRO_FUNNEL_PENDING_KEY, JSON.stringify(items)); |
| } catch { |
| |
| } |
| } |
|
|
| function clearPersistedFunnelEvents(): void { |
| try { |
| window.sessionStorage.removeItem(PRO_FUNNEL_PENDING_KEY); |
| } catch { |
| |
| } |
| } |
|
|
| function getUmami(): { track: (event: string, data?: Record<string, unknown>) => void } | undefined { |
| try { |
| return (window as Window & { |
| umami?: { track: (event: string, data?: Record<string, unknown>) => void }; |
| }).umami; |
| } catch { |
| return undefined; |
| } |
| } |
|
|
| function flushPendingFunnelEvents(): boolean { |
| const umami = getUmami(); |
| if (!umami) return false; |
| for (const item of pendingFunnelEvents.splice(0, pendingFunnelEvents.length)) { |
| try { umami.track(item.event, item.data); } catch { } |
| } |
| |
| |
| clearPersistedFunnelEvents(); |
| return true; |
| } |
|
|
| function trackFunnelEvent(event: string, data?: Record<string, unknown>): void { |
| try { |
| const umami = getUmami(); |
| if (umami) { |
| umami.track(event, data); |
| return; |
| } |
| if (pendingFunnelEvents.length >= FUNNEL_QUEUE_LIMIT) pendingFunnelEvents.shift(); |
| pendingFunnelEvents.push({ event, data }); |
| persistFunnelEventForReplay(event, data); |
| if (funnelFlushTimer === null) { |
| let attempts = 0; |
| funnelFlushTimer = window.setInterval(() => { |
| attempts += 1; |
| const flushed = flushPendingFunnelEvents(); |
| if ((flushed || attempts >= FUNNEL_FLUSH_MAX_ATTEMPTS) && funnelFlushTimer !== null) { |
| window.clearInterval(funnelFlushTimer); |
| funnelFlushTimer = null; |
| } |
| }, FUNNEL_FLUSH_INTERVAL_MS); |
| } |
| } catch { |
| |
| } |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const KNOWN_PRODUCT_IDS: ReadonlySet<string> = new Set( |
| (fallbackTiers as Array<{ monthlyProductId?: string; annualProductId?: string }>) |
| .flatMap((tier) => [tier.monthlyProductId, tier.annualProductId]) |
| .filter((id): id is string => typeof id === 'string' && id.length > 0), |
| ); |
|
|
| function bucketProductIdForAnalytics(productId: string): string { |
| return KNOWN_PRODUCT_IDS.has(productId) ? productId : 'unknown'; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| export type CheckoutPhase = |
| | { kind: 'idle' } |
| | { kind: 'creating_checkout'; productId: string } |
| | { kind: 'rate_limited'; retryAtMs: number }; |
|
|
| let _phase: CheckoutPhase = { kind: 'idle' }; |
| const phaseSubscribers = new Set<(phase: CheckoutPhase) => void>(); |
| let checkoutRateLimitTimer: number | null = null; |
|
|
| 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); }; |
| } |
|
|
| function currentCheckoutRateLimitSeconds(): number { |
| if (_phase.kind !== 'rate_limited') return 0; |
| return checkoutRetryRemainingSeconds(Date.now(), _phase.retryAtMs); |
| } |
|
|
| function activateCheckoutRateLimit(retryAfterHeader: string | null): number { |
| const retryAfterSeconds = parseCheckoutRetryAfterSeconds(retryAfterHeader); |
| const retryAtMs = checkoutRetryAtMs(Date.now(), retryAfterSeconds); |
| if (checkoutRateLimitTimer) window.clearTimeout(checkoutRateLimitTimer); |
| setPhase({ kind: 'rate_limited', retryAtMs }); |
| checkoutRateLimitTimer = window.setTimeout(() => { |
| checkoutRateLimitTimer = null; |
| if (_phase.kind === 'rate_limited' && currentCheckoutRateLimitSeconds() === 0) { |
| setPhase({ kind: 'idle' }); |
| } |
| }, retryAfterSeconds * 1_000); |
| showCheckoutRateLimitToast(retryAfterSeconds); |
| return retryAfterSeconds; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const WATCHDOG_INTERVAL_MS = 3_000; |
| const WATCHDOG_TIMEOUT_MS = 10 * 60 * 1000; |
|
|
| export function initOverlay(onSuccess?: () => void): void { |
| import('dodopayments-checkout').then(({ DodoPayments }) => { |
| const env = import.meta.env.VITE_DODO_ENVIRONMENT; |
|
|
| |
| |
| |
| |
| |
| |
| let _terminalFired = false; |
| let watchdog: EntitlementWatchdog | null = null; |
|
|
| const stopWatchdog = (): void => { |
| watchdog?.stop(); |
| watchdog = null; |
| }; |
|
|
| const safeCloseOverlay = (): void => { |
| try { |
| if (DodoPayments.Checkout.isOpen?.()) { |
| DodoPayments.Checkout.close(); |
| } |
| } catch { |
| |
| } |
| }; |
|
|
| |
| |
| |
| |
| |
| const fireTerminalSuccess = ( |
| reason: 'event-status' | 'event-redirect' | 'watchdog', |
| redirectTo?: string, |
| ): void => { |
| if (_terminalFired) return; |
| _terminalFired = true; |
| stopWatchdog(); |
|
|
| Sentry.addBreadcrumb({ |
| category: 'checkout', |
| message: `terminal success (${reason})`, |
| level: 'info', |
| data: { reason }, |
| }); |
|
|
| |
| |
| |
| |
| if (reason === 'watchdog') { |
| Sentry.captureMessage('Dodo wallet-return deadlock — watchdog resolved', { |
| level: 'info', |
| tags: { surface: 'pro-marketing', code: 'watchdog_resolved' }, |
| }); |
| } |
|
|
| try { |
| onSuccess?.(); |
| } catch (err) { |
| console.error('[checkout] onSuccess threw:', err); |
| Sentry.captureException(err, { |
| tags: { surface: 'pro-marketing', action: 'on-success' }, |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| if (reason === 'event-redirect') { |
| window.location.href = redirectTo || DASHBOARD_CHECKOUT_SUCCESS_URL; |
| } else { |
| safeCloseOverlay(); |
| window.location.href = DASHBOARD_CHECKOUT_SUCCESS_URL; |
| } |
| }; |
|
|
| const startWatchdog = (): void => { |
| if (watchdog !== null || _terminalFired) return; |
| watchdog = createEntitlementWatchdog( |
| { |
| endpoint: `${API_BASE}/me/entitlement`, |
| intervalMs: WATCHDOG_INTERVAL_MS, |
| timeoutMs: WATCHDOG_TIMEOUT_MS, |
| }, |
| { |
| getToken: getAuthToken, |
| fetch: (input, init) => fetch(input, init), |
| setInterval: (cb, ms) => window.setInterval(cb, ms), |
| clearInterval: (id) => window.clearInterval(id), |
| now: () => Date.now(), |
| onPro: () => fireTerminalSuccess('watchdog'), |
| }, |
| ); |
| watchdog.start(); |
| }; |
|
|
| DodoPayments.Initialize({ |
| mode: env === 'live_mode' ? 'live' : 'test', |
| displayType: 'overlay', |
| onEvent: (event: CheckoutEvent) => { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const data = event.data as Record<string, unknown> | undefined; |
| const msg = data?.message as Record<string, unknown> | undefined; |
| const status = msg?.status as string | undefined; |
| console.info('[checkout] dodo event', event.event_type, |
| status !== undefined ? { status } : undefined); |
|
|
| |
| |
| |
| |
| if (event.event_type === 'checkout.opened') { |
| _terminalFired = false; |
| startWatchdog(); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| if (event.event_type === 'checkout.status' && status === 'succeeded') { |
| fireTerminalSuccess('event-status'); |
| } |
| if (event.event_type === 'checkout.redirect_requested') { |
| const redirectTo = msg?.redirect_to as string | undefined; |
| |
| |
| |
| |
| |
| fireTerminalSuccess('event-redirect', redirectTo); |
| } |
| if (event.event_type === 'checkout.closed') { |
| |
| |
| stopWatchdog(); |
| } |
| if (event.event_type === 'checkout.link_expired') { |
| |
| |
| Sentry.captureMessage('Dodo checkout link expired', { |
| level: 'info', |
| tags: { surface: 'pro-marketing', code: 'link_expired' }, |
| }); |
| } |
| }, |
| }); |
| }).catch((err) => { |
| console.error('[checkout] Failed to load Dodo overlay SDK:', err); |
| }); |
| } |
|
|
| |
| |
| |
| |
| |
| |
| let startCheckoutEntryInFlight = false; |
|
|
| export async function startCheckout( |
| productId: string, |
| options?: { referralCode?: string; discountCode?: string; bypassPendingGuard?: boolean }, |
| ): Promise<boolean> { |
| if (checkoutInFlight) return false; |
| if (startCheckoutEntryInFlight) return false; |
| startCheckoutEntryInFlight = true; |
| try { |
| return await startCheckoutInner(productId, options); |
| } finally { |
| startCheckoutEntryInFlight = false; |
| } |
| } |
|
|
| async function startCheckoutInner( |
| productId: string, |
| options?: { referralCode?: string; discountCode?: string; bypassPendingGuard?: boolean }, |
| ): Promise<boolean> { |
| let c: LoadedClerk; |
| try { |
| c = await ensureClerk(); |
| } catch (err) { |
| console.error('[checkout] Failed to load Clerk:', err); |
| Sentry.captureException(err, { tags: { surface: 'pro-marketing', action: 'load-clerk' } }); |
| return false; |
| } |
|
|
| |
| |
| trackFunnelEvent('checkout-start', { |
| productId: bucketProductIdForAnalytics(productId), |
| surface: 'pro-page', |
| authed: Boolean(c.user), |
| }); |
|
|
| if (!c.user) { |
| |
| |
| |
| |
| |
| |
| |
| const returnUrl = buildCheckoutReturnUrl(window.location.href, productId, options); |
| try { |
| 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' } }); |
| } |
| return false; |
| } |
|
|
| return doCheckout(productId, options ?? {}); |
| } |
|
|
| export async function tryResumeCheckoutFromUrl(): Promise<boolean> { |
| const intent = parseCheckoutIntentFromSearch(window.location.search); |
| if (!intent) return false; |
|
|
| |
| const cleanSearch = stripCheckoutIntentFromSearch(window.location.search); |
| const cleanUrl = window.location.pathname + cleanSearch + window.location.hash; |
| window.history.replaceState({}, '', cleanUrl); |
|
|
| let c: LoadedClerk; |
| try { |
| c = await ensureClerk(); |
| } catch { |
| return false; |
| } |
| if (!c.user) return false; |
| const { productId, referralCode, discountCode } = intent; |
| |
| |
| |
| trackFunnelEvent('checkout-start', { productId: bucketProductIdForAnalytics(productId), surface: 'pro-resume', authed: true }); |
| return doCheckout(productId, { referralCode, discountCode }); |
| } |
|
|
| async function doCheckout( |
| productId: string, |
| options: { referralCode?: string; discountCode?: string; bypassPendingGuard?: boolean }, |
| ): Promise<boolean> { |
| const cooldownSeconds = currentCheckoutRateLimitSeconds(); |
| if (cooldownSeconds > 0) { |
| showCheckoutRateLimitToast(cooldownSeconds); |
| return false; |
| } |
| if (_phase.kind === 'rate_limited') setPhase({ kind: 'idle' }); |
| if (checkoutInFlight) return false; |
| checkoutInFlight = true; |
| |
| |
| |
| |
| |
| setPhase({ kind: 'creating_checkout', productId }); |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| try { |
| |
| |
| |
| |
| mountCheckoutInterstitial(); |
| const token = await getAuthToken(); |
| if (!token) { |
| console.error('[checkout] No auth token after retry'); |
| return false; |
| } |
|
|
| |
| |
| |
| const resp = await postCreateCheckout(createDefaultCheckoutTransportDeps(), { |
| url: `${API_BASE}/create-checkout`, |
| token, |
| payload: { |
| productId, |
| |
| |
| |
| |
| |
| |
| returnUrl: DASHBOARD_CHECKOUT_RETURN_URL, |
| discountCode: options.discountCode, |
| referralCode: options.referralCode, |
| |
| |
| ...(options.bypassPendingGuard ? { bypassPendingGuard: true } : {}), |
| }, |
| }); |
|
|
| if (!resp.ok) { |
| const err = await resp.json().catch(() => ({})); |
| console.error('[checkout] Edge error:', resp.status, err); |
| if (resp.status === 429) { |
| const retryAfterSeconds = activateCheckoutRateLimit( |
| resp.headers.get('Retry-After'), |
| ); |
| Sentry.captureMessage('Checkout temporarily rate limited', { |
| level: 'info', |
| tags: { surface: 'pro-marketing', code: 'rate_limited' }, |
| extra: { retryAfterSeconds }, |
| }); |
| } else if (resp.status === 409 && err?.error === ACTIVE_SUBSCRIPTION_EXISTS) { |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| const planKey = err?.subscription?.planKey; |
| showProDuplicateSubscriptionDialog({ |
| planDisplayName: resolveProPlanDisplayName(planKey), |
| |
| |
| targetProductId: productId, |
| onConfirm: async () => { |
| |
| |
| |
| |
| |
| |
| const reservedWin = prereserveBillingPortalTab(); |
| const freshToken = await getAuthToken(); |
| if (!freshToken) { |
| console.error('[checkout] No token available for billing portal'); |
| if (reservedWin && !reservedWin.closed) reservedWin.close(); |
| return; |
| } |
| void openBillingPortal(freshToken, reservedWin); |
| }, |
| onDismiss: () => { }, |
| }); |
| Sentry.captureMessage('Duplicate subscription checkout attempt', { |
| level: 'info', |
| tags: { surface: 'pro-marketing', code: 'duplicate_subscription' }, |
| extra: { serverMessage: err?.message }, |
| }); |
| } else if (resp.status === 409 && err?.error === PAYMENT_IN_PROGRESS) { |
| |
| |
| |
| |
| |
| const planKey = err?.pendingPayment?.planKey; |
| showProPendingPaymentDialog({ |
| planDisplayName: resolveProPlanDisplayName(planKey), |
| onConfirm: () => { |
| void doCheckout(productId, { ...options, bypassPendingGuard: true }); |
| }, |
| onDismiss: () => { }, |
| }); |
| Sentry.captureMessage('Pending-payment checkout attempt', { |
| level: 'info', |
| tags: { surface: 'pro-marketing', code: 'payment_in_progress' }, |
| extra: { serverMessage: err?.message }, |
| }); |
| } |
| return false; |
| } |
|
|
| const result = await resp.json(); |
| const hostedCheckoutUrl = safeHostedCheckoutUrl(result?.checkout_url); |
| if (!hostedCheckoutUrl) { |
| |
| |
| |
| |
| console.error('[checkout] No usable checkout_url in response'); |
| Sentry.captureMessage('Checkout returned 200 without a usable checkout_url', { |
| level: 'error', |
| tags: { surface: 'pro-marketing', code: 'missing_checkout_url' }, |
| }); |
| return false; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| window.location.assign(hostedCheckoutUrl); |
|
|
| return true; |
| } catch (err) { |
| console.error('[checkout] Failed:', err); |
| return false; |
| } finally { |
| checkoutInFlight = false; |
| unmountCheckoutInterstitial(); |
| if (_phase.kind === 'creating_checkout') setPhase({ kind: 'idle' }); |
| } |
| } |
|
|
| |
| |
| |
| |
| const HOSTED_CHECKOUT_HOSTS = new Set([ |
| 'checkout.dodopayments.com', |
| 'test.checkout.dodopayments.com', |
| ]); |
|
|
| function safeHostedCheckoutUrl(raw: unknown): string | null { |
| if (typeof raw !== 'string') return null; |
| try { |
| const url = new URL(raw); |
| if (url.protocol !== 'https:') return null; |
| if (!HOSTED_CHECKOUT_HOSTS.has(url.hostname)) return null; |
| return url.toString(); |
| } catch { |
| return null; |
| } |
| } |
|
|
| const INTERSTITIAL_ID = 'wm-checkout-interstitial'; |
| const INTERSTITIAL_SAFETY_MS = 10_000; |
| let interstitialSafetyTimer: ReturnType<typeof setTimeout> | null = null; |
|
|
| function mountCheckoutInterstitial(): void { |
| if (document.getElementById(INTERSTITIAL_ID)) return; |
|
|
| const overlay = document.createElement('div'); |
| overlay.id = INTERSTITIAL_ID; |
| overlay.setAttribute('role', 'status'); |
| overlay.setAttribute('aria-live', 'polite'); |
| Object.assign(overlay.style, { |
| position: 'fixed', |
| inset: '0', |
| zIndex: '99990', |
| background: 'rgba(10, 10, 10, 0.82)', |
| backdropFilter: 'blur(4px)', |
| display: 'flex', |
| flexDirection: 'column', |
| alignItems: 'center', |
| justifyContent: 'center', |
| gap: '16px', |
| color: '#e8e8e8', |
| fontSize: '14px', |
| fontFamily: "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace", |
| transition: 'opacity 0.2s ease', |
| opacity: '0', |
| }); |
| overlay.innerHTML = ` |
| <div style="width:36px;height:36px;border:3px solid rgba(68,255,136,0.2);border-top-color:#44ff88;border-radius:50%;animation:wm-checkout-spin 0.8s linear infinite;"></div> |
| <div>Opening checkout…</div> |
| <style>@keyframes wm-checkout-spin { to { transform: rotate(360deg); } }</style> |
| `; |
| document.body.appendChild(overlay); |
| requestAnimationFrame(() => { overlay.style.opacity = '1'; }); |
|
|
| interstitialSafetyTimer = setTimeout(() => { |
| unmountCheckoutInterstitial(); |
| showCheckoutLoadingToast(); |
| }, INTERSTITIAL_SAFETY_MS); |
| } |
|
|
| function unmountCheckoutInterstitial(): void { |
| if (interstitialSafetyTimer) { |
| clearTimeout(interstitialSafetyTimer); |
| interstitialSafetyTimer = null; |
| } |
| |
| |
| |
| |
| |
| const toast = document.getElementById('wm-checkout-loading-toast'); |
| if (toast) toast.remove(); |
|
|
| const overlay = document.getElementById(INTERSTITIAL_ID); |
| if (!overlay) return; |
| overlay.style.opacity = '0'; |
| setTimeout(() => overlay.remove(), 200); |
| } |
|
|
| function showCheckoutLoadingToast(): void { |
| const id = 'wm-checkout-loading-toast'; |
| if (document.getElementById(id)) return; |
| const toast = document.createElement('div'); |
| toast.id = id; |
| toast.setAttribute('role', 'alert'); |
| Object.assign(toast.style, { |
| position: 'fixed', |
| top: '20px', |
| left: '50%', |
| transform: 'translateX(-50%)', |
| zIndex: '99995', |
| background: 'rgba(20, 20, 20, 0.95)', |
| color: '#e8e8e8', |
| padding: '10px 18px', |
| borderRadius: '6px', |
| border: '1px solid #2a2a2a', |
| fontSize: '13px', |
| fontFamily: "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace", |
| boxShadow: '0 4px 16px rgba(0,0,0,0.4)', |
| }); |
| toast.textContent = 'Still loading, please wait…'; |
| document.body.appendChild(toast); |
| setTimeout(() => toast.remove(), 5_000); |
| } |
|
|
| function showCheckoutRateLimitToast(retryAfterSeconds: number): void { |
| const id = 'wm-checkout-rate-limit-toast'; |
| document.getElementById(id)?.remove(); |
| const toast = document.createElement('div'); |
| toast.id = id; |
| toast.setAttribute('role', 'alert'); |
| Object.assign(toast.style, { |
| position: 'fixed', |
| top: '20px', |
| left: '50%', |
| transform: 'translateX(-50%)', |
| zIndex: '99995', |
| background: 'rgba(127, 29, 29, 0.97)', |
| color: '#fff', |
| padding: '10px 18px', |
| borderRadius: '6px', |
| border: '1px solid rgba(248, 113, 113, 0.55)', |
| fontSize: '13px', |
| fontFamily: "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace", |
| boxShadow: '0 4px 16px rgba(0,0,0,0.4)', |
| }); |
| toast.textContent = `Checkout is temporarily rate limited. Try again in ${retryAfterSeconds} ${ |
| retryAfterSeconds === 1 ? 'second' : 'seconds' |
| }.`; |
| document.body.appendChild(toast); |
| window.setTimeout( |
| () => toast.remove(), |
| Math.min(retryAfterSeconds * 1_000, 10_000), |
| ); |
| } |
|
|
| async function getAuthToken(): Promise<string | null> { |
| const c = await ensureClerk().catch(() => null); |
| if (!c) return null; |
|
|
| let token = await c.session?.getToken({ template: 'convex' }).catch(() => null) |
| ?? await c.session?.getToken().catch(() => null); |
| if (!token) { |
| await new Promise((r) => setTimeout(r, 2000)); |
| token = await c.session?.getToken({ template: 'convex' }).catch(() => null) |
| ?? await c.session?.getToken().catch(() => null); |
| } |
| return token; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| |
| |
| function prereserveBillingPortalTab(): Window | null { |
| return window.open('', '_blank', 'noopener,noreferrer'); |
| } |
|
|
| async function openBillingPortal(token: string, preopened?: Window | null): Promise<void> { |
| |
| |
| |
| const reservedWin = preopened ?? null; |
| const navigate = (url: string): void => { |
| if (reservedWin && !reservedWin.closed) { |
| reservedWin.location.href = url; |
| } else { |
| |
| |
| |
| |
| const fresh = window.open(url, '_blank', 'noopener,noreferrer'); |
| if (!fresh) window.location.assign(url); |
| } |
| }; |
|
|
| try { |
| const resp = await fetch(`${API_BASE}/customer-portal`, { |
| method: 'POST', |
| headers: { |
| Authorization: `Bearer ${token}`, |
| }, |
| signal: AbortSignal.timeout(15_000), |
| }); |
|
|
| const result = await resp.json().catch(() => ({})); |
| const url = typeof result?.portal_url === 'string' |
| ? result.portal_url |
| : DODO_PORTAL_FALLBACK_URL; |
|
|
| if (!resp.ok) { |
| console.error('[checkout] Customer portal error:', resp.status, result); |
| } |
|
|
| navigate(url); |
| } catch (err) { |
| console.error('[checkout] Failed to open billing portal:', err); |
| navigate(DODO_PORTAL_FALLBACK_URL); |
| } |
| } |
|
|
| |
| |
| |
|
|
| const PRO_PLAN_DISPLAY_NAMES: Readonly<Record<string, string>> = { |
| pro_monthly: 'Pro Monthly', |
| pro_annual: 'Pro Annual', |
| pro_business_monthly: 'Pro Business Monthly', |
| pro_business_annual: 'Pro Business Annual', |
| api_starter: 'API Starter', |
| api_business: 'API Business', |
| }; |
|
|
| function resolveProPlanDisplayName(planKey: unknown): string { |
| if (typeof planKey !== 'string' || planKey.length === 0) return 'Pro'; |
| return PRO_PLAN_DISPLAY_NAMES[planKey] ?? 'Pro'; |
| } |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| const PRO_BUSINESS_PRODUCT_IDS: ReadonlySet<string> = new Set( |
| (fallbackTiers as Array<{ name?: string; monthlyProductId?: string; annualProductId?: string }>) |
| .filter((tier) => tier.name === 'Pro Business') |
| .flatMap((tier) => [tier.monthlyProductId, tier.annualProductId]) |
| .filter((id): id is string => typeof id === 'string' && id.length > 0), |
| ); |
|
|
| interface ProDuplicateDialogOptions { |
| planDisplayName: string; |
| |
| targetProductId?: string; |
| onConfirm: () => void; |
| onDismiss: () => void; |
| } |
|
|
| |
| function proDuplicateBodyHtml(options: ProDuplicateDialogOptions): string { |
| const plan = escapeHtml(options.planDisplayName); |
| if (options.targetProductId !== undefined && PRO_BUSINESS_PRODUCT_IDS.has(options.targetProductId)) { |
| return `Your account already has an active ${plan} subscription. Pro Business is a separate plan, so the upgrade takes two steps: cancel ${plan} in the billing portal, then start the Pro Business checkout again — you don't have to wait for your current term to end. Your ${plan} access continues until the term you've already paid for runs out, and Pro Business starts a new billing cycle as soon as you buy it. Need a hand? Email <a href="mailto:support@worldmonitor.app" style="color:#44ff88;">support@worldmonitor.app</a>.`; |
| } |
| return `Your account already has an active ${plan} subscription. Open the billing portal to manage it — you won't be charged twice.`; |
| } |
|
|
| const PRO_DUP_DIALOG_ID = 'wm-pro-duplicate-subscription-dialog'; |
|
|
| function showProDuplicateSubscriptionDialog(options: ProDuplicateDialogOptions): void { |
| if (document.getElementById(PRO_DUP_DIALOG_ID)) return; |
|
|
| const backdrop = document.createElement('div'); |
| backdrop.id = PRO_DUP_DIALOG_ID; |
| backdrop.setAttribute('role', 'dialog'); |
| backdrop.setAttribute('aria-modal', 'true'); |
| Object.assign(backdrop.style, { |
| position: 'fixed', |
| inset: '0', |
| zIndex: '99990', |
| background: 'rgba(10, 10, 10, 0.72)', |
| backdropFilter: 'blur(4px)', |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'center', |
| padding: '24px', |
| }); |
|
|
| const card = document.createElement('div'); |
| Object.assign(card.style, { |
| background: '#141414', |
| border: '1px solid #2a2a2a', |
| borderRadius: '8px', |
| padding: '20px 22px', |
| maxWidth: '440px', |
| width: '100%', |
| color: '#e8e8e8', |
| fontFamily: "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace", |
| boxShadow: '0 12px 40px rgba(0,0,0,0.5)', |
| }); |
|
|
| card.innerHTML = ` |
| <h2 style="font-size:16px;font-weight:600;margin:0 0 10px 0;color:#fff;">Subscription already active</h2> |
| <p style="font-size:13px;line-height:1.5;margin:0 0 18px 0;color:#c8c8c8;"> |
| ${proDuplicateBodyHtml(options)} |
| </p> |
| <div style="display:flex;justify-content:flex-end;gap:10px;"> |
| <button id="${PRO_DUP_DIALOG_ID}-dismiss" type="button" style="background:transparent;color:#aaa;border:1px solid #2a2a2a;border-radius:4px;padding:8px 14px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Dismiss</button> |
| <button id="${PRO_DUP_DIALOG_ID}-confirm" type="button" style="background:#44ff88;color:#0a0a0a;border:none;border-radius:4px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;">Open billing portal</button> |
| </div> |
| `; |
|
|
| backdrop.appendChild(card); |
| |
| |
| document.body.appendChild(backdrop); |
|
|
| let resolved = false; |
| const keyHandler = (e: KeyboardEvent) => { |
| if (e.key === 'Escape') dismiss(); |
| }; |
| const close = () => { |
| document.removeEventListener('keydown', keyHandler, true); |
| backdrop.remove(); |
| }; |
| const dismiss = () => { |
| if (resolved) return; |
| resolved = true; |
| close(); |
| options.onDismiss(); |
| }; |
|
|
| document.getElementById(`${PRO_DUP_DIALOG_ID}-confirm`)?.addEventListener('click', () => { |
| if (resolved) return; |
| resolved = true; |
| close(); |
| options.onConfirm(); |
| }); |
| document.getElementById(`${PRO_DUP_DIALOG_ID}-dismiss`)?.addEventListener('click', dismiss); |
| backdrop.addEventListener('click', (e) => { if (e.target === backdrop) dismiss(); }); |
| document.addEventListener('keydown', keyHandler, true); |
| } |
|
|
| |
| |
| |
| const PRO_PENDING_DIALOG_ID = 'wm-pro-pending-payment-dialog'; |
|
|
| function showProPendingPaymentDialog(options: ProDuplicateDialogOptions): void { |
| if (document.getElementById(PRO_PENDING_DIALOG_ID)) return; |
|
|
| const backdrop = document.createElement('div'); |
| backdrop.id = PRO_PENDING_DIALOG_ID; |
| backdrop.setAttribute('role', 'dialog'); |
| backdrop.setAttribute('aria-modal', 'true'); |
| backdrop.setAttribute('aria-labelledby', `${PRO_PENDING_DIALOG_ID}-title`); |
| Object.assign(backdrop.style, { |
| position: 'fixed', |
| inset: '0', |
| zIndex: '99990', |
| background: 'rgba(10, 10, 10, 0.72)', |
| backdropFilter: 'blur(4px)', |
| display: 'flex', |
| alignItems: 'center', |
| justifyContent: 'center', |
| padding: '24px', |
| }); |
|
|
| const card = document.createElement('div'); |
| Object.assign(card.style, { |
| background: '#141414', |
| border: '1px solid #2a2a2a', |
| borderRadius: '8px', |
| padding: '20px 22px', |
| maxWidth: '440px', |
| width: '100%', |
| color: '#e8e8e8', |
| fontFamily: "'SF Mono', Monaco, 'Cascadia Code', 'Fira Code', monospace", |
| boxShadow: '0 12px 40px rgba(0,0,0,0.5)', |
| }); |
|
|
| card.innerHTML = ` |
| <h2 id="${PRO_PENDING_DIALOG_ID}-title" style="font-size:16px;font-weight:600;margin:0 0 10px 0;color:#fff;">Payment in progress</h2> |
| <p style="font-size:13px;line-height:1.5;margin:0 0 18px 0;color:#c8c8c8;"> |
| You have a ${escapeHtml(options.planDisplayName)} payment in progress. It may still be completing — if it does and you're charged twice, contact support and we'll refund the duplicate. Start a new checkout anyway? |
| </p> |
| <div style="display:flex;justify-content:flex-end;gap:10px;"> |
| <button id="${PRO_PENDING_DIALOG_ID}-dismiss" type="button" style="background:transparent;color:#aaa;border:1px solid #2a2a2a;border-radius:4px;padding:8px 14px;font-size:12px;font-weight:600;cursor:pointer;font-family:inherit;">Cancel</button> |
| <button id="${PRO_PENDING_DIALOG_ID}-confirm" type="button" style="background:#44ff88;color:#0a0a0a;border:none;border-radius:4px;padding:8px 14px;font-size:12px;font-weight:700;cursor:pointer;font-family:inherit;">Start new checkout</button> |
| </div> |
| `; |
|
|
| backdrop.appendChild(card); |
| |
| |
| document.body.appendChild(backdrop); |
|
|
| let resolved = false; |
| const keyHandler = (e: KeyboardEvent) => { |
| if (e.key === 'Escape') dismiss(); |
| }; |
| const close = () => { |
| document.removeEventListener('keydown', keyHandler, true); |
| backdrop.remove(); |
| }; |
| const dismiss = () => { |
| if (resolved) return; |
| resolved = true; |
| close(); |
| options.onDismiss(); |
| }; |
|
|
| document.getElementById(`${PRO_PENDING_DIALOG_ID}-confirm`)?.addEventListener('click', () => { |
| if (resolved) return; |
| resolved = true; |
| close(); |
| options.onConfirm(); |
| }); |
| document.getElementById(`${PRO_PENDING_DIALOG_ID}-dismiss`)?.addEventListener('click', dismiss); |
| backdrop.addEventListener('click', (e) => { if (e.target === backdrop) dismiss(); }); |
| document.addEventListener('keydown', keyHandler, true); |
| } |
|
|
| function escapeHtml(s: string): string { |
| return s.replace(/[&<>"']/g, (c) => ({ |
| '&': '&', '<': '<', '>': '>', '"': '"', "'": ''', |
| }[c] ?? c)); |
| } |
|
|