2874 lines
132 KiB
TypeScript
2874 lines
132 KiB
TypeScript
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<TransactionPreviewRow> = [
|
||
{
|
||
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<AccountPreviewRow> = [
|
||
{
|
||
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 (
|
||
<div
|
||
className={cn(
|
||
'overflow-hidden rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#161615]',
|
||
className,
|
||
)}
|
||
>
|
||
{children}
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BankConnectionsPreview({
|
||
banks,
|
||
prependFromBottomCount = 15,
|
||
className,
|
||
}: {
|
||
banks: ReadonlyArray<PopularBank>;
|
||
prependFromBottomCount?: number;
|
||
className?: string;
|
||
}) {
|
||
const [translateY, setTranslateY] = useState(0);
|
||
const [maxTranslate, setMaxTranslate] = useState(0);
|
||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||
const listRef = useRef<HTMLUListElement | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
role="img"
|
||
aria-label={__('Popular banks available to connect')}
|
||
className={cn(
|
||
'relative min-h-[320px] overflow-hidden rounded-xl border border-[#e3e3e0]/70 bg-gradient-to-br from-zinc-50 to-zinc-100 select-none dark:border-[#3E3E3A]/30 dark:from-zinc-900 dark:to-zinc-950',
|
||
className,
|
||
)}
|
||
>
|
||
<ul
|
||
ref={listRef}
|
||
className="space-y-2 p-3 transition-transform duration-75 will-change-transform sm:p-4"
|
||
style={{ transform: `translate3d(0, -${translateY}px, 0)` }}
|
||
>
|
||
{previewBanks.map((bank, index) => {
|
||
const originalIndex =
|
||
(index - prependedBanksCount + banks.length) %
|
||
banks.length;
|
||
|
||
return (
|
||
<li
|
||
key={`${bank.name}-${index}`}
|
||
className="flex items-center gap-3 rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/75 px-3 py-2.5 dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/75"
|
||
>
|
||
<span className="w-6 text-xs font-medium text-[#706f6c] dark:text-[#A1A09A]">
|
||
{(originalIndex + 1)
|
||
.toString()
|
||
.padStart(2, '0')}
|
||
</span>
|
||
<BankLogo
|
||
src={bank.logo}
|
||
name={bank.name}
|
||
fallback="letter"
|
||
className="size-7 border border-[#e3e3e0] bg-white p-0.5 dark:border-[#3E3E3A] dark:bg-[#141413]"
|
||
/>
|
||
<span className="text-sm font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{bank.name}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-zinc-100 to-transparent dark:from-zinc-900" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-zinc-100 to-transparent dark:from-zinc-950" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement | null>(null);
|
||
const listRef = useRef<HTMLUListElement | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
role="img"
|
||
aria-label={__(
|
||
'Sample transactions with a curved scrolling effect',
|
||
)}
|
||
className="relative h-[320px] overflow-hidden rounded-xl border border-[#e3e3e0]/70 bg-gradient-to-br from-zinc-50 to-zinc-100 select-none dark:border-[#3E3E3A]/30 dark:from-zinc-900 dark:to-zinc-950"
|
||
>
|
||
<div className="absolute inset-x-0 top-0 z-10 grid grid-cols-[60px_minmax(0,1fr)_auto] items-center rounded-t-xl border-b border-[#e3e3e0]/80 bg-[#FDFDFC]/85 px-3 py-2 text-[11px] font-semibold tracking-[0.08em] text-[#706f6c] uppercase backdrop-blur sm:px-4 dark:border-[#3E3E3A]/80 dark:bg-[#161615]/85 dark:text-[#A1A09A]">
|
||
<span>{__('Date')}</span>
|
||
<span>{__('Description')}</span>
|
||
<span className="text-right">{__('Amount')}</span>
|
||
</div>
|
||
|
||
<ul
|
||
ref={listRef}
|
||
className="flex flex-col gap-2 p-3 pt-12 transition-transform duration-75 will-change-transform sm:p-4 sm:pt-12"
|
||
style={{ transform: `translate3d(0, -${translateY}px, 0)` }}
|
||
>
|
||
{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 (
|
||
<li
|
||
key={`${transaction.id}-${index}`}
|
||
className="flex items-center gap-3 rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/75 px-3 py-2.5 dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/75"
|
||
style={{
|
||
transform: `translate3d(${curveX}px, 0, 0) rotate(${curveRotate}deg) scale(${curveScale})`,
|
||
}}
|
||
>
|
||
<span className="w-[60px] shrink-0 text-xs font-medium text-[#706f6c] dark:text-[#A1A09A]">
|
||
{transaction.date}
|
||
</span>
|
||
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{__(transaction.description)}
|
||
</p>
|
||
<span
|
||
className={cn(
|
||
'mt-1 inline-flex max-w-full items-center gap-1 truncate rounded-full border border-black/10 px-2 py-0.5 text-[11px] font-medium dark:border-white/15',
|
||
categoryColorClasses.bg,
|
||
categoryColorClasses.text,
|
||
)}
|
||
>
|
||
<CategoryIcon className="size-3 shrink-0" />
|
||
{__(transaction.category.name)}
|
||
</span>
|
||
</div>
|
||
|
||
<span
|
||
className={cn(
|
||
'ml-2 flex shrink-0 items-center gap-1 text-sm font-semibold tabular-nums',
|
||
isIncome
|
||
? 'text-emerald-600 dark:text-emerald-400'
|
||
: 'text-[#1b1b18] dark:text-[#EDEDEC]',
|
||
)}
|
||
>
|
||
{isIncome ? (
|
||
<ArrowUpRightIcon className="size-3.5" />
|
||
) : (
|
||
<ArrowDownLeftIcon className="size-3.5" />
|
||
)}
|
||
<span>
|
||
{isIncome ? '+' : '-'}
|
||
{amountLabel}
|
||
</span>
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 top-8 h-10 bg-gradient-to-b from-zinc-100/95 to-transparent dark:from-zinc-900/95" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-zinc-100 to-transparent dark:from-zinc-950" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function AccountsBalancePreview({
|
||
currency,
|
||
locale,
|
||
}: {
|
||
currency: string;
|
||
locale: string;
|
||
}) {
|
||
const [scrollProgress, setScrollProgress] = useState(0);
|
||
const containerRef = useRef<HTMLDivElement | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
role="img"
|
||
aria-label={__('All your bank accounts at a glance')}
|
||
className="relative h-[320px] overflow-hidden rounded-xl border border-[#e3e3e0]/70 bg-gradient-to-br from-zinc-50 to-zinc-100 select-none dark:border-[#3E3E3A]/30 dark:from-zinc-900 dark:to-zinc-950"
|
||
>
|
||
{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 (
|
||
<div
|
||
key={account.id}
|
||
className="absolute inset-x-3 flex items-center gap-3 rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/90 px-3 py-2.5 will-change-transform sm:inset-x-4 dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/90"
|
||
style={{
|
||
top: 0,
|
||
transform: `translateY(${y}px) rotate(${rotate}deg) scale(${scale})`,
|
||
opacity,
|
||
zIndex,
|
||
}}
|
||
>
|
||
<BankLogo
|
||
src={null}
|
||
name={account.institution}
|
||
fallback="letter"
|
||
className="size-7 shrink-0 border border-[#e3e3e0] bg-white p-0.5 dark:border-[#3E3E3A] dark:bg-[#141413]"
|
||
/>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{__(account.name)}
|
||
</p>
|
||
<p className="truncate text-xs text-[#706f6c] dark:text-[#A1A09A]">
|
||
{account.institution}
|
||
</p>
|
||
</div>
|
||
<div className="flex shrink-0 flex-col items-end gap-1">
|
||
<span
|
||
className={cn(
|
||
'text-sm font-semibold tabular-nums',
|
||
isDebt
|
||
? 'text-red-600 dark:text-red-400'
|
||
: 'text-[#1b1b18] dark:text-[#EDEDEC]',
|
||
)}
|
||
>
|
||
{isDebt ? '-' : ''}
|
||
{balanceLabel}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
'inline-flex items-center rounded-full border border-black/10 px-2 py-0.5 text-[11px] font-medium dark:border-white/15',
|
||
colorClasses.bg,
|
||
colorClasses.text,
|
||
)}
|
||
>
|
||
{__(account.type)}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-14 bg-gradient-to-b from-zinc-100 to-transparent dark:from-zinc-900" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-zinc-100 to-transparent dark:from-zinc-950" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function ImportPreview({
|
||
currency,
|
||
locale,
|
||
}: {
|
||
currency: string;
|
||
locale: string;
|
||
}) {
|
||
const [scrollProgress, setScrollProgress] = useState(0);
|
||
const containerRef = useRef<HTMLDivElement | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
role="img"
|
||
aria-label={__('Import transactions from a CSV file')}
|
||
className="relative h-[320px] overflow-hidden rounded-xl border border-[#e3e3e0]/70 bg-gradient-to-br from-zinc-50 to-zinc-100 select-none dark:border-[#3E3E3A]/30 dark:from-zinc-900 dark:to-zinc-950"
|
||
>
|
||
{/* File card — drops from above */}
|
||
<div
|
||
className="absolute inset-x-3 flex items-center gap-3 rounded-lg border border-emerald-200 bg-[#FDFDFC] px-3 py-3 will-change-transform sm:inset-x-4 dark:border-emerald-800/60 dark:bg-[#1C1C1A]"
|
||
style={{
|
||
top: 0,
|
||
transform: `translateY(${fileY}px) scale(${fileScale})`,
|
||
opacity: fileOpacity,
|
||
}}
|
||
>
|
||
<div className="flex size-9 shrink-0 items-center justify-center rounded-md border border-emerald-200 bg-emerald-50 dark:border-emerald-800/60 dark:bg-emerald-950/40">
|
||
<FileSpreadsheetIcon className="size-5 text-emerald-600 dark:text-emerald-400" />
|
||
</div>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
transactions.csv
|
||
</p>
|
||
<p className="text-xs text-[#706f6c] dark:text-[#A1A09A]">
|
||
847 KB · 1,247 rows
|
||
</p>
|
||
</div>
|
||
<span className="inline-flex shrink-0 items-center gap-1 rounded-full border border-emerald-200 bg-emerald-50 px-2 py-0.5 text-[11px] font-medium text-emerald-700 dark:border-emerald-800/60 dark:bg-emerald-950/40 dark:text-emerald-400">
|
||
<CheckIcon className="size-3" />
|
||
{__('Imported')}
|
||
</span>
|
||
</div>
|
||
|
||
{/* 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 (
|
||
<div
|
||
key={transaction.id}
|
||
className="absolute inset-x-3 flex items-center gap-3 rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/90 px-3 py-2.5 will-change-transform sm:inset-x-4 dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/90"
|
||
style={{
|
||
top: 0,
|
||
transform: `translateY(${124 + index * 38}px) translateX(${rowX}px)`,
|
||
opacity: rowOpacity,
|
||
}}
|
||
>
|
||
<span className="w-[52px] shrink-0 text-xs font-medium text-[#706f6c] dark:text-[#A1A09A]">
|
||
{transaction.date}
|
||
</span>
|
||
<div className="min-w-0 flex-1">
|
||
<p className="truncate text-sm font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{__(transaction.description)}
|
||
</p>
|
||
</div>
|
||
<span
|
||
className={cn(
|
||
'hidden shrink-0 items-center gap-1 rounded-full border border-black/10 px-2 py-0.5 text-[11px] font-medium sm:inline-flex dark:border-white/15',
|
||
categoryColorClasses.bg,
|
||
categoryColorClasses.text,
|
||
)}
|
||
>
|
||
<CategoryIcon className="size-3 shrink-0" />
|
||
{__(transaction.category.name)}
|
||
</span>
|
||
<span
|
||
className={cn(
|
||
'ml-1 shrink-0 text-sm font-semibold tabular-nums',
|
||
isIncome
|
||
? 'text-emerald-600 dark:text-emerald-400'
|
||
: 'text-[#1b1b18] dark:text-[#EDEDEC]',
|
||
)}
|
||
>
|
||
{isIncome ? '+' : '-'}
|
||
{amountLabel}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-10 bg-gradient-to-b from-zinc-100 to-transparent dark:from-zinc-900" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-20 bg-gradient-to-t from-zinc-100 to-transparent dark:from-zinc-950" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
role="img"
|
||
aria-label={__(
|
||
'Your financial data stays private and is never shared',
|
||
)}
|
||
className="relative h-[320px] overflow-hidden rounded-xl border border-[#e3e3e0]/70 bg-gradient-to-br from-zinc-50 to-zinc-100 select-none dark:border-[#3E3E3A]/30 dark:from-zinc-900 dark:to-zinc-950"
|
||
>
|
||
<ul className="flex flex-col gap-2 p-3 sm:p-4">
|
||
{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 (
|
||
<li
|
||
key={row.label}
|
||
className="flex items-center gap-3 rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/75 px-3 py-2.5 dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/75"
|
||
>
|
||
<span className="w-[110px] shrink-0 text-xs font-medium text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(row.label)}
|
||
</span>
|
||
<div className="relative min-w-0 flex-1 overflow-hidden rounded">
|
||
<span className="block truncate text-sm font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{row.value}
|
||
</span>
|
||
<div
|
||
className="absolute inset-0 rounded bg-zinc-900 will-change-transform dark:bg-zinc-100"
|
||
style={{
|
||
transform: `translateX(${barX}%)`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
|
||
{/* Safe row — never redacted */}
|
||
<li className="flex items-center gap-1 rounded-lg border border-emerald-200 bg-emerald-50/60 px-3 py-2.5 dark:border-emerald-800/50 dark:bg-emerald-950/30">
|
||
<LockIcon className="mr-2 size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||
<span className="text-xs font-medium text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__('Shared')}
|
||
</span>
|
||
<span className="text-sm font-semibold text-emerald-700 dark:text-emerald-400">
|
||
{__('only with you')}
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-8 bg-gradient-to-b from-zinc-100 to-transparent dark:from-zinc-900" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-14 bg-gradient-to-t from-zinc-100 to-transparent dark:from-zinc-950" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
role="img"
|
||
aria-label={__('Cashflow income vs expenses bar chart')}
|
||
className="relative h-[320px] overflow-hidden rounded-xl border border-[#e3e3e0]/70 bg-gradient-to-br from-zinc-50 to-zinc-100 select-none dark:border-[#3E3E3A]/30 dark:from-zinc-900 dark:to-zinc-950"
|
||
>
|
||
<div className="flex h-full flex-col px-4 pt-5 pb-6">
|
||
{/* Legend */}
|
||
<div className="mb-3 flex items-center justify-end gap-4">
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="size-2 rounded-full bg-emerald-500" />
|
||
<span className="text-[10px] font-medium text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__('Income')}
|
||
</span>
|
||
</div>
|
||
<div className="flex items-center gap-1.5">
|
||
<span className="size-2 rounded-full bg-rose-400" />
|
||
<span className="text-[10px] font-medium text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__('Expenses')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Chart area */}
|
||
<div
|
||
className="relative flex-1"
|
||
style={{ height: chartHeight }}
|
||
>
|
||
{/* Dashed gridlines at 25 / 50 / 75 % */}
|
||
{[0.25, 0.5, 0.75].map((frac) => (
|
||
<div
|
||
key={frac}
|
||
className="absolute inset-x-0 border-t border-dashed border-[#e3e3e0]/80 dark:border-[#3E3E3A]/60"
|
||
style={{ bottom: `${frac * 100}%` }}
|
||
/>
|
||
))}
|
||
|
||
{/* Baseline */}
|
||
<div className="absolute inset-x-0 bottom-0 border-t border-[#e3e3e0] dark:border-[#3E3E3A]" />
|
||
|
||
{/* Columns */}
|
||
<div className="absolute inset-0 flex items-end justify-around px-1">
|
||
{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 (
|
||
<div
|
||
key={col.month}
|
||
className="flex flex-col items-center gap-1"
|
||
>
|
||
<div className="flex items-end gap-0.5">
|
||
{/* Income bar */}
|
||
<div
|
||
className="w-5 rounded-t-sm bg-emerald-500 will-change-transform dark:bg-emerald-400"
|
||
style={{ height: incomeH }}
|
||
/>
|
||
{/* Expense bar */}
|
||
<div
|
||
className="w-5 rounded-t-sm bg-rose-400 will-change-transform dark:bg-rose-500"
|
||
style={{ height: expenseH }}
|
||
/>
|
||
</div>
|
||
<span className="text-[9px] font-medium text-[#706f6c] dark:text-[#A1A09A]">
|
||
{col.month}
|
||
</span>
|
||
</div>
|
||
);
|
||
})}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-8 bg-gradient-to-b from-zinc-50 to-transparent dark:from-zinc-900" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-10 bg-gradient-to-t from-zinc-100 to-transparent dark:from-zinc-950" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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<HTMLDivElement | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
role="img"
|
||
aria-label={__('Budget goals by category')}
|
||
className="relative h-[320px] overflow-hidden rounded-xl border border-[#e3e3e0]/70 bg-gradient-to-br from-zinc-50 to-zinc-100 select-none dark:border-[#3E3E3A]/30 dark:from-zinc-900 dark:to-zinc-950"
|
||
>
|
||
<ul className="flex flex-col gap-2 p-3 sm:p-4">
|
||
{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 (
|
||
<li
|
||
key={row.name}
|
||
className="rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/75 px-3 py-2.5 will-change-transform dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/75"
|
||
style={{
|
||
transform: `translateX(${32 * (1 - slideProgress)}px)`,
|
||
opacity: slideProgress,
|
||
}}
|
||
>
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
'flex size-5 shrink-0 items-center justify-center rounded-full',
|
||
colorClasses.bg,
|
||
)}
|
||
>
|
||
<Icon
|
||
className={cn(
|
||
'size-2.5',
|
||
colorClasses.text,
|
||
)}
|
||
/>
|
||
</span>
|
||
<span className="text-xs font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{__(row.name)}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] text-[#706f6c] dark:text-[#A1A09A]">
|
||
{formatCurrency(
|
||
row.spentInCents,
|
||
currency,
|
||
locale,
|
||
)}{' '}
|
||
/{' '}
|
||
{formatCurrency(
|
||
row.budgetInCents,
|
||
currency,
|
||
locale,
|
||
)}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Progress bar */}
|
||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[#e3e3e0] dark:bg-[#3E3E3A]">
|
||
<div
|
||
className={cn(
|
||
'h-full rounded-full transition-none will-change-transform',
|
||
spentPct >= 0.85
|
||
? 'bg-rose-400 dark:bg-rose-500'
|
||
: colorClasses.bg.split(' ')[0] +
|
||
' ' +
|
||
colorClasses.bg
|
||
.split(' ')
|
||
.slice(1)
|
||
.join(' '),
|
||
)}
|
||
style={{ width: `${filledPct}%` }}
|
||
/>
|
||
</div>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-8 bg-gradient-to-b from-zinc-100 to-transparent dark:from-zinc-900" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-zinc-100 to-transparent dark:from-zinc-950" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BudgetDetailPreview({
|
||
currency,
|
||
locale,
|
||
}: {
|
||
currency: string;
|
||
locale: string;
|
||
}) {
|
||
const [scrollProgress, setScrollProgress] = useState(0);
|
||
const containerRef = useRef<HTMLDivElement | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
role="img"
|
||
aria-label={__('Budget progress for Groceries')}
|
||
className="relative h-[320px] overflow-hidden rounded-xl border border-[#e3e3e0]/70 bg-gradient-to-br from-zinc-50 to-zinc-100 select-none dark:border-[#3E3E3A]/30 dark:from-zinc-900 dark:to-zinc-950"
|
||
>
|
||
<div className="flex flex-col gap-3 p-4">
|
||
{/* Header */}
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
'flex size-7 shrink-0 items-center justify-center rounded-full',
|
||
getCategoryColorClasses(budget.color).bg,
|
||
)}
|
||
>
|
||
<ShoppingBasketIcon
|
||
className={cn(
|
||
'size-3.5',
|
||
getCategoryColorClasses(budget.color).text,
|
||
)}
|
||
/>
|
||
</span>
|
||
<span className="text-sm font-semibold text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{__(budget.name)}
|
||
</span>
|
||
<span className="ml-auto text-xs text-[#706f6c] dark:text-[#A1A09A]">
|
||
{Math.round(spentPct * 100)}% {__('used')}
|
||
</span>
|
||
</div>
|
||
|
||
{/* Big progress bar */}
|
||
<div className="h-3 w-full overflow-hidden rounded-full bg-[#e3e3e0] dark:bg-[#3E3E3A]">
|
||
<div
|
||
className="h-full rounded-full bg-red-400 transition-none will-change-transform dark:bg-red-500"
|
||
style={{ width: `${barFill}%` }}
|
||
/>
|
||
</div>
|
||
|
||
{/* Stat chips */}
|
||
<div
|
||
className="grid grid-cols-2 gap-2"
|
||
style={{ opacity: statsProgress }}
|
||
>
|
||
<div className="rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/75 px-3 py-2 dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/75">
|
||
<p className="text-[10px] text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__('Spent')}
|
||
</p>
|
||
<p className="text-sm font-semibold text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{formatCurrency(
|
||
budget.spentInCents,
|
||
currency,
|
||
locale,
|
||
)}
|
||
</p>
|
||
</div>
|
||
<div className="rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/75 px-3 py-2 dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/75">
|
||
<p className="text-[10px] text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__('Remaining')}
|
||
</p>
|
||
<p className="text-sm font-semibold text-emerald-600 dark:text-emerald-400">
|
||
{formatCurrency(remainingInCents, currency, locale)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Recent transactions */}
|
||
<ul className="flex flex-col gap-1.5">
|
||
{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 (
|
||
<li
|
||
key={row.id}
|
||
className="flex items-center gap-2.5 rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/75 px-3 py-2 will-change-transform dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/75"
|
||
style={{
|
||
transform: `translateY(${8 * (1 - rowProgress)}px)`,
|
||
opacity: rowProgress,
|
||
}}
|
||
>
|
||
<span
|
||
className={cn(
|
||
'flex size-5 shrink-0 items-center justify-center rounded-full',
|
||
getCategoryColorClasses(
|
||
row.category.color,
|
||
).bg,
|
||
)}
|
||
>
|
||
<Icon
|
||
className={cn(
|
||
'size-2.5',
|
||
getCategoryColorClasses(
|
||
row.category.color,
|
||
).text,
|
||
)}
|
||
/>
|
||
</span>
|
||
<span className="min-w-0 flex-1 truncate text-xs text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{row.description}
|
||
</span>
|
||
<span className="shrink-0 text-xs font-medium text-rose-500 dark:text-rose-400">
|
||
{formatCurrency(
|
||
Math.abs(row.amountInCents),
|
||
currency,
|
||
locale,
|
||
)}
|
||
</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
</div>
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-8 bg-gradient-to-b from-zinc-50 to-transparent dark:from-zinc-900" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-zinc-100 to-transparent dark:from-zinc-950" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function BudgetEditPreview() {
|
||
const [scrollProgress, setScrollProgress] = useState(0);
|
||
const containerRef = useRef<HTMLDivElement | null>(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 (
|
||
<div
|
||
ref={containerRef}
|
||
role="img"
|
||
aria-label={__('Budget alert and limit adjustment')}
|
||
className="relative h-[320px] overflow-hidden rounded-xl border border-[#e3e3e0]/70 bg-gradient-to-br from-zinc-50 to-zinc-100 select-none dark:border-[#3E3E3A]/30 dark:from-zinc-900 dark:to-zinc-950"
|
||
>
|
||
<div className="flex flex-col gap-3 p-4">
|
||
{/* Budget row */}
|
||
<div
|
||
className="rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/75 px-3 py-2.5 will-change-transform dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/75"
|
||
style={{ opacity: rowProgress }}
|
||
>
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
'flex size-5 shrink-0 items-center justify-center rounded-full',
|
||
colorClasses.bg,
|
||
)}
|
||
>
|
||
<Icon
|
||
className={cn(
|
||
'size-2.5',
|
||
colorClasses.text,
|
||
)}
|
||
/>
|
||
</span>
|
||
<span className="text-xs font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{__(budget.name)}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] font-semibold text-rose-500 dark:text-rose-400">
|
||
{Math.round(spentPct * 100)}%
|
||
</span>
|
||
</div>
|
||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[#e3e3e0] dark:bg-[#3E3E3A]">
|
||
<div
|
||
className="h-full rounded-full bg-rose-400 transition-none will-change-transform dark:bg-rose-500"
|
||
style={{ width: `${filledWidth}%` }}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Alert card */}
|
||
<div
|
||
className="rounded-lg border border-amber-200 bg-amber-50/80 px-3 py-3 will-change-transform dark:border-amber-800/50 dark:bg-amber-950/30"
|
||
style={{
|
||
transform: `translateY(${-14 * (1 - alertProgress)}px)`,
|
||
opacity: alertProgress,
|
||
}}
|
||
>
|
||
<div className="flex items-start gap-2.5">
|
||
<div className="mt-0.5 flex size-5 shrink-0 items-center justify-center rounded-full bg-amber-100 dark:bg-amber-800/50">
|
||
<span className="text-[10px] font-bold text-amber-600 dark:text-amber-400">
|
||
!
|
||
</span>
|
||
</div>
|
||
<div>
|
||
<p className="text-xs font-semibold text-amber-800 dark:text-amber-300">
|
||
{__("You've used 90% of your budget")}
|
||
</p>
|
||
<p className="mt-0.5 text-[10px] text-amber-700/80 dark:text-amber-400/80">
|
||
{__(
|
||
'Entertainment · Only $8 remaining this month',
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Dining out row (second budget, calmer) */}
|
||
<div
|
||
className="rounded-lg border border-[#e3e3e0]/85 bg-[#FDFDFC]/75 px-3 py-2.5 will-change-transform dark:border-[#3E3E3A]/80 dark:bg-[#1C1C1A]/75"
|
||
style={{
|
||
opacity: actionProgress,
|
||
transform: `translateY(${8 * (1 - actionProgress)}px)`,
|
||
}}
|
||
>
|
||
<div className="mb-2 flex items-center justify-between gap-2">
|
||
<div className="flex items-center gap-2">
|
||
<span
|
||
className={cn(
|
||
'flex size-5 shrink-0 items-center justify-center rounded-full',
|
||
diningColorClasses.bg,
|
||
)}
|
||
>
|
||
<DiningIcon
|
||
className={cn(
|
||
'size-2.5',
|
||
diningColorClasses.text,
|
||
)}
|
||
/>
|
||
</span>
|
||
<span className="text-xs font-medium text-[#1b1b18] dark:text-[#EDEDEC]">
|
||
{__(diningBudget.name)}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] text-[#706f6c] dark:text-[#A1A09A]">
|
||
{Math.round(
|
||
(diningBudget.spentInCents /
|
||
diningBudget.budgetInCents) *
|
||
100,
|
||
)}
|
||
%
|
||
</span>
|
||
</div>
|
||
<div className="h-1.5 w-full overflow-hidden rounded-full bg-[#e3e3e0] dark:bg-[#3E3E3A]">
|
||
<div
|
||
className={cn(
|
||
'h-full rounded-full transition-none',
|
||
diningColorClasses.bg.split(' ')[0] +
|
||
' ' +
|
||
diningColorClasses.bg
|
||
.split(' ')
|
||
.slice(1)
|
||
.join(' '),
|
||
)}
|
||
style={{
|
||
width: `${(diningBudget.spentInCents / diningBudget.budgetInCents) * actionProgress * 100}%`,
|
||
}}
|
||
/>
|
||
</div>
|
||
</div>
|
||
|
||
{/* Adjust limit action */}
|
||
<div
|
||
className="flex items-center justify-between rounded-lg border border-emerald-200 bg-emerald-50/60 px-3 py-2.5 will-change-transform dark:border-emerald-800/50 dark:bg-emerald-950/30"
|
||
style={{
|
||
opacity: actionProgress,
|
||
transform: `translateY(${10 * (1 - actionProgress)}px)`,
|
||
}}
|
||
>
|
||
<div className="flex items-center gap-2">
|
||
<CheckIcon className="size-3.5 shrink-0 text-emerald-600 dark:text-emerald-400" />
|
||
<span className="text-xs font-medium text-emerald-700 dark:text-emerald-400">
|
||
{__('Limit adjusted')}
|
||
</span>
|
||
</div>
|
||
<span className="text-[10px] text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__('Entertainment → $100')}
|
||
</span>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="pointer-events-none absolute inset-x-0 top-0 h-8 bg-gradient-to-b from-zinc-50 to-transparent dark:from-zinc-900" />
|
||
<div className="pointer-events-none absolute inset-x-0 bottom-0 h-16 bg-gradient-to-t from-zinc-100 to-transparent dark:from-zinc-950" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
/**
|
||
* 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 (
|
||
<div className="flex flex-col overflow-hidden rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||
<div className="flex flex-1 flex-col p-6 pt-2 sm:pt-12">
|
||
<h3 className="text-lg font-semibold">{__('Free')}</h3>
|
||
|
||
<div className="mt-3 flex items-baseline gap-2">
|
||
<span className="text-4xl font-bold tracking-tight">
|
||
{__('Free')}
|
||
</span>
|
||
<span className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__('forever')}
|
||
</span>
|
||
</div>
|
||
|
||
<p className="mt-3 text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
'Get started at no cost. No bank connections included.',
|
||
)}
|
||
</p>
|
||
|
||
<div className="my-5 h-px bg-[#e3e3e0] dark:bg-[#3E3E3A]" />
|
||
|
||
<p className="mt-1 text-xs text-[#706f6c] opacity-0 dark:text-[#A1A09A]">
|
||
{__('Create the account now, update at any moment')}
|
||
</p>
|
||
|
||
<ul className="flex-1 space-y-2.5">
|
||
{features.map((feature) => {
|
||
const isExcluded = excluded.has(feature);
|
||
|
||
return (
|
||
<li
|
||
key={feature}
|
||
className={cn(
|
||
'flex items-center gap-2.5',
|
||
isExcluded && 'opacity-40',
|
||
)}
|
||
>
|
||
{isExcluded ? (
|
||
<XIcon className="size-4 shrink-0 text-[#706f6c] dark:text-[#A1A09A]" />
|
||
) : (
|
||
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||
)}
|
||
<span className="text-sm">{__(feature)}</span>
|
||
</li>
|
||
);
|
||
})}
|
||
</ul>
|
||
|
||
<Link href="/register" className="mt-8">
|
||
<Button
|
||
className="w-full cursor-pointer border-[#e3e3e0] bg-transparent py-5 text-base text-[#1b1b18] shadow-sm transition-all hover:bg-[#f5f5f4] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:bg-[#1f1f1e]"
|
||
variant="outline"
|
||
>
|
||
{__('Get Started Free')}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
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 (
|
||
<div
|
||
className={cn(
|
||
'flex flex-col overflow-hidden rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#161615]',
|
||
isDefault && 'ring-2 ring-[#1b1b18] dark:ring-[#EDEDEC]',
|
||
)}
|
||
>
|
||
{(isDefault || isBestValue) && (
|
||
<div
|
||
className={cn(
|
||
'px-6 pt-6 text-xs font-semibold uppercase',
|
||
isDefault && 'text-[#1b1b18]/75 dark:text-[#aaa]',
|
||
isBestValue &&
|
||
!isDefault &&
|
||
'text-[#706f6c] dark:text-[#A1A09A]',
|
||
)}
|
||
>
|
||
{isDefault ? __('Most Popular') : __('Best Value')}
|
||
</div>
|
||
)}
|
||
|
||
<div
|
||
className={cn(
|
||
'flex flex-1 flex-col p-6 pt-2',
|
||
!isDefault && !isBestValue && 'sm:pt-12',
|
||
)}
|
||
>
|
||
<h3 className="text-lg font-semibold">{__(plan.name)}</h3>
|
||
|
||
<div className="mt-3 flex items-baseline gap-2">
|
||
{plan.original_price && (
|
||
<span className="text-lg font-medium text-[#706f6c] line-through dark:text-[#A1A09A]">
|
||
{formatCurrency(
|
||
(plan.billing_period === 'year'
|
||
? plan.original_price / 12
|
||
: plan.original_price) * 100,
|
||
currency,
|
||
locale,
|
||
)}
|
||
</span>
|
||
)}
|
||
<span className="text-4xl font-bold tracking-tight">
|
||
{formatCurrency(
|
||
monthlyEquivalent * 100,
|
||
currency,
|
||
locale,
|
||
)}
|
||
</span>
|
||
<span className="text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{getBillingLabel(plan.billing_period)}
|
||
</span>
|
||
</div>
|
||
|
||
{plan.billing_period === 'year' && (
|
||
<p className="mt-1 text-xs text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__('Billed annually at')}{' '}
|
||
{formatCurrency(plan.price * 100, currency, locale)}
|
||
</p>
|
||
)}
|
||
|
||
<p className="mt-3 text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
'Everything you need to manage your finances securely.',
|
||
)}
|
||
</p>
|
||
|
||
<div className="my-5 h-px bg-[#e3e3e0] dark:bg-[#3E3E3A]" />
|
||
|
||
<ul className="flex-1 space-y-2.5">
|
||
{plan.features.map((feature) => (
|
||
<li key={feature} className="flex items-center gap-2.5">
|
||
<CheckIcon className="size-4 shrink-0 text-[#1b1b18] dark:text-[#EDEDEC]" />
|
||
<span className="text-sm">{__(feature)}</span>
|
||
</li>
|
||
))}
|
||
</ul>
|
||
|
||
<Link href="/register" className="mt-8">
|
||
<Button
|
||
className={cn(
|
||
'w-full cursor-pointer py-5 text-base shadow-sm transition-all',
|
||
isDefault
|
||
? 'bg-gradient-to-t from-zinc-700 to-zinc-900 text-white hover:from-zinc-800 hover:to-black hover:shadow-md dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] hover:dark:from-zinc-50'
|
||
: 'border-[#e3e3e0] bg-transparent text-[#1b1b18] hover:bg-[#f5f5f4] dark:border-[#3E3E3A] dark:text-[#EDEDEC] dark:hover:bg-[#1f1f1e]',
|
||
)}
|
||
variant={isDefault ? 'default' : 'outline'}
|
||
>
|
||
{__('Get Started')}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function FaqItem({ question, answer }: { question: string; answer: string }) {
|
||
const [open, setOpen] = useState(false);
|
||
|
||
return (
|
||
<div className="border-b border-[#e3e3e0] dark:border-[#3E3E3A]">
|
||
<button
|
||
type="button"
|
||
className="flex w-full cursor-pointer items-center justify-between py-5 text-left"
|
||
onClick={() => setOpen(!open)}
|
||
>
|
||
<span className="text-base font-medium">{question}</span>
|
||
<ChevronDownIcon
|
||
className={cn(
|
||
'size-5 shrink-0 text-[#706f6c] transition-transform duration-200 dark:text-[#A1A09A]',
|
||
open && 'rotate-180',
|
||
)}
|
||
/>
|
||
</button>
|
||
<div
|
||
className={cn(
|
||
'grid transition-all duration-200',
|
||
open ? 'grid-rows-[1fr] pb-5' : 'grid-rows-[0fr]',
|
||
)}
|
||
>
|
||
<div className="overflow-hidden">
|
||
<p className="text-sm leading-relaxed text-[#706f6c] dark:text-[#A1A09A]">
|
||
{answer}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
);
|
||
}
|
||
|
||
function WaitlistForm() {
|
||
const [referrerCode, setReferrerCode] = useState('');
|
||
const { locale } = usePage<SharedData>().props;
|
||
|
||
useEffect(() => {
|
||
const params = new URLSearchParams(window.location.search);
|
||
const ref = params.get('ref');
|
||
if (ref) {
|
||
setReferrerCode(ref);
|
||
}
|
||
}, []);
|
||
|
||
return (
|
||
<Form
|
||
{...storeUserLead.form()}
|
||
className="flex w-full flex-col gap-3"
|
||
disableWhileProcessing
|
||
>
|
||
{({ processing, errors }) => (
|
||
<>
|
||
<input
|
||
type="hidden"
|
||
name="referrer_code"
|
||
value={referrerCode}
|
||
/>
|
||
<input type="hidden" name="locale" value={locale} />
|
||
<div className="flex w-full flex-col gap-1.5">
|
||
<div className="flex w-full flex-row gap-2">
|
||
<Input
|
||
type="email"
|
||
name="email"
|
||
required
|
||
autoComplete="email"
|
||
placeholder={__('Your email address')}
|
||
className="h-14 flex-1 text-base"
|
||
/>
|
||
<Button
|
||
type="submit"
|
||
className="text-shadow h-14 shrink-0 cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 px-6 text-base text-white shadow-sm transition-all duration-200 hover:from-zinc-800 hover:to-black hover:shadow-md dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:from-zinc-50"
|
||
>
|
||
{processing && <Spinner />}
|
||
{__('Join Waitlist')}
|
||
</Button>
|
||
</div>
|
||
<InputError message={errors.email} />
|
||
</div>
|
||
</>
|
||
)}
|
||
</Form>
|
||
);
|
||
}
|
||
|
||
export default function Welcome({
|
||
canRegister,
|
||
hideAuthButtons,
|
||
popularBanks,
|
||
}: {
|
||
canRegister?: boolean;
|
||
hideAuthButtons?: boolean;
|
||
popularBanks: PopularBank[];
|
||
}) {
|
||
const { appUrl, auth, subscriptionsEnabled, pricing, locale } =
|
||
usePage<SharedData>().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 (
|
||
<div className="flex h-screen items-center justify-center">
|
||
<Spinner className="size-8" />
|
||
</div>
|
||
);
|
||
}
|
||
|
||
return (
|
||
<>
|
||
<Head
|
||
title={__(
|
||
'Whisper Money - The Most Secure Personal Finance App',
|
||
)}
|
||
>
|
||
<meta
|
||
name="description"
|
||
content={__(
|
||
'The most secure privacy-first personal finance app. Track expenses, create budgets, and manage your money privately.',
|
||
)}
|
||
/>
|
||
|
||
<meta
|
||
name="keywords"
|
||
content={__(
|
||
'finance app, budgeting, expense tracking, secure finance, personal finance, money management, privacy, privacy-first finance app',
|
||
)}
|
||
/>
|
||
|
||
<link rel="canonical" href={appUrl} />
|
||
|
||
<meta property="og:site_name" content="Whisper Money" />
|
||
<meta
|
||
property="og:title"
|
||
content={__(
|
||
'Whisper Money - The Most Secure Personal Finance App',
|
||
)}
|
||
/>
|
||
|
||
<meta
|
||
property="og:description"
|
||
content={__(
|
||
'Your financial data stays private. The most secure way to manage your personal finances.',
|
||
)}
|
||
/>
|
||
|
||
<meta property="og:type" content="website" />
|
||
<meta property="og:url" content={appUrl} />
|
||
<meta
|
||
property="og:image"
|
||
content={`${appUrl}/images/og_whisper_money.png`}
|
||
/>
|
||
|
||
<meta property="og:image:width" content="1200" />
|
||
<meta property="og:image:height" content="630" />
|
||
<meta
|
||
property="og:image:alt"
|
||
content={__('Whisper Money - Secure Personal Finance App')}
|
||
/>
|
||
|
||
<meta property="og:locale" content={__('en_US')} />
|
||
|
||
<meta name="twitter:card" content={__('summary_large_image')} />
|
||
<meta
|
||
name="twitter:title"
|
||
content={__(
|
||
'Whisper Money - The Most Secure Personal Finance App',
|
||
)}
|
||
/>
|
||
|
||
<meta
|
||
name="twitter:description"
|
||
content={__(
|
||
'Your financial data stays private. The most secure way to manage your personal finances.',
|
||
)}
|
||
/>
|
||
|
||
<meta
|
||
name="twitter:image"
|
||
content={`${appUrl}/images/og_whisper_money.png`}
|
||
/>
|
||
|
||
<meta
|
||
name="twitter:image:alt"
|
||
content={__('Whisper Money - Secure Personal Finance App')}
|
||
/>
|
||
|
||
<script type="application/ld+json">
|
||
{JSON.stringify({
|
||
'@context': 'https://schema.org',
|
||
'@type': 'WebApplication',
|
||
name: 'Whisper Money',
|
||
description:
|
||
'The most secure privacy-first personal finance app. Track expenses, create budgets, and manage your money privately.',
|
||
url: appUrl,
|
||
applicationCategory: 'FinanceApplication',
|
||
featureList: [
|
||
'Privacy-first design',
|
||
'Smart budgeting',
|
||
'Expense tracking',
|
||
'Visual insights',
|
||
'Zero tracking',
|
||
'Open source',
|
||
],
|
||
})}
|
||
</script>
|
||
</Head>
|
||
<AuthenticatedRedirectDialog open={Boolean(auth.user)} />
|
||
<div className="flex min-h-screen flex-col bg-[#FDFDFC] text-[#1b1b18] dark:bg-[#0a0a0a] dark:text-[#EDEDEC]">
|
||
<Header
|
||
canRegister={canRegister}
|
||
hideAuthButtons={hideAuthButtons}
|
||
/>
|
||
|
||
<main className="flex flex-1 flex-col">
|
||
<section className="relative w-full overflow-hidden px-6 py-28 sm:py-32 md:py-40">
|
||
<div className="relative z-10 mx-auto flex max-w-7xl flex-col gap-12">
|
||
<div className="relative z-10 mx-auto flex w-full max-w-5xl flex-col items-start gap-6 sm:gap-8">
|
||
<span className="inline-flex items-center gap-2 rounded-full border border-[#e3e3e0] px-2.5 py-1 text-[0.8rem] font-medium dark:border-[#3E3E3A]">
|
||
<LockIcon className="size-3.5 opacity-75" />
|
||
<span className="text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__('Private & Secure')}
|
||
</span>
|
||
</span>
|
||
<h1 className="font-heading max-w-[840px] bg-gradient-to-r from-[#1b1b18] to-[#1b1b18] bg-clip-text text-4xl leading-tight font-semibold text-balance text-transparent drop-shadow-2xl sm:text-5xl sm:leading-tight lg:text-6xl lg:leading-tight dark:from-[#EDEDEC] dark:to-[#A1A09A]">
|
||
{__(
|
||
'All your money in one place. No spreadsheets. Private.',
|
||
)}
|
||
</h1>
|
||
<p className="mb-4 max-w-[840px] text-lg leading-8 font-medium text-balance text-[#706f6c] lg:text-xl lg:leading-8 dark:text-[#A1A09A]">
|
||
{__(
|
||
'Understand your finances and make better decisions without the friction. Track expenses, create budgets, and achieve your goals\u2014all in one place.',
|
||
)}
|
||
</p>
|
||
<div className="flex w-full max-w-lg flex-col gap-4">
|
||
{hideAuthButtons ? (
|
||
<WaitlistForm />
|
||
) : isMobile ? (
|
||
<InstallAppButton />
|
||
) : (
|
||
<div className="flex w-full flex-row gap-4">
|
||
<Link
|
||
href="/register"
|
||
className="w-full"
|
||
>
|
||
<Button className="text-shadow duration h-14 w-full cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:bg-[#eeeeec] dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] dark:hover:bg-white hover:dark:from-zinc-50 dark:hover:shadow-md">
|
||
{__('Get Started')}
|
||
</Button>
|
||
</Link>
|
||
<Link href="/login?demo=1">
|
||
<Button
|
||
variant={'secondary'}
|
||
size={'lg'}
|
||
className="h-14"
|
||
>
|
||
{__('Check Demo')}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
)}
|
||
<p className="text-xs text-[#706f6c] dark:text-[#A1A09A]">
|
||
{hideAuthButtons
|
||
? __(
|
||
"Join the waiting list. We'll let you know when you're in.",
|
||
)
|
||
: __(
|
||
'Your data stays private. Always.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
|
||
<div className="group relative sm:px-24">
|
||
{LANDING_IMAGES.map((image, index) => (
|
||
<div
|
||
key={image.key}
|
||
className={cn(
|
||
'relative z-10 transition-all delay-200 duration-700 ease-in-out',
|
||
'rotate-[-24deg] skew-y-12 group-hover:rotate-[-12deg] group-hover:skew-y-6',
|
||
'max-sm:rotate-[-16deg] max-sm:skew-y-8 max-sm:group-hover:rotate-[-8deg] max-sm:group-hover:skew-y-4',
|
||
index < LANDING_IMAGES.length - 1 &&
|
||
'h-[24px]',
|
||
image.className,
|
||
)}
|
||
>
|
||
<div className="relative z-10 overflow-hidden rounded-2xl border border-[#e3e3e0]/50 bg-[#FDFDFC]/50 p-2 shadow-2xl dark:border-[#3E3E3A]/10 dark:bg-[#161615]/50">
|
||
<div className="relative z-10 overflow-hidden rounded-md border border-[#e3e3e0]/70 shadow-2xl dark:border-[#3E3E3A]/5">
|
||
<div className="rounded-lg border-[#e3e3e0] bg-[#FDFDFC] dark:border-[#3E3E3A] dark:bg-[#0a0a0a]">
|
||
<img
|
||
src={image.light}
|
||
alt={image.alt}
|
||
className="w-full rounded-lg dark:hidden"
|
||
/>
|
||
|
||
<img
|
||
src={image.dark}
|
||
alt={image.alt}
|
||
className="hidden w-full rounded-lg dark:block"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
))}
|
||
|
||
<div
|
||
data-slot="glow"
|
||
className="animate-appear-zoom absolute top-[50%] mt-32 w-full opacity-0 delay-2000 lg:mt-4"
|
||
>
|
||
<div className="absolute left-1/2 h-[256px] w-[60%] -translate-x-1/2 -translate-y-1/2 scale-[2.5] rounded-[50%] bg-radial from-[#1b1b18]/50 from-10% to-[#1b1b18]/0 to-60% opacity-20 sm:h-[512px] dark:from-[#EDEDEC]/50 dark:to-[#EDEDEC]/0 dark:opacity-100"></div>
|
||
<div className="absolute left-1/2 h-[128px] w-[40%] -translate-x-1/2 -translate-y-1/2 scale-200 rounded-[50%] bg-radial from-[#1b1b18]/30 from-10% to-[#1b1b18]/0 to-60% opacity-20 sm:h-[256px] dark:from-[#EDEDEC]/30 dark:to-[#EDEDEC]/0 dark:opacity-100"></div>
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="grid gap-6 px-4 py-12 sm:py-16 md:py-20">
|
||
<div className="mx-auto grid max-w-7xl gap-3 sm:grid-cols-3">
|
||
{/* Row 1: Connect Your Banks (2 cols) + Import in Seconds (1 col) */}
|
||
<FeatureCard className="sm:col-span-2">
|
||
<div className="grid h-full grid-rows-1 gap-0 sm:grid-cols-2">
|
||
<div className="flex flex-col justify-center p-8 sm:p-12">
|
||
<h2 className="text-3xl leading-tight font-semibold text-balance sm:text-4xl sm:leading-tight">
|
||
{__('Connect Your Banks')}
|
||
</h2>
|
||
<p className="mt-4 text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
'Link your bank accounts directly. Transactions sync automatically, giving you a real-time view of your finances.',
|
||
)}
|
||
</p>
|
||
<ul className="mt-6 space-y-3">
|
||
<li className="flex items-center gap-2.5">
|
||
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
|
||
<span className="text-sm">
|
||
{__('Connect in seconds')}
|
||
</span>
|
||
</li>
|
||
<li className="flex items-center gap-2.5">
|
||
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
|
||
<span className="text-sm">
|
||
{__('Automatic sync')}
|
||
</span>
|
||
</li>
|
||
<li className="flex items-center gap-2.5">
|
||
<CheckIcon className="size-4 shrink-0 text-emerald-500" />
|
||
<span className="text-sm">
|
||
{__('Secure & encrypted')}
|
||
</span>
|
||
</li>
|
||
</ul>
|
||
</div>
|
||
<div className="relative min-h-[320px]">
|
||
<BankConnectionsPreview
|
||
banks={popularBanks}
|
||
className="absolute inset-2"
|
||
/>
|
||
</div>
|
||
</div>
|
||
</FeatureCard>
|
||
|
||
<FeatureCard>
|
||
<div className="p-2">
|
||
<ImportPreview
|
||
currency={pricing.currency}
|
||
locale={locale}
|
||
/>
|
||
</div>
|
||
<div className="p-6 pt-4">
|
||
<h3 className="text-xl font-semibold">
|
||
{__('Import in Seconds')}
|
||
</h3>
|
||
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
"Export a CSV or XLS from your bank and drag it in. A year's worth of transactions imported in under 10 seconds.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</FeatureCard>
|
||
|
||
{/* Row 2: All Your Accounts, Every Transaction, Your Data Your Rules */}
|
||
<FeatureCard>
|
||
<div className="p-2">
|
||
<AccountsBalancePreview
|
||
currency={pricing.currency}
|
||
locale={locale}
|
||
/>
|
||
</div>
|
||
<div className="p-6 pt-4">
|
||
<h3 className="text-xl font-semibold">
|
||
{__('All Your Accounts')}
|
||
</h3>
|
||
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
'See every account in one place. Track balances, monitor changes, and always know where you stand.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
</FeatureCard>
|
||
|
||
<FeatureCard>
|
||
<div className="p-2">
|
||
<TransactionRowsPreview
|
||
currency={pricing.currency}
|
||
locale={locale}
|
||
/>
|
||
</div>
|
||
<div className="p-6 pt-4">
|
||
<h3 className="text-xl font-semibold">
|
||
{__('Every Transaction')}
|
||
</h3>
|
||
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
'Search, filter, and categorize with ease. Understand exactly where your money goes.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
</FeatureCard>
|
||
|
||
<FeatureCard>
|
||
<div className="p-2">
|
||
<PrivacyRedactedPreview />
|
||
</div>
|
||
<div className="p-6 pt-4">
|
||
<h3 className="text-xl font-semibold">
|
||
{__('Your Data, Your Rules')}
|
||
</h3>
|
||
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
'No third-party sharing, no AI snooping. Your financial data belongs to you and only you.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
</FeatureCard>
|
||
|
||
{/* Row 3: Cashflow at a Glance (always full width) */}
|
||
<FeatureCard className="sm:col-span-3">
|
||
<div className="grid items-center gap-0 sm:grid-cols-2">
|
||
<div className="p-2">
|
||
<CashflowChartPreview />
|
||
</div>
|
||
<div className="p-8 sm:p-12">
|
||
<h2 className="text-3xl leading-tight font-semibold sm:text-4xl sm:leading-tight">
|
||
{__('Cashflow at a Glance')}
|
||
</h2>
|
||
<p className="mt-4 text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
'Visualize your money flow over time. See income vs. expenses and spot trends before they become problems.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
</div>
|
||
</FeatureCard>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="px-4 py-12 sm:py-16 md:py-20">
|
||
<div className="mx-auto flex max-w-7xl flex-col gap-8 sm:gap-12">
|
||
<div className="flex flex-col items-center gap-4 text-center">
|
||
<h2 className="max-w-[720px] text-3xl leading-tight font-semibold sm:text-5xl sm:leading-tight">
|
||
{__('Smart Budgets')}
|
||
</h2>
|
||
<p className="text-md max-w-[640px] font-medium text-[#706f6c] sm:text-xl dark:text-[#A1A09A]">
|
||
{__(
|
||
'Create budgets that adapt to your spending habits and help you reach your goals.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="grid gap-6 sm:grid-cols-3">
|
||
<FeatureCard>
|
||
<div className="p-2">
|
||
<BudgetsListPreview
|
||
currency={pricing.currency}
|
||
locale={locale}
|
||
/>
|
||
</div>
|
||
<div className="p-6 pt-4">
|
||
<h3 className="text-xl font-semibold">
|
||
{__('Set Your Goals')}
|
||
</h3>
|
||
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
'Define monthly budgets by category. Know exactly how much you can spend.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
</FeatureCard>
|
||
|
||
<FeatureCard>
|
||
<div className="p-2">
|
||
<BudgetDetailPreview
|
||
currency={pricing.currency}
|
||
locale={locale}
|
||
/>
|
||
</div>
|
||
<div className="p-6 pt-4">
|
||
<h3 className="text-xl font-semibold">
|
||
{__('Track Progress')}
|
||
</h3>
|
||
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
'See where you stand in real-time. Visual progress bars show spending vs. budget.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
</FeatureCard>
|
||
|
||
<FeatureCard>
|
||
<div className="p-2">
|
||
<BudgetEditPreview />
|
||
</div>
|
||
<div className="p-6 pt-4">
|
||
<h3 className="text-xl font-semibold">
|
||
{__('Stay on Track')}
|
||
</h3>
|
||
<p className="mt-2 text-sm text-[#706f6c] dark:text-[#A1A09A]">
|
||
{__(
|
||
"Get notified when you're close to your limit. Never overspend again.",
|
||
)}
|
||
</p>
|
||
</div>
|
||
</FeatureCard>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="w-full overflow-hidden px-0 py-12 sm:py-16 md:py-20">
|
||
<div className="mx-auto flex max-w-7xl flex-col items-center gap-8 px-4 text-center sm:gap-12">
|
||
<div className="flex flex-col items-center gap-4">
|
||
<h2 className="max-w-[720px] text-3xl leading-tight font-semibold text-balance sm:text-4xl sm:leading-tight">
|
||
{__(
|
||
'Trusted by people who value their privacy',
|
||
)}
|
||
</h2>
|
||
<p className="text-md max-w-[600px] font-medium text-[#706f6c] sm:text-lg dark:text-[#A1A09A]">
|
||
{__(
|
||
'Join thousands of users who have taken control of their finances without compromising their privacy.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="relative flex w-full flex-col gap-4">
|
||
{testimonialRows.map((row, rowIndex) => (
|
||
<div
|
||
key={rowIndex}
|
||
className={cn(
|
||
'group flex flex-row [gap:var(--gap)] overflow-hidden p-2 [--gap:1rem]',
|
||
rowIndex === 0
|
||
? '[--duration:34s]'
|
||
: '[--duration:27s]',
|
||
)}
|
||
>
|
||
{[0, 1].map((copy) => (
|
||
<div
|
||
key={copy}
|
||
aria-hidden={copy === 1}
|
||
className={cn(
|
||
'animate-marquee flex shrink-0 flex-row [gap:var(--gap)] group-hover:[animation-play-state:paused]',
|
||
rowIndex === 1 &&
|
||
'[animation-delay:-13s]',
|
||
)}
|
||
>
|
||
{row.map((testimonial) => (
|
||
<div
|
||
key={`${rowIndex}-${copy}-${testimonial.name}`}
|
||
className="flex w-[300px] shrink-0 flex-col rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] p-6 text-start shadow-sm sm:w-[360px] dark:border-[#3E3E3A] dark:bg-[#161615]"
|
||
>
|
||
<div className="flex items-center gap-3">
|
||
<Avatar className="size-10">
|
||
<AvatarImage
|
||
src={`https://www.gravatar.com/avatar/${testimonial.gravatar}?s=160&d=404`}
|
||
alt={
|
||
testimonial.name
|
||
}
|
||
loading="lazy"
|
||
className="object-cover"
|
||
/>
|
||
<AvatarFallback>
|
||
<Facehash
|
||
name={
|
||
testimonial.name
|
||
}
|
||
size={
|
||
40
|
||
}
|
||
colorClasses={
|
||
tailwindColorClasses
|
||
}
|
||
intensity3d="dramatic"
|
||
className="rounded-full"
|
||
enableBlink
|
||
/>
|
||
</AvatarFallback>
|
||
</Avatar>
|
||
<div className="flex flex-col items-start">
|
||
<h3 className="text-sm leading-none font-semibold">
|
||
{
|
||
testimonial.name
|
||
}
|
||
</h3>
|
||
</div>
|
||
</div>
|
||
<p className="mt-4 text-sm leading-relaxed text-[#706f6c] dark:text-[#A1A09A]">
|
||
{testimonial.text}
|
||
</p>
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
</div>
|
||
))}
|
||
|
||
<div className="pointer-events-none absolute inset-y-0 left-0 hidden w-1/6 bg-linear-to-r from-[#FDFDFC] sm:block dark:from-[#0a0a0a]"></div>
|
||
<div className="pointer-events-none absolute inset-y-0 right-0 hidden w-1/6 bg-linear-to-l from-[#FDFDFC] sm:block dark:from-[#0a0a0a]"></div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
{subscriptionsEnabled &&
|
||
!hideAuthButtons &&
|
||
planEntries.length > 0 && (
|
||
<section
|
||
id="pricing"
|
||
className="px-4 py-12 sm:py-16 md:py-20"
|
||
>
|
||
<div className="mx-auto flex max-w-5xl flex-col items-center gap-8 sm:gap-12">
|
||
<div className="flex flex-col items-center gap-4 text-center">
|
||
<h2 className="text-3xl leading-tight font-semibold sm:text-5xl sm:leading-tight">
|
||
{__('Simple, transparent pricing')}
|
||
</h2>
|
||
<p className="text-md max-w-[600px] font-medium text-[#706f6c] sm:text-lg dark:text-[#A1A09A]">
|
||
{__(
|
||
'Choose the plan that works for you. No hidden fees.',
|
||
)}
|
||
</p>
|
||
|
||
{hasMonthlyAndYearly && (
|
||
<div className="mt-2 flex items-center rounded-full border border-[#e3e3e0] p-1 dark:border-[#3E3E3A]">
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setBillingPeriod(
|
||
'month',
|
||
)
|
||
}
|
||
className={cn(
|
||
'rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
||
billingPeriod ===
|
||
'month'
|
||
? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
|
||
: 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]',
|
||
)}
|
||
>
|
||
{__('Monthly')}
|
||
</button>
|
||
<button
|
||
type="button"
|
||
onClick={() =>
|
||
setBillingPeriod('year')
|
||
}
|
||
className={cn(
|
||
'flex items-center gap-2 rounded-full px-4 py-1.5 text-sm font-medium transition-colors',
|
||
billingPeriod === 'year'
|
||
? 'bg-[#1b1b18] text-white dark:bg-[#EDEDEC] dark:text-[#1b1b18]'
|
||
: 'text-[#706f6c] hover:text-[#1b1b18] dark:text-[#A1A09A] dark:hover:text-[#EDEDEC]',
|
||
)}
|
||
>
|
||
{__('Yearly')}
|
||
{yearlyDiscount !==
|
||
null && (
|
||
<span
|
||
className={cn(
|
||
'rounded-full px-2 py-0.5 text-xs font-semibold',
|
||
billingPeriod ===
|
||
'year'
|
||
? 'bg-emerald-500/20 text-emerald-200 dark:bg-emerald-900/60 dark:text-emerald-300'
|
||
: 'bg-emerald-100 text-emerald-700 dark:bg-emerald-900/40 dark:text-emerald-400',
|
||
)}
|
||
>
|
||
{__(
|
||
'Save :percent%',
|
||
).replace(
|
||
':percent',
|
||
String(
|
||
yearlyDiscount,
|
||
),
|
||
)}
|
||
</span>
|
||
)}
|
||
</button>
|
||
</div>
|
||
)}
|
||
</div>
|
||
|
||
<div
|
||
className={cn(
|
||
'grid w-full gap-6',
|
||
displayedPlanEntries.length + 1 ===
|
||
1 && 'mx-auto max-w-md',
|
||
displayedPlanEntries.length + 1 ===
|
||
2 &&
|
||
'mx-auto max-w-3xl grid-cols-1 sm:grid-cols-2',
|
||
displayedPlanEntries.length + 1 >=
|
||
3 &&
|
||
'grid-cols-1 sm:grid-cols-2 lg:grid-cols-3',
|
||
)}
|
||
>
|
||
<FreePlanCard
|
||
features={[
|
||
...planEntries[0][1].features,
|
||
]}
|
||
/>
|
||
{displayedPlanEntries.map(
|
||
([key, plan]) => (
|
||
<LandingPlanCard
|
||
key={key}
|
||
plan={plan}
|
||
isDefault={
|
||
key ===
|
||
pricing.defaultPlan
|
||
}
|
||
isBestValue={
|
||
key ===
|
||
pricing.bestValuePlan
|
||
}
|
||
promoEnabled={
|
||
pricing.promo.enabled
|
||
}
|
||
promoBadge={
|
||
pricing.promo.badge
|
||
}
|
||
currency={pricing.currency}
|
||
locale={locale}
|
||
/>
|
||
),
|
||
)}
|
||
</div>
|
||
</div>
|
||
</section>
|
||
)}
|
||
|
||
<section className="px-4 py-12 sm:py-16 md:py-20">
|
||
<div className="mx-auto max-w-3xl">
|
||
<div className="mb-8 flex flex-col items-center gap-4 text-center sm:mb-12">
|
||
<h2 className="text-3xl leading-tight font-semibold sm:text-5xl sm:leading-tight">
|
||
{__('Frequently Asked Questions')}
|
||
</h2>
|
||
<p className="text-md max-w-[600px] font-medium text-[#706f6c] sm:text-lg dark:text-[#A1A09A]">
|
||
{__(
|
||
'Everything you need to know before getting started.',
|
||
)}
|
||
</p>
|
||
</div>
|
||
|
||
<div className="rounded-2xl border border-[#e3e3e0] bg-[#FDFDFC] px-6 dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||
<FaqItem
|
||
question={__(
|
||
'How is my financial data kept private?',
|
||
)}
|
||
answer={__(
|
||
"Your data is stored securely and never shared with third parties. We don't use your financial data for AI training, advertising, or any purpose other than providing you the service.",
|
||
)}
|
||
/>
|
||
<FaqItem
|
||
question={__(
|
||
'Can I connect my bank accounts directly?',
|
||
)}
|
||
answer={__(
|
||
'Yes. Bank connections use secure Open Banking and never require your bank credentials, so transactions can sync automatically. This is a Pro feature; free users can import everything, but only through the CSV/Excel importer.',
|
||
)}
|
||
/>
|
||
<FaqItem
|
||
question={__(
|
||
'Can I export or delete my data?',
|
||
)}
|
||
answer={__(
|
||
'Absolutely. You own your data. You can export all your financial data at any time, and you can permanently delete your account and all associated data whenever you want.',
|
||
)}
|
||
/>
|
||
<FaqItem
|
||
question={__(
|
||
'What file formats are supported for import?',
|
||
)}
|
||
answer={__(
|
||
'We support CSV and XLS files. Most banks allow you to export your transaction history in one of these formats. The import process automatically maps columns and categorizes transactions.',
|
||
)}
|
||
/>
|
||
<FaqItem
|
||
question={__(
|
||
'Is Whisper Money open source?',
|
||
)}
|
||
answer={__(
|
||
'Yes! Whisper Money is fully open source. You can review the code, suggest improvements, or even self-host it. Transparency is a core part of our privacy commitment.',
|
||
)}
|
||
/>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
|
||
<section className="px-4 py-12 sm:py-16 md:py-20">
|
||
<div className="mx-auto max-w-7xl">
|
||
<div className="flex flex-col items-center gap-6 px-6 py-12 text-center sm:px-12 sm:py-16 dark:border-[#3E3E3A] dark:bg-[#161615]">
|
||
<h2 className="max-w-[600px] text-3xl leading-tight font-semibold text-balance sm:text-4xl sm:leading-tight">
|
||
{__(
|
||
'Ready to take control of your finances?',
|
||
)}
|
||
</h2>
|
||
<p className="max-w-[480px] text-balance text-[#706f6c] sm:text-lg dark:text-[#A1A09A]">
|
||
{__(
|
||
'Start managing your money privately. No credit card required.',
|
||
)}
|
||
</p>
|
||
<div className="flex flex-col gap-3 sm:flex-row">
|
||
{hideAuthButtons ? (
|
||
<WaitlistForm />
|
||
) : isMobile ? (
|
||
<InstallAppButton />
|
||
) : (
|
||
<Link href="/register">
|
||
<Button className="h-12 cursor-pointer bg-gradient-to-t from-zinc-700 to-zinc-900 px-8 text-base text-white shadow-sm transition-all hover:from-zinc-800 hover:to-black hover:shadow-md dark:from-zinc-200 dark:to-zinc-300 dark:text-[#1C1C1A] hover:dark:from-zinc-50">
|
||
{__('Get Started for Free')}
|
||
</Button>
|
||
</Link>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</div>
|
||
</section>
|
||
</main>
|
||
|
||
<footer className="py-8 lg:mt-12 dark:border-[#3E3E3A]">
|
||
<div className="mx-auto flex max-w-5xl flex-col items-center justify-between gap-4 px-6 text-sm text-[#706f6c] sm:flex-row lg:px-8 dark:text-[#A1A09A]">
|
||
<p>
|
||
© {new Date().getFullYear()}
|
||
{__(
|
||
'Whisper Money. All\n rights reserved.',
|
||
)}
|
||
</p>
|
||
<div className="flex gap-6">
|
||
<Link
|
||
href="/privacy"
|
||
className="hover:text-[#1b1b18] dark:hover:text-[#EDEDEC]"
|
||
>
|
||
{__('Privacy Policy')}
|
||
</Link>
|
||
<Link
|
||
href="/terms"
|
||
className="hover:text-[#1b1b18] dark:hover:text-[#EDEDEC]"
|
||
>
|
||
{__('Terms of Service')}
|
||
</Link>
|
||
<a
|
||
href={`/?lang=${locale === 'es' ? 'en' : 'es'}`}
|
||
className="cursor-pointer hover:text-[#1b1b18] dark:hover:text-[#EDEDEC]"
|
||
>
|
||
{locale === 'es' ? 'English' : 'Español'}
|
||
</a>
|
||
</div>
|
||
</div>
|
||
</footer>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|