- Add BigInt.prototype.toJSON polyfill in main.ts so Express can serialize Prisma BigInt fields (priceVND, revenue amounts) - Fix: admin/moderation and admin/revenue returning 500 Internal Error - Fix pricing compare table: Enterprise column text invisible in dark mode (bg-green-50 without dark variant → add dark:bg-green-950/40) Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
616 lines
22 KiB
TypeScript
616 lines
22 KiB
TypeScript
'use client';
|
|
|
|
import { Check, Crown, Rocket, Shield, X, Zap } from 'lucide-react';
|
|
import { useTranslations } from 'next-intl';
|
|
import { useState } from 'react';
|
|
import { CheckoutModal } from '@/components/subscription/checkout-modal';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import {
|
|
Card,
|
|
CardContent,
|
|
CardDescription,
|
|
CardHeader,
|
|
CardTitle,
|
|
} from '@/components/ui/card';
|
|
import { Link } from '@/i18n/navigation';
|
|
import { useAuthStore } from '@/lib/auth-store';
|
|
import { formatVND } from '@/lib/currency';
|
|
import { usePlans, useBillingHistory } from '@/lib/hooks/use-subscription';
|
|
import type { PlanDto } from '@/lib/subscription-api';
|
|
import { cn } from '@/lib/utils';
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Constants
|
|
// ---------------------------------------------------------------------------
|
|
|
|
const PLAN_TIER_ORDER = ['FREE', 'AGENT_PRO', 'INVESTOR', 'ENTERPRISE'];
|
|
|
|
const TIER_ICONS: Record<string, React.ReactNode> = {
|
|
FREE: <Zap className="h-6 w-6" />,
|
|
AGENT_PRO: <Rocket className="h-6 w-6" />,
|
|
INVESTOR: <Shield className="h-6 w-6" />,
|
|
ENTERPRISE: <Crown className="h-6 w-6" />,
|
|
};
|
|
|
|
const TIER_COLORS: Record<string, string> = {
|
|
FREE: 'text-muted-foreground',
|
|
AGENT_PRO: 'text-blue-600',
|
|
INVESTOR: 'text-purple-600',
|
|
ENTERPRISE: 'text-amber-600',
|
|
};
|
|
|
|
/** Fallback data when API is unavailable */
|
|
const FALLBACK_PLANS: PlanDto[] = [
|
|
{
|
|
id: 'fallback-free',
|
|
tier: 'FREE',
|
|
name: 'Miễn phí',
|
|
priceMonthlyVND: '0',
|
|
priceYearlyVND: '0',
|
|
maxListings: 3,
|
|
maxSavedSearches: 5,
|
|
features: {
|
|
basicSearch: true,
|
|
listingPost: true,
|
|
maxPhotos: 5,
|
|
analytics: false,
|
|
prioritySupport: false,
|
|
aiValuation: false,
|
|
featuredListing: false,
|
|
},
|
|
isActive: true,
|
|
},
|
|
{
|
|
id: 'fallback-agent',
|
|
tier: 'AGENT_PRO',
|
|
name: 'Agent Pro',
|
|
priceMonthlyVND: '499000',
|
|
priceYearlyVND: '4990000',
|
|
maxListings: 50,
|
|
maxSavedSearches: 30,
|
|
features: {
|
|
basicSearch: true,
|
|
listingPost: true,
|
|
maxPhotos: 30,
|
|
analytics: true,
|
|
prioritySupport: true,
|
|
aiValuation: true,
|
|
featuredListing: true,
|
|
leadManagement: true,
|
|
agentProfile: true,
|
|
},
|
|
isActive: true,
|
|
},
|
|
{
|
|
id: 'fallback-investor',
|
|
tier: 'INVESTOR',
|
|
name: 'Investor',
|
|
priceMonthlyVND: '999000',
|
|
priceYearlyVND: '9990000',
|
|
maxListings: 20,
|
|
maxSavedSearches: 100,
|
|
features: {
|
|
basicSearch: true,
|
|
listingPost: true,
|
|
maxPhotos: 15,
|
|
analytics: true,
|
|
prioritySupport: true,
|
|
aiValuation: true,
|
|
featuredListing: false,
|
|
marketReports: true,
|
|
priceAlerts: true,
|
|
portfolioTracking: true,
|
|
},
|
|
isActive: true,
|
|
},
|
|
{
|
|
id: 'fallback-enterprise',
|
|
tier: 'ENTERPRISE',
|
|
name: 'Enterprise',
|
|
priceMonthlyVND: '4990000',
|
|
priceYearlyVND: '49900000',
|
|
maxListings: -1,
|
|
maxSavedSearches: -1,
|
|
features: {
|
|
basicSearch: true,
|
|
listingPost: true,
|
|
maxPhotos: 100,
|
|
analytics: true,
|
|
prioritySupport: true,
|
|
aiValuation: true,
|
|
featuredListing: true,
|
|
leadManagement: true,
|
|
agentProfile: true,
|
|
marketReports: true,
|
|
priceAlerts: true,
|
|
portfolioTracking: true,
|
|
apiAccess: true,
|
|
whiteLabel: true,
|
|
dedicatedSupport: true,
|
|
},
|
|
isActive: true,
|
|
},
|
|
];
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Helpers
|
|
// ---------------------------------------------------------------------------
|
|
|
|
// Feature labels mapped for the comparison table
|
|
const FEATURE_LABELS: Record<string, string> = {
|
|
maxListings: 'Tin đăng',
|
|
maxSavedSearches: 'Tìm kiếm đã lưu',
|
|
maxPhotos: 'Ảnh/tin đăng',
|
|
analytics: 'Phân tích thị trường',
|
|
prioritySupport: 'Hỗ trợ ưu tiên',
|
|
aiValuation: 'Định giá AI',
|
|
featuredListing: 'Tin đăng nổi bật',
|
|
leadManagement: 'Quản lý khách hàng',
|
|
agentProfile: 'Hồ sơ môi giới',
|
|
marketReports: 'Báo cáo thị trường',
|
|
priceAlerts: 'Cảnh báo giá',
|
|
portfolioTracking: 'Theo dõi danh mục',
|
|
apiAccess: 'Truy cập API',
|
|
whiteLabel: 'Giao diện riêng',
|
|
dedicatedSupport: 'Hỗ trợ chuyên biệt',
|
|
};
|
|
|
|
const COMPARISON_FEATURES = [
|
|
'maxListings',
|
|
'maxSavedSearches',
|
|
'maxPhotos',
|
|
'analytics',
|
|
'aiValuation',
|
|
'featuredListing',
|
|
'prioritySupport',
|
|
'leadManagement',
|
|
'agentProfile',
|
|
'marketReports',
|
|
'priceAlerts',
|
|
'portfolioTracking',
|
|
'apiAccess',
|
|
'whiteLabel',
|
|
'dedicatedSupport',
|
|
];
|
|
|
|
function getFeatureValue(
|
|
plan: PlanDto,
|
|
key: string,
|
|
): boolean | number | string {
|
|
if (key === 'maxListings') {
|
|
return plan.maxListings === -1 ? 'Không giới hạn' : plan.maxListings;
|
|
}
|
|
if (key === 'maxSavedSearches') {
|
|
return plan.maxSavedSearches === -1
|
|
? 'Không giới hạn'
|
|
: plan.maxSavedSearches;
|
|
}
|
|
if (key === 'maxPhotos') {
|
|
return plan.features['maxPhotos'] ?? false;
|
|
}
|
|
return plan.features[key] ?? false;
|
|
}
|
|
|
|
// ---------------------------------------------------------------------------
|
|
// Component
|
|
// ---------------------------------------------------------------------------
|
|
|
|
export default function PricingPage() {
|
|
const t = useTranslations('pricing');
|
|
const { data: plansData, isLoading, error } = usePlans();
|
|
const { isAuthenticated } = useAuthStore();
|
|
const { data: billing } = useBillingHistory();
|
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>(
|
|
'monthly',
|
|
);
|
|
|
|
// Checkout modal state
|
|
const [checkoutPlan, setCheckoutPlan] = useState<PlanDto | null>(null);
|
|
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
|
|
|
const plans = (plansData ?? (error ? FALLBACK_PLANS : []))
|
|
.slice()
|
|
.sort(
|
|
(a, b) =>
|
|
PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier),
|
|
);
|
|
|
|
// Current subscription info for logged-in users
|
|
const currentTier = billing?.subscription?.planTier ?? 'FREE';
|
|
const currentTierIndex = PLAN_TIER_ORDER.indexOf(currentTier);
|
|
|
|
const handleSelectPlan = (plan: PlanDto) => {
|
|
if (plan.tier === 'ENTERPRISE') return; // Enterprise uses contact form
|
|
if (plan.tier === 'FREE') return; // Free doesn't need payment
|
|
|
|
if (!isAuthenticated) {
|
|
// Redirect to register with plan context
|
|
return;
|
|
}
|
|
|
|
setCheckoutPlan(plan);
|
|
setCheckoutOpen(true);
|
|
};
|
|
|
|
const getPlanCta = (plan: PlanDto) => {
|
|
const tierIndex = PLAN_TIER_ORDER.indexOf(plan.tier);
|
|
|
|
if (plan.tier === 'FREE') {
|
|
if (isAuthenticated && currentTier === 'FREE') {
|
|
return { label: t('ctaCurrentPlan'), disabled: true, variant: 'outline' as const };
|
|
}
|
|
return { label: t('ctaFree'), disabled: false, variant: 'outline' as const };
|
|
}
|
|
|
|
if (plan.tier === 'ENTERPRISE') {
|
|
return { label: t('ctaEnterprise'), disabled: false, variant: 'outline' as const };
|
|
}
|
|
|
|
if (isAuthenticated) {
|
|
if (plan.tier === currentTier) {
|
|
return { label: t('ctaCurrentPlan'), disabled: true, variant: 'outline' as const };
|
|
}
|
|
if (tierIndex > currentTierIndex) {
|
|
return { label: t('ctaUpgrade'), disabled: false, variant: 'default' as const };
|
|
}
|
|
// Downgrade not supported from pricing page
|
|
return { label: t('ctaDowngrade'), disabled: true, variant: 'outline' as const };
|
|
}
|
|
|
|
return { label: t('ctaUpgrade'), disabled: false, variant: 'default' as const };
|
|
};
|
|
|
|
return (
|
|
<div className="bg-background">
|
|
{/* Hero section */}
|
|
<section className="relative overflow-hidden border-b bg-gradient-to-b from-primary/5 to-background pb-16 pt-16 sm:pt-24">
|
|
<div className="mx-auto max-w-7xl px-4 text-center">
|
|
<Badge variant="secondary" className="mb-4">
|
|
{t('badge')}
|
|
</Badge>
|
|
<h1 className="text-3xl font-bold tracking-tight sm:text-4xl lg:text-5xl">
|
|
{t('title')}
|
|
</h1>
|
|
<p className="mx-auto mt-4 max-w-2xl text-lg text-muted-foreground">
|
|
{t('subtitle')}
|
|
</p>
|
|
|
|
{/* Current plan indicator for logged-in users */}
|
|
{isAuthenticated && billing?.subscription && (
|
|
<div className="mx-auto mt-4 max-w-md">
|
|
<Badge variant="default" className="px-3 py-1 text-sm">
|
|
{t('currentPlanBadge', {
|
|
plan: t(`tiers.${currentTier}`),
|
|
})}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
|
|
{/* Billing cycle toggle */}
|
|
<div className="mt-8 flex items-center justify-center gap-3">
|
|
<Button
|
|
variant={billingCycle === 'monthly' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setBillingCycle('monthly')}
|
|
>
|
|
{t('monthly')}
|
|
</Button>
|
|
<Button
|
|
variant={billingCycle === 'yearly' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setBillingCycle('yearly')}
|
|
>
|
|
{t('yearly')}
|
|
<Badge variant="secondary" className="ml-2">
|
|
{t('yearlyDiscount')}
|
|
</Badge>
|
|
</Button>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Pricing cards */}
|
|
<section className="mx-auto -mt-10 max-w-7xl px-4 pb-16">
|
|
{isLoading ? (
|
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
|
{t('loading')}
|
|
</div>
|
|
) : (
|
|
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
|
|
{plans.map((plan) => {
|
|
const isPopular = plan.tier === 'AGENT_PRO';
|
|
const isCurrent = isAuthenticated && plan.tier === currentTier;
|
|
const price =
|
|
billingCycle === 'monthly'
|
|
? plan.priceMonthlyVND
|
|
: plan.priceYearlyVND;
|
|
|
|
const cta = getPlanCta(plan);
|
|
|
|
return (
|
|
<Card
|
|
key={plan.id}
|
|
className={cn(
|
|
'relative flex flex-col transition-shadow hover:shadow-lg',
|
|
isPopular && 'border-primary shadow-md ring-1 ring-primary',
|
|
isCurrent && !isPopular && 'border-green-500 ring-1 ring-green-500',
|
|
)}
|
|
>
|
|
{isPopular && (
|
|
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
|
|
<Badge className="bg-primary text-primary-foreground">
|
|
{t('popular')}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
{isCurrent && (
|
|
<div className="absolute -top-3 right-4">
|
|
<Badge className="bg-green-600 text-white">
|
|
{t('currentPlan')}
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
<CardHeader className="pb-2">
|
|
<div
|
|
className={cn(
|
|
'mb-2 flex items-center gap-2',
|
|
TIER_COLORS[plan.tier],
|
|
)}
|
|
>
|
|
{TIER_ICONS[plan.tier]}
|
|
<CardTitle className="text-lg">
|
|
{t(`tiers.${plan.tier}`)}
|
|
</CardTitle>
|
|
</div>
|
|
<CardDescription className="text-sm">
|
|
{t(`tierDescriptions.${plan.tier}`)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="flex flex-1 flex-col">
|
|
{/* Price */}
|
|
<div className="mb-6">
|
|
<span className="text-3xl font-bold text-foreground">
|
|
{formatVND(price)}
|
|
</span>
|
|
{Number(price) > 0 && (
|
|
<span className="text-sm text-muted-foreground">
|
|
/{billingCycle === 'monthly' ? t('perMonth') : t('perYear')}
|
|
</span>
|
|
)}
|
|
</div>
|
|
|
|
{/* Key features list */}
|
|
<ul className="mb-6 flex-1 space-y-2.5 text-sm">
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>
|
|
{plan.maxListings === -1
|
|
? t('unlimited')
|
|
: `${plan.maxListings} ${t('listingsCount')}`}
|
|
</span>
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>
|
|
{plan.maxSavedSearches === -1
|
|
? t('unlimited')
|
|
: `${plan.maxSavedSearches} ${t('savedSearchesCount')}`}
|
|
</span>
|
|
</li>
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>{plan.features['maxPhotos']} {t('photosPerListing')}</span>
|
|
</li>
|
|
{plan.features['analytics'] && (
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>{t('features.analytics')}</span>
|
|
</li>
|
|
)}
|
|
{plan.features['aiValuation'] && (
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>{t('features.aiValuation')}</span>
|
|
</li>
|
|
)}
|
|
{plan.features['prioritySupport'] && (
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>{t('features.prioritySupport')}</span>
|
|
</li>
|
|
)}
|
|
{plan.features['featuredListing'] && (
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>{t('features.featuredListing')}</span>
|
|
</li>
|
|
)}
|
|
{plan.features['leadManagement'] && (
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>{t('features.leadManagement')}</span>
|
|
</li>
|
|
)}
|
|
{plan.features['marketReports'] && (
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>{t('features.marketReports')}</span>
|
|
</li>
|
|
)}
|
|
{plan.features['portfolioTracking'] && (
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>{t('features.portfolioTracking')}</span>
|
|
</li>
|
|
)}
|
|
{plan.features['apiAccess'] && (
|
|
<li className="flex items-center gap-2">
|
|
<Check className="h-4 w-4 shrink-0 text-green-600" />
|
|
<span>{t('features.apiAccess')}</span>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
|
|
{/* CTA */}
|
|
{plan.tier === 'FREE' && !isAuthenticated ? (
|
|
<Link href={'/register' as const} className="w-full">
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
size="lg"
|
|
>
|
|
{t('ctaFree')}
|
|
</Button>
|
|
</Link>
|
|
) : plan.tier === 'ENTERPRISE' ? (
|
|
<Link href={'/' as const} className="w-full">
|
|
<Button
|
|
className="w-full"
|
|
variant="outline"
|
|
size="lg"
|
|
>
|
|
{t('ctaEnterprise')}
|
|
</Button>
|
|
</Link>
|
|
) : !isAuthenticated ? (
|
|
<Link href={'/register' as const} className="w-full">
|
|
<Button
|
|
className="w-full"
|
|
variant={isPopular ? 'default' : 'outline'}
|
|
size="lg"
|
|
>
|
|
{t('ctaUpgrade')}
|
|
</Button>
|
|
</Link>
|
|
) : (
|
|
<Button
|
|
className="w-full"
|
|
variant={cta.variant}
|
|
size="lg"
|
|
disabled={cta.disabled}
|
|
onClick={() => handleSelectPlan(plan)}
|
|
>
|
|
{cta.label}
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</section>
|
|
|
|
{/* Feature comparison table */}
|
|
<section className="border-t bg-muted/30 py-16">
|
|
<div className="mx-auto max-w-7xl px-4">
|
|
<h2 className="mb-2 text-center text-2xl font-bold">
|
|
{t('comparisonTitle')}
|
|
</h2>
|
|
<p className="mb-8 text-center text-muted-foreground">
|
|
{t('comparisonSubtitle')}
|
|
</p>
|
|
|
|
<div className="overflow-x-auto">
|
|
<table className="w-full min-w-[640px] border-collapse">
|
|
<thead>
|
|
<tr className="border-b">
|
|
<th className="px-4 py-3 text-left text-sm font-semibold">
|
|
{t('feature')}
|
|
</th>
|
|
{plans.map((plan) => (
|
|
<th
|
|
key={plan.id}
|
|
className={cn(
|
|
'px-4 py-3 text-center text-sm font-semibold',
|
|
plan.tier === 'AGENT_PRO' && 'bg-primary/5',
|
|
isAuthenticated && plan.tier === currentTier && 'bg-green-50 dark:bg-green-950/40',
|
|
)}
|
|
>
|
|
<span>{t(`tiers.${plan.tier}`)}</span>
|
|
{isAuthenticated && plan.tier === currentTier && (
|
|
<Badge variant="secondary" className="ml-1 text-[10px]">
|
|
{t('currentPlan')}
|
|
</Badge>
|
|
)}
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{COMPARISON_FEATURES.map((featureKey) => (
|
|
<tr key={featureKey} className="border-b last:border-0">
|
|
<td className="px-4 py-3 text-sm text-muted-foreground">
|
|
{FEATURE_LABELS[featureKey]}
|
|
</td>
|
|
{plans.map((plan) => {
|
|
const val = getFeatureValue(plan, featureKey);
|
|
return (
|
|
<td
|
|
key={plan.id}
|
|
className={cn(
|
|
'px-4 py-3 text-center text-sm',
|
|
plan.tier === 'AGENT_PRO' && 'bg-primary/5',
|
|
isAuthenticated && plan.tier === currentTier && 'bg-green-50 dark:bg-green-950/40',
|
|
)}
|
|
>
|
|
{typeof val === 'boolean' ? (
|
|
val ? (
|
|
<Check className="mx-auto h-4 w-4 text-green-600" />
|
|
) : (
|
|
<X className="mx-auto h-4 w-4 text-muted-foreground/40" />
|
|
)
|
|
) : (
|
|
<span className="font-medium">{String(val)}</span>
|
|
)}
|
|
</td>
|
|
);
|
|
})}
|
|
</tr>
|
|
))}
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* FAQ / CTA section */}
|
|
<section className="py-16">
|
|
<div className="mx-auto max-w-3xl px-4 text-center">
|
|
<h2 className="text-2xl font-bold">{t('ctaTitle')}</h2>
|
|
<p className="mt-3 text-muted-foreground">
|
|
{t('ctaDescription')}
|
|
</p>
|
|
<div className="mt-6 flex flex-wrap items-center justify-center gap-3">
|
|
{isAuthenticated ? (
|
|
<Link href={'/dashboard/subscription' as const}>
|
|
<Button size="lg">{t('ctaManageSubscription')}</Button>
|
|
</Link>
|
|
) : (
|
|
<Link href={'/register' as const}>
|
|
<Button size="lg">{t('ctaRegister')}</Button>
|
|
</Link>
|
|
)}
|
|
<Link href={'/' as const}>
|
|
<Button variant="outline" size="lg">
|
|
{t('ctaLearnMore')}
|
|
</Button>
|
|
</Link>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
|
|
{/* Checkout modal */}
|
|
<CheckoutModal
|
|
open={checkoutOpen}
|
|
onOpenChange={setCheckoutOpen}
|
|
plan={checkoutPlan}
|
|
billingCycle={billingCycle}
|
|
isUpgrade={isAuthenticated && currentTierIndex > 0}
|
|
currentTier={currentTier}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|