Files
goodgo-platform/apps/web/app/(dashboard)/dashboard/subscription/page.tsx
Ho Ngoc Hai 0c26dd85ef fix: resolve all lint errors across codebase
- Convert CacheTTL enum to const object to fix duplicate value errors
- Fix import ordering in test files (eslint-disable for vi.mock pattern)
- Fix unused variable warnings (prefix with underscore)
- Auto-fix import ordering in subscription page, dashboard layout
- 0 lint errors remaining

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-08 23:13:35 +07:00

372 lines
15 KiB
TypeScript

'use client';
import { useQueryClient } from '@tanstack/react-query';
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 {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
import { usePlans, useBillingHistory, useQuota, subscriptionKeys } from '@/lib/hooks/use-subscription';
import {
subscriptionApi,
type PlanDto,
type QuotaCheckResult,
} from '@/lib/subscription-api';
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) return `${(num / 1_000_000).toFixed(0)} triệu đ`;
return num.toLocaleString('vi-VN') + ' đ';
}
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' },
};
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 [upgradeTarget, setUpgradeTarget] = useState<PlanDto | null>(null);
const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly');
const [processing, setProcessing] = useState(false);
const [error, setError] = useState<string | null>(null);
const [activeTab, setActiveTab] = useState('plan');
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 = async () => {
if (!upgradeTarget) return;
setProcessing(true);
setError(null);
try {
if (billing?.subscription) {
await subscriptionApi.upgradeSubscription(upgradeTarget.tier);
} else {
await subscriptionApi.createSubscription(upgradeTarget.tier, billingCycle);
}
await queryClient.invalidateQueries({ queryKey: subscriptionKeys.billing() });
setUpgradeTarget(null);
} catch (e) {
setError(e instanceof Error ? e.message : 'Nâng cấp thất bại');
} finally {
setProcessing(false);
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Gói dịch vụ</h1>
<p className="mt-2 text-muted-foreground">
Quản gói đăng 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>
<TabsTrigger value="plan">Gói hiện tại</TabsTrigger>
<TabsTrigger value="plans">So sánh gói</TabsTrigger>
<TabsTrigger value="history">Lịch sử thanh toán</TabsTrigger>
</TabsList>
{/* Current plan tab */}
<TabsContent value="plan" className="space-y-6">
<Card>
<CardHeader>
<div className="flex items-center 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>
{subStatus && <Badge variant={subStatus.variant}>{subStatus.label}</Badge>}
</div>
</CardHeader>
<CardContent>
{/* 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 ${pct > 90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-primary'}`}
style={{ width: `${Math.min(pct, 100)}%` }}
/>
</div>
</div>
);
})}
</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-primary ring-1 ring-primary' : ''}
>
<CardHeader>
<CardTitle className="text-lg">
{PLAN_TIER_LABELS[plan.tier] ?? plan.name}
</CardTitle>
<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>
{plan.features &&
Object.entries(plan.features).map(([key, val]) => (
<div key={key} className="flex justify-between">
<span className="text-muted-foreground">{key}</span>
<span className="font-medium">
{typeof val === 'boolean' ? (val ? '✓' : '✗') : String(val)}
</span>
</div>
))}
</div>
{isCurrent ? (
<Button variant="outline" className="w-full" disabled>
Gói hiện tại
</Button>
) : isUpgrade ? (
<Button className="w-full" onClick={() => setUpgradeTarget(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 giao dịch nào
</div>
) : (
<div className="space-y-3">
{billing.payments.map((p) => (
<div
key={p.id}
className="flex items-center justify-between rounded-lg border p-3"
>
<div>
<p className="text-sm font-medium">{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={
p.status === 'COMPLETED'
? 'default'
: p.status === 'FAILED'
? 'destructive'
: 'secondary'
}
>
{p.status === 'COMPLETED'
? 'Thành công'
: p.status === 'FAILED'
? 'Thất bại'
: p.status === 'PENDING'
? 'Chờ xử lý'
: p.status}
</Badge>
</div>
</div>
))}
</div>
)}
</CardContent>
</Card>
</TabsContent>
</Tabs>
)}
{/* Upgrade dialog */}
<Dialog open={!!upgradeTarget} onOpenChange={(o) => !o && setUpgradeTarget(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
Nâng cấp lên {PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name}
</DialogTitle>
<DialogDescription>
Xác nhận nâng cấp gói dịch vụ. Bạn sẽ đưc chuyển hướng đến trang thanh toán.
</DialogDescription>
</DialogHeader>
<div className="space-y-2 rounded-lg border bg-muted/50 p-4 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Gói</span>
<span className="font-medium">
{PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Chu kỳ</span>
<span className="font-medium">
{billingCycle === 'monthly' ? 'Hàng tháng' : 'Hàng năm'}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Giá</span>
<span className="font-semibold text-primary">
{upgradeTarget &&
formatVND(
billingCycle === 'monthly'
? upgradeTarget.priceMonthlyVND
: upgradeTarget.priceYearlyVND,
)}
</span>
</div>
</div>
<DialogFooter>
<Button variant="outline" onClick={() => setUpgradeTarget(null)}>
Hủy
</Button>
<Button onClick={handleUpgrade} disabled={processing}>
{processing ? 'Đang xử lý...' : 'Xác nhận nâng cấp'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}