463 lines
15 KiB
TypeScript
463 lines
15 KiB
TypeScript
import { Button } from '@/components/ui/button';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { useCountUp } from '@/hooks/use-count-up';
|
|
import { useLocale } from '@/hooks/use-locale';
|
|
import { cn } from '@/lib/utils';
|
|
import { dashboard } from '@/routes';
|
|
import { index as connectionsIndex } from '@/routes/settings/connections';
|
|
import { checkout } from '@/routes/subscribe';
|
|
import { type SharedData } from '@/types';
|
|
import { Plan } from '@/types/pricing';
|
|
import { formatCurrency } from '@/utils/currency';
|
|
import { __ } from '@/utils/i18n';
|
|
import { Head, router, usePage } from '@inertiajs/react';
|
|
import {
|
|
CheckIcon,
|
|
FolderIcon,
|
|
LandmarkIcon,
|
|
LockIcon,
|
|
PiggyBankIcon,
|
|
ReceiptIcon,
|
|
TrendingUpIcon,
|
|
UsersIcon,
|
|
WalletIcon,
|
|
} from 'lucide-react';
|
|
import { useEffect, useState } from 'react';
|
|
|
|
interface PaywallStats {
|
|
accountsCount: number;
|
|
transactionsCount: number;
|
|
categoriesCount: number;
|
|
automationRulesCount: number;
|
|
balancesByCurrency: Record<string, number>;
|
|
}
|
|
|
|
interface PaywallPageProps extends SharedData {
|
|
stats: PaywallStats;
|
|
canUseFreePlan: boolean;
|
|
canManageConnectionsForFreePlan: boolean;
|
|
}
|
|
|
|
function getEquivalentBillingLabel(
|
|
billingPeriod: string | null,
|
|
t: typeof __,
|
|
): string {
|
|
if (!billingPeriod) {
|
|
return t('one-time');
|
|
}
|
|
|
|
return t('/month');
|
|
}
|
|
|
|
const socialProofs = [
|
|
{
|
|
icon: TrendingUpIcon,
|
|
highlightKey: '15% more savings',
|
|
textKey: 'after 3 months with Whisper Money',
|
|
},
|
|
{
|
|
icon: PiggyBankIcon,
|
|
highlightKey: '23% better',
|
|
textKey: 'spending awareness reported',
|
|
},
|
|
{
|
|
icon: LockIcon,
|
|
highlightKey: '100% private',
|
|
textKey: '- we never sell your data',
|
|
},
|
|
{
|
|
icon: UsersIcon,
|
|
highlightKey: '1,200+ users',
|
|
textKey: 'taking control of their finances',
|
|
},
|
|
];
|
|
|
|
function SocialProofSlider() {
|
|
const [currentIndex, setCurrentIndex] = useState(0);
|
|
|
|
useEffect(() => {
|
|
const interval = setInterval(() => {
|
|
setCurrentIndex((prev) => (prev + 1) % socialProofs.length);
|
|
}, 4000);
|
|
return () => clearInterval(interval);
|
|
}, []);
|
|
|
|
const currentProof = socialProofs[currentIndex];
|
|
const Icon = currentProof.icon;
|
|
|
|
return (
|
|
<div className="flex flex-col items-center gap-4">
|
|
<div
|
|
key={`icon-${currentIndex}`}
|
|
className="flex h-16 w-16 animate-in items-center justify-center rounded-full bg-emerald-100 duration-500 zoom-in-95 fade-in dark:bg-emerald-900/30"
|
|
>
|
|
<Icon className="h-8 w-8 text-emerald-600 dark:text-emerald-400" />
|
|
</div>
|
|
|
|
<div className="relative w-full overflow-hidden text-center">
|
|
<p
|
|
key={currentIndex}
|
|
className="animate-in text-lg text-balance duration-500 fade-in slide-in-from-right-4"
|
|
>
|
|
<span className="font-semibold text-emerald-600 dark:text-emerald-400">
|
|
{__(currentProof.highlightKey)}
|
|
</span>{' '}
|
|
<span className="text-muted-foreground">
|
|
{__(currentProof.textKey)}
|
|
</span>
|
|
</p>
|
|
</div>
|
|
|
|
<div className="flex gap-1.5">
|
|
{socialProofs.map((_, index) => (
|
|
<button
|
|
key={index}
|
|
onClick={() => setCurrentIndex(index)}
|
|
className={cn(
|
|
'h-1.5 rounded-full transition-all',
|
|
index === currentIndex
|
|
? 'w-4 bg-emerald-500'
|
|
: 'w-1.5 bg-muted-foreground/30 hover:bg-muted-foreground/50',
|
|
)}
|
|
aria-label={`Go to slide ${index + 1}`}
|
|
/>
|
|
))}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function StatItem({
|
|
icon: Icon,
|
|
value,
|
|
label,
|
|
delay = 0,
|
|
}: {
|
|
icon: React.ElementType;
|
|
value: number;
|
|
label: string;
|
|
delay?: number;
|
|
}) {
|
|
const animatedValue = useCountUp(value, { delay });
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col items-center gap-0.5">
|
|
<Icon className="mb-1.5 h-4 w-4 text-emerald-500" />
|
|
<span className="text-xl font-bold">{animatedValue}</span>
|
|
<span className="text-xs text-muted-foreground">{label}</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function BalanceDisplay({
|
|
balancesByCurrency,
|
|
}: {
|
|
balancesByCurrency: Record<string, number>;
|
|
}) {
|
|
const locale = useLocale();
|
|
const entries = Object.entries(balancesByCurrency);
|
|
|
|
if (entries.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<div className="flex flex-1 flex-col items-center gap-0.5">
|
|
<WalletIcon className="mb-1.5 h-4 w-4 text-emerald-500" />
|
|
<div className="flex flex-col items-center">
|
|
{entries.map(([currency, amount]) => (
|
|
<span key={currency} className="text-xl font-bold">
|
|
{formatCurrency(
|
|
Math.abs(amount),
|
|
currency,
|
|
locale,
|
|
0,
|
|
0,
|
|
)}
|
|
</span>
|
|
))}
|
|
</div>
|
|
<span className="text-xs text-muted-foreground">
|
|
{__('Balance')}
|
|
</span>
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function FinancialSnapshot({ stats }: { stats: PaywallStats }) {
|
|
const hasData =
|
|
stats.accountsCount > 0 ||
|
|
stats.transactionsCount > 0 ||
|
|
stats.categoriesCount > 0;
|
|
|
|
if (!hasData) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<Card className="animate-in duration-500 [animation-delay:200ms] fade-in">
|
|
<CardContent className="flex flex-row gap-6">
|
|
{stats.accountsCount > 0 && (
|
|
<StatItem
|
|
icon={PiggyBankIcon}
|
|
value={stats.accountsCount}
|
|
label={__('Accounts')}
|
|
delay={100}
|
|
/>
|
|
)}
|
|
{stats.transactionsCount > 0 && (
|
|
<StatItem
|
|
icon={ReceiptIcon}
|
|
value={stats.transactionsCount}
|
|
label={__('Transactions')}
|
|
delay={200}
|
|
/>
|
|
)}
|
|
{stats.categoriesCount > 0 && (
|
|
<StatItem
|
|
icon={FolderIcon}
|
|
value={stats.categoriesCount}
|
|
label={__('Categories')}
|
|
delay={300}
|
|
/>
|
|
)}
|
|
{Object.keys(stats.balancesByCurrency).length > 0 && (
|
|
<BalanceDisplay
|
|
balancesByCurrency={stats.balancesByCurrency}
|
|
/>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
}
|
|
|
|
function FeaturesSection({ features }: { features: string[] }) {
|
|
return (
|
|
<div className="flex flex-col gap-3">
|
|
<div className="flex items-center gap-3 rounded-lg border border-emerald-100/50 bg-emerald-50/25 px-4 py-3 dark:border-emerald-800/50 dark:bg-emerald-950/20">
|
|
<div className="flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-emerald-100 dark:bg-emerald-900/50">
|
|
<LandmarkIcon className="h-4 w-4 text-emerald-600 dark:text-emerald-400" />
|
|
</div>
|
|
<div className="flex-1">
|
|
<p className="text-sm font-semibold">
|
|
{__('Connected banks')}
|
|
</p>
|
|
<p className="text-xs text-balance text-muted-foreground">
|
|
{__(
|
|
'Sync transactions and balances automatically. Forget about manually importing CSVs from your bank.',
|
|
)}
|
|
</p>
|
|
</div>
|
|
<CheckIcon className="h-4 w-4 shrink-0 text-emerald-500" />
|
|
</div>
|
|
|
|
{features.length > 0 && (
|
|
<ul className="grid grid-cols-2 gap-x-4 gap-y-1.5 px-1">
|
|
{features.map((feature) => (
|
|
<li key={feature} className="flex items-center gap-1.5">
|
|
<CheckIcon className="size-3.5 shrink-0 text-emerald-500" />
|
|
<span className="text-xs text-muted-foreground">
|
|
{__(feature)}
|
|
</span>
|
|
</li>
|
|
))}
|
|
</ul>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
function CompactPlanCard({
|
|
plan,
|
|
isSelected,
|
|
onSelect,
|
|
currency,
|
|
}: {
|
|
plan: Plan;
|
|
isSelected: boolean;
|
|
onSelect: () => void;
|
|
currency: string;
|
|
}) {
|
|
const locale = useLocale();
|
|
const savingsPercent =
|
|
plan.original_price && plan.billing_period === 'year'
|
|
? Math.round(
|
|
((plan.original_price - plan.price) / plan.original_price) *
|
|
100,
|
|
)
|
|
: null;
|
|
const monthlyEquivalent =
|
|
plan.billing_period === 'year' ? plan.price / 12 : plan.price;
|
|
|
|
return (
|
|
<button
|
|
onClick={onSelect}
|
|
className={cn(
|
|
'flex flex-1 flex-col rounded-lg border p-3 text-left transition-all',
|
|
isSelected
|
|
? 'border-emerald-500 bg-emerald-50 ring-2 ring-emerald-500 dark:bg-emerald-950/30'
|
|
: 'border-border bg-card hover:border-muted-foreground/50',
|
|
)}
|
|
>
|
|
<div className="flex items-center gap-2">
|
|
<span className="text-xs font-medium tracking-wide text-muted-foreground uppercase">
|
|
{plan.billing_period === 'year'
|
|
? __('Annual')
|
|
: __('Monthly')}
|
|
</span>
|
|
{savingsPercent && savingsPercent > 0 && (
|
|
<span className="text-xs font-medium text-emerald-600 dark:text-emerald-400">
|
|
{__('Saving')} {savingsPercent}%
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="mt-1 flex items-baseline gap-1">
|
|
<span className="text-xl font-bold">
|
|
{formatCurrency(monthlyEquivalent * 100, currency, locale)}
|
|
</span>
|
|
<span className="text-sm text-muted-foreground">
|
|
{getEquivalentBillingLabel(plan.billing_period, __)}
|
|
</span>
|
|
</div>
|
|
{plan.billing_period === 'year' && (
|
|
<span className="mt-2 text-xs text-muted-foreground">
|
|
{__('Billed annually at')}{' '}
|
|
{formatCurrency(plan.price * 100, currency, locale)}
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
}
|
|
|
|
function PricingSection({
|
|
planEntries,
|
|
defaultPlan,
|
|
currency,
|
|
canUseFreePlan,
|
|
canManageConnectionsForFreePlan,
|
|
}: {
|
|
planEntries: [string, Plan][];
|
|
defaultPlan: string;
|
|
currency: string;
|
|
canUseFreePlan: boolean;
|
|
canManageConnectionsForFreePlan: boolean;
|
|
}) {
|
|
const [selectedPlan, setSelectedPlan] = useState(defaultPlan);
|
|
const [freeButtonVisible, setFreeButtonVisible] = useState(false);
|
|
|
|
const selectedPlanData = planEntries.find(
|
|
([key]) => key === selectedPlan,
|
|
)?.[1];
|
|
|
|
useEffect(() => {
|
|
if (!canUseFreePlan) {
|
|
return;
|
|
}
|
|
const timer = setTimeout(() => setFreeButtonVisible(true), 5000);
|
|
return () => clearTimeout(timer);
|
|
}, [canUseFreePlan]);
|
|
|
|
return (
|
|
<div className="flex flex-col gap-4">
|
|
{selectedPlanData && (
|
|
<FeaturesSection
|
|
features={selectedPlanData.features.filter(
|
|
(feature) => feature !== 'Connect bank accounts',
|
|
)}
|
|
/>
|
|
)}
|
|
|
|
<div className="flex gap-3">
|
|
{planEntries.map(([key, plan]) => (
|
|
<CompactPlanCard
|
|
key={key}
|
|
plan={plan}
|
|
isSelected={key === selectedPlan}
|
|
onSelect={() => setSelectedPlan(key)}
|
|
currency={currency}
|
|
/>
|
|
))}
|
|
</div>
|
|
|
|
<a href={checkout.url({ query: { plan: selectedPlan } })}>
|
|
<Button
|
|
className="w-full bg-emerald-600 py-6 hover:bg-emerald-700 dark:bg-emerald-600 dark:hover:bg-emerald-700"
|
|
size="lg"
|
|
>
|
|
{__('Start My Financial Journey')}
|
|
</Button>
|
|
</a>
|
|
|
|
{canManageConnectionsForFreePlan && (
|
|
<div className="rounded-lg border bg-muted/30 p-3 text-center">
|
|
<p className="mb-3 text-sm text-muted-foreground">
|
|
{__(
|
|
'Want to continue for free? Disconnect all bank connections in Settings.',
|
|
)}
|
|
</p>
|
|
<Button
|
|
variant="outline"
|
|
className="w-full"
|
|
onClick={() => router.visit(connectionsIndex().url)}
|
|
>
|
|
{__('Go to Settings')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
|
|
{canUseFreePlan && (
|
|
<div
|
|
className={cn(
|
|
'transition-opacity duration-1000',
|
|
freeButtonVisible
|
|
? 'opacity-100'
|
|
: 'pointer-events-none opacity-0',
|
|
)}
|
|
>
|
|
<Button
|
|
variant="ghost"
|
|
className="w-full"
|
|
onClick={() => router.visit(dashboard().url)}
|
|
>
|
|
{__('Continue for free')}
|
|
</Button>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default function Paywall() {
|
|
const { pricing, stats, canUseFreePlan, canManageConnectionsForFreePlan } =
|
|
usePage<PaywallPageProps>().props;
|
|
const planEntries = Object.entries(pricing.plans);
|
|
|
|
if (planEntries.length === 0) {
|
|
return null;
|
|
}
|
|
|
|
return (
|
|
<>
|
|
<Head title={__('Start Your Financial Journey')} />
|
|
|
|
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4 py-8">
|
|
<div className="flex w-full max-w-md flex-col gap-6">
|
|
<SocialProofSlider />
|
|
|
|
<FinancialSnapshot stats={stats} />
|
|
|
|
<PricingSection
|
|
planEntries={planEntries}
|
|
defaultPlan={pricing.defaultPlan}
|
|
currency={pricing.currency}
|
|
canUseFreePlan={canUseFreePlan}
|
|
canManageConnectionsForFreePlan={
|
|
canManageConnectionsForFreePlan
|
|
}
|
|
/>
|
|
</div>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|