import { BankLogo } from '@/components/bank-logo'; import InputError from '@/components/input-error'; import AuthenticatedRedirectDialog from '@/components/landing/authenticated-redirect-dialog'; import InstallAppButton from '@/components/landing/install-app-button'; import Header from '@/components/partials/header'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; import { Spinner } from '@/components/ui/spinner'; import { tailwindColorClasses } from '@/components/user-info'; import { usePwaInstall } from '@/hooks/use-pwa-install'; import { cn } from '@/lib/utils'; import { store as storeUserLead } from '@/routes/user-leads'; import { type SharedData } from '@/types'; import { type CategoryColor, getCategoryColorClasses } from '@/types/category'; import { Plan } from '@/types/pricing'; import { formatCurrency } from '@/utils/currency'; import { __ } from '@/utils/i18n'; import { Form, Head, Link, router, usePage } from '@inertiajs/react'; import { Facehash } from 'facehash'; import { ArrowDownLeftIcon, ArrowLeftRightIcon, ArrowUpRightIcon, BoltIcon, BriefcaseIcon, BusIcon, CheckIcon, ChevronDownIcon, ClapperboardIcon, CoinsIcon, FileSpreadsheetIcon, HeartPulseIcon, LockIcon, type LucideIcon, ShoppingBasketIcon, WineIcon, WrenchIcon, XIcon, } from 'lucide-react'; import { type ReactNode, useEffect, useMemo, useRef, useState } from 'react'; const LANDING_IMAGES = [ { key: 'bank-accounts', light: '/images/landing/whisper.money_light_3.png', dark: '/images/landing/whisper.money_dark_3.png', alt: 'Your transactions at a glance', className: 'left-[-24%] group-hover:left-[-32%]', }, { key: 'unlock-key', light: '/images/landing/whisper.money_light_2.png', dark: '/images/landing/whisper.money_dark_2.png', alt: 'Manage all your accounts in a single place', className: '', }, { key: 'transactions', light: '/images/landing/whisper.money_light_1.png', dark: '/images/landing/whisper.money_dark_1.png', alt: 'Analyze your money, how it evolves, and how do you spent it', className: 'left-[32%] group-hover:left-[48%]', }, ] as const; type PopularBank = { name: string; logo: string | null; }; type TransactionPreviewRow = { id: string; date: string; description: string; category: { name: string; color: CategoryColor; icon: LucideIcon; }; amountInCents: number; }; type AccountPreviewRow = { id: string; institution: string; name: string; type: string; color: CategoryColor; balanceInCents: number; }; const TRANSACTION_PREVIEW_ROWS: ReadonlyArray = [ { id: 'txn-1', date: 'Mar 5', description: 'Payroll Deposit', category: { name: 'Salary', color: 'green', icon: CoinsIcon, }, amountInCents: 248500, }, { id: 'txn-2', date: 'Mar 4', description: 'Whole Foods Market', category: { name: 'Groceries', color: 'red', icon: ShoppingBasketIcon, }, amountInCents: -8742, }, { id: 'txn-3', date: 'Mar 4', description: 'City Electric Co.', category: { name: 'Electricity', color: 'orange', icon: BoltIcon, }, amountInCents: -12840, }, { id: 'txn-4', date: 'Mar 3', description: 'Coffee Lab', category: { name: 'Cafes, restaurants, bars', color: 'red', icon: WineIcon, }, amountInCents: -1295, }, { id: 'txn-5', date: 'Mar 3', description: 'Rent Payment', category: { name: 'Rent and maintanence', color: 'orange', icon: WrenchIcon, }, amountInCents: -145000, }, { id: 'txn-6', date: 'Mar 2', description: 'Metro Transit', category: { name: 'Transportation', color: 'amber', icon: BusIcon, }, amountInCents: -520, }, { id: 'txn-7', date: 'Mar 1', description: 'Freelance Invoice', category: { name: 'Self-Employment Income', color: 'green', icon: BriefcaseIcon, }, amountInCents: 62000, }, { id: 'txn-8', date: 'Feb 28', description: 'Movie Night', category: { name: 'Theatre, music, cinema', color: 'violet', icon: ClapperboardIcon, }, amountInCents: -2499, }, { id: 'txn-9', date: 'Feb 28', description: 'Healthy Pharmacy', category: { name: 'Health and pharmaceuticals', color: 'rose', icon: HeartPulseIcon, }, amountInCents: -3599, }, { id: 'txn-10', date: 'Feb 27', description: 'Savings Transfer', category: { name: 'Personal transfers', color: 'cyan', icon: ArrowLeftRightIcon, }, amountInCents: -50000, }, ] as const; const ACCOUNT_PREVIEW_ROWS: ReadonlyArray = [ { id: 'acc-1', institution: 'Chase', name: 'Main Checking', type: 'Checking', color: 'blue', balanceInCents: 324580, }, { id: 'acc-2', institution: 'Marcus', name: 'High-Yield Savings', type: 'Savings', color: 'green', balanceInCents: 1245000, }, { id: 'acc-3', institution: 'American Express', name: 'Gold Card', type: 'Credit', color: 'red', balanceInCents: -189240, }, { id: 'acc-4', institution: 'Vanguard', name: 'Investment Portfolio', type: 'Investment', color: 'violet', balanceInCents: 2875000, }, { id: 'acc-5', institution: 'Ally', name: 'Emergency Fund', type: 'Savings', color: 'cyan', balanceInCents: 850000, }, ] as const; function getBillingLabel(billingPeriod: string | null): string { if (!billingPeriod) { return 'one-time'; } return '/month'; } function FeatureCard({ children, className, }: { children: ReactNode; className?: string; }) { return (
{children}
); } function BankConnectionsPreview({ banks, prependFromBottomCount = 15, className, }: { banks: ReadonlyArray; prependFromBottomCount?: number; className?: string; }) { const [translateY, setTranslateY] = useState(0); const [maxTranslate, setMaxTranslate] = useState(0); const containerRef = useRef(null); const listRef = useRef(null); const scrollSpeed = 0.15; const prependedBanksCount = Math.min(prependFromBottomCount, banks.length); const previewBanks = useMemo( () => [...banks.slice(-prependedBanksCount), ...banks], [banks, prependedBanksCount], ); useEffect(() => { const updateMaxTranslate = () => { const container = containerRef.current; const list = listRef.current; if (!container || !list) { return; } const travelDistance = Math.max( 0, list.scrollHeight - container.clientHeight + 24, ); setMaxTranslate(travelDistance); }; updateMaxTranslate(); window.addEventListener('resize', updateMaxTranslate); return () => { window.removeEventListener('resize', updateMaxTranslate); }; }, []); useEffect(() => { const updateTranslate = () => { const container = containerRef.current; if (!container) { return; } const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight || 1; const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height); const clampedProgress = Math.min(1, Math.max(0, progress)); setTranslateY(clampedProgress * maxTranslate * scrollSpeed); }; updateTranslate(); window.addEventListener('scroll', updateTranslate, { passive: true }); window.addEventListener('resize', updateTranslate); return () => { window.removeEventListener('scroll', updateTranslate); window.removeEventListener('resize', updateTranslate); }; }, [maxTranslate, scrollSpeed]); return (
    {previewBanks.map((bank, index) => { const originalIndex = (index - prependedBanksCount + banks.length) % banks.length; return (
  • {(originalIndex + 1) .toString() .padStart(2, '0')} {bank.name}
  • ); })}
); } function TransactionRowsPreview({ currency, locale, }: { currency: string; locale: string; }) { const [translateY, setTranslateY] = useState(0); const [maxTranslate, setMaxTranslate] = useState(0); const [scrollProgress, setScrollProgress] = useState(0); const containerRef = useRef(null); const listRef = useRef(null); const prependedRowsCount = Math.min(10, TRANSACTION_PREVIEW_ROWS.length); const scrollSpeed = 0.28; const previewRows = useMemo( () => [ ...TRANSACTION_PREVIEW_ROWS.slice(-prependedRowsCount), ...TRANSACTION_PREVIEW_ROWS, ], [prependedRowsCount], ); useEffect(() => { const updateMaxTranslate = () => { const container = containerRef.current; const list = listRef.current; if (!container || !list) { return; } const travelDistance = Math.max( 0, list.scrollHeight - container.clientHeight + 56, ); setMaxTranslate(travelDistance); }; updateMaxTranslate(); window.addEventListener('resize', updateMaxTranslate); return () => { window.removeEventListener('resize', updateMaxTranslate); }; }, []); useEffect(() => { const updateTranslate = () => { const container = containerRef.current; if (!container) { return; } const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight || 1; const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height); const clampedProgress = Math.min(1, Math.max(0, progress)); setScrollProgress(clampedProgress); setTranslateY(clampedProgress * maxTranslate * scrollSpeed); }; updateTranslate(); window.addEventListener('scroll', updateTranslate, { passive: true }); window.addEventListener('resize', updateTranslate); return () => { window.removeEventListener('scroll', updateTranslate); window.removeEventListener('resize', updateTranslate); }; }, [maxTranslate, scrollSpeed]); return (
{__('Date')} {__('Description')} {__('Amount')}
    {previewRows.map((transaction, index) => { const originalIndex = (index - prependedRowsCount + TRANSACTION_PREVIEW_ROWS.length) % TRANSACTION_PREVIEW_ROWS.length; const CategoryIcon = transaction.category.icon; const categoryColorClasses = getCategoryColorClasses( transaction.category.color, ); const curveSeed = originalIndex * 0.7 + scrollProgress * Math.PI * 2.9; const curveX = Math.sin(curveSeed) * 11; const curveRotate = Math.sin(curveSeed + Math.PI / 3) * 1.6; const curveScale = 1 - Math.abs(Math.sin(curveSeed)) * 0.015; const isIncome = transaction.amountInCents > 0; const amountLabel = formatCurrency( Math.abs(transaction.amountInCents), currency, locale, ); return (
  • {transaction.date}

    {__(transaction.description)}

    {__(transaction.category.name)}
    {isIncome ? ( ) : ( )} {isIncome ? '+' : '-'} {amountLabel}
  • ); })}
); } function AccountsBalancePreview({ currency, locale, }: { currency: string; locale: string; }) { const [scrollProgress, setScrollProgress] = useState(0); const containerRef = useRef(null); const cardCount = ACCOUNT_PREVIEW_ROWS.length; const staggerDelay = 0.12; const staggerRange = 1 - (cardCount - 1) * staggerDelay; useEffect(() => { const updateProgress = () => { const container = containerRef.current; if (!container) { return; } const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight || 1; const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height); setScrollProgress(Math.min(1, Math.max(0, progress))); }; updateProgress(); window.addEventListener('scroll', updateProgress, { passive: true }); window.addEventListener('resize', updateProgress); return () => { window.removeEventListener('scroll', updateProgress); window.removeEventListener('resize', updateProgress); }; }, []); return (
{ACCOUNT_PREVIEW_ROWS.map((account, index) => { const cardProgress = Math.min( 1, Math.max( 0, (scrollProgress - index * staggerDelay) / staggerRange, ), ); // Stack: cards centered in container with small per-card offset const stackY = 130 + index * 5; // Spread: cards spaced evenly from top padding const spreadY = 16 + index * 66; const y = stackY + (spreadY - stackY) * cardProgress; // Rotation: cards tilt in the stack, straighten as they spread const stackRotate = (index - Math.floor(cardCount / 2)) * 2; const rotate = stackRotate * (1 - cardProgress); // Scale: back cards slightly smaller in stack, full size when spread const stackScale = 1 - index * 0.025; const scale = stackScale + (1 - stackScale) * cardProgress; // Opacity: back cards dimmer in stack, fully opaque when spread const stackOpacity = Math.max(0.45, 1 - index * 0.12); const opacity = stackOpacity + (1 - stackOpacity) * cardProgress; const zIndex = (cardCount - index) * 10; const colorClasses = getCategoryColorClasses(account.color); const isDebt = account.balanceInCents < 0; const balanceLabel = formatCurrency( Math.abs(account.balanceInCents), currency, locale, ); return (

{__(account.name)}

{account.institution}

{isDebt ? '-' : ''} {balanceLabel} {__(account.type)}
); })}
); } function ImportPreview({ currency, locale, }: { currency: string; locale: string; }) { const [scrollProgress, setScrollProgress] = useState(0); const containerRef = useRef(null); // How many transaction rows to show below the file card const previewRows = TRANSACTION_PREVIEW_ROWS.slice(0, 5); // Scroll range for the file card drop: 0 → FILE_DROP_END const FILE_DROP_END = 0.4; // Each row slides in over this span of scroll progress const ROW_SPAN = 0.12; // Row i starts appearing at this scroll progress const rowStart = (i: number) => 0.3 + i * 0.1; useEffect(() => { const updateProgress = () => { const container = containerRef.current; if (!container) { return; } const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight || 1; const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height); setScrollProgress(Math.min(1, Math.max(0, progress))); }; updateProgress(); window.addEventListener('scroll', updateProgress, { passive: true }); window.addEventListener('resize', updateProgress); return () => { window.removeEventListener('scroll', updateProgress); window.removeEventListener('resize', updateProgress); }; }, []); // File card animation: drops from above, scales and fades in const fileProgress = Math.min(1, scrollProgress / FILE_DROP_END); const fileY = -80 + 92 * fileProgress; const fileScale = 0.9 + 0.1 * fileProgress; const fileOpacity = fileProgress; return (
{/* File card — drops from above */}

transactions.csv

847 KB · 1,247 rows

{__('Imported')}
{/* Transaction rows — cascade in from the right after file card lands */} {previewRows.map((transaction, index) => { const start = rowStart(index); const rowProgress = Math.min( 1, Math.max(0, (scrollProgress - start) / ROW_SPAN), ); const rowX = 24 * (1 - rowProgress); const rowOpacity = rowProgress; const CategoryIcon = transaction.category.icon; const categoryColorClasses = getCategoryColorClasses( transaction.category.color, ); const isIncome = transaction.amountInCents > 0; const amountLabel = formatCurrency( Math.abs(transaction.amountInCents), currency, locale, ); return (
{transaction.date}

{__(transaction.description)}

{__(transaction.category.name)} {isIncome ? '+' : '-'} {amountLabel}
); })}
); } function PrivacyRedactedPreview() { const PRIVACY_ROWS = [ { label: 'Account Holder', value: 'Jonathan Mitchell' }, { label: 'Account No.', value: 'GB29 NWBK 6016 1331 9268 19' }, { label: 'Balance', value: '$12,450.00' }, { label: 'Last Login', value: __('Today') + ', 9:42 AM' }, { label: 'Statement', value: 'Q4 2024 · ' + __('Quartely') }, ] as const; const [scrollProgress, setScrollProgress] = useState(0); const containerRef = useRef(null); // Each bar slides in over this span of scroll progress const BAR_SPAN = 0.15; // Row i's bar starts sliding at this scroll progress const barStart = (i: number) => 0.1 + i * 0.12; useEffect(() => { const updateProgress = () => { const container = containerRef.current; if (!container) { return; } const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight || 1; const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height); setScrollProgress(Math.min(1, Math.max(0, progress))); }; updateProgress(); window.addEventListener('scroll', updateProgress, { passive: true }); window.addEventListener('resize', updateProgress); return () => { window.removeEventListener('scroll', updateProgress); window.removeEventListener('resize', updateProgress); }; }, []); return (
    {PRIVACY_ROWS.map((row, index) => { const start = barStart(index); const barProgress = Math.min( 1, Math.max(0, (scrollProgress - start) / BAR_SPAN), ); const barX = 100 * (1 - barProgress); return (
  • {__(row.label)}
    {row.value}
  • ); })} {/* Safe row — never redacted */}
  • {__('Shared')} {__('only with you')}
); } const CASHFLOW_PREVIEW_DATA = [ { month: 'Jan', incomeInCents: 420000, expensesInCents: 310000 }, { month: 'Feb', incomeInCents: 385000, expensesInCents: 290000 }, { month: 'Mar', incomeInCents: 510000, expensesInCents: 355000 }, { month: 'Apr', incomeInCents: 448000, expensesInCents: 380000 }, { month: 'May', incomeInCents: 495000, expensesInCents: 320000 }, { month: 'Jun', incomeInCents: 530000, expensesInCents: 410000 }, ] as const; function CashflowChartPreview() { const [scrollProgress, setScrollProgress] = useState(0); const containerRef = useRef(null); useEffect(() => { const updateProgress = () => { const container = containerRef.current; if (!container) { return; } const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight || 1; const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height); setScrollProgress(Math.min(1, Math.max(0, progress))); }; updateProgress(); window.addEventListener('scroll', updateProgress, { passive: true }); window.addEventListener('resize', updateProgress); return () => { window.removeEventListener('scroll', updateProgress); window.removeEventListener('resize', updateProgress); }; }, []); const maxValue = Math.max( ...CASHFLOW_PREVIEW_DATA.map((d) => Math.max(d.incomeInCents, d.expensesInCents), ), ); const chartHeight = 200; const COL_SPAN = 0.28; const COL_START_STEP = 0.07; return (
{/* Legend */}
{__('Income')}
{__('Expenses')}
{/* Chart area */}
{/* Dashed gridlines at 25 / 50 / 75 % */} {[0.25, 0.5, 0.75].map((frac) => (
))} {/* Baseline */}
{/* Columns */}
{CASHFLOW_PREVIEW_DATA.map((col, i) => { const colProgress = Math.min( 1, Math.max( 0, (scrollProgress - i * COL_START_STEP) / COL_SPAN, ), ); const incomeH = (col.incomeInCents / maxValue) * chartHeight * colProgress; const expenseH = (col.expensesInCents / maxValue) * chartHeight * colProgress; return (
{/* Income bar */}
{/* Expense bar */}
{col.month}
); })}
); } const BUDGETS_PREVIEW_ROWS = [ { name: 'Groceries', color: 'red' as const, icon: ShoppingBasketIcon, budgetInCents: 35000, spentInCents: 23800, }, { name: 'Transportation', color: 'amber' as const, icon: BusIcon, budgetInCents: 12000, spentInCents: 5400, }, { name: 'Entertainment', color: 'violet' as const, icon: ClapperboardIcon, budgetInCents: 8000, spentInCents: 7200, }, { name: 'Dining Out', color: 'orange' as const, icon: WineIcon, budgetInCents: 20000, spentInCents: 11000, }, ] as const; function BudgetsListPreview({ currency, locale, }: { currency: string; locale: string; }) { const [scrollProgress, setScrollProgress] = useState(0); const containerRef = useRef(null); const ROW_SLIDE_SPAN = 0.15; const BAR_FILL_SPAN = 0.2; const rowSlideStart = (i: number) => 0.05 + i * 0.1; const barFillStart = (i: number) => rowSlideStart(i) + 0.12; useEffect(() => { const updateProgress = () => { const container = containerRef.current; if (!container) { return; } const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight || 1; const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height); setScrollProgress(Math.min(1, Math.max(0, progress))); }; updateProgress(); window.addEventListener('scroll', updateProgress, { passive: true }); window.addEventListener('resize', updateProgress); return () => { window.removeEventListener('scroll', updateProgress); window.removeEventListener('resize', updateProgress); }; }, []); return (
    {BUDGETS_PREVIEW_ROWS.map((row, index) => { const slideProgress = Math.min( 1, Math.max( 0, (scrollProgress - rowSlideStart(index)) / ROW_SLIDE_SPAN, ), ); const fillProgress = Math.min( 1, Math.max( 0, (scrollProgress - barFillStart(index)) / BAR_FILL_SPAN, ), ); const spentPct = row.spentInCents / row.budgetInCents; const filledPct = spentPct * fillProgress * 100; const colorClasses = getCategoryColorClasses(row.color); const Icon = row.icon; return (
  • {__(row.name)}
    {formatCurrency( row.spentInCents, currency, locale, )}{' '} /{' '} {formatCurrency( row.budgetInCents, currency, locale, )}
    {/* Progress bar */}
    = 0.85 ? 'bg-rose-400 dark:bg-rose-500' : colorClasses.bg.split(' ')[0] + ' ' + colorClasses.bg .split(' ') .slice(1) .join(' '), )} style={{ width: `${filledPct}%` }} />
  • ); })}
); } function BudgetDetailPreview({ currency, locale, }: { currency: string; locale: string; }) { const [scrollProgress, setScrollProgress] = useState(0); const containerRef = useRef(null); // Grocery budget detail — 68% spent const budget = BUDGETS_PREVIEW_ROWS[0]; const spentPct = budget.spentInCents / budget.budgetInCents; // 0.68 const remainingInCents = budget.budgetInCents - budget.spentInCents; const BAR_SPAN = 0.35; const STATS_START = 0.3; const STATS_SPAN = 0.2; const ROWS_START = 0.25; const ROW_STEP = 0.1; const ROW_SPAN = 0.18; useEffect(() => { const updateProgress = () => { const container = containerRef.current; if (!container) { return; } const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight || 1; const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height); setScrollProgress(Math.min(1, Math.max(0, progress))); }; updateProgress(); window.addEventListener('scroll', updateProgress, { passive: true }); window.addEventListener('resize', updateProgress); return () => { window.removeEventListener('scroll', updateProgress); window.removeEventListener('resize', updateProgress); }; }, []); const barFill = Math.min(1, Math.max(0, scrollProgress / BAR_SPAN)) * spentPct * 100; const statsProgress = Math.min( 1, Math.max(0, (scrollProgress - STATS_START) / STATS_SPAN), ); // Pick the grocery/food transactions const detailRows = TRANSACTION_PREVIEW_ROWS.slice(1, 4); return (
{/* Header */}
{__(budget.name)} {Math.round(spentPct * 100)}% {__('used')}
{/* Big progress bar */}
{/* Stat chips */}

{__('Spent')}

{formatCurrency( budget.spentInCents, currency, locale, )}

{__('Remaining')}

{formatCurrency(remainingInCents, currency, locale)}

{/* Recent transactions */}
    {detailRows.map((row, i) => { const rowProgress = Math.min( 1, Math.max( 0, (scrollProgress - (ROWS_START + i * ROW_STEP)) / ROW_SPAN, ), ); const Icon = row.category.icon; return (
  • {row.description} {formatCurrency( Math.abs(row.amountInCents), currency, locale, )}
  • ); })}
); } function BudgetEditPreview() { const [scrollProgress, setScrollProgress] = useState(0); const containerRef = useRef(null); // "Dining Out" budget — 90% spent (high alert) const budget = BUDGETS_PREVIEW_ROWS[2]; // Entertainment, 90% const diningBudget = BUDGETS_PREVIEW_ROWS[3]; // Dining Out, 55% for the row display const spentPct = budget.spentInCents / budget.budgetInCents; const ROW_SPAN = 0.2; const ALERT_START = 0.18; const ALERT_SPAN = 0.22; const ACTION_START = 0.35; const ACTION_SPAN = 0.18; useEffect(() => { const updateProgress = () => { const container = containerRef.current; if (!container) { return; } const rect = container.getBoundingClientRect(); const viewportHeight = window.innerHeight || 1; const progress = (viewportHeight - rect.top) / (viewportHeight + rect.height); setScrollProgress(Math.min(1, Math.max(0, progress))); }; updateProgress(); window.addEventListener('scroll', updateProgress, { passive: true }); window.addEventListener('resize', updateProgress); return () => { window.removeEventListener('scroll', updateProgress); window.removeEventListener('resize', updateProgress); }; }, []); const rowProgress = Math.min(1, Math.max(0, scrollProgress / ROW_SPAN)); const alertProgress = Math.min( 1, Math.max(0, (scrollProgress - ALERT_START) / ALERT_SPAN), ); const actionProgress = Math.min( 1, Math.max(0, (scrollProgress - ACTION_START) / ACTION_SPAN), ); const Icon = budget.icon; const DiningIcon = diningBudget.icon; const colorClasses = getCategoryColorClasses(budget.color); const diningColorClasses = getCategoryColorClasses(diningBudget.color); const filledWidth = spentPct * rowProgress * 100; return (
{/* Budget row */}
{__(budget.name)}
{Math.round(spentPct * 100)}%
{/* Alert card */}
!

{__("You've used 90% of your budget")}

{__( 'Entertainment · Only $8 remaining this month', )}

{/* Dining out row (second budget, calmer) */}
{__(diningBudget.name)}
{Math.round( (diningBudget.spentInCents / diningBudget.budgetInCents) * 100, )} %
{/* Adjust limit action */}
{__('Limit adjusted')}
{__('Entertainment → $100')}
); } /** * Features reserved for paid plans. On the free plan card these are shown * dimmed with an X to set expectations before sign-up. */ const PRO_ONLY_FEATURES = [ 'Connect bank accounts', 'AI Suggestions', 'Priority support', ]; function FreePlanCard({ features }: { features: string[] }) { const excluded = new Set(PRO_ONLY_FEATURES); return (

{__('Free')}

{__('Free')} {__('forever')}

{__( 'Get started at no cost. No bank connections included.', )}

{__('Create the account now, update at any moment')}

    {features.map((feature) => { const isExcluded = excluded.has(feature); return (
  • {isExcluded ? ( ) : ( )} {__(feature)}
  • ); })}
); } function LandingPlanCard({ plan, isDefault, isBestValue, currency, locale, }: { plan: Plan; isDefault: boolean; isBestValue: boolean; promoEnabled: boolean; promoBadge: string; currency: string; locale: string; }) { const monthlyEquivalent = plan.billing_period === 'year' ? plan.price / 12 : plan.price; return (
{(isDefault || isBestValue) && (
{isDefault ? __('Most Popular') : __('Best Value')}
)}

{__(plan.name)}

{plan.original_price && ( {formatCurrency( (plan.billing_period === 'year' ? plan.original_price / 12 : plan.original_price) * 100, currency, locale, )} )} {formatCurrency( monthlyEquivalent * 100, currency, locale, )} {getBillingLabel(plan.billing_period)}
{plan.billing_period === 'year' && (

{__('Billed annually at')}{' '} {formatCurrency(plan.price * 100, currency, locale)}

)}

{__( 'Everything you need to manage your finances securely.', )}

    {plan.features.map((feature) => (
  • {__(feature)}
  • ))}
); } function FaqItem({ question, answer }: { question: string; answer: string }) { const [open, setOpen] = useState(false); return (

{answer}

); } function WaitlistForm() { const [referrerCode, setReferrerCode] = useState(''); const { locale } = usePage().props; useEffect(() => { const params = new URLSearchParams(window.location.search); const ref = params.get('ref'); if (ref) { setReferrerCode(ref); } }, []); return (
{({ processing, errors }) => ( <>
)}
); } export default function Welcome({ canRegister, hideAuthButtons, popularBanks, }: { canRegister?: boolean; hideAuthButtons?: boolean; popularBanks: PopularBank[]; }) { const { appUrl, auth, subscriptionsEnabled, pricing, locale } = usePage().props; const planEntries = Object.entries(pricing.plans); const { isMobile } = usePwaInstall(); const testimonials = [ { name: 'Brian Bansuela', gravatar: '9314f776a17ae977871076ac71f2ff60', text: __( 'I just started syncing my accounts and it already feels like a great app. The interface is lovely and it looks like a really solid tool. Great work!', ), }, { name: 'David Carrión', gravatar: '2a0a1e872f2f883da214b65e1c2e2156', text: __( "I have a lot of faith in this project — it's really well built and your MVP is fantastic. Thanks for everything!", ), }, { name: 'Jorge Navarrete', gravatar: 'd20d4e05a100d5b20b45c84f3c566a25', text: __( "I'm exploring the web app and the design and UX are excellent. Thanks, team!", ), }, { name: 'Marcus Oliveira', gravatar: '3c4342baddf0beb8b0bd9fe89168e282', text: __( 'Thank you for developing Whisper Money. The focus on privacy and centralizing finances is an excellent proposition.', ), }, { name: 'Carla Álvarez', gravatar: '9901ee5e849cf9a0caea00e897cb8123', text: __( 'I found Whisper Money and instantly knew I needed it. I was stuck doing everything in a spreadsheet — a chore I kept putting off. This makes it effortless.', ), }, { name: 'Yaritza Rey', gravatar: 'a519f143865c358b013f3f6dcdbc387a', text: __( 'Thank you so much for creating such a clean, simple app.', ), }, { name: 'Will Harris', gravatar: 'c6fbc4911d6143fe723a42f46230275e', text: __('Great project!'), }, { name: 'Haru', gravatar: '3e52d6b2cbefb0fa2a572a588b3f7953', text: __('I love this project!'), }, { name: 'Tom', gravatar: 'd721bb1875ac11132d4d33295867cbd9', text: __( "I'm genuinely happy using an open-source project with a real commitment to privacy — that's exactly what I want from a finance app.", ), }, { name: 'Elena', gravatar: '9867fc6636afc02ae519820e657e4485', text: __( "I can't wait to discover everything the app can do. Thank you — it must have taken a tremendous effort. Congratulations!", ), }, { name: 'Albert G.', gravatar: 'bb92a036f4feb9d12d0a70dd2d9a5c5f', text: __( 'The app is intuitive, functional, and a real help for managing my finances day to day. What stands out most is how much the free version offers — it really shows your commitment to your users. I’ll keep recommending it!', ), }, { name: 'Víctor Falcón (co-owner)', gravatar: '50901af884c50a8f12804b0cf3aeb98a', text: __( 'I built the app I needed to make better decisions. Understanding how I spend and where my income comes from has brought me real financial peace of mind.', ), }, ]; const half = Math.ceil(testimonials.length / 2); const testimonialRows = [ testimonials.slice(0, half), testimonials.slice(half), ]; const hasMonthlyAndYearly = planEntries.some(([, p]) => p.billing_period === 'month') && planEntries.some(([, p]) => p.billing_period === 'year'); const [billingPeriod, setBillingPeriod] = useState<'month' | 'year'>( 'year', ); const yearlyDiscount = useMemo(() => { const monthlyPlan = planEntries.find( ([, p]) => p.billing_period === 'month', )?.[1]; const yearlyPlan = planEntries.find( ([, p]) => p.billing_period === 'year', )?.[1]; if (!monthlyPlan || !yearlyPlan || monthlyPlan.price === 0) { return null; } const yearlyMonthlyEquivalent = yearlyPlan.price / 12; const discount = Math.round( (1 - yearlyMonthlyEquivalent / monthlyPlan.price) * 100, ); return discount > 0 ? discount : null; }, [planEntries]); const displayedPlanEntries = hasMonthlyAndYearly ? planEntries.filter(([, p]) => p.billing_period === billingPeriod) : planEntries; // Handle localStorage for language preference useEffect(() => { const urlParams = new URLSearchParams(window.location.search); const langParam = urlParams.get('lang'); if (langParam && ['en', 'es'].includes(langParam)) { // Store the preference in localStorage localStorage.setItem('whisper_landing_locale', langParam); } else { // No query param - check if we have a stored preference const storedLocale = localStorage.getItem('whisper_landing_locale'); if ( storedLocale && storedLocale !== locale && ['en', 'es'].includes(storedLocale) ) { // Redirect to stored preference window.location.href = `/?lang=${storedLocale}`; return; } else if (!storedLocale && locale) { // First visit - store the detected locale from session/header localStorage.setItem('whisper_landing_locale', locale); } } }, [locale]); const [isPwa] = useState(() => { if (typeof window === 'undefined') { return false; } return ( window.matchMedia('(display-mode: standalone)').matches || ('standalone' in navigator && (navigator as Navigator & { standalone: boolean }).standalone) ); }); useEffect(() => { if (isPwa) { router.visit('/dashboard'); } }, [isPwa]); if (isPwa) { return (
); } return ( <>
{__('Private & Secure')}

{__( 'All your money in one place. No spreadsheets. Private.', )}

{__( 'Understand your finances and make better decisions without the friction. Track expenses, create budgets, and achieve your goals\u2014all in one place.', )}

{hideAuthButtons ? ( ) : isMobile ? ( ) : (
)}

{hideAuthButtons ? __( "Join the waiting list. We'll let you know when you're in.", ) : __( 'Your data stays private. Always.', )}

{LANDING_IMAGES.map((image, index) => (
{image.alt} {image.alt}
))}
{/* Row 1: Connect Your Banks (2 cols) + Import in Seconds (1 col) */}

{__('Connect Your Banks')}

{__( 'Link your bank accounts directly. Transactions sync automatically, giving you a real-time view of your finances.', )}

  • {__('Connect in seconds')}
  • {__('Automatic sync')}
  • {__('Secure & encrypted')}

{__('Import in Seconds')}

{__( "Export a CSV or XLS from your bank and drag it in. A year's worth of transactions imported in under 10 seconds.", )}

{/* Row 2: All Your Accounts, Every Transaction, Your Data Your Rules */}

{__('All Your Accounts')}

{__( 'See every account in one place. Track balances, monitor changes, and always know where you stand.', )}

{__('Every Transaction')}

{__( 'Search, filter, and categorize with ease. Understand exactly where your money goes.', )}

{__('Your Data, Your Rules')}

{__( 'No third-party sharing, no AI snooping. Your financial data belongs to you and only you.', )}

{/* Row 3: Cashflow at a Glance (always full width) */}

{__('Cashflow at a Glance')}

{__( 'Visualize your money flow over time. See income vs. expenses and spot trends before they become problems.', )}

{__('Smart Budgets')}

{__( 'Create budgets that adapt to your spending habits and help you reach your goals.', )}

{__('Set Your Goals')}

{__( 'Define monthly budgets by category. Know exactly how much you can spend.', )}

{__('Track Progress')}

{__( 'See where you stand in real-time. Visual progress bars show spending vs. budget.', )}

{__('Stay on Track')}

{__( "Get notified when you're close to your limit. Never overspend again.", )}

{__( 'Trusted by people who value their privacy', )}

{__( 'Join thousands of users who have taken control of their finances without compromising their privacy.', )}

{testimonialRows.map((row, rowIndex) => (
{[0, 1].map((copy) => (
{row.map((testimonial) => (

{ testimonial.name }

{testimonial.text}

))}
))}
))}
{subscriptionsEnabled && !hideAuthButtons && planEntries.length > 0 && (

{__('Simple, transparent pricing')}

{__( 'Choose the plan that works for you. No hidden fees.', )}

{hasMonthlyAndYearly && (
)}
= 3 && 'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3', )} > {displayedPlanEntries.map( ([key, plan]) => ( ), )}
)}

{__('Frequently Asked Questions')}

{__( 'Everything you need to know before getting started.', )}

{__( 'Ready to take control of your finances?', )}

{__( 'Start managing your money privately. No credit card required.', )}

{hideAuthButtons ? ( ) : isMobile ? ( ) : ( )}
); }