whisper-money/resources/js/pages/subscription/paywall.tsx

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>
</>
);
}