| import { useState, useEffect, useCallback } from 'react'; |
| import { motion } from 'motion/react'; |
| import { Check, ShieldCheck, ArrowRight, Zap, Loader2 } from 'lucide-react'; |
| import { startCheckout, subscribeCheckoutPhase, type CheckoutPhase } from '../services/checkout'; |
| import { t, tArray } from '../i18n'; |
|
|
| |
| import fallbackTiers from '../generated/tiers.json'; |
| import { resolveCheckoutProduct } from './pricing-billing-mode'; |
|
|
| interface Tier { |
| name: string; |
| localeKey?: string; |
| description: string; |
| features: string[]; |
| |
| highlightFeatures?: string[]; |
| highlighted?: boolean; |
| price?: number | null; |
| period?: string; |
| monthlyPrice?: number; |
| annualPrice?: number | null; |
| cta?: string; |
| href?: string; |
| monthlyProductId?: string; |
| annualProductId?: string; |
| } |
|
|
| const CATALOG_API = 'https://api.worldmonitor.app/api/product-catalog'; |
|
|
| function usePricingData(): Tier[] { |
| const [tiers, setTiers] = useState<Tier[]>(fallbackTiers as Tier[]); |
|
|
| useEffect(() => { |
| let cancelled = false; |
| fetch(CATALOG_API, { signal: AbortSignal.timeout(5000) }) |
| .then(res => res.ok ? res.json() : null) |
| .then(data => { |
| if (!cancelled && data?.tiers?.length) { |
| setTiers(data.tiers as Tier[]); |
| } |
| }) |
| .catch(() => { }); |
| return () => { cancelled = true; }; |
| }, []); |
|
|
| return tiers; |
| } |
|
|
| |
| |
| |
| |
| |
| function tierKey(tier: Tier): string { |
| return tier.localeKey ?? tier.name.toLowerCase(); |
| } |
|
|
| |
| const BAND_TIER_KEY = 'enterprise'; |
|
|
| |
| |
| |
| |
| |
| function localizeTier(tier: Tier): { |
| description: string; |
| features: string[]; |
| highlightFeatures?: string[]; |
| cta?: string; |
| } { |
| const key = tierKey(tier); |
| const description = t(`pricing.tiers.${key}.description`, { defaultValue: tier.description }); |
| const features = tArray(`pricing.tiers.${key}.features`) ?? tier.features; |
| |
| |
| |
| const highlightFeatures = |
| tArray(`pricing.tiers.${key}.highlightFeatures`) ?? tier.highlightFeatures; |
| |
| |
| |
| const SENTINEL = '__no_locale_cta__'; |
| const localeCta = t(`pricing.tiers.${key}.cta`, { defaultValue: SENTINEL }); |
| const cta = localeCta !== SENTINEL ? localeCta : tier.cta; |
| return { description, features, highlightFeatures, cta }; |
| } |
|
|
| function formatPrice(tier: Tier, billing: 'monthly' | 'annual'): { amount: string; suffix: string } { |
| |
| if (tier.price === 0) { |
| return { amount: "$0", suffix: t('pricing.suffixForever') }; |
| } |
| |
| if (tier.price === null && tier.monthlyPrice === undefined) { |
| return { amount: t('pricing.amountCustom'), suffix: t('pricing.suffixTailored') }; |
| } |
| |
| if (tier.annualPrice === null && tier.monthlyPrice !== undefined) { |
| return { amount: `$${tier.monthlyPrice}`, suffix: t('pricing.suffixPerMonth') }; |
| } |
| |
| if (billing === 'annual' && tier.annualPrice != null) { |
| return { amount: `$${tier.annualPrice}`, suffix: t('pricing.suffixPerYear') }; |
| } |
| return { amount: `$${tier.monthlyPrice}`, suffix: t('pricing.suffixPerMonth') }; |
| } |
|
|
| type CtaProps = |
| | { type: 'link'; label: string; href: string; external: boolean } |
| | { type: 'checkout'; label: string; productId: string; billedMonthlyOnly: boolean }; |
|
|
| |
| |
| |
| |
| |
| |
| |
| |
| function isInProductHref(href: string): boolean { |
| if (!href || href === '#' || href.startsWith('#')) return true; |
| |
| |
| |
| if (href.startsWith('/') && !href.startsWith('//')) return true; |
| |
| |
| |
| |
| |
| const looksRelative = !/^[a-z][a-z0-9+.-]*:/i.test(href) && !href.startsWith('//'); |
| try { |
| const base = typeof window !== 'undefined' ? window.location.href : 'https://worldmonitor.app/'; |
| const url = new URL(href, looksRelative ? base : undefined); |
| if (url.protocol !== 'http:' && url.protocol !== 'https:') return false; |
| return url.hostname === 'worldmonitor.app' || |
| url.hostname.endsWith('.worldmonitor.app') || |
| |
| url.hostname === 'localhost' || |
| url.hostname === '127.0.0.1'; |
| } catch { |
| return false; |
| } |
| } |
|
|
| function getCtaProps(tier: Tier, billing: 'monthly' | 'annual'): CtaProps { |
| if (tier.cta && tier.href && tier.price === 0) { |
| |
| |
| |
| return { |
| type: 'link', |
| label: tier.cta, |
| href: tier.href, |
| external: !isInProductHref(tier.href), |
| }; |
| } |
| if (tier.cta && tier.href && tier.price === null) { |
| |
| |
| |
| return { |
| type: 'link', |
| label: tier.cta, |
| href: tier.href, |
| external: !isInProductHref(tier.href), |
| }; |
| } |
| const resolved = resolveCheckoutProduct(tier, billing); |
| if (resolved) { |
| |
| |
| |
| |
| |
| |
| |
| return { |
| type: 'checkout', |
| label: tier.cta ?? t('pricing.cta.checkoutDefault'), |
| productId: resolved.productId, |
| billedMonthlyOnly: resolved.billedMonthlyOnly, |
| }; |
| } |
| return { type: 'link', label: t('pricing.cta.learnMore'), href: '#', external: false }; |
| } |
|
|
| |
| |
| |
| |
| |
| function TierCta({ cta, highlighted, loadingProductId, rateLimited, onCheckout }: { |
| cta: CtaProps; |
| highlighted: boolean; |
| loadingProductId: string | null; |
| rateLimited: boolean; |
| onCheckout: (productId: string) => void; |
| }) { |
| if (cta.type === 'link') { |
| return ( |
| <a |
| href={cta.href} |
| target={cta.external ? "_blank" : undefined} |
| rel={cta.external ? "noreferrer" : undefined} |
| className={`block text-center py-3 rounded-sm font-mono text-xs uppercase tracking-wider font-bold transition-colors ${ |
| 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" /> |
| </a> |
| ); |
| } |
|
|
| const isLoading = loadingProductId === cta.productId; |
| const isDisabled = isLoading || rateLimited; |
| |
| |
| |
| |
| return ( |
| <button |
| onClick={() => onCheckout(cta.productId)} |
| disabled={isDisabled} |
| 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' : rateLimited ? 'cursor-not-allowed opacity-60' : 'cursor-pointer' |
| } ${ |
| 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>{t('pricing.opening')}</span> |
| </> |
| ) : ( |
| <> |
| {cta.label} <ArrowRight className="w-3.5 h-3.5 inline-block ml-1" aria-hidden="true" /> |
| </> |
| )} |
| </button> |
| ); |
| } |
|
|
| export function PricingSection({ refCode }: { refCode?: string }) { |
| const [billing, setBilling] = useState<'monthly' | 'annual'>(() => { |
| const planKey = new URLSearchParams(window.location.search).get('wm_reactivate_plan'); |
| return planKey?.endsWith('_annual') ? 'annual' : 'monthly'; |
| }); |
| |
| |
| |
| |
| |
| |
| const [phase, setPhase] = useState<CheckoutPhase>({ kind: 'idle' }); |
| const loadingProductId = phase.kind === 'creating_checkout' ? phase.productId : null; |
| const rateLimited = phase.kind === 'rate_limited'; |
| const TIERS = usePricingData(); |
| |
| |
| |
| |
| |
| |
| const gridTiers = TIERS.filter(tier => tierKey(tier) !== BAND_TIER_KEY); |
| const bandTier = TIERS.find(tier => tierKey(tier) === BAND_TIER_KEY); |
|
|
| useEffect(() => subscribeCheckoutPhase(setPhase), []); |
|
|
| |
| |
| const handleCheckout = useCallback((productId: string) => { |
| void startCheckout(productId, { referralCode: refCode }); |
| }, [refCode]); |
|
|
| return ( |
| <section id="pricing" className="py-24 px-6 border-t border-wm-border bg-[#060606]"> |
| <div className="max-w-7xl mx-auto"> |
| {/* Header */} |
| <div className="text-center mb-16"> |
| <motion.h2 |
| className="text-3xl md:text-5xl font-display font-bold mb-4" |
| initial={{ opacity: 0, y: 20 }} |
| whileInView={{ opacity: 1, y: 0 }} |
| viewport={{ once: true }} |
| transition={{ duration: 0.5 }} |
| > |
| {t('pricing.headerTitle')} |
| </motion.h2> |
| <motion.p |
| className="text-wm-muted max-w-xl mx-auto mb-8" |
| initial={{ opacity: 0, y: 10 }} |
| whileInView={{ opacity: 1, y: 0 }} |
| viewport={{ once: true }} |
| transition={{ duration: 0.5, delay: 0.1 }} |
| > |
| {t('pricing.headerSubtitle')} |
| </motion.p> |
| |
| {/* 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 }} |
| whileInView={{ opacity: 1, y: 0 }} |
| viewport={{ once: true }} |
| transition={{ duration: 0.5, delay: 0.2 }} |
| > |
| <button |
| onClick={() => setBilling('monthly')} |
| 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' |
| }`} |
| > |
| {t('pricing.billingMonthly')} |
| </button> |
| <button |
| onClick={() => setBilling('annual')} |
| 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. */} |
| {t('pricing.billingAnnual')} |
| <span className={`text-[10px] px-1.5 py-0.5 rounded-sm ${ |
| billing === 'annual' |
| ? 'bg-wm-bg/20 text-wm-bg' |
| : 'bg-wm-green/10 text-wm-green' |
| }`}> |
| {t('pricing.saveAnnual')} |
| </span> |
| </button> |
| </motion.div> |
| </div> |
| |
| {/* Tier cards grid */} |
| {/* 5 self-serve tiers since Pro Business was published (#5604): |
| 2-up on tablet, 3+2 on laptop, all five across on desktop. |
| Enterprise renders below as a band, not as a sixth column. */} |
| <div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-5 gap-6"> |
| {/* Personal / Commercial axis header — xl only. It is a pair of grid |
| children so the spans line up with the columns they label |
| (Personal = Free + Pro, Commercial = the three licensed tiers). |
| Below xl the grid wraps to 3+2 or 2-up, where a spanning header |
| would sit above the wrong cards; there the per-card license |
| chips carry the same story. |
| Deliberately NOT motion elements: they start `display: none`, so |
| an entrance animation gated on whileInView would depend on the |
| observer re-firing when a resize past xl reveals them — a label |
| stuck at opacity 0 is a worse failure than no animation. */} |
| <div className="hidden xl:block xl:col-span-2 border-t border-wm-border pt-3"> |
| <p className="font-mono text-xs uppercase tracking-wider text-wm-muted"> |
| {t('pricing.axisPersonal')} |
| </p> |
| <p className="text-xs text-wm-muted/70 mt-0.5">{t('pricing.axisPersonalNote')}</p> |
| </div> |
| <div className="hidden xl:block xl:col-span-3 border-t border-wm-green pt-3"> |
| <p className="font-mono text-xs uppercase tracking-wider text-wm-green font-bold"> |
| {t('pricing.axisCommercial')} |
| </p> |
| <p className="text-xs text-wm-muted/70 mt-0.5">{t('pricing.axisCommercialNote')}</p> |
| </div> |
| |
| {gridTiers.map((tier, i) => { |
| const price = formatPrice(tier, billing); |
| const localized = localizeTier(tier); |
| // Build a localized tier shape so getCtaProps picks the right |
| // CTA label per locale (link CTAs read tier.cta directly). |
| const localizedTier: Tier = { ...tier, cta: localized.cta }; |
| const cta = getCtaProps(localizedTier, billing); |
| |
| return ( |
| <motion.div |
| key={tier.name} |
| className={`relative bg-zinc-900 rounded-lg p-6 flex flex-col ${ |
| tier.highlighted |
| ? 'border-2 border-wm-green shadow-lg shadow-wm-green/10' |
| : 'border border-wm-border' |
| }`} |
| initial={{ opacity: 0, y: 30 }} |
| whileInView={{ opacity: 1, y: 0 }} |
| viewport={{ once: true }} |
| transition={{ duration: 0.5, delay: i * 0.1 }} |
| > |
| {/* Most Popular badge */} |
| {tier.highlighted && ( |
| <div className="absolute -top-3 left-1/2 -translate-x-1/2 inline-flex items-center gap-1 bg-wm-green text-wm-bg px-3 py-1 rounded-full text-xs font-mono font-bold uppercase tracking-wider"> |
| <Zap className="w-3 h-3" aria-hidden="true" /> |
| {t('pricing.mostPopular')} |
| </div> |
| )} |
| |
| {/* Tier name */} |
| <h3 className={`font-display text-lg font-bold mb-1 ${ |
| tier.highlighted ? 'text-wm-green' : 'text-wm-text' |
| }`}> |
| {tier.name} |
| </h3> |
| |
| {/* Description */} |
| <p className="text-xs text-wm-muted mb-4">{localized.description}</p> |
| |
| {/* Price */} |
| <div className="mb-6"> |
| <span className="text-4xl font-display font-bold">{price.amount}</span> |
| <span className="text-sm text-wm-muted ml-1">{price.suffix}</span> |
| {cta.type === 'checkout' && cta.billedMonthlyOnly && ( |
| <p className="mt-1 text-[11px] font-mono uppercase tracking-wider text-wm-muted"> |
| {t('pricing.billedMonthlyNote', { defaultValue: 'Billed monthly — no annual plan' })} |
| </p> |
| )} |
| </div> |
| |
| {/* Features */} |
| <ul className="space-y-3 mb-8 flex-1"> |
| {localized.features.map((feature, fi) => ( |
| <li key={fi} className="flex items-start gap-2 text-sm"> |
| <Check className={`w-4 h-4 shrink-0 mt-0.5 ${ |
| tier.highlighted ? 'text-wm-green' : 'text-wm-muted' |
| }`} aria-hidden="true" /> |
| <span className="text-wm-muted">{feature}</span> |
| </li> |
| ))} |
| {localized.highlightFeatures?.map((hf, hi) => ( |
| <li key={`hl-${hi}`} className="flex items-start gap-2 text-sm"> |
| <ShieldCheck className="w-4 h-4 shrink-0 mt-0.5 text-wm-green" aria-hidden="true" /> |
| <span className="text-wm-green font-medium">{hf}</span> |
| </li> |
| ))} |
| </ul> |
| |
| {/* CTA button */} |
| <TierCta |
| cta={cta} |
| highlighted={!!tier.highlighted} |
| loadingProductId={loadingProductId} |
| rateLimited={rateLimited} |
| onCheckout={handleCheckout} |
| /> |
| </motion.div> |
| ); |
| })} |
| </div> |
| |
| {/* Enterprise band — full width below the self-serve columns: |
| identity + custom price on the left, features across the middle, |
| Contact Sales on the right. Stacks to a single column below lg. |
| Its stagger delay continues from the band's rendered position |
| (after the grid), not from Enterprise's index in the catalog. */} |
| {bandTier && (() => { |
| const price = formatPrice(bandTier, billing); |
| const localized = localizeTier(bandTier); |
| const localizedTier: Tier = { ...bandTier, cta: localized.cta }; |
| const cta = getCtaProps(localizedTier, billing); |
| |
| return ( |
| <motion.div |
| data-tier-band={BAND_TIER_KEY} |
| className="mt-6 bg-zinc-900 border border-wm-border rounded-lg p-6 md:p-8 flex flex-col gap-6 lg:flex-row lg:items-center lg:gap-10" |
| initial={{ opacity: 0, y: 30 }} |
| whileInView={{ opacity: 1, y: 0 }} |
| viewport={{ once: true }} |
| transition={{ duration: 0.5, delay: gridTiers.length * 0.1 }} |
| > |
| {/* Identity + price */} |
| <div className="lg:w-64 lg:shrink-0"> |
| <h3 className="font-display text-lg font-bold mb-1 text-wm-text">{bandTier.name}</h3> |
| <p className="text-xs text-wm-muted mb-4">{localized.description}</p> |
| <div> |
| <span className="text-4xl font-display font-bold">{price.amount}</span> |
| <span className="text-sm text-wm-muted ml-1">{price.suffix}</span> |
| </div> |
| </div> |
| |
| {/* Features — multi-column so the band stays a band */} |
| <ul className="flex-1 grid grid-cols-1 sm:grid-cols-2 xl:grid-cols-3 gap-x-8 gap-y-3"> |
| {localized.features.map((feature, fi) => ( |
| <li key={fi} className="flex items-start gap-2 text-sm"> |
| <Check className="w-4 h-4 shrink-0 mt-0.5 text-wm-muted" aria-hidden="true" /> |
| <span className="text-wm-muted">{feature}</span> |
| </li> |
| ))} |
| {localized.highlightFeatures?.map((hf, hi) => ( |
| <li key={`hl-${hi}`} className="flex items-start gap-2 text-sm"> |
| <ShieldCheck className="w-4 h-4 shrink-0 mt-0.5 text-wm-green" aria-hidden="true" /> |
| <span className="text-wm-green font-medium">{hf}</span> |
| </li> |
| ))} |
| </ul> |
| |
| {/* CTA */} |
| <div className="lg:w-56 lg:shrink-0"> |
| <TierCta |
| cta={cta} |
| highlighted={!!bandTier.highlighted} |
| loadingProductId={loadingProductId} |
| rateLimited={rateLimited} |
| onCheckout={handleCheckout} |
| /> |
| </div> |
| </motion.div> |
| ); |
| })()} |
| |
| {/* Discount code note */} |
| <p className="text-center text-xs text-wm-muted font-mono mt-8"> |
| {t('pricing.promoCodeNote')} |
| </p> |
| </div> |
| </section> |
| ); |
| } |
|
|