Files
goodgo-platform/apps/web/app/[locale]/(public)/pricing/page.tsx
Ho Ngoc Hai 55a01c5738 feat(web): centralise Vietnamese price formatting across all pages
Create a single `currency.ts` utility with `formatPrice`, `formatVND`,
`formatPricePerM2`, and `parseVND` to replace 9+ duplicated inline
formatters. This fixes inconsistent decimal handling (1.5M was truncated
to "1 triệu") and standardises price/m² display. Integrated across
property cards, listing detail, dashboard, analytics, payments, pricing,
and admin moderation pages with 19 new unit tests.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-10 23:33:31 +07:00

490 lines
17 KiB
TypeScript

'use client';
import { Check, Crown, Rocket, Shield, X, Zap } from 'lucide-react';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
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 { formatVND } from '@/lib/currency';
import { usePlans } 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 [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>(
'monthly',
);
const plans = (plansData ?? (error ? FALLBACK_PLANS : []))
.slice()
.sort(
(a, b) =>
PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier),
);
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>
{/* 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 price =
billingCycle === 'monthly'
? plan.priceMonthlyVND
: plan.priceYearlyVND;
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',
)}
>
{isPopular && (
<div className="absolute -top-3 left-1/2 -translate-x-1/2">
<Badge className="bg-primary text-primary-foreground">
{t('popular')}
</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 */}
<Link href={'/register' as const} className="w-full">
<Button
className="w-full"
variant={isPopular ? 'default' : 'outline'}
size="lg"
>
{plan.tier === 'FREE'
? t('ctaFree')
: plan.tier === 'ENTERPRISE'
? t('ctaEnterprise')
: t('ctaUpgrade')}
</Button>
</Link>
</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',
)}
>
{t(`tiers.${plan.tier}`)}
</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',
)}
>
{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">
<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>
</div>
);
}