- Pricing page: enhanced with checkout modal integration, plan comparison table, and subscription funnel - Payment return page: new VNPay/MoMo callback handler - Subscription components: new checkout-modal with payment method selection (VNPay, MoMo, ZaloPay) - API modules: type-safe PII encryption, improved error handling in MFA/auth/payments/analytics/search/notifications modules - Audit docs: comprehensive Wave 13 platform assessment, pricing audit, production readiness checklist - Updated PROJECT_TRACKER with Wave 13 status Co-Authored-By: Paperclip <noreply@paperclip.ing>
358 lines
15 KiB
TypeScript
358 lines
15 KiB
TypeScript
'use client';
|
|
|
|
import { useQueryClient } from '@tanstack/react-query';
|
|
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 { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
|
|
import { Link } from '@/i18n/navigation';
|
|
import { formatVND } from '@/lib/currency';
|
|
import { usePlans, useBillingHistory, useQuota, subscriptionKeys } from '@/lib/hooks/use-subscription';
|
|
import {
|
|
subscriptionApi,
|
|
type PlanDto,
|
|
type QuotaCheckResult,
|
|
} from '@/lib/subscription-api';
|
|
|
|
const PLAN_TIER_ORDER = ['FREE', 'AGENT_PRO', 'INVESTOR', 'ENTERPRISE'];
|
|
const PLAN_TIER_LABELS: Record<string, string> = {
|
|
FREE: 'Miễn phí',
|
|
AGENT_PRO: 'Môi giới Pro',
|
|
INVESTOR: 'Nhà đầu tư',
|
|
ENTERPRISE: 'Doanh nghiệp',
|
|
};
|
|
|
|
const STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
|
ACTIVE: { label: 'Đang hoạt động', variant: 'default' },
|
|
PAST_DUE: { label: 'Quá hạn', variant: 'destructive' },
|
|
CANCELLED: { label: 'Đã hủy', variant: 'outline' },
|
|
EXPIRED: { label: 'Hết hạn', variant: 'secondary' },
|
|
};
|
|
|
|
const PAYMENT_STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
|
|
COMPLETED: { label: 'Thành công', variant: 'default' },
|
|
FAILED: { label: 'Thất bại', variant: 'destructive' },
|
|
PENDING: { label: 'Chờ xử lý', variant: 'secondary' },
|
|
CANCELLED: { label: 'Đã hủy', variant: 'outline' },
|
|
};
|
|
|
|
const PAYMENT_TYPE_LABELS: Record<string, string> = {
|
|
SUBSCRIPTION: 'Đăng ký gói',
|
|
LISTING_FEE: 'Phí đăng tin',
|
|
DEPOSIT: 'Đặt cọc',
|
|
FEATURED_LISTING: 'Tin nổi bật',
|
|
};
|
|
|
|
export default function SubscriptionPage() {
|
|
const queryClient = useQueryClient();
|
|
const { data: plansData, isLoading: plansLoading } = usePlans();
|
|
const { data: billing, isLoading: billingLoading } = useBillingHistory();
|
|
const { data: listingsQuota } = useQuota('listings');
|
|
const { data: savedSearchesQuota } = useQuota('saved_searches');
|
|
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
|
|
const [cancelProcessing, setCancelProcessing] = useState(false);
|
|
const [error, setError] = useState<string | null>(null);
|
|
const [activeTab, setActiveTab] = useState('plan');
|
|
|
|
// Checkout modal state
|
|
const [checkoutPlan, setCheckoutPlan] = useState<PlanDto | null>(null);
|
|
const [checkoutOpen, setCheckoutOpen] = useState(false);
|
|
|
|
const loading = plansLoading || billingLoading;
|
|
const plans = (plansData ?? []).slice().sort(
|
|
(a, b) => PLAN_TIER_ORDER.indexOf(a.tier) - PLAN_TIER_ORDER.indexOf(b.tier),
|
|
);
|
|
const quotas = [listingsQuota, savedSearchesQuota].filter(
|
|
(q): q is QuotaCheckResult => q != null,
|
|
);
|
|
|
|
const currentTier = billing?.subscription?.planTier ?? 'FREE';
|
|
const currentTierIndex = PLAN_TIER_ORDER.indexOf(currentTier);
|
|
const subStatus = billing?.subscription?.status
|
|
? STATUS_MAP[billing.subscription.status] ?? { label: 'Đang hoạt động', variant: 'default' as const }
|
|
: null;
|
|
|
|
const handleUpgrade = (plan: PlanDto) => {
|
|
setCheckoutPlan(plan);
|
|
setCheckoutOpen(true);
|
|
};
|
|
|
|
const handleCancel = async () => {
|
|
setCancelProcessing(true);
|
|
setError(null);
|
|
try {
|
|
await subscriptionApi.cancelSubscription('Hủy từ trang quản lý');
|
|
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.billing() });
|
|
} catch (e) {
|
|
setError(e instanceof Error ? e.message : 'Hủy gói thất bại');
|
|
} finally {
|
|
setCancelProcessing(false);
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
<div>
|
|
<h1 className="text-2xl font-bold sm:text-3xl">Gói dịch vụ</h1>
|
|
<p className="mt-2 text-muted-foreground">
|
|
Quản lý gói đăng ký và theo dõi hạn mức sử dụng
|
|
</p>
|
|
</div>
|
|
|
|
{error && (
|
|
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
|
{error}
|
|
<button onClick={() => setError(null)} className="ml-2 font-medium underline">
|
|
Đóng
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{loading ? (
|
|
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
|
Đang tải...
|
|
</div>
|
|
) : (
|
|
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
|
<TabsList className="w-full justify-start overflow-x-auto">
|
|
<TabsTrigger value="plan" className="min-w-fit">Gói hiện tại</TabsTrigger>
|
|
<TabsTrigger value="plans" className="min-w-fit">So sánh gói</TabsTrigger>
|
|
<TabsTrigger value="history" className="min-w-fit">Lịch sử thanh toán</TabsTrigger>
|
|
</TabsList>
|
|
|
|
{/* Current plan tab */}
|
|
<TabsContent value="plan" className="space-y-6">
|
|
<Card>
|
|
<CardHeader>
|
|
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
|
|
<div>
|
|
<CardTitle className="text-lg">
|
|
Gói {PLAN_TIER_LABELS[currentTier] ?? currentTier}
|
|
</CardTitle>
|
|
<CardDescription>
|
|
{billing?.subscription
|
|
? `Kỳ hiện tại: ${new Date(billing.subscription.currentPeriodStart).toLocaleDateString('vi-VN')} — ${new Date(billing.subscription.currentPeriodEnd).toLocaleDateString('vi-VN')}`
|
|
: 'Bạn đang sử dụng gói miễn phí'}
|
|
</CardDescription>
|
|
</div>
|
|
<div className="flex items-center gap-2">
|
|
{subStatus && <Badge variant={subStatus.variant}>{subStatus.label}</Badge>}
|
|
</div>
|
|
</div>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
{/* Quota usage */}
|
|
{quotas.length > 0 && (
|
|
<div className="space-y-4">
|
|
<h3 className="font-semibold">Hạn mức sử dụng</h3>
|
|
{quotas.map((q) => {
|
|
const pct = q.limit > 0 ? (q.used / q.limit) * 100 : 0;
|
|
return (
|
|
<div key={q.metric} className="space-y-1">
|
|
<div className="flex justify-between text-sm">
|
|
<span className="text-muted-foreground">
|
|
{q.metric === 'listings' ? 'Tin đăng' : q.metric === 'saved_searches' ? 'Tìm kiếm đã lưu' : q.metric}
|
|
</span>
|
|
<span>
|
|
{q.used}/{q.limit}
|
|
</span>
|
|
</div>
|
|
<div className="h-2 w-full rounded-full bg-muted">
|
|
<div
|
|
className={`h-2 rounded-full transition-all ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-primary'}`}
|
|
style={{ width: `${Math.min(pct, 100)}%` }}
|
|
/>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
|
|
{/* Quick actions */}
|
|
<div className="flex flex-col gap-2 border-t pt-4 sm:flex-row">
|
|
{currentTier !== 'ENTERPRISE' && (
|
|
<Button onClick={() => setActiveTab('plans')}>
|
|
Nâng cấp gói
|
|
</Button>
|
|
)}
|
|
{billing?.subscription && billing.subscription.status === 'ACTIVE' && (
|
|
<Button
|
|
variant="outline"
|
|
onClick={handleCancel}
|
|
disabled={cancelProcessing}
|
|
className="text-red-600 hover:text-red-700"
|
|
>
|
|
{cancelProcessing ? 'Đang xử lý...' : 'Hủy gói'}
|
|
</Button>
|
|
)}
|
|
<Link href={'/pricing' as const}>
|
|
<Button variant="outline">Xem bảng giá</Button>
|
|
</Link>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
|
|
{/* Plan comparison tab */}
|
|
<TabsContent value="plans" className="space-y-6">
|
|
{/* Billing cycle toggle */}
|
|
<div className="flex items-center justify-center gap-3">
|
|
<Button
|
|
variant={billingCycle === 'monthly' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setBillingCycle('monthly')}
|
|
>
|
|
Theo tháng
|
|
</Button>
|
|
<Button
|
|
variant={billingCycle === 'yearly' ? 'default' : 'outline'}
|
|
size="sm"
|
|
onClick={() => setBillingCycle('yearly')}
|
|
>
|
|
Theo năm
|
|
<Badge variant="secondary" className="ml-2">
|
|
-17%
|
|
</Badge>
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
|
|
{plans.map((plan) => {
|
|
const tierIndex = PLAN_TIER_ORDER.indexOf(plan.tier);
|
|
const isCurrent = plan.tier === currentTier;
|
|
const isUpgrade = tierIndex > currentTierIndex;
|
|
const price = billingCycle === 'monthly' ? plan.priceMonthlyVND : plan.priceYearlyVND;
|
|
|
|
return (
|
|
<Card
|
|
key={plan.id}
|
|
className={isCurrent ? 'border-green-500 ring-1 ring-green-500' : ''}
|
|
>
|
|
<CardHeader>
|
|
<div className="flex items-center justify-between">
|
|
<CardTitle className="text-lg">
|
|
{PLAN_TIER_LABELS[plan.tier] ?? plan.name}
|
|
</CardTitle>
|
|
{isCurrent && (
|
|
<Badge className="bg-green-600 text-white">Hiện tại</Badge>
|
|
)}
|
|
</div>
|
|
<CardDescription>
|
|
<span className="text-2xl font-bold text-foreground">
|
|
{formatVND(price)}
|
|
</span>
|
|
{Number(price) > 0 && (
|
|
<span className="text-sm">
|
|
/{billingCycle === 'monthly' ? 'tháng' : 'năm'}
|
|
</span>
|
|
)}
|
|
</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
<div className="space-y-2 text-sm">
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Tin đăng</span>
|
|
<span className="font-medium">
|
|
{plan.maxListings === -1 ? 'Không giới hạn' : plan.maxListings}
|
|
</span>
|
|
</div>
|
|
<div className="flex justify-between">
|
|
<span className="text-muted-foreground">Tìm kiếm lưu</span>
|
|
<span className="font-medium">
|
|
{plan.maxSavedSearches === -1
|
|
? 'Không giới hạn'
|
|
: plan.maxSavedSearches}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{isCurrent ? (
|
|
<Button variant="outline" className="w-full" disabled>
|
|
Gói hiện tại
|
|
</Button>
|
|
) : isUpgrade ? (
|
|
plan.tier === 'ENTERPRISE' ? (
|
|
<Link href={'/pricing' as const} className="block w-full">
|
|
<Button variant="outline" className="w-full">
|
|
Liên hệ tư vấn
|
|
</Button>
|
|
</Link>
|
|
) : (
|
|
<Button className="w-full" onClick={() => handleUpgrade(plan)}>
|
|
Nâng cấp
|
|
</Button>
|
|
)
|
|
) : (
|
|
<Button variant="outline" className="w-full" disabled>
|
|
—
|
|
</Button>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
);
|
|
})}
|
|
</div>
|
|
</TabsContent>
|
|
|
|
{/* Payment history tab */}
|
|
<TabsContent value="history">
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle className="text-lg">Lịch sử thanh toán</CardTitle>
|
|
<CardDescription>Các giao dịch liên quan đến gói dịch vụ</CardDescription>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{!billing || billing.payments.length === 0 ? (
|
|
<div className="flex h-32 items-center justify-center text-muted-foreground">
|
|
Chưa có giao dịch nào
|
|
</div>
|
|
) : (
|
|
<div className="space-y-3">
|
|
{billing.payments.map((p) => {
|
|
const pStatus = PAYMENT_STATUS_MAP[p.status] ?? { label: p.status, variant: 'secondary' as const };
|
|
return (
|
|
<div
|
|
key={p.id}
|
|
className="flex items-center justify-between rounded-lg border p-3"
|
|
>
|
|
<div>
|
|
<p className="text-sm font-medium">
|
|
{PAYMENT_TYPE_LABELS[p.type] ?? p.type}
|
|
</p>
|
|
<p className="text-xs text-muted-foreground">
|
|
{new Date(p.createdAt).toLocaleDateString('vi-VN')} — {p.provider}
|
|
</p>
|
|
</div>
|
|
<div className="text-right">
|
|
<p className="font-semibold">{formatVND(p.amountVND)}</p>
|
|
<Badge variant={pStatus.variant}>
|
|
{pStatus.label}
|
|
</Badge>
|
|
</div>
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
)}
|
|
</CardContent>
|
|
</Card>
|
|
</TabsContent>
|
|
</Tabs>
|
|
)}
|
|
|
|
{/* Checkout modal */}
|
|
<CheckoutModal
|
|
open={checkoutOpen}
|
|
onOpenChange={setCheckoutOpen}
|
|
plan={checkoutPlan}
|
|
billingCycle={billingCycle}
|
|
isUpgrade={currentTierIndex > 0}
|
|
currentTier={currentTier}
|
|
/>
|
|
</div>
|
|
);
|
|
}
|