feat(web): add /pricing page with subscription tier comparison
Complete public pricing page showing all 4 subscription plans (FREE, AGENT_PRO, INVESTOR, ENTERPRISE) with billing cycle toggle, feature comparison table, VND formatting, and Vietnamese/English i18n support. Also adds pricing link to public navigation header and footer. Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
@@ -1,7 +1,9 @@
|
||||
'use client';
|
||||
|
||||
import { Menu, X } from 'lucide-react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
import { useTranslations } from 'next-intl';
|
||||
import { useState } from 'react';
|
||||
import { Button } from '@/components/ui/button';
|
||||
import { LanguageSwitcher } from '@/components/ui/language-switcher';
|
||||
import { Link } from '@/i18n/navigation';
|
||||
@@ -12,6 +14,25 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
const pathname = usePathname();
|
||||
const { user } = useAuthStore();
|
||||
const t = useTranslations();
|
||||
const [mobileMenuOpen, setMobileMenuOpen] = useState(false);
|
||||
|
||||
const navLinks = [
|
||||
{
|
||||
href: '/' as const,
|
||||
label: t('nav.home'),
|
||||
isActive: pathname === '/' || !!pathname.match(/^\/(vi|en)\/?$/),
|
||||
},
|
||||
{
|
||||
href: '/search' as const,
|
||||
label: t('nav.search'),
|
||||
isActive: pathname.includes('/search'),
|
||||
},
|
||||
{
|
||||
href: '/pricing' as const,
|
||||
label: t('nav.pricing'),
|
||||
isActive: pathname.includes('/pricing'),
|
||||
},
|
||||
];
|
||||
|
||||
return (
|
||||
<div className="min-h-screen bg-background">
|
||||
@@ -24,29 +45,22 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
|
||||
</Link>
|
||||
|
||||
<nav aria-label={t('nav.mainNav')} className="flex items-center space-x-1">
|
||||
<Link
|
||||
href="/"
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname === '/' || pathname.match(/^\/(vi|en)\/?$/)
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{t('nav.home')}
|
||||
</Link>
|
||||
<Link
|
||||
href="/search"
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
pathname.includes('/search')
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{t('nav.search')}
|
||||
</Link>
|
||||
{/* Desktop nav */}
|
||||
<nav aria-label={t('nav.mainNav')} className="hidden items-center space-x-1 sm:flex">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
className={cn(
|
||||
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
|
||||
link.isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground',
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
|
||||
<div className="ml-auto flex items-center space-x-2">
|
||||
@@ -62,18 +76,73 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
</>
|
||||
) : (
|
||||
<>
|
||||
<Link href="/login">
|
||||
<Link href="/login" className="hidden sm:inline-flex">
|
||||
<Button variant="ghost" size="sm">
|
||||
{t('common.login')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/register">
|
||||
<Link href="/register" className="hidden sm:inline-flex">
|
||||
<Button size="sm">{t('common.register')}</Button>
|
||||
</Link>
|
||||
</>
|
||||
)}
|
||||
{/* Mobile menu toggle */}
|
||||
<button
|
||||
aria-label={mobileMenuOpen ? t('nav.closeMenu') : t('nav.openMenu')}
|
||||
className="inline-flex items-center justify-center rounded-md p-2 text-muted-foreground hover:bg-accent hover:text-accent-foreground sm:hidden"
|
||||
onClick={() => setMobileMenuOpen(!mobileMenuOpen)}
|
||||
>
|
||||
{mobileMenuOpen ? <X className="h-5 w-5" /> : <Menu className="h-5 w-5" />}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Mobile menu */}
|
||||
{mobileMenuOpen && (
|
||||
<nav
|
||||
aria-label={t('nav.mainNav')}
|
||||
className="border-t px-4 pb-4 pt-2 sm:hidden"
|
||||
>
|
||||
<div className="space-y-1">
|
||||
{navLinks.map((link) => (
|
||||
<Link
|
||||
key={link.href}
|
||||
href={link.href}
|
||||
onClick={() => setMobileMenuOpen(false)}
|
||||
className={cn(
|
||||
'block rounded-md px-3 py-2.5 text-sm font-medium transition-colors',
|
||||
link.isActive
|
||||
? 'bg-accent text-accent-foreground'
|
||||
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
|
||||
)}
|
||||
>
|
||||
{link.label}
|
||||
</Link>
|
||||
))}
|
||||
</div>
|
||||
<div className="mt-3 space-y-2 border-t pt-3">
|
||||
{user ? (
|
||||
<>
|
||||
<p className="px-3 text-sm text-muted-foreground">{user.fullName}</p>
|
||||
<Link href="/dashboard" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button size="sm" className="w-full">{t('common.dashboard')}</Button>
|
||||
</Link>
|
||||
</>
|
||||
) : (
|
||||
<div className="flex gap-2">
|
||||
<Link href="/login" className="flex-1" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button variant="ghost" size="sm" className="w-full">
|
||||
{t('common.login')}
|
||||
</Button>
|
||||
</Link>
|
||||
<Link href="/register" className="flex-1" onClick={() => setMobileMenuOpen(false)}>
|
||||
<Button size="sm" className="w-full">{t('common.register')}</Button>
|
||||
</Link>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</nav>
|
||||
)}
|
||||
</header>
|
||||
|
||||
<main id="main-content" role="main">
|
||||
@@ -110,6 +179,7 @@ export default function PublicLayout({ children }: { children: React.ReactNode }
|
||||
<div>
|
||||
<h3 className="mb-3 text-sm font-semibold">{t('footer.support')}</h3>
|
||||
<ul className="space-y-2 text-sm text-muted-foreground">
|
||||
<li><Link href="/pricing" className="hover:text-foreground">{t('nav.pricing')}</Link></li>
|
||||
<li><Link href="/login" className="hover:text-foreground">{t('common.login')}</Link></li>
|
||||
<li><Link href="/register" className="hover:text-foreground">{t('common.register')}</Link></li>
|
||||
</ul>
|
||||
|
||||
498
apps/web/app/[locale]/(public)/pricing/page.tsx
Normal file
498
apps/web/app/[locale]/(public)/pricing/page.tsx
Normal file
@@ -0,0 +1,498 @@
|
||||
'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 { 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
|
||||
// ---------------------------------------------------------------------------
|
||||
|
||||
function formatVND(amount: string | number): string {
|
||||
const num = typeof amount === 'string' ? Number(amount) : amount;
|
||||
if (num === 0) return 'Miễn phí';
|
||||
if (num >= 1_000_000) {
|
||||
const millions = num / 1_000_000;
|
||||
return `${millions % 1 === 0 ? millions.toFixed(0) : millions.toFixed(1)} triệu đ`;
|
||||
}
|
||||
return num.toLocaleString('vi-VN') + ' đ';
|
||||
}
|
||||
|
||||
// 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>
|
||||
);
|
||||
}
|
||||
@@ -25,15 +25,19 @@
|
||||
"nav": {
|
||||
"home": "Home",
|
||||
"search": "Search",
|
||||
"pricing": "Pricing",
|
||||
"mainNav": "Main navigation",
|
||||
"dashboardNav": "Dashboard",
|
||||
"adminNav": "Administration"
|
||||
"adminNav": "Administration",
|
||||
"openMenu": "Open menu",
|
||||
"closeMenu": "Close menu"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Dashboard",
|
||||
"listings": "Listings",
|
||||
"createListing": "Create listing",
|
||||
"analytics": "Analytics",
|
||||
"savedSearches": "Saved searches",
|
||||
"aiValuation": "AI Valuation",
|
||||
"profile": "Profile",
|
||||
"subscription": "Subscription",
|
||||
@@ -142,6 +146,54 @@
|
||||
"default": "An error occurred during login. Please try again."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"badge": "Pricing Plans",
|
||||
"title": "Choose the right plan for you",
|
||||
"subtitle": "From individuals to enterprises — GoodGo has a plan for every real estate need",
|
||||
"monthly": "Monthly",
|
||||
"yearly": "Yearly",
|
||||
"yearlyDiscount": "-17%",
|
||||
"perMonth": "month",
|
||||
"perYear": "year",
|
||||
"loading": "Loading plans...",
|
||||
"popular": "Most popular",
|
||||
"unlimited": "Unlimited",
|
||||
"listingsCount": "listings",
|
||||
"savedSearchesCount": "saved searches",
|
||||
"photosPerListing": "photos/listing",
|
||||
"tiers": {
|
||||
"FREE": "Free",
|
||||
"AGENT_PRO": "Agent Pro",
|
||||
"INVESTOR": "Investor",
|
||||
"ENTERPRISE": "Enterprise"
|
||||
},
|
||||
"tierDescriptions": {
|
||||
"FREE": "Get started for free, explore the platform",
|
||||
"AGENT_PRO": "For professional real estate agents",
|
||||
"INVESTOR": "Analytics tools for investors",
|
||||
"ENTERPRISE": "Comprehensive solution for businesses"
|
||||
},
|
||||
"features": {
|
||||
"analytics": "Market analytics",
|
||||
"aiValuation": "AI valuation",
|
||||
"prioritySupport": "Priority support",
|
||||
"featuredListing": "Featured listings",
|
||||
"leadManagement": "Lead management",
|
||||
"marketReports": "Market reports",
|
||||
"portfolioTracking": "Portfolio tracking",
|
||||
"apiAccess": "API access"
|
||||
},
|
||||
"ctaFree": "Register for free",
|
||||
"ctaUpgrade": "Get started",
|
||||
"ctaEnterprise": "Contact sales",
|
||||
"comparisonTitle": "Compare plans in detail",
|
||||
"comparisonSubtitle": "See all features for each plan",
|
||||
"feature": "Feature",
|
||||
"ctaTitle": "Ready to get started?",
|
||||
"ctaDescription": "Sign up today and start your real estate journey with GoodGo",
|
||||
"ctaRegister": "Register now",
|
||||
"ctaLearnMore": "Learn more"
|
||||
},
|
||||
"search": {
|
||||
"filters": "Filters",
|
||||
"allTransactions": "All transactions",
|
||||
|
||||
@@ -25,15 +25,19 @@
|
||||
"nav": {
|
||||
"home": "Trang chủ",
|
||||
"search": "Tìm kiếm",
|
||||
"pricing": "Bảng giá",
|
||||
"mainNav": "Điều hướng chính",
|
||||
"dashboardNav": "Bảng điều khiển",
|
||||
"adminNav": "Quản trị"
|
||||
"adminNav": "Quản trị",
|
||||
"openMenu": "Mở menu",
|
||||
"closeMenu": "Đóng menu"
|
||||
},
|
||||
"dashboard": {
|
||||
"title": "Bảng điều khiển",
|
||||
"listings": "Tin đăng",
|
||||
"createListing": "Đăng tin",
|
||||
"analytics": "Phân tích",
|
||||
"savedSearches": "Tìm kiếm đã lưu",
|
||||
"aiValuation": "Định giá AI",
|
||||
"profile": "Hồ sơ",
|
||||
"subscription": "Gói dịch vụ",
|
||||
@@ -142,6 +146,54 @@
|
||||
"default": "Đã xảy ra lỗi khi đăng nhập. Vui lòng thử lại."
|
||||
}
|
||||
},
|
||||
"pricing": {
|
||||
"badge": "Bảng giá dịch vụ",
|
||||
"title": "Chọn gói dịch vụ phù hợp",
|
||||
"subtitle": "Từ cá nhân đến doanh nghiệp — GoodGo có gói dịch vụ phù hợp cho mọi nhu cầu bất động sản của bạn",
|
||||
"monthly": "Theo tháng",
|
||||
"yearly": "Theo năm",
|
||||
"yearlyDiscount": "-17%",
|
||||
"perMonth": "tháng",
|
||||
"perYear": "năm",
|
||||
"loading": "Đang tải gói dịch vụ...",
|
||||
"popular": "Phổ biến nhất",
|
||||
"unlimited": "Không giới hạn",
|
||||
"listingsCount": "tin đăng",
|
||||
"savedSearchesCount": "tìm kiếm đã lưu",
|
||||
"photosPerListing": "ảnh/tin đăng",
|
||||
"tiers": {
|
||||
"FREE": "Miễn phí",
|
||||
"AGENT_PRO": "Môi giới Pro",
|
||||
"INVESTOR": "Nhà đầu tư",
|
||||
"ENTERPRISE": "Doanh nghiệp"
|
||||
},
|
||||
"tierDescriptions": {
|
||||
"FREE": "Bắt đầu miễn phí, khám phá nền tảng",
|
||||
"AGENT_PRO": "Dành cho môi giới chuyên nghiệp",
|
||||
"INVESTOR": "Công cụ phân tích cho nhà đầu tư",
|
||||
"ENTERPRISE": "Giải pháp toàn diện cho doanh nghiệp"
|
||||
},
|
||||
"features": {
|
||||
"analytics": "Phân tích thị trường",
|
||||
"aiValuation": "Định giá AI",
|
||||
"prioritySupport": "Hỗ trợ ưu tiên",
|
||||
"featuredListing": "Tin đăng nổi bật",
|
||||
"leadManagement": "Quản lý khách hàng",
|
||||
"marketReports": "Báo cáo thị trường",
|
||||
"portfolioTracking": "Theo dõi danh mục",
|
||||
"apiAccess": "Truy cập API"
|
||||
},
|
||||
"ctaFree": "Đăng ký miễn phí",
|
||||
"ctaUpgrade": "Bắt đầu ngay",
|
||||
"ctaEnterprise": "Liên hệ tư vấn",
|
||||
"comparisonTitle": "So sánh chi tiết các gói",
|
||||
"comparisonSubtitle": "Xem đầy đủ tính năng của từng gói dịch vụ",
|
||||
"feature": "Tính năng",
|
||||
"ctaTitle": "Bạn đã sẵn sàng?",
|
||||
"ctaDescription": "Đăng ký ngay hôm nay để bắt đầu hành trình bất động sản cùng GoodGo",
|
||||
"ctaRegister": "Đăng ký ngay",
|
||||
"ctaLearnMore": "Tìm hiểu thêm"
|
||||
},
|
||||
"search": {
|
||||
"filters": "Bộ lọc",
|
||||
"allTransactions": "Tất cả giao dịch",
|
||||
|
||||
Reference in New Issue
Block a user