feat(web): add i18n locale routes and language switcher component

Add locale-prefixed routes for admin, auth, dashboard, and public pages.
Add error, loading, and not-found pages for locale context. Add language
switcher UI component for Vietnamese/English toggle.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-09 09:44:18 +07:00
parent 2250e17a09
commit 7195064f12
43 changed files with 7418 additions and 1 deletions

View File

@@ -0,0 +1,427 @@
'use client';
import {
CheckCircle,
XCircle,
RefreshCw,
ChevronLeft,
ChevronRight,
FileText,
ShieldCheck,
X,
} from 'lucide-react';
import Image from 'next/image';
import { useEffect, useState, useCallback } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { adminApi, type KycQueueItem, type PaginatedResult } from '@/lib/admin-api';
function kycStatusBadge(status: string) {
switch (status) {
case 'VERIFIED': return <Badge variant="success">Đã xác minh</Badge>;
case 'PENDING': return <Badge variant="warning">Chờ duyệt</Badge>;
case 'REJECTED': return <Badge variant="destructive">Bị từ chối</Badge>;
default: return <Badge variant="secondary">{status}</Badge>;
}
}
interface KycData {
idType?: string;
idNumber?: string;
frontImageUrl?: string;
backImageUrl?: string;
selfieUrl?: string;
[key: string]: unknown;
}
function KycDetailView({ item, onApprove, onReject }: {
item: KycQueueItem;
onApprove: () => void;
onReject: () => void;
}) {
const kycData = item.kycData as KycData | null;
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">{item.fullName}</h3>
<p className="text-sm text-muted-foreground">{item.phone}</p>
{item.email && (
<p className="text-sm text-muted-foreground">{item.email}</p>
)}
</div>
{kycStatusBadge(item.kycStatus)}
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">Vai trò</div>
<div className="mt-1 text-sm font-medium">{item.role}</div>
</div>
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">Ngày gửi</div>
<div className="mt-1 text-sm font-medium">
{new Date(item.createdAt).toLocaleDateString('vi-VN')}
</div>
</div>
</div>
{kycData && (
<div className="space-y-3">
<h4 className="text-sm font-medium">Thông tin KYC</h4>
{kycData.idType && (
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">Loại giấy tờ</div>
<div className="mt-1 text-sm font-medium">{kycData.idType}</div>
</div>
)}
{kycData.idNumber && (
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">Số giấy tờ</div>
<div className="mt-1 text-sm font-medium">{kycData.idNumber}</div>
</div>
)}
<div className="grid gap-2">
{kycData.frontImageUrl && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Mặt trước</div>
<div className="relative aspect-video overflow-hidden rounded-md border bg-muted">
<Image
src={kycData.frontImageUrl}
alt="Mặt trước giấy tờ"
fill
sizes="(max-width: 768px) 100vw, 400px"
className="object-contain"
/>
</div>
</div>
)}
{kycData.backImageUrl && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">Mặt sau</div>
<div className="relative aspect-video overflow-hidden rounded-md border bg-muted">
<Image
src={kycData.backImageUrl}
alt="Mặt sau giấy tờ"
fill
sizes="(max-width: 768px) 100vw, 400px"
className="object-contain"
/>
</div>
</div>
)}
{kycData.selfieUrl && (
<div className="space-y-1">
<div className="text-xs text-muted-foreground">nh selfie</div>
<div className="relative aspect-video overflow-hidden rounded-md border bg-muted">
<Image
src={kycData.selfieUrl}
alt="Selfie"
fill
sizes="(max-width: 768px) 100vw, 400px"
className="object-contain"
/>
</div>
</div>
)}
</div>
</div>
)}
{item.kycStatus === 'PENDING' && (
<div className="flex gap-2">
<Button className="flex-1" onClick={onApprove}>
<CheckCircle className="mr-2 h-4 w-4" />
Duyệt KYC
</Button>
<Button variant="destructive" className="flex-1" onClick={onReject}>
<XCircle className="mr-2 h-4 w-4" />
Từ chối
</Button>
</div>
)}
</div>
);
}
export default function AdminKycPage() {
const [result, setResult] = useState<PaginatedResult<KycQueueItem> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [selectedItem, setSelectedItem] = useState<KycQueueItem | null>(null);
// Approve dialog
const [approveDialog, setApproveDialog] = useState<string | null>(null);
const [approveNotes, setApproveNotes] = useState('');
// Reject dialog
const [rejectDialog, setRejectDialog] = useState<string | null>(null);
const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const fetchQueue = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await adminApi.getKycQueue(page, 20);
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : 'Không thể tải hàng đợi KYC');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
fetchQueue();
}, [fetchQueue]);
const handleApprove = async () => {
if (!approveDialog) return;
setActionLoading(true);
try {
await adminApi.approveKyc(approveDialog, approveNotes || undefined);
setApproveDialog(null);
setApproveNotes('');
setSelectedItem(null);
fetchQueue();
} catch (e) {
setActionError(e instanceof Error ? e.message : 'Thao tác thất bại. Vui lòng thử lại.');
} finally {
setActionLoading(false);
}
};
const handleReject = async () => {
if (!rejectDialog || !rejectReason.trim()) return;
setActionLoading(true);
try {
await adminApi.rejectKyc(rejectDialog, rejectReason);
setRejectDialog(null);
setRejectReason('');
setSelectedItem(null);
fetchQueue();
} catch (e) {
setActionError(e instanceof Error ? e.message : 'Thao tác thất bại. Vui lòng thử lại.');
} finally {
setActionLoading(false);
}
};
return (
<div className="space-y-6">
{actionError && (
<div className="flex items-center justify-between rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<span>{actionError}</span>
<button onClick={() => setActionError(null)} className="ml-2">
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Duyệt KYC</h1>
<p className="text-sm text-muted-foreground">
Xác minh danh tính người dùng đi
</p>
</div>
<Button variant="outline" size="sm" onClick={fetchQueue}>
<RefreshCw className="mr-2 h-4 w-4" />
Làm mới
</Button>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_400px]">
{/* Table */}
<Card>
<CardContent className="p-0">
{loading ? (
<div className="flex h-48 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex h-48 flex-col items-center justify-center gap-2">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={fetchQueue}>
Thử lại
</Button>
</div>
) : !result || result.data.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center gap-2">
<ShieldCheck className="h-8 w-8 text-green-500" />
<p className="text-sm text-muted-foreground">
Không yêu cầu KYC nào đang chờ
</p>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Họ tên</TableHead>
<TableHead className="hidden sm:table-cell">SĐT</TableHead>
<TableHead>Vai trò</TableHead>
<TableHead>Trạng thái</TableHead>
<TableHead className="hidden md:table-cell">Ngày gửi</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.data.map((item) => (
<TableRow
key={item.userId}
className={`cursor-pointer ${selectedItem?.userId === item.userId ? 'bg-muted/50' : ''}`}
onClick={() => setSelectedItem(item)}
>
<TableCell>
<div className="font-medium">{item.fullName}</div>
{item.email && (
<div className="text-xs text-muted-foreground">{item.email}</div>
)}
</TableCell>
<TableCell className="hidden sm:table-cell">{item.phone}</TableCell>
<TableCell>
<Badge variant="outline">{item.role}</Badge>
</TableCell>
<TableCell>{kycStatusBadge(item.kycStatus)}</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{new Date(item.createdAt).toLocaleDateString('vi-VN')}
</TableCell>
<TableCell>
<FileText className="h-4 w-4 text-muted-foreground" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{result.totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-3">
<span className="text-sm text-muted-foreground">
Trang {result.page}/{result.totalPages} ({result.total} yêu cầu)
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="icon"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={page >= result.totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Detail sidebar */}
<div className="hidden lg:block">
<Card>
<CardContent className="p-4">
{selectedItem ? (
<KycDetailView
item={selectedItem}
onApprove={() => {
setApproveDialog(selectedItem.userId);
setApproveNotes('');
}}
onReject={() => {
setRejectDialog(selectedItem.userId);
setRejectReason('');
}}
/>
) : (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Chọn yêu cầu KYC đ xem chi tiết
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Approve dialog */}
<Dialog open={!!approveDialog} onOpenChange={() => setApproveDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Duyệt KYC</DialogTitle>
<DialogDescription>
Xác nhận danh tính người dùng đã đưc xác minh thành công.
</DialogDescription>
</DialogHeader>
<Input
placeholder="Ghi chú (không bắt buộc)..."
value={approveNotes}
onChange={(e) => setApproveNotes(e.target.value)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setApproveDialog(null)}>
Hủy
</Button>
<Button onClick={handleApprove} disabled={actionLoading}>
{actionLoading ? 'Đang xử lý...' : 'Xác nhận duyệt'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject dialog */}
<Dialog open={!!rejectDialog} onOpenChange={() => setRejectDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Từ chối KYC</DialogTitle>
<DialogDescription>
Vui lòng nhập do từ chối. Người dùng sẽ cần gửi lại hồ .
</DialogDescription>
</DialogHeader>
<Input
placeholder="Lý do từ chối (bắt buộc)..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectDialog(null)}>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleReject}
disabled={actionLoading || !rejectReason.trim()}
>
{actionLoading ? 'Đang xử lý...' : 'Từ chối KYC'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,428 @@
'use client';
import {
CheckCircle,
XCircle,
RefreshCw,
ChevronLeft,
ChevronRight,
AlertTriangle,
X,
} from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import { adminApi, type ModerationQueueItem, type PaginatedResult } from '@/lib/admin-api';
function formatPrice(price: number): string {
if (price >= 1_000_000_000) {
return `${(price / 1_000_000_000).toFixed(1)} tỷ`;
}
if (price >= 1_000_000) {
return `${(price / 1_000_000).toFixed(0)} triệu`;
}
return price.toLocaleString('vi-VN');
}
function moderationScoreBadge(score: number | null) {
if (score === null) return <Badge variant="secondary">N/A</Badge>;
if (score >= 80) return <Badge variant="success">{score}</Badge>;
if (score >= 50) return <Badge variant="warning">{score}</Badge>;
return <Badge variant="destructive">{score}</Badge>;
}
export default function AdminModerationPage() {
const [result, setResult] = useState<PaginatedResult<ModerationQueueItem> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
// Selected items for bulk
const [selected, setSelected] = useState<Set<string>>(new Set());
// Action dialogs
const [approveDialog, setApproveDialog] = useState<string | null>(null);
const [rejectDialog, setRejectDialog] = useState<string | null>(null);
const [approveNotes, setApproveNotes] = useState('');
const [rejectReason, setRejectReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
// Bulk action
const [bulkAction, setBulkAction] = useState<'approve' | 'reject' | null>(null);
const [bulkReason, setBulkReason] = useState('');
const fetchQueue = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await adminApi.getModerationQueue(page, 20);
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : 'Không thể tải hàng đợi');
} finally {
setLoading(false);
}
}, [page]);
useEffect(() => {
fetchQueue();
}, [fetchQueue]);
const handleApprove = async () => {
if (!approveDialog) return;
setActionLoading(true);
try {
await adminApi.approveListing(approveDialog, approveNotes || undefined);
setApproveDialog(null);
setApproveNotes('');
fetchQueue();
} catch (e) {
setActionError(e instanceof Error ? e.message : 'Thao tác thất bại. Vui lòng thử lại.');
} finally {
setActionLoading(false);
}
};
const handleReject = async () => {
if (!rejectDialog || !rejectReason.trim()) return;
setActionLoading(true);
try {
await adminApi.rejectListing(rejectDialog, rejectReason);
setRejectDialog(null);
setRejectReason('');
fetchQueue();
} catch (e) {
setActionError(e instanceof Error ? e.message : 'Thao tác thất bại. Vui lòng thử lại.');
} finally {
setActionLoading(false);
}
};
const handleBulkAction = async () => {
if (!bulkAction || selected.size === 0) return;
setActionLoading(true);
try {
await adminApi.bulkModerate(
Array.from(selected),
bulkAction,
bulkReason || undefined,
);
setSelected(new Set());
setBulkAction(null);
setBulkReason('');
fetchQueue();
} catch (e) {
setActionError(e instanceof Error ? e.message : 'Thao tác thất bại. Vui lòng thử lại.');
} finally {
setActionLoading(false);
}
};
const toggleSelect = (id: string) => {
setSelected((prev) => {
const next = new Set(prev);
if (next.has(id)) next.delete(id);
else next.add(id);
return next;
});
};
const toggleSelectAll = () => {
if (!result) return;
if (selected.size === result.data.length) {
setSelected(new Set());
} else {
setSelected(new Set(result.data.map((item) => item.listingId)));
}
};
return (
<div className="space-y-6">
{actionError && (
<div className="flex items-center justify-between rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<span>{actionError}</span>
<button onClick={() => setActionError(null)} className="ml-2">
<X className="h-4 w-4" />
</button>
</div>
)}
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Kiểm duyệt tin đăng</h1>
<p className="text-sm text-muted-foreground">
Duyệt hoặc từ chối các tin đăng chờ phê duyệt
</p>
</div>
<div className="flex gap-2">
{selected.size > 0 && (
<>
<Button
size="sm"
onClick={() => { setBulkAction('approve'); setBulkReason(''); }}
>
<CheckCircle className="mr-2 h-4 w-4" />
Duyệt ({selected.size})
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => { setBulkAction('reject'); setBulkReason(''); }}
>
<XCircle className="mr-2 h-4 w-4" />
Từ chối ({selected.size})
</Button>
</>
)}
<Button variant="outline" size="sm" onClick={fetchQueue}>
<RefreshCw className="mr-2 h-4 w-4" />
Làm mới
</Button>
</div>
</div>
<Card>
<CardContent className="p-0">
{loading ? (
<div className="flex h-48 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex h-48 flex-col items-center justify-center gap-2">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={fetchQueue}>
Thử lại
</Button>
</div>
) : !result || result.data.length === 0 ? (
<div className="flex h-48 flex-col items-center justify-center gap-2">
<CheckCircle className="h-8 w-8 text-green-500" />
<p className="text-sm text-muted-foreground">
Không tin nào chờ kiểm duyệt
</p>
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead className="w-10">
<input
type="checkbox"
checked={selected.size === result.data.length && result.data.length > 0}
onChange={toggleSelectAll}
className="rounded border-input"
/>
</TableHead>
<TableHead>Tiêu đ</TableHead>
<TableHead className="hidden sm:table-cell">Loại</TableHead>
<TableHead className="hidden md:table-cell">Giá</TableHead>
<TableHead className="hidden lg:table-cell">Người đăng</TableHead>
<TableHead>Điểm AI</TableHead>
<TableHead className="hidden md:table-cell">Ngày đăng</TableHead>
<TableHead className="text-right">Hành đng</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.data.map((item) => (
<TableRow key={item.listingId}>
<TableCell>
<input
type="checkbox"
checked={selected.has(item.listingId)}
onChange={() => toggleSelect(item.listingId)}
className="rounded border-input"
/>
</TableCell>
<TableCell>
<div className="font-medium max-w-[200px] truncate">
{item.propertyTitle}
</div>
<div className="text-xs text-muted-foreground">
{item.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'}
</div>
</TableCell>
<TableCell className="hidden sm:table-cell">
<Badge variant="outline">{item.propertyType}</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">
{formatPrice(item.priceVND)} VND
</TableCell>
<TableCell className="hidden lg:table-cell">
<span className="text-sm">{item.sellerName}</span>
</TableCell>
<TableCell>
{moderationScoreBadge(item.moderationScore)}
</TableCell>
<TableCell className="hidden md:table-cell text-sm text-muted-foreground">
{new Date(item.createdAt).toLocaleDateString('vi-VN')}
</TableCell>
<TableCell>
<div className="flex justify-end gap-1">
<Button
variant="ghost"
size="icon"
title="Duyệt"
onClick={() => {
setApproveDialog(item.listingId);
setApproveNotes('');
}}
>
<CheckCircle className="h-4 w-4 text-green-600" />
</Button>
<Button
variant="ghost"
size="icon"
title="Từ chối"
onClick={() => {
setRejectDialog(item.listingId);
setRejectReason('');
}}
>
<XCircle className="h-4 w-4 text-red-600" />
</Button>
</div>
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{result.totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-3">
<span className="text-sm text-muted-foreground">
Trang {result.page}/{result.totalPages} ({result.total} tin)
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="icon"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={page >= result.totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Approve dialog */}
<Dialog open={!!approveDialog} onOpenChange={() => setApproveDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Duyệt tin đăng</DialogTitle>
<DialogDescription>
Tin đăng sẽ đưc hiển thị công khai sau khi duyệt.
</DialogDescription>
</DialogHeader>
<Input
placeholder="Ghi chú (không bắt buộc)..."
value={approveNotes}
onChange={(e) => setApproveNotes(e.target.value)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setApproveDialog(null)}>
Hủy
</Button>
<Button onClick={handleApprove} disabled={actionLoading}>
{actionLoading ? 'Đang xử lý...' : 'Duyệt tin'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Reject dialog */}
<Dialog open={!!rejectDialog} onOpenChange={() => setRejectDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>Từ chối tin đăng</DialogTitle>
<DialogDescription>
Vui lòng nhập do từ chối. Người đăng sẽ nhận đưc thông báo.
</DialogDescription>
</DialogHeader>
<Input
placeholder="Lý do từ chối (bắt buộc)..."
value={rejectReason}
onChange={(e) => setRejectReason(e.target.value)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setRejectDialog(null)}>
Hủy
</Button>
<Button
variant="destructive"
onClick={handleReject}
disabled={actionLoading || !rejectReason.trim()}
>
{actionLoading ? 'Đang xử lý...' : 'Từ chối'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
{/* Bulk action dialog */}
<Dialog open={!!bulkAction} onOpenChange={() => setBulkAction(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{bulkAction === 'approve' ? 'Duyệt hàng loạt' : 'Từ chối hàng loạt'}
</DialogTitle>
<DialogDescription>
<span className="flex items-center gap-2">
<AlertTriangle className="h-4 w-4" />
Thao tác sẽ áp dụng cho {selected.size} tin đăng đã chọn.
</span>
</DialogDescription>
</DialogHeader>
{bulkAction === 'reject' && (
<Input
placeholder="Lý do từ chối (bắt buộc)..."
value={bulkReason}
onChange={(e) => setBulkReason(e.target.value)}
/>
)}
<DialogFooter>
<Button variant="outline" onClick={() => setBulkAction(null)}>
Hủy
</Button>
<Button
variant={bulkAction === 'reject' ? 'destructive' : 'default'}
onClick={handleBulkAction}
disabled={actionLoading || (bulkAction === 'reject' && !bulkReason.trim())}
>
{actionLoading
? 'Đang xử lý...'
: bulkAction === 'approve'
? `Duyệt ${selected.size} tin`
: `Từ chối ${selected.size} tin`}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,223 @@
'use client';
import {
Users,
Home,
ClipboardCheck,
Clock,
UserCheck,
ShieldCheck,
ArrowUpRight,
ArrowDownRight,
TrendingUp,
RefreshCw,
} from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { adminApi, type DashboardStats, type RevenueStatsItem } from '@/lib/admin-api';
interface StatCardProps {
title: string;
value: number;
icon: React.ElementType;
description?: string;
trend?: 'up' | 'down';
trendValue?: string;
}
function StatCard({ title, value, icon: Icon, description, trend, trendValue }: StatCardProps) {
return (
<Card>
<CardHeader className="flex flex-row items-center justify-between space-y-0 pb-2">
<CardTitle className="text-sm font-medium">{title}</CardTitle>
<Icon className="h-4 w-4 text-muted-foreground" />
</CardHeader>
<CardContent>
<div className="text-2xl font-bold">{value.toLocaleString('vi-VN')}</div>
{(description || trendValue) && (
<p className="text-xs text-muted-foreground mt-1 flex items-center gap-1">
{trend === 'up' && <ArrowUpRight className="h-3 w-3 text-green-600" />}
{trend === 'down' && <ArrowDownRight className="h-3 w-3 text-red-600" />}
{trendValue && (
<span className={trend === 'up' ? 'text-green-600' : trend === 'down' ? 'text-red-600' : ''}>
{trendValue}
</span>
)}
{description && <span>{description}</span>}
</p>
)}
</CardContent>
</Card>
);
}
function RevenueChart({ data }: { data: RevenueStatsItem[] }) {
if (data.length === 0) {
return (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Chưa dữ liệu doanh thu
</div>
);
}
const maxRevenue = Math.max(...data.map((d) => d.totalRevenue), 1);
return (
<div className="space-y-3">
{data.map((item) => {
const pct = (item.totalRevenue / maxRevenue) * 100;
return (
<div key={item.period} className="space-y-1">
<div className="flex items-center justify-between text-sm">
<span className="text-muted-foreground">{item.period}</span>
<span className="font-medium">
{item.totalRevenue.toLocaleString('vi-VN')} VND
</span>
</div>
<div className="h-2 w-full rounded-full bg-muted">
<div
className="h-2 rounded-full bg-primary transition-all"
style={{ width: `${pct}%` }}
/>
</div>
<div className="flex gap-3 text-xs text-muted-foreground">
<span>Subscription: {item.subscriptionRevenue.toLocaleString('vi-VN')}</span>
<span>Listing fee: {item.listingFeeRevenue.toLocaleString('vi-VN')}</span>
<span>{item.transactionCount} GD</span>
</div>
</div>
);
})}
</div>
);
}
export default function AdminDashboardPage() {
const [stats, setStats] = useState<DashboardStats | null>(null);
const [revenue, setRevenue] = useState<RevenueStatsItem[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const fetchData = useCallback(async () => {
setLoading(true);
setError(null);
try {
const endDate = new Date().toISOString().split('T')[0]!;
const startDate = new Date(Date.now() - 180 * 86400000).toISOString().split('T')[0]!;
const [statsData, revenueData] = await Promise.all([
adminApi.getDashboardStats(),
adminApi.getRevenueStats(startDate, endDate, 'month'),
]);
setStats(statsData);
setRevenue(revenueData);
} catch (e) {
setError(e instanceof Error ? e.message : 'Không thể tải dữ liệu');
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
fetchData();
}, [fetchData]);
if (loading) {
return (
<div className="flex h-64 items-center justify-center">
<RefreshCw className="h-6 w-6 animate-spin text-muted-foreground" />
</div>
);
}
if (error) {
return (
<div className="flex h-64 flex-col items-center justify-center gap-3">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={fetchData}>
Thử lại
</Button>
</div>
);
}
if (!stats) return null;
return (
<div className="space-y-6">
<div className="flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold tracking-tight">Dashboard</h1>
<p className="text-sm text-muted-foreground">
Tổng quan hệ thống GoodGo
</p>
</div>
<Button variant="outline" size="sm" onClick={fetchData}>
<RefreshCw className="mr-2 h-4 w-4" />
Làm mới
</Button>
</div>
{/* Stats grid */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Tổng người dùng"
value={stats.totalUsers}
icon={Users}
trend="up"
trendValue={`+${stats.newUsersLast30Days}`}
description="trong 30 ngày"
/>
<StatCard
title="Tổng tin đăng"
value={stats.totalListings}
icon={Home}
trend="up"
trendValue={`+${stats.newListingsLast30Days}`}
description="trong 30 ngày"
/>
<StatCard
title="Tin đang hoạt động"
value={stats.activeListings}
icon={ClipboardCheck}
description="đang hiển thị"
/>
<StatCard
title="Chờ kiểm duyệt"
value={stats.pendingModerationCount}
icon={Clock}
description="tin đang chờ duyệt"
/>
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
<StatCard
title="Tổng đại lý"
value={stats.totalAgents}
icon={UserCheck}
/>
<StatCard
title="Đại lý đã xác minh"
value={stats.verifiedAgents}
icon={ShieldCheck}
/>
<StatCard
title="Tổng giao dịch"
value={stats.totalTransactions}
icon={TrendingUp}
/>
</div>
{/* Revenue chart */}
<Card>
<CardHeader>
<CardTitle className="text-base">Doanh thu 6 tháng gần nhất</CardTitle>
</CardHeader>
<CardContent>
<RevenueChart data={revenue} />
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,478 @@
'use client';
import {
Search,
RefreshCw,
ChevronLeft,
ChevronRight,
UserX,
UserCheck,
Eye,
X,
} from 'lucide-react';
import { useEffect, useState, useCallback } from 'react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import {
Dialog,
DialogContent,
DialogHeader,
DialogTitle,
DialogDescription,
DialogFooter,
} from '@/components/ui/dialog';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Table, TableBody, TableCell, TableHead, TableHeader, TableRow } from '@/components/ui/table';
import {
adminApi,
type UserListItem,
type UserDetail,
type PaginatedResult,
} from '@/lib/admin-api';
function kycBadgeVariant(status: string) {
switch (status) {
case 'VERIFIED': return 'success' as const;
case 'PENDING': return 'warning' as const;
case 'REJECTED': return 'destructive' as const;
default: return 'secondary' as const;
}
}
function roleBadgeVariant(role: string) {
switch (role) {
case 'ADMIN': return 'default' as const;
case 'AGENT': return 'info' as const;
default: return 'secondary' as const;
}
}
function UserDetailPanel({
user,
onClose,
onToggleStatus,
}: {
user: UserDetail;
onClose: () => void;
onToggleStatus: (userId: string, isActive: boolean) => void;
}) {
return (
<div className="space-y-4">
<div className="flex items-start justify-between">
<div>
<h3 className="text-lg font-semibold">{user.fullName}</h3>
<p className="text-sm text-muted-foreground">{user.phone}</p>
{user.email && (
<p className="text-sm text-muted-foreground">{user.email}</p>
)}
</div>
<button onClick={onClose}>
<X className="h-5 w-5 text-muted-foreground" />
</button>
</div>
<div className="grid grid-cols-2 gap-3">
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">Vai trò</div>
<Badge variant={roleBadgeVariant(user.role)} className="mt-1">{user.role}</Badge>
</div>
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">KYC</div>
<Badge variant={kycBadgeVariant(user.kycStatus)} className="mt-1">{user.kycStatus}</Badge>
</div>
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">Trạng thái</div>
<Badge variant={user.isActive ? 'success' : 'destructive'} className="mt-1">
{user.isActive ? 'Hoạt động' : 'Bị khóa'}
</Badge>
</div>
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground">Ngày tạo</div>
<div className="mt-1 text-sm font-medium">
{new Date(user.createdAt).toLocaleDateString('vi-VN')}
</div>
</div>
</div>
<div className="grid grid-cols-3 gap-3">
<div className="rounded-md border p-3 text-center">
<div className="text-2xl font-bold">{user.listingsCount}</div>
<div className="text-xs text-muted-foreground">Tin đăng</div>
</div>
<div className="rounded-md border p-3 text-center">
<div className="text-2xl font-bold">{user.activeListingsCount}</div>
<div className="text-xs text-muted-foreground">Đang hiển thị</div>
</div>
<div className="rounded-md border p-3 text-center">
<div className="text-2xl font-bold">{user.transactionsCount}</div>
<div className="text-xs text-muted-foreground">Giao dịch</div>
</div>
</div>
{user.subscription && (
<div className="rounded-md border p-3">
<div className="text-xs text-muted-foreground mb-1">Gói đăng </div>
<div className="flex items-center gap-2">
<Badge variant="info">{user.subscription.planTier}</Badge>
<Badge variant={user.subscription.status === 'active' ? 'success' : 'warning'}>
{user.subscription.status}
</Badge>
<span className="text-xs text-muted-foreground">
đến {new Date(user.subscription.currentPeriodEnd).toLocaleDateString('vi-VN')}
</span>
</div>
</div>
)}
{user.recentActivity.length > 0 && (
<div>
<h4 className="text-sm font-medium mb-2">Hoạt đng gần đây</h4>
<div className="space-y-2">
{user.recentActivity.slice(0, 5).map((activity, i) => (
<div key={i} className="flex items-start gap-2 text-sm">
<div className="mt-0.5 h-2 w-2 rounded-full bg-muted-foreground flex-shrink-0" />
<div>
<span>{activity.description}</span>
<span className="ml-2 text-xs text-muted-foreground">
{new Date(activity.createdAt).toLocaleDateString('vi-VN')}
</span>
</div>
</div>
))}
</div>
</div>
)}
<div className="flex gap-2">
<Button
variant={user.isActive ? 'destructive' : 'default'}
size="sm"
className="flex-1"
onClick={() => onToggleStatus(user.id, !user.isActive)}
>
{user.isActive ? (
<>
<UserX className="mr-2 h-4 w-4" /> Khóa tài khoản
</>
) : (
<>
<UserCheck className="mr-2 h-4 w-4" /> Mở khóa
</>
)}
</Button>
</div>
</div>
);
}
export default function AdminUsersPage() {
const [result, setResult] = useState<PaginatedResult<UserListItem> | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [page, setPage] = useState(1);
const [search, setSearch] = useState('');
const [roleFilter, setRoleFilter] = useState('');
const [statusFilter, setStatusFilter] = useState('');
// Detail panel
const [selectedUser, setSelectedUser] = useState<UserDetail | null>(null);
const [detailLoading, setDetailLoading] = useState(false);
// Ban dialog
const [banDialog, setBanDialog] = useState<{ userId: string; isActive: boolean } | null>(null);
const [banReason, setBanReason] = useState('');
const [actionLoading, setActionLoading] = useState(false);
const [actionError, setActionError] = useState<string | null>(null);
const fetchUsers = useCallback(async () => {
setLoading(true);
setError(null);
try {
const data = await adminApi.getUsers({
page,
limit: 20,
role: roleFilter || undefined,
isActive: statusFilter === '' ? undefined : statusFilter === 'active',
search: search || undefined,
});
setResult(data);
} catch (e) {
setError(e instanceof Error ? e.message : 'Không thể tải danh sách');
} finally {
setLoading(false);
}
}, [page, roleFilter, statusFilter, search]);
useEffect(() => {
fetchUsers();
}, [fetchUsers]);
const openDetail = async (userId: string) => {
setDetailLoading(true);
try {
const detail = await adminApi.getUserDetail(userId);
setSelectedUser(detail);
} catch (e) {
setActionError(e instanceof Error ? e.message : 'Không thể tải chi tiết người dùng');
} finally {
setDetailLoading(false);
}
};
const handleToggleStatus = (userId: string, newActive: boolean) => {
setBanDialog({ userId, isActive: newActive });
setBanReason('');
};
const confirmToggleStatus = async () => {
if (!banDialog) return;
setActionLoading(true);
try {
await adminApi.banUser(
banDialog.userId,
banReason || 'Admin action',
banDialog.isActive, // unban = true if making active
);
setBanDialog(null);
setSelectedUser(null);
fetchUsers();
} catch (e) {
setActionError(e instanceof Error ? e.message : 'Thao tác thất bại. Vui lòng thử lại.');
} finally {
setActionLoading(false);
}
};
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
setPage(1);
fetchUsers();
};
return (
<div className="space-y-6">
<div>
<h1 className="text-2xl font-bold tracking-tight">Quản người dùng</h1>
<p className="text-sm text-muted-foreground">
Danh sách quản tài khoản người dùng
</p>
</div>
{actionError && (
<div className="flex items-center justify-between rounded-md border border-destructive/50 bg-destructive/10 px-4 py-2 text-sm text-destructive">
<span>{actionError}</span>
<button onClick={() => setActionError(null)} className="ml-2">
<X className="h-4 w-4" />
</button>
</div>
)}
{/* Filters */}
<div className="flex flex-col gap-3 sm:flex-row sm:items-end">
<form onSubmit={handleSearch} className="flex flex-1 gap-2">
<div className="relative flex-1">
<Search className="absolute left-3 top-1/2 h-4 w-4 -translate-y-1/2 text-muted-foreground" />
<Input
placeholder="Tìm theo tên, SĐT, email..."
value={search}
onChange={(e) => setSearch(e.target.value)}
className="pl-9"
/>
</div>
<Button type="submit" size="sm">
Tìm
</Button>
</form>
<Select
value={roleFilter}
onChange={(e) => { setRoleFilter(e.target.value); setPage(1); }}
className="w-40"
>
<option value="">Tất cả vai trò</option>
<option value="USER">Người dùng</option>
<option value="AGENT">Đi </option>
<option value="ADMIN">Admin</option>
</Select>
<Select
value={statusFilter}
onChange={(e) => { setStatusFilter(e.target.value); setPage(1); }}
className="w-40"
>
<option value="">Tất cả trạng thái</option>
<option value="active">Hoạt đng</option>
<option value="inactive">Bị khóa</option>
</Select>
</div>
<div className="grid gap-6 lg:grid-cols-[1fr_380px]">
{/* Table */}
<Card>
<CardContent className="p-0">
{loading ? (
<div className="flex h-48 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : error ? (
<div className="flex h-48 flex-col items-center justify-center gap-2">
<p className="text-sm text-destructive">{error}</p>
<Button variant="outline" size="sm" onClick={fetchUsers}>
Thử lại
</Button>
</div>
) : !result || result.data.length === 0 ? (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Không tìm thấy người dùng nào
</div>
) : (
<>
<Table>
<TableHeader>
<TableRow>
<TableHead>Họ tên</TableHead>
<TableHead className="hidden sm:table-cell">SĐT</TableHead>
<TableHead>Vai trò</TableHead>
<TableHead>KYC</TableHead>
<TableHead className="hidden md:table-cell">Trạng thái</TableHead>
<TableHead className="w-10"></TableHead>
</TableRow>
</TableHeader>
<TableBody>
{result.data.map((user) => (
<TableRow
key={user.id}
className="cursor-pointer"
onClick={() => openDetail(user.id)}
>
<TableCell>
<div className="font-medium">{user.fullName}</div>
{user.email && (
<div className="text-xs text-muted-foreground">{user.email}</div>
)}
</TableCell>
<TableCell className="hidden sm:table-cell">{user.phone}</TableCell>
<TableCell>
<Badge variant={roleBadgeVariant(user.role)}>{user.role}</Badge>
</TableCell>
<TableCell>
<Badge variant={kycBadgeVariant(user.kycStatus)}>{user.kycStatus}</Badge>
</TableCell>
<TableCell className="hidden md:table-cell">
<Badge variant={user.isActive ? 'success' : 'destructive'}>
{user.isActive ? 'Hoạt động' : 'Bị khóa'}
</Badge>
</TableCell>
<TableCell>
<Eye className="h-4 w-4 text-muted-foreground" />
</TableCell>
</TableRow>
))}
</TableBody>
</Table>
{/* Pagination */}
{result.totalPages > 1 && (
<div className="flex items-center justify-between border-t px-4 py-3">
<span className="text-sm text-muted-foreground">
Trang {result.page}/{result.totalPages} ({result.total} người dùng)
</span>
<div className="flex gap-1">
<Button
variant="outline"
size="icon"
disabled={page <= 1}
onClick={() => setPage((p) => p - 1)}
>
<ChevronLeft className="h-4 w-4" />
</Button>
<Button
variant="outline"
size="icon"
disabled={page >= result.totalPages}
onClick={() => setPage((p) => p + 1)}
>
<ChevronRight className="h-4 w-4" />
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Detail sidebar */}
<div className="hidden lg:block">
<Card>
<CardContent className="p-4">
{detailLoading ? (
<div className="flex h-48 items-center justify-center">
<RefreshCw className="h-5 w-5 animate-spin text-muted-foreground" />
</div>
) : selectedUser ? (
<UserDetailPanel
user={selectedUser}
onClose={() => setSelectedUser(null)}
onToggleStatus={handleToggleStatus}
/>
) : (
<div className="flex h-48 items-center justify-center text-sm text-muted-foreground">
Chọn người dùng đ xem chi tiết
</div>
)}
</CardContent>
</Card>
</div>
</div>
{/* Mobile detail dialog */}
<Dialog open={!!selectedUser && typeof window !== 'undefined' && window.innerWidth < 1024} onOpenChange={() => setSelectedUser(null)}>
<DialogContent>
{selectedUser && (
<UserDetailPanel
user={selectedUser}
onClose={() => setSelectedUser(null)}
onToggleStatus={handleToggleStatus}
/>
)}
</DialogContent>
</Dialog>
{/* Ban/unban confirmation */}
<Dialog open={!!banDialog} onOpenChange={() => setBanDialog(null)}>
<DialogContent>
<DialogHeader>
<DialogTitle>
{banDialog?.isActive ? 'Mở khóa tài khoản' : 'Khóa tài khoản'}
</DialogTitle>
<DialogDescription>
{banDialog?.isActive
? 'Người dùng sẽ có thể đăng nhập và sử dụng hệ thống.'
: 'Người dùng sẽ không thể đăng nhập và các tin đăng sẽ bị ẩn.'}
</DialogDescription>
</DialogHeader>
<Input
placeholder="Lý do..."
value={banReason}
onChange={(e) => setBanReason(e.target.value)}
/>
<DialogFooter>
<Button variant="outline" onClick={() => setBanDialog(null)}>
Hủy
</Button>
<Button
variant={banDialog?.isActive ? 'default' : 'destructive'}
onClick={confirmToggleStatus}
disabled={actionLoading}
>
{actionLoading ? 'Đang xử lý...' : 'Xác nhận'}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
</div>
);
}

View File

@@ -0,0 +1,58 @@
'use client';
import { useEffect } from 'react';
export default function AdminError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Admin error:', error);
}, [error]);
return (
<div className="flex min-h-[400px] flex-col items-center justify-center px-4">
<div className="mx-auto max-w-md text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
<svg
className="h-7 w-7 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 15v2m-6 4h12a2 2 0 002-2v-6a2 2 0 00-2-2H6a2 2 0 00-2 2v6a2 2 0 002 2zm10-10V7a4 4 0 00-8 0v4h8z"
/>
</svg>
</div>
<h2 className="mt-4 text-xl font-semibold">Lỗi trang quản trị</h2>
<p className="mt-2 text-sm text-muted-foreground">
Không thể tải trang quản trị. Vui lòng thử lại sau.
</p>
{error.digest && (
<p className="mt-1 text-xs text-muted-foreground"> lỗi: {error.digest}</p>
)}
<div className="mt-6 flex justify-center gap-3">
<button
onClick={reset}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
>
Thử lại
</button>
<a
href="/admin"
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
Tải lại trang
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,145 @@
'use client';
import {
LayoutDashboard,
Users,
ClipboardList,
ShieldCheck,
LogOut,
Menu,
X,
} from 'lucide-react';
import { usePathname, useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { Link } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
export default function AdminLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const router = useRouter();
const { user, logout } = useAuthStore();
const [sidebarOpen, setSidebarOpen] = useState(false);
const t = useTranslations();
const adminNavItems = [
{ href: '/admin' as const, label: t('adminNav.dashboard'), icon: LayoutDashboard },
{ href: '/admin/users' as const, label: t('adminNav.users'), icon: Users },
{ href: '/admin/moderation' as const, label: t('adminNav.moderation'), icon: ClipboardList },
{ href: '/admin/kyc' as const, label: t('adminNav.kyc'), icon: ShieldCheck },
];
useEffect(() => {
if (user && user.role !== 'ADMIN') {
router.replace('/dashboard');
}
}, [user, router]);
if (!user) {
return (
<div className="flex min-h-screen items-center justify-center" role="status">
<div className="text-muted-foreground">{t('common.loading')}</div>
</div>
);
}
if (user.role !== 'ADMIN') {
return null;
}
return (
<div className="flex min-h-screen bg-background">
{/* Mobile overlay */}
{sidebarOpen && (
<div
className="fixed inset-0 z-40 bg-black/50 lg:hidden"
onClick={() => setSidebarOpen(false)}
aria-hidden="true"
/>
)}
{/* Sidebar */}
<aside
role="navigation"
aria-label={t('nav.adminNav')}
className={cn(
'fixed inset-y-0 left-0 z-50 w-64 border-r bg-card transition-transform lg:static lg:translate-x-0',
sidebarOpen ? 'translate-x-0' : '-translate-x-full',
)}
>
<div className="flex h-14 items-center border-b px-4">
<Link href="/admin" className="flex items-center gap-2">
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
<span className="rounded bg-primary/10 px-1.5 py-0.5 text-xs font-semibold text-primary">
{t('common.admin')}
</span>
</Link>
<button
aria-label={t('adminNav.closeMenu')}
className="ml-auto lg:hidden"
onClick={() => setSidebarOpen(false)}
>
<X className="h-5 w-5" />
</button>
</div>
<nav aria-label={t('nav.adminNav')} className="flex flex-col gap-1 p-3">
{adminNavItems.map((item) => {
const Icon = item.icon;
const isActive =
item.href === '/admin'
? pathname === '/admin' || pathname.match(/^\/(vi|en)\/admin$/)
: pathname.includes(item.href);
return (
<Link
key={item.href}
href={item.href}
onClick={() => setSidebarOpen(false)}
className={cn(
'flex items-center gap-3 rounded-md px-3 py-2 text-sm font-medium transition-colors',
isActive
? 'bg-primary/10 text-primary'
: 'text-muted-foreground hover:bg-accent hover:text-accent-foreground',
)}
>
<Icon className="h-4 w-4" aria-hidden="true" />
{item.label}
</Link>
);
})}
</nav>
<div className="mt-auto border-t p-3">
<div className="mb-2 flex items-center justify-between px-3">
<span className="text-xs text-muted-foreground truncate">{user.fullName}</span>
<LanguageSwitcher />
</div>
<Button
variant="ghost"
size="sm"
className="w-full justify-start gap-2"
onClick={() => logout()}
>
<LogOut className="h-4 w-4" aria-hidden="true" />
{t('common.logout')}
</Button>
</div>
</aside>
{/* Main content */}
<div className="flex flex-1 flex-col">
<header className="sticky top-0 z-30 flex h-14 items-center border-b bg-background/95 px-4 backdrop-blur lg:hidden">
<button aria-label={t('adminNav.openMenu')} onClick={() => setSidebarOpen(true)}>
<Menu className="h-5 w-5" />
</button>
<span className="ml-3 text-sm font-semibold">{t('common.goodgo')} {t('common.admin')}</span>
</header>
<main id="main-content" role="main" className="flex-1 p-4 md:p-6">{children}</main>
</div>
</div>
);
}

View File

@@ -0,0 +1,60 @@
export default function AdminLoading() {
return (
<div className="space-y-6">
{/* Header skeleton */}
<div className="flex items-center justify-between">
<div>
<div className="h-7 w-36 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-48 animate-pulse rounded bg-muted" />
</div>
<div className="h-9 w-24 animate-pulse rounded-md bg-muted" />
</div>
{/* Stats grid skeleton */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-6 shadow-sm">
<div className="flex items-center justify-between">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
</div>
<div className="mt-3 h-7 w-20 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-28 animate-pulse rounded bg-muted" />
</div>
))}
</div>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 3 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-6 shadow-sm">
<div className="flex items-center justify-between">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-4 w-4 animate-pulse rounded bg-muted" />
</div>
<div className="mt-3 h-7 w-20 animate-pulse rounded bg-muted" />
</div>
))}
</div>
{/* Revenue chart skeleton */}
<div className="rounded-lg border bg-card shadow-sm">
<div className="p-6">
<div className="h-5 w-48 animate-pulse rounded bg-muted" />
</div>
<div className="px-6 pb-6">
<div className="space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="space-y-2">
<div className="flex items-center justify-between">
<div className="h-3 w-20 animate-pulse rounded bg-muted" />
<div className="h-3 w-28 animate-pulse rounded bg-muted" />
</div>
<div className="h-2 w-full animate-pulse rounded-full bg-muted" />
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,199 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/auth-store';
// Mock next-intl with Vietnamese messages
const viMessages = await import('@/messages/vi.json');
vi.mock('next-intl', () => ({
useTranslations: (namespace: string) => {
const messages = viMessages.default ?? viMessages;
const ns = messages[namespace as keyof typeof messages] as Record<string, unknown> | undefined;
return (key: string) => {
if (!ns) return key;
const parts = key.split('.');
let val: unknown = ns;
for (const p of parts) {
val = (val as Record<string, unknown>)?.[p];
}
return typeof val === 'string' ? val : key;
};
},
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
}));
// Mock next/navigation
const mockPush = vi.fn();
const mockSearchParams = new URLSearchParams();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
useSearchParams: () => mockSearchParams,
}));
// Mock next/link
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
// Mock i18n navigation (Link used in login page)
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => '/login',
redirect: vi.fn(),
}));
// Mock auth store
vi.mock('@/lib/auth-store', () => {
const store = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: vi.fn(),
register: vi.fn(),
handleOAuthCallback: vi.fn(),
logout: vi.fn(),
refreshToken: vi.fn(),
fetchProfile: vi.fn(),
initialize: vi.fn(),
clearError: vi.fn(),
};
return {
useAuthStore: vi.fn((selector) => {
if (typeof selector === 'function') return selector(store);
return store;
}),
};
});
import LoginPage from '../login/page';
const mockedUseAuthStore = vi.mocked(useAuthStore);
describe('LoginPage', () => {
let mockStore: {
user: null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: ReturnType<typeof vi.fn>;
register: ReturnType<typeof vi.fn>;
handleOAuthCallback: ReturnType<typeof vi.fn>;
logout: ReturnType<typeof vi.fn>;
refreshToken: ReturnType<typeof vi.fn>;
fetchProfile: ReturnType<typeof vi.fn>;
initialize: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.clearAllMocks();
mockStore = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: vi.fn(),
register: vi.fn(),
handleOAuthCallback: vi.fn(),
logout: vi.fn(),
refreshToken: vi.fn(),
fetchProfile: vi.fn(),
initialize: vi.fn(),
clearError: vi.fn(),
};
mockedUseAuthStore.mockImplementation((selector) => {
if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore);
return mockStore as ReturnType<typeof useAuthStore>;
});
});
it('renders login form with phone and password fields', () => {
render(<LoginPage />);
expect(screen.getByRole('heading', { name: 'Đăng nhập' })).toBeInTheDocument();
expect(screen.getByLabelText('Số điện thoại')).toBeInTheDocument();
expect(screen.getByLabelText('Mật khẩu')).toBeInTheDocument();
expect(screen.getByRole('button', { name: /đăng nhập/i })).toBeInTheDocument();
});
it('renders OAuth buttons', () => {
render(<LoginPage />);
expect(screen.getByRole('button', { name: /google/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /zalo/i })).toBeInTheDocument();
});
it('renders register link', () => {
render(<LoginPage />);
const registerLink = screen.getByRole('link', { name: /đăng ký/i });
expect(registerLink).toHaveAttribute('href', '/register');
});
it('submits form with valid data', async () => {
mockStore.login.mockResolvedValue(undefined);
render(<LoginPage />);
await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678');
await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123');
await userEvent.click(screen.getByRole('button', { name: /đăng nhập/i }));
await waitFor(() => {
expect(mockStore.login).toHaveBeenCalledWith({
phone: '0912345678',
password: 'password123',
});
});
});
it('shows validation errors for empty fields', async () => {
render(<LoginPage />);
await userEvent.click(screen.getByRole('button', { name: /đăng nhập/i }));
await waitFor(() => {
const alerts = screen.getAllByRole('alert');
expect(alerts.length).toBeGreaterThan(0);
});
});
it('toggles password visibility', async () => {
render(<LoginPage />);
const passwordInput = screen.getByLabelText('Mật khẩu');
expect(passwordInput).toHaveAttribute('type', 'password');
await userEvent.click(screen.getByText('Hiện'));
expect(passwordInput).toHaveAttribute('type', 'text');
await userEvent.click(screen.getByText('Ẩn'));
expect(passwordInput).toHaveAttribute('type', 'password');
});
it('displays store error message', () => {
mockStore.error = 'Sai mật khẩu';
render(<LoginPage />);
expect(screen.getByText('Sai mật khẩu')).toBeInTheDocument();
});
it('navigates to home after successful login', async () => {
mockStore.login.mockResolvedValue(undefined);
render(<LoginPage />);
await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678');
await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123');
await userEvent.click(screen.getByRole('button', { name: /đăng nhập/i }));
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/');
});
});
});

View File

@@ -0,0 +1,199 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
import { useAuthStore } from '@/lib/auth-store';
// Mock next-intl with Vietnamese messages
const viMessages = await import('@/messages/vi.json');
vi.mock('next-intl', () => ({
useTranslations: (namespace: string) => {
const messages = viMessages.default ?? viMessages;
const ns = messages[namespace as keyof typeof messages] as Record<string, unknown> | undefined;
return (key: string) => {
if (!ns) return key;
const parts = key.split('.');
let val: unknown = ns;
for (const p of parts) {
val = (val as Record<string, unknown>)?.[p];
}
return typeof val === 'string' ? val : key;
};
},
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
}));
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}));
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
// Mock i18n navigation (Link used in register page)
vi.mock('@/i18n/navigation', () => ({
Link: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
useRouter: () => ({ push: vi.fn(), replace: vi.fn() }),
usePathname: () => '/register',
redirect: vi.fn(),
}));
vi.mock('@/lib/auth-store', () => {
const store = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: vi.fn(),
register: vi.fn(),
handleOAuthCallback: vi.fn(),
logout: vi.fn(),
refreshToken: vi.fn(),
fetchProfile: vi.fn(),
initialize: vi.fn(),
clearError: vi.fn(),
};
return {
useAuthStore: vi.fn((selector) => {
if (typeof selector === 'function') return selector(store);
return store;
}),
};
});
import RegisterPage from '../register/page';
const mockedUseAuthStore = vi.mocked(useAuthStore);
describe('RegisterPage', () => {
let mockStore: {
user: null;
isAuthenticated: boolean;
isLoading: boolean;
error: string | null;
login: ReturnType<typeof vi.fn>;
register: ReturnType<typeof vi.fn>;
handleOAuthCallback: ReturnType<typeof vi.fn>;
logout: ReturnType<typeof vi.fn>;
refreshToken: ReturnType<typeof vi.fn>;
fetchProfile: ReturnType<typeof vi.fn>;
initialize: ReturnType<typeof vi.fn>;
clearError: ReturnType<typeof vi.fn>;
};
beforeEach(() => {
vi.clearAllMocks();
mockStore = {
user: null,
isAuthenticated: false,
isLoading: false,
error: null,
login: vi.fn(),
register: vi.fn(),
handleOAuthCallback: vi.fn(),
logout: vi.fn(),
refreshToken: vi.fn(),
fetchProfile: vi.fn(),
initialize: vi.fn(),
clearError: vi.fn(),
};
mockedUseAuthStore.mockImplementation((selector) => {
if (typeof selector === 'function') return (selector as (s: typeof mockStore) => unknown)(mockStore);
return mockStore as ReturnType<typeof useAuthStore>;
});
});
it('renders register form with all fields', () => {
render(<RegisterPage />);
expect(screen.getByText('Đăng ký')).toBeInTheDocument();
expect(screen.getByLabelText('Họ và tên')).toBeInTheDocument();
expect(screen.getByLabelText('Số điện thoại')).toBeInTheDocument();
expect(screen.getByLabelText(/email/i)).toBeInTheDocument();
expect(screen.getByLabelText('Mật khẩu')).toBeInTheDocument();
expect(screen.getByLabelText('Xác nhận mật khẩu')).toBeInTheDocument();
});
it('renders login link', () => {
render(<RegisterPage />);
const loginLink = screen.getByRole('link', { name: /đăng nhập/i });
expect(loginLink).toHaveAttribute('href', '/login');
});
it('submits form with valid data', async () => {
mockStore.register.mockResolvedValue(undefined);
render(<RegisterPage />);
await userEvent.type(screen.getByLabelText('Họ và tên'), 'Nguyen Van A');
await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678');
await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123');
await userEvent.type(screen.getByLabelText('Xác nhận mật khẩu'), 'password123');
await userEvent.click(screen.getByRole('button', { name: /đăng ký/i }));
await waitFor(() => {
expect(mockStore.register).toHaveBeenCalledWith({
phone: '0912345678',
password: 'password123',
fullName: 'Nguyen Van A',
email: undefined,
});
});
});
it('shows validation error for short password', async () => {
render(<RegisterPage />);
await userEvent.type(screen.getByLabelText('Họ và tên'), 'Nguyen Van A');
await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678');
await userEvent.type(screen.getByLabelText('Mật khẩu'), 'short');
await userEvent.type(screen.getByLabelText('Xác nhận mật khẩu'), 'short');
await userEvent.click(screen.getByRole('button', { name: /đăng ký/i }));
await waitFor(() => {
const alerts = screen.getAllByRole('alert');
expect(alerts.length).toBeGreaterThan(0);
});
});
it('shows error when passwords do not match', async () => {
render(<RegisterPage />);
await userEvent.type(screen.getByLabelText('Họ và tên'), 'Nguyen Van A');
await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678');
await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123');
await userEvent.type(screen.getByLabelText('Xác nhận mật khẩu'), 'differentpw');
await userEvent.click(screen.getByRole('button', { name: /đăng ký/i }));
await waitFor(() => {
const alerts = screen.getAllByRole('alert');
expect(alerts.length).toBeGreaterThan(0);
});
});
it('displays store error message', () => {
mockStore.error = 'Số điện thoại đã tồn tại';
render(<RegisterPage />);
expect(screen.getByText('Số điện thoại đã tồn tại')).toBeInTheDocument();
});
it('navigates to home after successful registration', async () => {
mockStore.register.mockResolvedValue(undefined);
render(<RegisterPage />);
await userEvent.type(screen.getByLabelText('Họ và tên'), 'Nguyen Van A');
await userEvent.type(screen.getByLabelText('Số điện thoại'), '0912345678');
await userEvent.type(screen.getByLabelText('Mật khẩu'), 'password123');
await userEvent.type(screen.getByLabelText('Xác nhận mật khẩu'), 'password123');
await userEvent.click(screen.getByRole('button', { name: /đăng ký/i }));
await waitFor(() => {
expect(mockPush).toHaveBeenCalledWith('/');
});
});
});

View File

@@ -0,0 +1,58 @@
'use client';
import { useEffect } from 'react';
export default function AuthError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
useEffect(() => {
console.error('Auth error:', error);
}, [error]);
return (
<div className="rounded-lg border bg-card p-8 shadow-sm">
<div className="text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
<svg
className="h-7 w-7 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h2 className="mt-4 text-xl font-semibold">Lỗi xác thực</h2>
<p className="mt-2 text-sm text-muted-foreground">
Đã xảy ra lỗi trong quá trình xác thực. Vui lòng thử lại.
</p>
{error.digest && (
<p className="mt-1 text-xs text-muted-foreground"> lỗi: {error.digest}</p>
)}
<div className="mt-6 flex justify-center gap-3">
<button
onClick={reset}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90"
>
Thử lại
</button>
<a
href="/login"
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
Về trang đăng nhập
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,7 @@
export default function AuthLayout({ children }: { children: React.ReactNode }) {
return (
<main id="main-content" role="main" className="flex min-h-screen items-center justify-center bg-muted/40 px-4 py-12">
<div className="w-full max-w-md">{children}</div>
</main>
);
}

View File

@@ -0,0 +1,40 @@
export default function AuthLoading() {
return (
<div className="rounded-lg border bg-card p-8 shadow-sm">
<div className="space-y-6">
{/* Logo / title skeleton */}
<div className="text-center">
<div className="mx-auto h-8 w-24 animate-pulse rounded bg-muted" />
<div className="mx-auto mt-3 h-5 w-40 animate-pulse rounded bg-muted" />
<div className="mx-auto mt-2 h-4 w-56 animate-pulse rounded bg-muted" />
</div>
{/* Form fields skeleton */}
<div className="space-y-4">
<div>
<div className="h-4 w-16 animate-pulse rounded bg-muted" />
<div className="mt-2 h-10 w-full animate-pulse rounded-md bg-muted" />
</div>
<div>
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="mt-2 h-10 w-full animate-pulse rounded-md bg-muted" />
</div>
</div>
{/* Submit button skeleton */}
<div className="h-10 w-full animate-pulse rounded-md bg-muted" />
{/* OAuth buttons skeleton */}
<div className="space-y-3">
<div className="flex items-center gap-3">
<div className="h-px flex-1 bg-muted" />
<div className="h-3 w-12 animate-pulse rounded bg-muted" />
<div className="h-px flex-1 bg-muted" />
</div>
<div className="h-10 w-full animate-pulse rounded-md bg-muted" />
<div className="h-10 w-full animate-pulse rounded-md bg-muted" />
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,138 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { OAuthButtons } from '@/components/auth/oauth-buttons';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Link } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { loginSchema, type LoginFormData } from '@/lib/validations/auth';
export default function LoginPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { login, isLoading, error, clearError } = useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const t = useTranslations('auth');
const oauthError = searchParams.get('error');
const oauthErrorMessage = oauthError
? t(`oauthErrors.${oauthError}` as Parameters<typeof t>[0]) ?? t('oauthErrors.default')
: null;
const {
register,
handleSubmit,
formState: { errors },
} = useForm<LoginFormData>({
resolver: zodResolver(loginSchema),
});
const onSubmit = async (data: LoginFormData) => {
try {
await login(data);
router.push('/');
} catch {
// Error is handled by the store
}
};
return (
<Card>
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">{t('loginTitle')}</CardTitle>
<CardDescription>{t('loginDescription')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{oauthErrorMessage && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive" role="alert">
{oauthErrorMessage}
</div>
)}
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive" role="alert">
{error}
<button type="button" onClick={clearError} className="ml-2 font-medium underline">
{t('dismiss')}
</button>
</div>
)}
<div className="space-y-2">
<Label htmlFor="phone">{t('phone')}</Label>
<Input
id="phone"
type="tel"
placeholder={t('phonePlaceholder')}
autoComplete="tel"
aria-describedby={errors.phone ? 'phone-error' : undefined}
aria-invalid={!!errors.phone}
{...register('phone')}
/>
{errors.phone && (
<p id="phone-error" className="text-sm text-destructive" role="alert">{errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">{t('password')}</Label>
<button
type="button"
className="text-xs text-muted-foreground hover:text-primary"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? t('hidePassword') : t('showPassword')}
>
{showPassword ? t('hidePassword') : t('showPassword')}
</button>
</div>
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder={t('passwordPlaceholder')}
autoComplete="current-password"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
{...register('password')}
/>
{errors.password && (
<p id="password-error" className="text-sm text-destructive" role="alert">{errors.password.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
{t('loginButton')}
</Button>
</form>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">{t('orLoginWith')}</span>
</div>
</div>
<OAuthButtons />
</CardContent>
<CardFooter className="justify-center">
<p className="text-sm text-muted-foreground">
{t('noAccount')}{' '}
<Link href="/register" className="font-medium text-primary hover:underline">
{t('registerLink')}
</Link>
</p>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,180 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { Loader2 } from 'lucide-react';
import { useRouter } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useState } from 'react';
import { useForm } from 'react-hook-form';
import { OAuthButtons } from '@/components/auth/oauth-buttons';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardFooter, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Link } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { registerSchema, type RegisterFormData } from '@/lib/validations/auth';
export default function RegisterPage() {
const router = useRouter();
const { register: registerUser, isLoading, error, clearError } = useAuthStore();
const [showPassword, setShowPassword] = useState(false);
const t = useTranslations('auth');
const {
register,
handleSubmit,
formState: { errors },
} = useForm<RegisterFormData>({
resolver: zodResolver(registerSchema),
});
const onSubmit = async (data: RegisterFormData) => {
try {
await registerUser({
phone: data.phone,
password: data.password,
fullName: data.fullName,
email: data.email || undefined,
});
router.push('/');
} catch {
// Error is handled by the store
}
};
return (
<Card>
<CardHeader className="space-y-1 text-center">
<CardTitle className="text-2xl font-bold">{t('registerTitle')}</CardTitle>
<CardDescription>{t('registerDescription')}</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleSubmit(onSubmit)} className="space-y-4" noValidate>
{error && (
<div className="rounded-md bg-destructive/10 p-3 text-sm text-destructive" role="alert">
{error}
<button type="button" onClick={clearError} className="ml-2 font-medium underline">
{t('dismiss')}
</button>
</div>
)}
<div className="space-y-2">
<Label htmlFor="fullName">{t('fullName')}</Label>
<Input
id="fullName"
type="text"
placeholder={t('fullNamePlaceholder')}
autoComplete="name"
aria-describedby={errors.fullName ? 'fullName-error' : undefined}
aria-invalid={!!errors.fullName}
{...register('fullName')}
/>
{errors.fullName && (
<p id="fullName-error" className="text-sm text-destructive" role="alert">{errors.fullName.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">{t('phone')}</Label>
<Input
id="phone"
type="tel"
placeholder={t('phonePlaceholder')}
autoComplete="tel"
aria-describedby={errors.phone ? 'phone-error' : undefined}
aria-invalid={!!errors.phone}
{...register('phone')}
/>
{errors.phone && (
<p id="phone-error" className="text-sm text-destructive" role="alert">{errors.phone.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="email">{t('email')}</Label>
<Input
id="email"
type="email"
placeholder={t('emailPlaceholder')}
autoComplete="email"
aria-describedby={errors.email ? 'email-error' : undefined}
aria-invalid={!!errors.email}
{...register('email')}
/>
{errors.email && (
<p id="email-error" className="text-sm text-destructive" role="alert">{errors.email.message}</p>
)}
</div>
<div className="space-y-2">
<div className="flex items-center justify-between">
<Label htmlFor="password">{t('password')}</Label>
<button
type="button"
className="text-xs text-muted-foreground hover:text-primary"
onClick={() => setShowPassword(!showPassword)}
aria-label={showPassword ? t('hidePassword') : t('showPassword')}
>
{showPassword ? t('hidePassword') : t('showPassword')}
</button>
</div>
<Input
id="password"
type={showPassword ? 'text' : 'password'}
placeholder={t('passwordPlaceholder')}
autoComplete="new-password"
aria-describedby={errors.password ? 'password-error' : undefined}
aria-invalid={!!errors.password}
{...register('password')}
/>
{errors.password && (
<p id="password-error" className="text-sm text-destructive" role="alert">{errors.password.message}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="confirmPassword">{t('confirmPassword')}</Label>
<Input
id="confirmPassword"
type={showPassword ? 'text' : 'password'}
placeholder={t('confirmPasswordPlaceholder')}
autoComplete="new-password"
aria-describedby={errors.confirmPassword ? 'confirmPassword-error' : undefined}
aria-invalid={!!errors.confirmPassword}
{...register('confirmPassword')}
/>
{errors.confirmPassword && (
<p id="confirmPassword-error" className="text-sm text-destructive" role="alert">{errors.confirmPassword.message}</p>
)}
</div>
<Button type="submit" className="w-full" disabled={isLoading}>
{isLoading && <Loader2 className="mr-2 h-4 w-4 animate-spin" aria-hidden="true" />}
{t('registerButton')}
</Button>
</form>
<div className="relative my-4">
<div className="absolute inset-0 flex items-center">
<span className="w-full border-t" />
</div>
<div className="relative flex justify-center text-xs uppercase">
<span className="bg-card px-2 text-muted-foreground">{t('orRegisterWith')}</span>
</div>
</div>
<OAuthButtons />
</CardContent>
<CardFooter className="justify-center">
<p className="text-sm text-muted-foreground">
{t('hasAccount')}{' '}
<Link href="/login" className="font-medium text-primary hover:underline">
{t('loginLink')}
</Link>
</p>
</CardFooter>
</Card>
);
}

View File

@@ -0,0 +1,421 @@
'use client';
import dynamic from 'next/dynamic';
import { useEffect, useState } from 'react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import {
useMarketReport,
useHeatmap,
useDistrictStats,
usePriceTrend,
} from '@/lib/hooks/use-analytics';
const DistrictBarChart = dynamic(
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đ...</div> },
);
const PriceTrendChart = dynamic(
() => import('@/components/charts/price-trend-chart').then((mod) => mod.PriceTrendChart),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đ...</div> },
);
const DistrictHeatmap = dynamic(
() => import('@/components/charts/district-heatmap').then((mod) => mod.DistrictHeatmap),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải bản đ nhiệt...</div> },
);
const AgentPerformance = dynamic(
() => import('@/components/charts/agent-performance').then((mod) => mod.AgentPerformance),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải...</div> },
);
const CITIES = ['Ho Chi Minh', 'Ha Noi', 'Da Nang'];
const CURRENT_PERIOD = '2026-Q1';
const TREND_PERIODS = ['2025-Q1', '2025-Q2', '2025-Q3', '2025-Q4', '2026-Q1'];
function formatPrice(priceStr: string): string {
const num = Number(priceStr);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
return num.toLocaleString('vi-VN');
}
function formatPriceM2(price: number): string {
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
return `${price.toLocaleString('vi-VN')} đ/m²`;
}
function YoYBadge({ value }: { value: number | null }) {
if (value === null) return <span className="text-xs text-muted-foreground">N/A</span>;
const isPositive = value >= 0;
return (
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-xs font-medium ${isPositive ? 'bg-green-100 text-green-800' : 'bg-red-100 text-red-800'}`}
>
{isPositive ? '+' : ''}
{value.toFixed(1)}%
</span>
);
}
export default function AnalyticsPage() {
const [city, setCity] = useState<string>(CITIES[0] ?? 'Ho Chi Minh');
const period = CURRENT_PERIOD;
const [tab, setTab] = useState('overview');
const [trendDistrict, setTrendDistrict] = useState<string>('');
const { data: reportData, isLoading: reportLoading, error: reportError } = useMarketReport(city, period);
const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(city, period);
const { data: statsData, isLoading: statsLoading } = useDistrictStats(city, period);
const { data: trendData, isLoading: trendLoading } = usePriceTrend(
trendDistrict,
city,
'APARTMENT',
TREND_PERIODS,
);
const loading = reportLoading || heatmapLoading || statsLoading;
const error = reportError ? 'Không thể tải dữ liệu phân tích' : null;
const marketReport = reportData?.districts ?? [];
const heatmap = heatmapData?.dataPoints ?? [];
const districtStats = statsData?.districts ?? [];
const priceTrend = trendData?.trend ?? [];
// Auto-select first district for trend
const firstDistrict = marketReport[0]?.district ?? '';
useEffect(() => {
if (firstDistrict && !trendDistrict) {
setTrendDistrict(firstDistrict);
}
}, [firstDistrict, trendDistrict]);
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
const avgDaysOnMarket =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length
: 0;
const avgPriceM2 =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length
: 0;
const uniqueDistricts = [...new Set(marketReport.map((d) => d.district))];
// Chart data for bar chart
const barChartData = heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.map((p) => ({
district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'),
price: Math.round(p.avgPriceM2 / 1_000_000),
listings: p.totalListings,
}));
// Chart data for line chart
const trendChartData = priceTrend.map((p) => ({
period: p.period,
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
'Tin đăng': p.totalListings,
}));
return (
<div className="space-y-8">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-3xl font-bold">Phân tích thị trường</h1>
<p className="mt-2 text-muted-foreground">
Báo cáo thị trường bất đng sản - {period}
</p>
</div>
<div className="flex gap-2">
{CITIES.map((c) => (
<Button
key={c}
variant={city === c ? 'default' : 'outline'}
size="sm"
onClick={() => setCity(c)}
>
{c}
</Button>
))}
</div>
</div>
{error && <div className="rounded-md bg-red-50 p-4 text-red-700">{error}</div>}
{/* Summary Cards */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng tin đăng</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Giá TB/m²</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : formatPriceM2(avgPriceM2)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Ngày trung bình đ bán</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Số quận/huyện</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Tabs */}
<Tabs value={tab} onValueChange={setTab}>
<TabsList>
<TabsTrigger value="overview">Tổng quan</TabsTrigger>
<TabsTrigger value="trends">Xu hướng giá</TabsTrigger>
<TabsTrigger value="districts">Chi tiết quận</TabsTrigger>
<TabsTrigger value="performance">Hiệu suất</TabsTrigger>
</TabsList>
{/* Overview Tab */}
<TabsContent value="overview">
<div className="mt-4 grid gap-6 lg:grid-cols-2">
{/* Bar Chart - Price by District */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Giá trung bình theo quận</CardTitle>
<CardDescription>Triệu VND/m² tại {city}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : barChartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<DistrictBarChart data={barChartData} height={300} dataKey="price" />
)}
</CardContent>
</Card>
{/* Heatmap - Mapbox Map */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Bản đ nhiệt giá theo quận</CardTitle>
<CardDescription>So sánh giá trung bình/m² tại {city}</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : heatmap.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<DistrictHeatmap
data={heatmap}
city={city}
className="h-[350px]"
onDistrictClick={(district) => {
setTrendDistrict(district);
setTab('trends');
}}
/>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Trends Tab */}
<TabsContent value="trends">
<div className="mt-4 space-y-6">
{/* District selector */}
<div className="flex flex-wrap gap-2">
{uniqueDistricts.map((d) => (
<Button
key={d}
variant={trendDistrict === d ? 'default' : 'outline'}
size="sm"
onClick={() => setTrendDistrict(d)}
>
{d}
</Button>
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">
Xu hướng giá - {trendDistrict || 'Chọn quận'}
</CardTitle>
<CardDescription>
Biến đng giá trung bình/m² qua các quý (Căn hộ)
</CardDescription>
</CardHeader>
<CardContent>
{trendLoading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : trendChartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chưa dữ liệu xu hướng
</div>
) : (
<PriceTrendChart data={trendChartData} height={350} />
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* District Stats Tab */}
<TabsContent value="districts">
<div className="mt-4 space-y-6">
{/* Stats Table */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Thống chi tiết theo quận</CardTitle>
<CardDescription>
Dữ liệu thị trường bất đng sản tại {city} - {period}
</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : districtStats.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="pb-2 pr-4 font-medium">Quận</th>
<th className="pb-2 pr-4 font-medium">Loại BĐS</th>
<th className="pb-2 pr-4 font-medium text-right">Giá trung vị</th>
<th className="pb-2 pr-4 font-medium text-right">Giá/m²</th>
<th className="pb-2 pr-4 font-medium text-right">Tin đăng</th>
<th className="pb-2 pr-4 font-medium text-right">Ngày bán</th>
<th className="pb-2 font-medium text-right">YoY</th>
</tr>
</thead>
<tbody>
{districtStats.map((stat, i) => (
<tr
key={`${stat.district}-${stat.propertyType}-${i}`}
className="border-b last:border-0"
>
<td className="py-2 pr-4">{stat.district}</td>
<td className="py-2 pr-4 text-xs text-muted-foreground">
{stat.propertyType}
</td>
<td className="py-2 pr-4 text-right font-medium">
{formatPrice(stat.medianPrice)}
</td>
<td className="py-2 pr-4 text-right">
{formatPriceM2(stat.avgPriceM2)}
</td>
<td className="py-2 pr-4 text-right">{stat.totalListings}</td>
<td className="py-2 pr-4 text-right">
{stat.daysOnMarket.toFixed(0)}
</td>
<td className="py-2 text-right">
<YoYBadge value={stat.yoyChange} />
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</CardContent>
</Card>
{/* Market Report Cards */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Báo cáo thị trường</CardTitle>
<CardDescription>Tổng hợp chỉ số thị trường theo từng quận</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : marketReport.length === 0 ? (
<div className="flex h-48 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{[...new Map(marketReport.map((d) => [d.district, d])).values()].map(
(district) => (
<div key={district.district} className="rounded-lg border p-4">
<h3 className="font-semibold">{district.district}</h3>
<div className="mt-2 space-y-1 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Giá trung vị</span>
<span className="font-medium">
{formatPrice(district.medianPrice)}
</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Giá/m²</span>
<span>{formatPriceM2(district.avgPriceM2)}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tin đăng</span>
<span>{district.totalListings}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Tồn kho</span>
<span>{district.inventoryLevel}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Thay đi YoY</span>
<YoYBadge value={district.yoyChange} />
</div>
</div>
</div>
),
)}
</div>
)}
</CardContent>
</Card>
</div>
</TabsContent>
{/* Agent Performance Tab */}
<TabsContent value="performance">
<div className="mt-4">
<AgentPerformance />
</div>
</TabsContent>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,315 @@
'use client';
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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { Select } from '@/components/ui/select';
import { apiClient } from '@/lib/api-client';
import { useAuthStore } from '@/lib/auth-store';
const KYC_STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline'; description: string }> = {
NONE: { label: 'Chưa xác minh', variant: 'outline', description: 'Bạn chưa gửi hồ sơ xác minh danh tính. Hoàn tất KYC để mở khóa đầy đủ tính năng.' },
PENDING: { label: 'Đang chờ duyệt', variant: 'secondary', description: 'Hồ sơ của bạn đã được gửi và đang chờ đội ngũ quản trị xem xét. Vui lòng chờ 1-3 ngày làm việc.' },
VERIFIED: { label: 'Đã xác minh', variant: 'default', description: 'Danh tính của bạn đã được xác minh thành công. Bạn có thể sử dụng đầy đủ tính năng.' },
REJECTED: { label: 'Bị từ chối', variant: 'destructive', description: 'Hồ sơ xác minh bị từ chối. Vui lòng kiểm tra lại và gửi lại hồ sơ.' },
};
const DOCUMENT_TYPES = [
{ value: 'CCCD', label: 'Căn cước công dân (CCCD)' },
{ value: 'CMND', label: 'Chứng minh nhân dân (CMND)' },
{ value: 'PASSPORT', label: 'Hộ chiếu' },
{ value: 'BUSINESS_LICENSE', label: 'Giấy phép kinh doanh' },
];
const KYC_STEPS = [
{ step: 1, title: 'Loại giấy tờ', description: 'Chọn loại giấy tờ tùy thân' },
{ step: 2, title: 'Tải ảnh', description: 'Tải ảnh mặt trước, mặt sau và ảnh selfie' },
{ step: 3, title: 'Xác nhận', description: 'Kiểm tra và gửi hồ sơ' },
];
export default function KycPage() {
const { user, fetchProfile } = useAuthStore();
const [currentStep, setCurrentStep] = useState(1);
const [submitting, setSubmitting] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState(false);
const [documentType, setDocumentType] = useState('CCCD');
const [documentNumber, setDocumentNumber] = useState('');
const [frontImage, setFrontImage] = useState<File | null>(null);
const [backImage, setBackImage] = useState<File | null>(null);
const [selfieImage, setSelfieImage] = useState<File | null>(null);
const kycStatus = user?.kycStatus ?? 'NONE';
const kycInfo = KYC_STATUS_MAP[kycStatus] ?? { label: 'Chưa xác minh', variant: 'outline' as const, description: 'Bạn chưa gửi hồ sơ xác minh danh tính.' };
const canSubmit = kycStatus === 'NONE' || kycStatus === 'REJECTED';
const handleSubmit = async () => {
if (!documentNumber.trim()) {
setError('Vui lòng nhập số giấy tờ');
return;
}
if (!frontImage) {
setError('Vui lòng tải ảnh mặt trước');
return;
}
setSubmitting(true);
setError(null);
try {
await apiClient.patch('/auth/profile', {
kycData: {
documentType,
documentNumber: documentNumber.trim(),
submittedAt: new Date().toISOString(),
},
});
await fetchProfile();
setSuccess(true);
} catch (e) {
setError(e instanceof Error ? e.message : 'Gửi hồ sơ thất bại');
} finally {
setSubmitting(false);
}
};
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Xác minh danh tính (KYC)</h1>
<p className="mt-2 text-muted-foreground">
Xác minh danh tính đ sử dụng đy đ tính năng của GoodGo
</p>
</div>
{/* KYC Status */}
<Card>
<CardHeader>
<div className="flex items-center justify-between">
<CardTitle className="text-lg">Trạng thái xác minh</CardTitle>
<Badge variant={kycInfo.variant}>{kycInfo.label}</Badge>
</div>
</CardHeader>
<CardContent>
<p className="text-sm text-muted-foreground">{kycInfo.description}</p>
</CardContent>
</Card>
{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>
)}
{success && (
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-700">
Hồ KYC đã đưc gửi thành công. Vui lòng chờ 1-3 ngày làm việc đ đưc xem xét.
</div>
)}
{/* KYC Form */}
{canSubmit && !success && (
<>
{/* Step indicator */}
<div className="flex items-center justify-center gap-2">
{KYC_STEPS.map((s, i) => (
<div key={s.step} className="flex items-center">
<div
className={`flex h-8 w-8 items-center justify-center rounded-full text-sm font-semibold ${
currentStep >= s.step
? 'bg-primary text-primary-foreground'
: 'bg-muted text-muted-foreground'
}`}
>
{s.step}
</div>
<span className="ml-2 hidden text-sm sm:inline">{s.title}</span>
{i < KYC_STEPS.length - 1 && (
<div className="mx-3 h-px w-8 bg-border sm:w-16" />
)}
</div>
))}
</div>
<Card>
<CardHeader>
<CardTitle className="text-lg">
{KYC_STEPS[currentStep - 1]?.title}
</CardTitle>
<CardDescription>
{KYC_STEPS[currentStep - 1]?.description}
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Step 1: Document type */}
{currentStep === 1 && (
<>
<div className="space-y-2">
<Label htmlFor="docType">Loại giấy tờ</Label>
<Select
id="docType"
value={documentType}
onChange={(e) => setDocumentType(e.target.value)}
>
{DOCUMENT_TYPES.map((dt) => (
<option key={dt.value} value={dt.value}>
{dt.label}
</option>
))}
</Select>
</div>
<div className="space-y-2">
<Label htmlFor="docNumber">Số giấy tờ</Label>
<Input
id="docNumber"
value={documentNumber}
onChange={(e) => setDocumentNumber(e.target.value)}
placeholder="Nhập số CCCD/CMND/Hộ chiếu"
/>
</div>
</>
)}
{/* Step 2: Upload images */}
{currentStep === 2 && (
<>
<div className="space-y-2">
<Label htmlFor="frontImg">nh mặt trước *</Label>
<Input
id="frontImg"
type="file"
accept="image/*"
onChange={(e) => setFrontImage(e.target.files?.[0] ?? null)}
/>
{frontImage && (
<p className="text-xs text-muted-foreground">{frontImage.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="backImg">nh mặt sau</Label>
<Input
id="backImg"
type="file"
accept="image/*"
onChange={(e) => setBackImage(e.target.files?.[0] ?? null)}
/>
{backImage && (
<p className="text-xs text-muted-foreground">{backImage.name}</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="selfieImg">nh selfie cầm giấy tờ</Label>
<Input
id="selfieImg"
type="file"
accept="image/*"
onChange={(e) => setSelfieImage(e.target.files?.[0] ?? null)}
/>
{selfieImage && (
<p className="text-xs text-muted-foreground">{selfieImage.name}</p>
)}
</div>
</>
)}
{/* Step 3: Confirm */}
{currentStep === 3 && (
<div className="space-y-3 rounded-lg border bg-muted/50 p-4">
<h3 className="font-semibold">Kiểm tra thông tin</h3>
<div className="grid gap-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Loại giấy tờ</span>
<span>{DOCUMENT_TYPES.find((d) => d.value === documentType)?.label}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Số giấy tờ</span>
<span>{documentNumber}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">nh mặt trước</span>
<span>{frontImage ? frontImage.name : 'Chưa tải'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">nh mặt sau</span>
<span>{backImage ? backImage.name : 'Không có'}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">nh selfie</span>
<span>{selfieImage ? selfieImage.name : 'Không có'}</span>
</div>
</div>
</div>
)}
{/* Navigation buttons */}
<div className="flex justify-between pt-2">
{currentStep > 1 ? (
<Button variant="outline" onClick={() => setCurrentStep((s) => s - 1)}>
Quay lại
</Button>
) : (
<div />
)}
{currentStep < 3 ? (
<Button
onClick={() => {
if (currentStep === 1 && !documentNumber.trim()) {
setError('Vui lòng nhập số giấy tờ');
return;
}
setError(null);
setCurrentStep((s) => s + 1);
}}
>
Tiếp tục
</Button>
) : (
<Button onClick={handleSubmit} disabled={submitting}>
{submitting ? 'Đang gửi...' : 'Gửi hồ sơ xác minh'}
</Button>
)}
</div>
</CardContent>
</Card>
</>
)}
{/* Already verified */}
{kycStatus === 'VERIFIED' && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-green-100 text-3xl">
</div>
<h2 className="text-xl font-semibold">Danh tính đã đưc xác minh</h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Tài khoản của bạn đã đưc xác minh đy đ. Bạn thể sử dụng tất cả tính năng của
GoodGo.
</p>
</CardContent>
</Card>
)}
{/* Pending status */}
{kycStatus === 'PENDING' && !success && (
<Card>
<CardContent className="flex flex-col items-center justify-center py-12">
<div className="mb-4 flex h-16 w-16 items-center justify-center rounded-full bg-yellow-100 text-3xl">
</div>
<h2 className="text-xl font-semibold">Đang xem xét hồ </h2>
<p className="mt-2 text-center text-sm text-muted-foreground">
Đi ngũ quản trị đang xem xét hồ của bạn. Thời gian dự kiến: 1-3 ngày làm việc.
</p>
</CardContent>
</Card>
)}
</div>
);
}

View File

@@ -0,0 +1,289 @@
'use client';
import dynamic from 'next/dynamic';
import Image from 'next/image';
import Link from 'next/link';
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { useMarketReport, useHeatmap } from '@/lib/hooks/use-analytics';
import { useListingsSearch } from '@/lib/hooks/use-listings';
const DistrictBarChart = dynamic(
() => import('@/components/charts/district-bar-chart').then((mod) => mod.DistrictBarChart),
{ ssr: false, loading: () => <div className="flex h-64 items-center justify-center text-muted-foreground">Đang tải biểu đ...</div> },
);
const CITY = 'Ho Chi Minh';
const PERIOD = '2026-Q1';
function formatPrice(priceStr: string): string {
const num = Number(priceStr);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
return num.toLocaleString('vi-VN');
}
function formatPriceM2(price: number): string {
if (price >= 1_000_000) return `${(price / 1_000_000).toFixed(1)} tr/m²`;
return `${price.toLocaleString('vi-VN')} đ/m²`;
}
interface StatCardProps {
title: string;
value: string;
description?: string;
trend?: number | null;
}
function StatCard({ title, value, description, trend }: StatCardProps) {
return (
<Card>
<CardHeader className="pb-2">
<CardDescription>{title}</CardDescription>
<CardTitle className="text-2xl">{value}</CardTitle>
</CardHeader>
{(description || trend != null) && (
<CardContent>
<div className="flex items-center gap-2">
{trend != null && (
<span
className={`text-xs font-medium ${trend >= 0 ? 'text-green-600' : 'text-red-600'}`}
>
{trend >= 0 ? '+' : ''}
{trend.toFixed(1)}%
</span>
)}
{description && (
<span className="text-xs text-muted-foreground">{description}</span>
)}
</div>
</CardContent>
)}
</Card>
);
}
export default function DashboardPage() {
const { data: reportData, isLoading: reportLoading } = useMarketReport(CITY, PERIOD);
const { data: heatmapData, isLoading: heatmapLoading } = useHeatmap(CITY, PERIOD);
const { data: listings, isLoading: listingsLoading } = useListingsSearch({ page: 1, limit: 6 });
const loading = reportLoading || heatmapLoading || listingsLoading;
const marketReport = reportData?.districts ?? [];
const heatmap = heatmapData?.dataPoints ?? [];
const totalListings = marketReport.reduce((sum, d) => sum + d.totalListings, 0);
const avgPriceM2 =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.avgPriceM2, 0) / marketReport.length
: 0;
const avgDaysOnMarket =
marketReport.length > 0
? marketReport.reduce((sum, d) => sum + d.daysOnMarket, 0) / marketReport.length
: 0;
const avgYoy =
marketReport.filter((d) => d.yoyChange != null).length > 0
? marketReport
.filter((d) => d.yoyChange != null)
.reduce((sum, d) => sum + (d.yoyChange ?? 0), 0) /
marketReport.filter((d) => d.yoyChange != null).length
: null;
const myListingsCount = listings?.total ?? 0;
const totalViews = listings?.data.reduce((s, l) => s + l.viewCount, 0) ?? 0;
const totalInquiries = listings?.data.reduce((s, l) => s + l.inquiryCount, 0) ?? 0;
const chartData = heatmap
.sort((a, b) => b.avgPriceM2 - a.avgPriceM2)
.slice(0, 8)
.map((p) => ({
district: p.district.replace(/^Quan\s*/i, 'Q.').replace(/^Huyen\s*/i, 'H.'),
'Gia/m2': Math.round(p.avgPriceM2 / 1_000_000),
listings: p.totalListings,
}));
return (
<div className="space-y-8">
<div className="flex items-center justify-between">
<div>
<h1 className="text-3xl font-bold">Bảng điều khiển</h1>
<p className="mt-2 text-muted-foreground">
Tổng quan thị trường tin đăng của bạn
</p>
</div>
<Link href="/listings/new">
<Button>Đăng tin mới</Button>
</Link>
</div>
{/* Stats overview */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
<StatCard
title="Tin đăng của tôi"
value={loading ? '...' : myListingsCount.toLocaleString('vi-VN')}
description="Tổng số tin đã đăng"
/>
<StatCard
title="Lượt xem"
value={loading ? '...' : totalViews.toLocaleString('vi-VN')}
description="Trên tất cả tin đăng"
/>
<StatCard
title="Liên hệ"
value={loading ? '...' : totalInquiries.toLocaleString('vi-VN')}
description="Yêu cầu từ khách hàng"
/>
<StatCard
title="Giá TB thị trường"
value={loading ? '...' : formatPriceM2(avgPriceM2)}
trend={avgYoy}
description="YoY"
/>
</div>
{/* Market overview + quick stats */}
<div className="grid gap-6 lg:grid-cols-3">
{/* Price chart */}
<Card className="lg:col-span-2">
<CardHeader>
<CardTitle className="text-lg">Giá trung bình theo quận</CardTitle>
<CardDescription>{CITY} - {PERIOD} (triệu VND/m²)</CardDescription>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : chartData.length === 0 ? (
<div className="flex h-64 items-center justify-center text-muted-foreground">
Chưa dữ liệu
</div>
) : (
<DistrictBarChart
data={chartData}
height={280}
dataKey="Gia/m2"
tooltipFormatter={(value) => [`${value} tr/m²`, 'Giá']}
/>
)}
</CardContent>
</Card>
{/* Market summary */}
<Card>
<CardHeader>
<CardTitle className="text-lg">Thị trường {CITY}</CardTitle>
<CardDescription>Chỉ số chính - {PERIOD}</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tổng tin đăng</span>
<span className="font-semibold">
{loading ? '...' : totalListings.toLocaleString('vi-VN')}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Giá TB/m²</span>
<span className="font-semibold">
{loading ? '...' : formatPriceM2(avgPriceM2)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Ngày TB đ bán</span>
<span className="font-semibold">
{loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Số quận</span>
<span className="font-semibold">
{loading ? '...' : new Set(marketReport.map((d) => d.district)).size}
</span>
</div>
<div className="pt-2">
<Link href="/analytics">
<Button variant="outline" size="sm" className="w-full">
Xem phân tích chi tiết
</Button>
</Link>
</div>
</CardContent>
</Card>
</div>
{/* Recent listings */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg">Tin đăng gần đây</CardTitle>
<CardDescription>Danh sách tin đăng mới nhất của bạn</CardDescription>
</div>
<Link href="/listings">
<Button variant="outline" size="sm">
Xem tất cả
</Button>
</Link>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : !listings || listings.data.length === 0 ? (
<div className="flex h-32 flex-col items-center justify-center text-muted-foreground">
<p>Chưa tin đăng nào</p>
<Link href="/listings/new" className="mt-2">
<Button variant="outline" size="sm">
Đăng tin đu tiên
</Button>
</Link>
</div>
) : (
<div className="space-y-3">
{listings.data.slice(0, 5).map((listing) => (
<Link
key={listing.id}
href={`/listings/${listing.id}`}
className="flex items-center gap-4 rounded-lg border p-3 transition-colors hover:bg-accent"
>
<div className="relative h-12 w-16 flex-shrink-0 overflow-hidden rounded bg-muted">
{listing.property.media.length > 0 ? (
<Image
src={listing.property.media[0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="64px"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
N/A
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate font-medium">{listing.property.title}</p>
<p className="text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
</div>
<div className="text-right">
<p className="font-semibold text-primary">
{formatPrice(listing.priceVND)}
</p>
<ListingStatusBadge status={listing.status} />
</div>
<div className="hidden sm:flex sm:gap-3 sm:text-sm sm:text-muted-foreground">
<span>{listing.viewCount} lượt xem</span>
<span>{listing.inquiryCount} liên hệ</span>
</div>
</Link>
))}
</div>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,239 @@
'use client';
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 { Select } from '@/components/ui/select';
import {
Table,
TableBody,
TableCell,
TableHead,
TableHeader,
TableRow,
} from '@/components/ui/table';
import { useTransactions } from '@/lib/hooks/use-payments';
function formatVND(amount: string | number): string {
const num = typeof amount === 'string' ? Number(amount) : amount;
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ đ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu đ`;
return num.toLocaleString('vi-VN') + ' đ';
}
const STATUS_LABELS: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
PENDING: { label: 'Chờ xử lý', variant: 'secondary' },
PROCESSING: { label: 'Đang xử lý', variant: 'secondary' },
COMPLETED: { label: 'Thành công', variant: 'default' },
FAILED: { label: 'Thất bại', variant: 'destructive' },
REFUNDED: { label: 'Hoàn tiền', variant: 'outline' },
};
const TYPE_LABELS: Record<string, string> = {
SUBSCRIPTION: 'Gói dịch vụ',
LISTING_FEE: 'Phí đăng tin',
DEPOSIT: 'Đặt cọc',
FEATURED_LISTING: 'Tin nổi bật',
};
const PROVIDER_LABELS: Record<string, string> = {
VNPAY: 'VNPay',
MOMO: 'MoMo',
ZALOPAY: 'ZaloPay',
BANK_TRANSFER: 'Chuyển khoản',
};
export default function PaymentsPage() {
const [statusFilter, setStatusFilter] = useState('');
const [page, setPage] = useState(0);
const limit = 20;
const { data: transactions, isLoading: loading } = useTransactions({
status: statusFilter || undefined,
limit,
offset: page * limit,
});
const totalPages = transactions ? Math.ceil(transactions.total / limit) : 0;
// Summary stats
const completedTotal =
transactions?.items
.filter((t) => t.status === 'COMPLETED')
.reduce((sum, t) => sum + Number(t.amountVND), 0) ?? 0;
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Thanh toán</h1>
<p className="mt-2 text-muted-foreground">
Lịch sử giao dịch quản thanh toán
</p>
</div>
{/* Summary cards */}
<div className="grid gap-4 sm:grid-cols-3">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng giao dịch</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : (transactions?.total ?? 0)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Đã thanh toán</CardDescription>
<CardTitle className="text-2xl">
{loading ? '...' : formatVND(completedTotal)}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Đang chờ</CardDescription>
<CardTitle className="text-2xl">
{loading
? '...'
: (transactions?.items.filter((t) => t.status === 'PENDING' || t.status === 'PROCESSING').length ?? 0)}
</CardTitle>
</CardHeader>
</Card>
</div>
{/* Transactions table */}
<Card>
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg">Lịch sử giao dịch</CardTitle>
<CardDescription>Tất cả giao dịch thanh toán của bạn</CardDescription>
</div>
<div className="w-40">
<Select
value={statusFilter}
onChange={(e) => {
setStatusFilter(e.target.value);
setPage(0);
}}
>
<option value="">Tất cả</option>
<option value="PENDING">Chờ xử </option>
<option value="PROCESSING">Đang xử </option>
<option value="COMPLETED">Thành công</option>
<option value="FAILED">Thất bại</option>
<option value="REFUNDED">Hoàn tiền</option>
</Select>
</div>
</CardHeader>
<CardContent>
{loading ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : !transactions || transactions.items.length === 0 ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
Chưa giao dịch nào
</div>
) : (
<>
{/* Desktop table */}
<div className="hidden sm:block">
<Table>
<TableHeader>
<TableRow>
<TableHead>Ngày</TableHead>
<TableHead>Loại</TableHead>
<TableHead>Nhà cung cấp</TableHead>
<TableHead className="text-right">Số tiền</TableHead>
<TableHead>Trạng thái</TableHead>
<TableHead> GD</TableHead>
</TableRow>
</TableHeader>
<TableBody>
{transactions.items.map((tx) => {
const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const };
return (
<TableRow key={tx.id}>
<TableCell className="text-sm">
{new Date(tx.createdAt).toLocaleDateString('vi-VN')}
</TableCell>
<TableCell className="text-sm">
{TYPE_LABELS[tx.type] ?? tx.type}
</TableCell>
<TableCell className="text-sm">
{PROVIDER_LABELS[tx.provider] ?? tx.provider}
</TableCell>
<TableCell className="text-right font-semibold">
{formatVND(tx.amountVND)}
</TableCell>
<TableCell>
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
</TableCell>
<TableCell className="text-xs text-muted-foreground">
{tx.providerTxId ? tx.providerTxId.slice(0, 12) + '...' : '—'}
</TableCell>
</TableRow>
);
})}
</TableBody>
</Table>
</div>
{/* Mobile cards */}
<div className="space-y-3 sm:hidden">
{transactions.items.map((tx) => {
const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const };
return (
<div key={tx.id} className="rounded-lg border p-3">
<div className="flex items-center justify-between">
<span className="text-sm font-medium">
{TYPE_LABELS[tx.type] ?? tx.type}
</span>
<Badge variant={statusInfo.variant}>{statusInfo.label}</Badge>
</div>
<div className="mt-2 flex items-center justify-between text-sm">
<span className="text-muted-foreground">
{new Date(tx.createdAt).toLocaleDateString('vi-VN')} {' '}
{PROVIDER_LABELS[tx.provider] ?? tx.provider}
</span>
<span className="font-semibold">{formatVND(tx.amountVND)}</span>
</div>
</div>
);
})}
</div>
{/* Pagination */}
{totalPages > 1 && (
<div className="mt-4 flex items-center justify-between">
<p className="text-sm text-muted-foreground">
Trang {page + 1}/{totalPages} ({transactions.total} giao dịch)
</p>
<div className="flex gap-2">
<Button
variant="outline"
size="sm"
disabled={page === 0}
onClick={() => setPage((p) => p - 1)}
>
Trước
</Button>
<Button
variant="outline"
size="sm"
disabled={page + 1 >= totalPages}
onClick={() => setPage((p) => p + 1)}
>
Sau
</Button>
</div>
</div>
)}
</>
)}
</CardContent>
</Card>
</div>
);
}

View File

@@ -0,0 +1,283 @@
'use client';
import { useEffect, 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 { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
import { useAuthStore } from '@/lib/auth-store';
import { profileApi, type AgentProfile } from '@/lib/profile-api';
const KYC_STATUS_MAP: Record<string, { label: string; variant: 'default' | 'secondary' | 'destructive' | 'outline' }> = {
NONE: { label: 'Chưa xác minh', variant: 'outline' },
PENDING: { label: 'Đang chờ duyệt', variant: 'secondary' },
VERIFIED: { label: 'Đã xác minh', variant: 'default' },
REJECTED: { label: 'Bị từ chối', variant: 'destructive' },
};
export default function ProfilePage() {
const { user, fetchProfile } = useAuthStore();
const [agentProfile, setAgentProfile] = useState<AgentProfile | null>(null);
const [loading, setLoading] = useState(true);
const [editing, setEditing] = useState(false);
const [saving, setSaving] = useState(false);
const [error, setError] = useState<string | null>(null);
const [success, setSuccess] = useState<string | null>(null);
const [formData, setFormData] = useState({
fullName: '',
email: '',
phone: '',
});
useEffect(() => {
setLoading(true);
profileApi
.getAgentProfile()
.then((agent) => setAgentProfile(agent))
.catch(() => {})
.finally(() => setLoading(false));
}, []);
useEffect(() => {
if (user) {
setFormData({
fullName: user.fullName,
email: user.email ?? '',
phone: user.phone,
});
}
}, [user]);
const handleSave = async () => {
setSaving(true);
setError(null);
setSuccess(null);
try {
await profileApi.updateProfile({
fullName: formData.fullName,
email: formData.email || undefined,
});
await fetchProfile();
setSuccess('Cập nhật hồ sơ thành công');
setEditing(false);
} catch (e) {
setError(e instanceof Error ? e.message : 'Cập nhật thất bại');
} finally {
setSaving(false);
}
};
const kycInfo = KYC_STATUS_MAP[user?.kycStatus ?? 'NONE'] ?? { label: 'Chưa xác minh', variant: 'outline' as const };
return (
<div className="space-y-6">
<div>
<h1 className="text-3xl font-bold">Hồ nhân</h1>
<p className="mt-2 text-muted-foreground">Quản thông tin tài khoản của bạn</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>
)}
{success && (
<div className="rounded-lg border border-green-200 bg-green-50 p-4 text-sm text-green-700">
{success}
<button onClick={() => setSuccess(null)} className="ml-2 font-medium underline">
Đóng
</button>
</div>
)}
<div className="grid gap-6 lg:grid-cols-3">
{/* Profile info */}
<Card className="lg:col-span-2">
<CardHeader className="flex flex-row items-center justify-between">
<div>
<CardTitle className="text-lg">Thông tin nhân</CardTitle>
<CardDescription>Thông tin bản trên hồ của bạn</CardDescription>
</div>
{!editing && (
<Button variant="outline" size="sm" onClick={() => setEditing(true)}>
Chỉnh sửa
</Button>
)}
</CardHeader>
<CardContent className="space-y-4">
{loading ? (
<div className="flex h-32 items-center justify-center text-muted-foreground">
Đang tải...
</div>
) : (
<>
<div className="space-y-2">
<Label htmlFor="fullName">Họ tên</Label>
{editing ? (
<Input
id="fullName"
value={formData.fullName}
onChange={(e) => setFormData((p) => ({ ...p, fullName: e.target.value }))}
/>
) : (
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
{user?.fullName ?? '—'}
</p>
)}
</div>
<div className="space-y-2">
<Label htmlFor="phone">Số điện thoại</Label>
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
{user?.phone ?? '—'}
</p>
<p className="text-xs text-muted-foreground">
Số điện thoại không thể thay đi
</p>
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
{editing ? (
<Input
id="email"
type="email"
value={formData.email}
onChange={(e) => setFormData((p) => ({ ...p, email: e.target.value }))}
placeholder="email@example.com"
/>
) : (
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
{user?.email ?? 'Chưa cập nhật'}
</p>
)}
</div>
<div className="space-y-2">
<Label>Vai trò</Label>
<p className="rounded-md border bg-muted/50 px-3 py-2 text-sm">
{user?.role === 'AGENT' ? 'Môi giới' : user?.role === 'ADMIN' ? 'Quản trị viên' : user?.role === 'SELLER' ? 'Người bán' : 'Người mua'}
</p>
</div>
{editing && (
<div className="flex gap-2 pt-2">
<Button onClick={handleSave} disabled={saving}>
{saving ? 'Đang lưu...' : 'Lưu thay đổi'}
</Button>
<Button
variant="outline"
onClick={() => {
setEditing(false);
if (user) {
setFormData({
fullName: user.fullName,
email: user.email ?? '',
phone: user.phone,
});
}
}}
>
Hủy
</Button>
</div>
)}
</>
)}
</CardContent>
</Card>
{/* Status sidebar */}
<div className="space-y-6">
<Card>
<CardHeader>
<CardTitle className="text-lg">Trạng thái tài khoản</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tài khoản</span>
<Badge variant={user?.isActive ? 'default' : 'destructive'}>
{user?.isActive ? 'Hoạt động' : 'Bị khóa'}
</Badge>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Xác minh KYC</span>
<Badge variant={kycInfo.variant}>{kycInfo.label}</Badge>
</div>
{user?.kycStatus !== 'VERIFIED' && (
<a href="/dashboard/kyc">
<Button variant="outline" size="sm" className="mt-2 w-full">
{user?.kycStatus === 'NONE' ? 'Bắt đầu xác minh' : 'Xem trạng thái KYC'}
</Button>
</a>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Tham gia</span>
<span className="text-sm">
{user?.createdAt
? new Date(user.createdAt).toLocaleDateString('vi-VN')
: '—'}
</span>
</div>
</CardContent>
</Card>
{/* Agent details */}
{agentProfile && (
<Card>
<CardHeader>
<CardTitle className="text-lg">Thông tin môi giới</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground"> chứng chỉ</span>
<span className="text-sm font-medium">
{agentProfile.licenseNumber ?? 'Chưa có'}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Công ty</span>
<span className="text-sm font-medium">
{agentProfile.agency ?? 'Độc lập'}
</span>
</div>
{agentProfile.qualityScore != null && (
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Điểm chất lượng</span>
<span className="text-sm font-semibold text-primary">
{agentProfile.qualityScore}/100
</span>
</div>
)}
<div className="flex items-center justify-between">
<span className="text-sm text-muted-foreground">Xác minh</span>
<Badge variant={agentProfile.isVerified ? 'default' : 'outline'}>
{agentProfile.isVerified ? 'Đã xác minh' : 'Chưa xác minh'}
</Badge>
</div>
{agentProfile.serviceAreas.length > 0 && (
<div>
<span className="text-sm text-muted-foreground">Khu vực hoạt đng</span>
<div className="mt-1 flex flex-wrap gap-1">
{agentProfile.serviceAreas.map((area) => (
<Badge key={area} variant="secondary">
{area}
</Badge>
))}
</div>
</div>
)}
</CardContent>
</Card>
)}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,371 @@
'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>
);
}

View File

@@ -0,0 +1,74 @@
'use client';
import { useState } from 'react';
import { ValuationForm } from '@/components/valuation/valuation-form';
import { ValuationHistory } from '@/components/valuation/valuation-history';
import { ValuationResults } from '@/components/valuation/valuation-results';
import {
useValuationPredict,
useValuationHistory,
useValuationDetail,
} from '@/lib/hooks/use-valuation';
import type { ValuationRequest, ValuationResult } from '@/lib/valuation-api';
export default function ValuationPage() {
const [historyPage, setHistoryPage] = useState(1);
const [selectedId, setSelectedId] = useState<string | null>(null);
const predictMutation = useValuationPredict();
const { data: historyData, isLoading: historyLoading } = useValuationHistory(historyPage);
const { data: selectedResult } = useValuationDetail(selectedId ?? '');
const currentResult: ValuationResult | undefined =
predictMutation.data ?? selectedResult;
const handleSubmit = (data: ValuationRequest) => {
setSelectedId(null);
predictMutation.mutate(data);
};
const handleSelectHistory = (id: string) => {
setSelectedId(id);
};
return (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">Dinh gia AI</h1>
<p className="mt-2 text-muted-foreground">
Su dung AI de uoc tinh gia tri bat dong san dua tren du lieu thi truong
</p>
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Form + Results */}
<div className="space-y-6 lg:col-span-2">
<ValuationForm
onSubmit={handleSubmit}
isLoading={predictMutation.isPending}
/>
{predictMutation.isError && (
<div className="rounded-lg border border-destructive/50 bg-destructive/10 p-4 text-sm text-destructive">
Khong the dinh gia. Vui long thu lai sau.
</div>
)}
{currentResult && <ValuationResults result={currentResult} />}
</div>
{/* History sidebar */}
<div>
<ValuationHistory
items={historyData?.data ?? []}
total={historyData?.total ?? 0}
page={historyPage}
onPageChange={setHistoryPage}
onSelect={handleSelectHistory}
isLoading={historyLoading}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,96 @@
'use client';
import { useEffect, useState } from 'react';
export default function DashboardError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const [retryCount, setRetryCount] = useState(0);
const [autoRetrying, setAutoRetrying] = useState(false);
useEffect(() => {
console.error('Dashboard error:', error);
}, [error]);
// Auto-retry once after 3 seconds
useEffect(() => {
if (retryCount > 0) return;
setAutoRetrying(true);
const timer = setTimeout(() => {
setAutoRetrying(false);
setRetryCount((c) => c + 1);
reset();
}, 3000);
return () => clearTimeout(timer);
}, [error, reset, retryCount]);
const handleRetry = () => {
setRetryCount((c) => c + 1);
reset();
};
return (
<div className="flex min-h-[400px] flex-col items-center justify-center px-4">
<div className="mx-auto max-w-md text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
<svg
className="h-7 w-7 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h2 className="mt-4 text-xl font-semibold">Không thể tải bảng điều khiển</h2>
<p className="mt-2 text-sm text-muted-foreground">
{autoRetrying
? 'Đang tự động thử lại...'
: 'Đã xảy ra lỗi khi tải dữ liệu. Vui lòng thử lại sau.'}
</p>
{error.digest && (
<p className="mt-1 text-xs text-muted-foreground"> lỗi: {error.digest}</p>
)}
{retryCount > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
Đã thử lại {retryCount} lần
</p>
)}
<div className="mt-6 flex justify-center gap-3">
<button
onClick={handleRetry}
disabled={autoRetrying}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{autoRetrying ? (
<>
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Đang thử lại...
</>
) : (
'Thử lại'
)}
</button>
<a
href="/dashboard"
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
Tải lại trang
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,93 @@
'use client';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { useTheme } from '@/components/providers/theme-provider';
import { Button } from '@/components/ui/button';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { Link } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
export default function DashboardLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user, logout } = useAuthStore();
const { theme, toggleTheme } = useTheme();
const t = useTranslations();
const navItems = [
{ href: '/dashboard' as const, label: t('dashboard.title'), icon: '🏠' },
{ href: '/listings' as const, label: t('dashboard.listings'), icon: '📋' },
{ href: '/listings/new' as const, label: t('dashboard.createListing'), icon: '' },
{ href: '/analytics' as const, label: t('dashboard.analytics'), icon: '📊' },
{ href: '/dashboard/valuation' as const, label: t('dashboard.aiValuation'), icon: '🤖' },
{ href: '/dashboard/profile' as const, label: t('dashboard.profile'), icon: '👤' },
{ href: '/dashboard/subscription' as const, label: t('dashboard.subscription'), icon: '💎' },
{ href: '/dashboard/payments' as const, label: t('dashboard.payments'), icon: '💳' },
];
return (
<div className="min-h-screen bg-background">
<header
role="banner"
className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div className="mx-auto flex h-14 max-w-7xl items-center px-4">
<Link href="/" className="mr-6 flex items-center space-x-2">
<span className="text-lg font-bold text-primary">{t('common.goodgo')}</span>
</Link>
<nav aria-label={t('nav.dashboardNav')} className="flex items-center space-x-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
aria-label={item.label}
className={cn(
'rounded-md px-2 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground sm:px-3',
pathname === item.href || (item.href !== '/dashboard' && pathname.startsWith(item.href))
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground',
)}
>
<span className="sm:mr-1.5" aria-hidden="true">{item.icon}</span>
<span className="hidden sm:inline">{item.label}</span>
</Link>
))}
</nav>
<div className="ml-auto flex items-center space-x-2">
{user && (
<span className="hidden text-sm text-muted-foreground sm:inline">
{user.fullName}
</span>
)}
<LanguageSwitcher />
<Button
variant="ghost"
size="sm"
onClick={toggleTheme}
aria-label={theme === 'light' ? t('dashboard.darkMode') : t('dashboard.lightMode')}
className="h-9 w-9 p-0"
>
{theme === 'light' ? (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M20.354 15.354A9 9 0 018.646 3.646 9.003 9.003 0 0012 21a9.003 9.003 0 008.354-5.646z" />
</svg>
) : (
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24" aria-hidden="true">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M12 3v1m0 16v1m9-9h-1M4 12H3m15.364 6.364l-.707-.707M6.343 6.343l-.707-.707m12.728 0l-.707.707M6.343 17.657l-.707.707M16 12a4 4 0 11-8 0 4 4 0 018 0z" />
</svg>
)}
</Button>
<Button variant="ghost" size="sm" onClick={() => logout()}>
{t('common.logout')}
</Button>
</div>
</div>
</header>
<main id="main-content" role="main" className="mx-auto max-w-7xl px-4 py-6">{children}</main>
</div>
);
}

View File

@@ -0,0 +1,131 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useParams, useRouter } from 'next/navigation';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import {
StepBasicInfo,
StepLocation,
StepDetails,
StepPricing,
} from '@/components/listings/listing-form-steps';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
import {
createListingSchema,
type CreateListingFormData,
} from '@/lib/validations/listings';
export default function EditListingPage() {
const { id } = useParams<{ id: string }>();
const router = useRouter();
const [listing, setListing] = React.useState<ListingDetail | null>(null);
const [loading, setLoading] = React.useState(true);
const [activeTab, setActiveTab] = React.useState('basic');
const {
register,
reset,
formState: { errors },
} = useForm<CreateListingFormData>({
resolver: zodResolver(createListingSchema),
mode: 'onTouched',
});
React.useEffect(() => {
listingsApi
.getById(id)
.then((data) => {
setListing(data);
const { property } = data;
reset({
transactionType: data.transactionType,
propertyType: property.propertyType,
title: property.title,
description: property.description,
address: property.address,
ward: property.ward,
district: property.district,
city: property.city,
areaM2: String(property.areaM2),
bedrooms: property.bedrooms != null ? String(property.bedrooms) : '',
bathrooms: property.bathrooms != null ? String(property.bathrooms) : '',
floors: property.floors != null ? String(property.floors) : '',
direction: property.direction ?? '',
yearBuilt: property.yearBuilt != null ? String(property.yearBuilt) : '',
legalStatus: property.legalStatus ?? '',
projectName: property.projectName ?? '',
amenities: property.amenities?.join(', ') ?? '',
priceVND: data.priceVND,
rentPriceMonthly: data.rentPriceMonthly ?? '',
commissionPct: data.commissionPct != null ? String(data.commissionPct) : '',
});
})
.catch(() => setListing(null))
.finally(() => setLoading(false));
}, [id, reset]);
if (loading) {
return (
<div className="flex min-h-[400px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (!listing) {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
<p className="text-destructive">Không tìm thấy tin đăng</p>
<Button variant="outline" onClick={() => router.push('/listings')}>
Quay lại
</Button>
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Chỉnh sửa tin đăng</h1>
<Button variant="outline" onClick={() => router.push(`/listings/${id}`)}>
Xem tin
</Button>
</div>
<p className="text-sm text-muted-foreground">
Chức năng chỉnh sửa sẽ đưc hoàn thiện khi backend API hỗ trợ PATCH /listings/:id.
Hiện tại bạn thể xem lại thông tin đã nhập.
</p>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic"> bản</TabsTrigger>
<TabsTrigger value="location">Vị trí</TabsTrigger>
<TabsTrigger value="details">Chi tiết</TabsTrigger>
<TabsTrigger value="pricing">Giá cả</TabsTrigger>
</TabsList>
<Card className="mt-4">
<CardContent className="pt-6">
<TabsContent value="basic">
<StepBasicInfo register={register} errors={errors} />
</TabsContent>
<TabsContent value="location">
<StepLocation register={register} errors={errors} />
</TabsContent>
<TabsContent value="details">
<StepDetails register={register} errors={errors} />
</TabsContent>
<TabsContent value="pricing">
<StepPricing register={register} errors={errors} />
</TabsContent>
</CardContent>
</Card>
</Tabs>
</div>
);
}

View File

@@ -0,0 +1,111 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
const mockPush = vi.fn();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush }),
}));
vi.mock('@/lib/listings-api', () => ({
listingsApi: {
create: vi.fn(),
uploadMedia: vi.fn(),
},
}));
vi.mock('@/components/listings/image-upload', () => ({
ImageUpload: ({ onChange }: { onChange: (imgs: unknown[]) => void }) => (
<div data-testid="image-upload">
<button type="button" onClick={() => onChange([])}>Upload Mock</button>
</div>
),
}));
import { listingsApi } from '@/lib/listings-api';
import CreateListingPage from '../new/page';
const _mockedListingsApi = vi.mocked(listingsApi);
describe('CreateListingPage', () => {
beforeEach(() => {
vi.clearAllMocks();
});
it('renders the page title and step indicators', () => {
render(<CreateListingPage />);
expect(screen.getByText('Đăng tin mới')).toBeInTheDocument();
expect(screen.getByText('Thông tin')).toBeInTheDocument();
expect(screen.getByText('Vị trí')).toBeInTheDocument();
expect(screen.getByText('Chi tiết')).toBeInTheDocument();
expect(screen.getByText('Giá cả')).toBeInTheDocument();
expect(screen.getByText('Hình ảnh')).toBeInTheDocument();
});
it('renders step 1 (basic info) initially', () => {
render(<CreateListingPage />);
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
expect(screen.getByLabelText(/loại giao dịch/i)).toBeInTheDocument();
expect(screen.getByLabelText(/loại bất động sản/i)).toBeInTheDocument();
expect(screen.getByLabelText(/tiêu đề/i)).toBeInTheDocument();
expect(screen.getByLabelText(/mô tả/i)).toBeInTheDocument();
});
it('has back button disabled on first step', () => {
render(<CreateListingPage />);
expect(screen.getByRole('button', { name: /quay lại/i })).toBeDisabled();
});
it('navigates to step 2 when basic info is filled and next is clicked', async () => {
render(<CreateListingPage />);
// Fill step 1
await userEvent.selectOptions(screen.getByLabelText(/loại giao dịch/i), 'SALE');
await userEvent.selectOptions(screen.getByLabelText(/loại bất động sản/i), 'APARTMENT');
await userEvent.type(screen.getByLabelText(/tiêu đề/i), 'Bán căn hộ 2PN tại Quận 7');
await userEvent.type(screen.getByLabelText(/mô tả/i), 'Căn hộ view sông tuyệt đẹp, nội thất cao cấp');
await userEvent.click(screen.getByRole('button', { name: /tiếp theo/i }));
await waitFor(() => {
expect(screen.getByLabelText(/địa chỉ/i)).toBeInTheDocument();
});
});
it('shows validation errors when required fields are empty on step 1', async () => {
render(<CreateListingPage />);
await userEvent.click(screen.getByRole('button', { name: /tiếp theo/i }));
// Step should not advance - still showing basic info
await waitFor(() => {
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
});
});
it('navigates back to previous step', async () => {
render(<CreateListingPage />);
// Fill step 1 and go to step 2
await userEvent.selectOptions(screen.getByLabelText(/loại giao dịch/i), 'SALE');
await userEvent.selectOptions(screen.getByLabelText(/loại bất động sản/i), 'APARTMENT');
await userEvent.type(screen.getByLabelText(/tiêu đề/i), 'Test listing title here');
await userEvent.type(screen.getByLabelText(/mô tả/i), 'A detailed description of the property for sale');
await userEvent.click(screen.getByRole('button', { name: /tiếp theo/i }));
await waitFor(() => {
expect(screen.getByLabelText(/địa chỉ/i)).toBeInTheDocument();
});
// Go back
await userEvent.click(screen.getByRole('button', { name: /quay lại/i }));
await waitFor(() => {
expect(screen.getByText('Thông tin cơ bản')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,221 @@
'use client';
import { zodResolver } from '@hookform/resolvers/zod';
import { useRouter } from 'next/navigation';
import * as React from 'react';
import { useForm } from 'react-hook-form';
import { ImageUpload, type ImageFile } from '@/components/listings/image-upload';
import {
StepBasicInfo,
StepLocation,
StepDetails,
StepPricing,
} from '@/components/listings/listing-form-steps';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { listingsApi, type CreateListingPayload, type Direction } from '@/lib/listings-api';
import { cn } from '@/lib/utils';
import {
createListingSchema,
listingBasicSchema,
listingLocationSchema,
listingDetailsSchema,
listingPricingSchema,
type CreateListingFormData,
} from '@/lib/validations/listings';
const STEPS = [
{ title: 'Thông tin', schemaKeys: Object.keys(listingBasicSchema.shape) },
{ title: 'Vị trí', schemaKeys: Object.keys(listingLocationSchema.shape) },
{ title: 'Chi tiết', schemaKeys: Object.keys(listingDetailsSchema.shape) },
{ title: 'Giá cả', schemaKeys: Object.keys(listingPricingSchema.shape) },
{ title: 'Hình ảnh', schemaKeys: null },
];
function toNum(val: string | undefined): number | undefined {
if (!val) return undefined;
const n = Number(val);
return isNaN(n) ? undefined : n;
}
export default function CreateListingPage() {
const router = useRouter();
const [currentStep, setCurrentStep] = React.useState(0);
const [images, setImages] = React.useState<ImageFile[]>([]);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const {
register,
handleSubmit,
trigger,
formState: { errors },
} = useForm<CreateListingFormData>({
resolver: zodResolver(createListingSchema),
mode: 'onTouched',
});
const goNext = async () => {
const step = STEPS[currentStep];
if (step?.schemaKeys) {
const valid = await trigger(step.schemaKeys as Array<keyof CreateListingFormData>);
if (!valid) return;
}
setCurrentStep((s) => Math.min(s + 1, STEPS.length - 1));
};
const goBack = () => setCurrentStep((s) => Math.max(s - 1, 0));
const onSubmit = async (data: CreateListingFormData) => {
setIsSubmitting(true);
setError(null);
try {
const payload: CreateListingPayload = {
transactionType: data.transactionType,
propertyType: data.propertyType,
title: data.title,
description: data.description,
address: data.address,
ward: data.ward,
district: data.district,
city: data.city,
latitude: toNum(data.latitude) ?? 0,
longitude: toNum(data.longitude) ?? 0,
areaM2: Number(data.areaM2),
priceVND: data.priceVND,
};
const usableAreaM2 = toNum(data.usableAreaM2);
if (usableAreaM2 != null) payload.usableAreaM2 = usableAreaM2;
const bedrooms = toNum(data.bedrooms);
if (bedrooms != null) payload.bedrooms = bedrooms;
const bathrooms = toNum(data.bathrooms);
if (bathrooms != null) payload.bathrooms = bathrooms;
const floors = toNum(data.floors);
if (floors != null) payload.floors = floors;
const floor = toNum(data.floor);
if (floor != null) payload.floor = floor;
const totalFloors = toNum(data.totalFloors);
if (totalFloors != null) payload.totalFloors = totalFloors;
if (data.direction) payload.direction = data.direction as Direction;
const yearBuilt = toNum(data.yearBuilt);
if (yearBuilt != null) payload.yearBuilt = yearBuilt;
if (data.legalStatus) payload.legalStatus = data.legalStatus;
if (data.projectName) payload.projectName = data.projectName;
if (data.amenities) {
payload.amenities = data.amenities.split(',').map((s) => s.trim()).filter(Boolean);
}
if (data.rentPriceMonthly) payload.rentPriceMonthly = data.rentPriceMonthly;
const commissionPct = toNum(data.commissionPct);
if (commissionPct != null) payload.commissionPct = commissionPct;
const result = await listingsApi.create(payload);
for (const img of images) {
try {
await listingsApi.uploadMedia(result.listingId, img.file);
} catch {
// Continue with remaining images
}
}
router.push(`/listings/${result.listingId}`);
} catch (err) {
setError(err instanceof Error ? err.message : 'Có lỗi xảy ra');
} finally {
setIsSubmitting(false);
}
};
return (
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-2xl font-bold">Đăng tin mới</h1>
{/* Step indicators */}
<div className="mb-8 flex items-center justify-between">
{STEPS.map((step, index) => (
<div key={step.title} className="flex items-center">
<button
type="button"
onClick={() => index < currentStep && setCurrentStep(index)}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors',
index === currentStep
? 'bg-primary text-primary-foreground'
: index < currentStep
? 'bg-primary/20 text-primary cursor-pointer'
: 'bg-muted text-muted-foreground',
)}
>
{index < currentStep ? '\u2713' : index + 1}
</button>
<span
className={cn(
'ml-2 hidden text-sm sm:inline',
index === currentStep ? 'font-medium' : 'text-muted-foreground',
)}
>
{step.title}
</span>
{index < STEPS.length - 1 && (
<div
className={cn(
'mx-3 h-px w-8 sm:w-12',
index < currentStep ? 'bg-primary' : 'bg-muted',
)}
/>
)}
</div>
))}
</div>
{error && (
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
<button className="ml-2 font-medium underline" onClick={() => setError(null)}>
Đóng
</button>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent className="pt-6">
{currentStep === 0 && <StepBasicInfo register={register} errors={errors} />}
{currentStep === 1 && <StepLocation register={register} errors={errors} />}
{currentStep === 2 && <StepDetails register={register} errors={errors} />}
{currentStep === 3 && <StepPricing register={register} errors={errors} />}
{currentStep === 4 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Hình nh</h3>
<ImageUpload images={images} onChange={setImages} />
</div>
)}
</CardContent>
</Card>
<div className="mt-6 flex justify-between">
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={currentStep === 0}
>
Quay lại
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={goNext}>
Tiếp theo
</Button>
) : (
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Đang đăng...' : 'Đăng tin'}
</Button>
)}
</div>
</form>
</div>
);
}

View File

@@ -0,0 +1,345 @@
'use client';
import Image from 'next/image';
import Link from 'next/link';
import * as React from 'react';
import { ListingStatusBadge } from '@/components/listings/listing-status-badge';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Select } from '@/components/ui/select';
import { useListingsSearch } from '@/lib/hooks/use-listings';
import type { ListingDetail as _ListingDetail } from '@/lib/listings-api';
import { PROPERTY_TYPES, TRANSACTION_TYPES, LISTING_STATUSES } from '@/lib/validations/listings';
function formatPrice(priceVND: string): string {
const num = Number(priceVND);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
return num.toLocaleString('vi-VN');
}
function formatDate(dateStr: string | null): string {
if (!dateStr) return 'N/A';
return new Date(dateStr).toLocaleDateString('vi-VN', {
day: '2-digit',
month: '2-digit',
year: 'numeric',
});
}
type ViewMode = 'grid' | 'table';
export default function ListingsPage() {
const [viewMode, setViewMode] = React.useState<ViewMode>('grid');
const [filters, setFilters] = React.useState({
transactionType: '',
propertyType: '',
status: '' as string,
page: 1,
});
const searchParams = React.useMemo(() => {
const params: Record<string, string | number> = { page: filters.page, limit: 12 };
if (filters.transactionType) params['transactionType'] = filters.transactionType;
if (filters.propertyType) params['propertyType'] = filters.propertyType;
if (filters.status) params['status'] = filters.status;
return params;
}, [filters]);
const { data: result, isLoading: loading } = useListingsSearch(searchParams);
// Stats from current page data
const stats = React.useMemo(() => {
if (!result) return { total: 0, active: 0, pending: 0, views: 0 };
return {
total: result.total,
active: result.data.filter((l) => l.status === 'ACTIVE').length,
pending: result.data.filter((l) => l.status === 'PENDING_REVIEW').length,
views: result.data.reduce((s, l) => s + l.viewCount, 0),
};
}, [result]);
return (
<div className="space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<div>
<h1 className="text-2xl font-bold">Quản tin đăng</h1>
<p className="text-sm text-muted-foreground">
Quản , theo dõi cập nhật các tin đăng của bạn
</p>
</div>
<Link href="/listings/new">
<Button>Đăng tin mới</Button>
</Link>
</div>
{/* Stats */}
<div className="grid gap-3 sm:grid-cols-4">
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng tin đăng</CardDescription>
<CardTitle className="text-xl">{loading ? '...' : stats.total}</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Đang hoạt đng</CardDescription>
<CardTitle className="text-xl text-green-600">
{loading ? '...' : stats.active}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Chờ duyệt</CardDescription>
<CardTitle className="text-xl text-yellow-600">
{loading ? '...' : stats.pending}
</CardTitle>
</CardHeader>
</Card>
<Card>
<CardHeader className="pb-2">
<CardDescription>Tổng lượt xem</CardDescription>
<CardTitle className="text-xl">{loading ? '...' : stats.views}</CardTitle>
</CardHeader>
</Card>
</div>
{/* Filters + View Toggle */}
<div className="flex flex-wrap items-center gap-3">
<Select
value={filters.transactionType}
onChange={(e) =>
setFilters((f) => ({ ...f, transactionType: e.target.value, page: 1 }))
}
className="w-40"
>
<option value="">Tất cả giao dịch</option>
{TRANSACTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<Select
value={filters.propertyType}
onChange={(e) =>
setFilters((f) => ({ ...f, propertyType: e.target.value, page: 1 }))
}
className="w-44"
>
<option value="">Tất cả loại BĐS</option>
{PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<Select
value={filters.status}
onChange={(e) => setFilters((f) => ({ ...f, status: e.target.value, page: 1 }))}
className="w-40"
>
<option value="">Tất cả trạng thái</option>
{Object.entries(LISTING_STATUSES).map(([key, { label }]) => (
<option key={key} value={key}>
{label}
</option>
))}
</Select>
<div className="ml-auto flex gap-1">
<Button
variant={viewMode === 'grid' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('grid')}
>
Lưới
</Button>
<Button
variant={viewMode === 'table' ? 'default' : 'outline'}
size="sm"
onClick={() => setViewMode('table')}
>
Bảng
</Button>
</div>
</div>
{/* Content */}
{loading ? (
<div className="flex min-h-[300px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : !result || result.data.length === 0 ? (
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
<p>Chưa tin đăng nào</p>
<Link href="/listings/new" className="mt-2">
<Button variant="outline" size="sm">
Đăng tin đu tiên
</Button>
</Link>
</div>
) : viewMode === 'grid' ? (
/* Grid View */
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{result.data.map((listing) => (
<Link key={listing.id} href={`/listings/${listing.id}`}>
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
<div className="relative aspect-[4/3] bg-muted">
{listing.property.media.length > 0 ? (
<Image
src={listing.property.media[0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
Chưa nh
</div>
)}
<div className="absolute left-2 top-2">
<ListingStatusBadge status={listing.status} />
</div>
</div>
<CardContent className="p-4">
<p className="text-lg font-bold text-primary">
{formatPrice(listing.priceVND)} VND
</p>
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<Badge variant="secondary" className="text-xs">
{listing.property.areaM2} m²
</Badge>
{listing.property.bedrooms != null && (
<Badge variant="secondary" className="text-xs">
{listing.property.bedrooms} PN
</Badge>
)}
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
<Badge variant="secondary" className="text-xs">
{listing.property.bathrooms} PT
</Badge>
)}
</div>
<div className="mt-3 flex gap-3 text-xs text-muted-foreground">
<span>{listing.viewCount} lượt xem</span>
<span>{listing.inquiryCount} liên hệ</span>
<span>{listing.saveCount} đã lưu</span>
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
/* Table View */
<Card>
<CardContent className="p-0">
<div className="overflow-x-auto">
<table className="w-full text-sm">
<thead>
<tr className="border-b text-left">
<th className="p-3 font-medium">Tin đăng</th>
<th className="p-3 font-medium">Loại</th>
<th className="p-3 font-medium text-right">Giá</th>
<th className="p-3 font-medium text-right">Diện tích</th>
<th className="p-3 font-medium text-center">Trạng thái</th>
<th className="p-3 font-medium text-right">Lượt xem</th>
<th className="p-3 font-medium text-right">Liên hệ</th>
<th className="p-3 font-medium text-right">Ngày đăng</th>
</tr>
</thead>
<tbody>
{result.data.map((listing) => (
<tr
key={listing.id}
className="border-b last:border-0 transition-colors hover:bg-accent/50"
>
<td className="p-3">
<Link
href={`/listings/${listing.id}`}
className="group flex items-center gap-3"
>
<div className="relative h-10 w-14 flex-shrink-0 overflow-hidden rounded bg-muted">
{listing.property.media.length > 0 ? (
<Image
src={listing.property.media[0]?.url ?? ''}
alt={listing.property.title}
fill
sizes="56px"
className="object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
N/A
</div>
)}
</div>
<div className="min-w-0">
<p className="truncate font-medium group-hover:text-primary">
{listing.property.title}
</p>
<p className="truncate text-xs text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
</div>
</Link>
</td>
<td className="p-3 text-xs text-muted-foreground">
{listing.property.propertyType}
</td>
<td className="p-3 text-right font-medium text-primary">
{formatPrice(listing.priceVND)}
</td>
<td className="p-3 text-right">{listing.property.areaM2} m²</td>
<td className="p-3 text-center">
<ListingStatusBadge status={listing.status} />
</td>
<td className="p-3 text-right">{listing.viewCount}</td>
<td className="p-3 text-right">{listing.inquiryCount}</td>
<td className="p-3 text-right text-xs text-muted-foreground">
{formatDate(listing.publishedAt ?? listing.createdAt)}
</td>
</tr>
))}
</tbody>
</table>
</div>
</CardContent>
</Card>
)}
{/* Pagination */}
{result && result.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page <= 1}
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {result.page} / {result.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={filters.page >= result.totalPages}
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
>
Tiếp
</Button>
</div>
)}
</div>
);
}

View File

@@ -0,0 +1,71 @@
export default function DashboardLoading() {
return (
<div className="space-y-8">
{/* Header skeleton */}
<div className="flex items-center justify-between">
<div>
<div className="h-8 w-48 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-72 animate-pulse rounded bg-muted" />
</div>
<div className="h-10 w-28 animate-pulse rounded-md bg-muted" />
</div>
{/* Stats grid skeleton */}
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card p-6 shadow-sm">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="mt-3 h-7 w-16 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-32 animate-pulse rounded bg-muted" />
</div>
))}
</div>
{/* Chart + sidebar skeleton */}
<div className="grid gap-6 lg:grid-cols-3">
<div className="rounded-lg border bg-card p-6 shadow-sm lg:col-span-2">
<div className="h-5 w-40 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-56 animate-pulse rounded bg-muted" />
<div className="mt-6 h-64 animate-pulse rounded bg-muted" />
</div>
<div className="rounded-lg border bg-card p-6 shadow-sm">
<div className="h-5 w-36 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-28 animate-pulse rounded bg-muted" />
<div className="mt-6 space-y-4">
{Array.from({ length: 4 }).map((_, i) => (
<div key={i} className="flex items-center justify-between">
<div className="h-4 w-24 animate-pulse rounded bg-muted" />
<div className="h-4 w-16 animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
</div>
{/* Recent listings skeleton */}
<div className="rounded-lg border bg-card shadow-sm">
<div className="p-6">
<div className="h-5 w-36 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-56 animate-pulse rounded bg-muted" />
</div>
<div className="px-6 pb-6">
<div className="space-y-3">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="flex items-center gap-4 rounded-lg border p-3">
<div className="h-12 w-16 flex-shrink-0 animate-pulse rounded bg-muted" />
<div className="min-w-0 flex-1">
<div className="h-4 w-3/4 animate-pulse rounded bg-muted" />
<div className="mt-2 h-3 w-1/2 animate-pulse rounded bg-muted" />
</div>
<div className="text-right">
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="mt-1 h-5 w-16 animate-pulse rounded bg-muted" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,125 @@
'use client';
import { usePathname } from 'next/navigation';
import { useTranslations } from 'next-intl';
import { Button } from '@/components/ui/button';
import { LanguageSwitcher } from '@/components/ui/language-switcher';
import { Link } from '@/i18n/navigation';
import { useAuthStore } from '@/lib/auth-store';
import { cn } from '@/lib/utils';
export default function PublicLayout({ children }: { children: React.ReactNode }) {
const pathname = usePathname();
const { user } = useAuthStore();
const t = useTranslations();
return (
<div className="min-h-screen bg-background">
<header
role="banner"
className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60"
>
<div className="mx-auto flex h-14 max-w-7xl items-center px-4">
<Link href="/" className="mr-6 flex items-center space-x-2">
<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>
</nav>
<div className="ml-auto flex items-center space-x-2">
<LanguageSwitcher />
{user ? (
<>
<span className="hidden text-sm text-muted-foreground sm:inline">
{user.fullName}
</span>
<Link href="/dashboard">
<Button size="sm">{t('common.dashboard')}</Button>
</Link>
</>
) : (
<>
<Link href="/login">
<Button variant="ghost" size="sm">
{t('common.login')}
</Button>
</Link>
<Link href="/register">
<Button size="sm">{t('common.register')}</Button>
</Link>
</>
)}
</div>
</div>
</header>
<main id="main-content" role="main">
{children}
</main>
<footer role="contentinfo" className="border-t bg-muted/40">
<div className="mx-auto max-w-7xl px-4 py-8">
<div className="grid gap-8 sm:grid-cols-2 lg:grid-cols-4">
<div>
<h3 className="mb-3 text-sm font-semibold">{t('common.goodgo')}</h3>
<p className="text-sm text-muted-foreground">
{t('footer.description')}
</p>
</div>
<div>
<h3 className="mb-3 text-sm font-semibold">{t('footer.propertyTypes')}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><Link href="/search?propertyType=APARTMENT" className="hover:text-foreground">{t('propertyTypes.APARTMENT')}</Link></li>
<li><Link href="/search?propertyType=HOUSE" className="hover:text-foreground">{t('propertyTypes.HOUSE')}</Link></li>
<li><Link href="/search?propertyType=VILLA" className="hover:text-foreground">{t('propertyTypes.VILLA')}</Link></li>
<li><Link href="/search?propertyType=LAND" className="hover:text-foreground">{t('propertyTypes.LAND')}</Link></li>
</ul>
</div>
<div>
<h3 className="mb-3 text-sm font-semibold">{t('footer.areas')}</h3>
<ul className="space-y-2 text-sm text-muted-foreground">
<li><Link href="/search?city=Hồ Chí Minh" className="hover:text-foreground">TP. Hồ Chí Minh</Link></li>
<li><Link href="/search?city=Hà Nội" className="hover:text-foreground"> Nội</Link></li>
<li><Link href="/search?city=Đà Nẵng" className="hover:text-foreground">Đà Nẵng</Link></li>
<li><Link href="/search?city=Nha Trang" className="hover:text-foreground">Nha Trang</Link></li>
</ul>
</div>
<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="/login" className="hover:text-foreground">{t('common.login')}</Link></li>
<li><Link href="/register" className="hover:text-foreground">{t('common.register')}</Link></li>
</ul>
</div>
</div>
<div className="mt-8 border-t pt-4 text-center text-sm text-muted-foreground">
{t('common.allRightsReserved')}
</div>
</div>
</footer>
</div>
);
}

View File

@@ -0,0 +1,349 @@
'use client';
import dynamic from 'next/dynamic';
import Link from 'next/link';
import { useParams } from 'next/navigation';
import * as React from 'react';
import { ImageGallery } from '@/components/listings/image-gallery';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { AiEstimateButton } from '@/components/valuation/ai-estimate-button';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
const ListingMap = dynamic(
() => import('@/components/map/listing-map').then((mod) => mod.ListingMap),
{
ssr: false,
loading: () => (
<div className="flex h-[300px] items-center justify-center rounded-lg bg-muted">
<p className="text-sm text-muted-foreground">Đang tải bản đ...</p>
</div>
),
},
);
function formatPrice(priceVND: string): string {
const num = Number(priceVND);
if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`;
if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`;
return num.toLocaleString('vi-VN');
}
function getLabel(list: readonly { value: string; label: string }[], value: string | null) {
if (!value) return null;
return list.find((item) => item.value === value)?.label ?? value;
}
export default function PublicListingDetailPage() {
const { id } = useParams<{ id: string }>();
const [listing, setListing] = React.useState<ListingDetail | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(null);
React.useEffect(() => {
listingsApi
.getById(id)
.then(setListing)
.catch((err) => setError(err instanceof Error ? err.message : 'Không tải được tin đăng'))
.finally(() => setLoading(false));
}, [id]);
if (loading) {
return (
<div className="mx-auto max-w-6xl px-4 py-8">
{/* Skeleton loader */}
<div className="animate-pulse space-y-6">
<div className="h-8 w-2/3 rounded bg-muted" />
<div className="aspect-video rounded-lg bg-muted" />
<div className="grid gap-6 lg:grid-cols-3">
<div className="space-y-4 lg:col-span-2">
<div className="h-40 rounded-lg bg-muted" />
<div className="h-32 rounded-lg bg-muted" />
</div>
<div className="h-48 rounded-lg bg-muted" />
</div>
</div>
</div>
);
}
if (error || !listing) {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
<svg className="h-12 w-12 text-muted-foreground" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
<p className="text-destructive">{error || 'Không tìm thấy tin đăng'}</p>
<Link href="/search">
<Button variant="outline">Quay lại tìm kiếm</Button>
</Link>
</div>
);
}
const { property, seller, agent } = listing;
const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType);
const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType);
return (
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Breadcrumb */}
<nav className="mb-4 flex items-center gap-1.5 text-sm text-muted-foreground">
<Link href="/" className="hover:text-foreground">Trang chủ</Link>
<span>/</span>
<Link href="/search" className="hover:text-foreground">Tìm kiếm</Link>
<span>/</span>
<span className="truncate text-foreground">{property.title}</span>
</nav>
{/* Header */}
<div className="mb-6 flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div className="min-w-0 flex-1">
<div className="mb-2 flex flex-wrap items-center gap-2">
{transactionLabel && (
<Badge variant={listing.transactionType === 'SALE' ? 'default' : 'secondary'}>
{transactionLabel}
</Badge>
)}
{propertyTypeLabel && <Badge variant="outline">{propertyTypeLabel}</Badge>}
</div>
<h1 className="text-2xl font-bold md:text-3xl">{property.title}</h1>
<p className="mt-1 flex items-center gap-1 text-muted-foreground">
<svg className="h-4 w-4 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M15 11a3 3 0 11-6 0 3 3 0 016 0z" />
</svg>
{property.address}, {property.ward}, {property.district}, {property.city}
</p>
</div>
<div className="shrink-0 text-right">
<p className="text-2xl font-bold text-primary md:text-3xl">{formatPrice(listing.priceVND)} VND</p>
{listing.pricePerM2 != null && (
<p className="text-sm text-muted-foreground">
~{listing.pricePerM2.toLocaleString('vi-VN')} VND/m²
</p>
)}
{listing.rentPriceMonthly && (
<p className="text-sm text-muted-foreground">
Thuê: {formatPrice(listing.rentPriceMonthly)}/tháng
</p>
)}
</div>
</div>
{/* Image gallery */}
<ImageGallery media={property.media} />
{/* Quick specs bar */}
<div className="my-6 flex flex-wrap gap-4 rounded-lg border bg-card p-4">
<QuickStat icon="area" label="Diện tích" value={`${property.areaM2} m\u00B2`} />
{property.bedrooms != null && (
<QuickStat icon="bed" label="Phòng ngủ" value={`${property.bedrooms}`} />
)}
{property.bathrooms != null && (
<QuickStat icon="bath" label="Phòng tắm" value={`${property.bathrooms}`} />
)}
{property.floors != null && (
<QuickStat icon="floors" label="Số tầng" value={`${property.floors}`} />
)}
{property.direction && (
<QuickStat icon="compass" label="Hướng" value={getLabel(DIRECTIONS, property.direction) || ''} />
)}
</div>
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="space-y-6 lg:col-span-2">
{/* Description */}
<Card>
<CardHeader>
<CardTitle> tả</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{property.description}</p>
</CardContent>
</Card>
{/* Details */}
<Card>
<CardHeader>
<CardTitle>Thông tin chi tiết</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<InfoItem label="Loại BĐS" value={propertyTypeLabel || '---'} />
<InfoItem label="Diện tích" value={`${property.areaM2} m\u00B2`} />
<InfoItem label="Phòng ngủ" value={property.bedrooms != null ? `${property.bedrooms}` : '---'} />
<InfoItem label="Phòng tắm" value={property.bathrooms != null ? `${property.bathrooms}` : '---'} />
<InfoItem label="Số tầng" value={property.floors != null ? `${property.floors}` : '---'} />
<InfoItem label="Hướng" value={getLabel(DIRECTIONS, property.direction) || '---'} />
<InfoItem label="Năm xây" value={property.yearBuilt ? `${property.yearBuilt}` : '---'} />
<InfoItem label="Pháp lý" value={property.legalStatus || '---'} />
<InfoItem label="Dự án" value={property.projectName || '---'} />
</div>
</CardContent>
</Card>
{/* Amenities */}
{property.amenities && property.amenities.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Tiện ích</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{property.amenities.map((a) => (
<Badge key={a} variant="secondary">
{a}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
{/* Map */}
<Card>
<CardHeader>
<CardTitle>Vị trí trên bản đ</CardTitle>
</CardHeader>
<CardContent>
<ListingMap
listings={[listing]}
className="h-[300px]"
/>
</CardContent>
</Card>
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Contact card */}
<Card className="sticky top-20">
<CardHeader>
<CardTitle>Liên hệ</CardTitle>
</CardHeader>
<CardContent className="space-y-4">
<div className="flex items-center gap-3">
<div className="flex h-10 w-10 items-center justify-center rounded-full bg-primary/10 text-primary">
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
</svg>
</div>
<div>
<p className="font-medium">{seller.fullName}</p>
<p className="text-sm text-muted-foreground">{seller.phone}</p>
</div>
</div>
<a href={`tel:${seller.phone}`}>
<Button className="w-full gap-2">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 5a2 2 0 012-2h3.28a1 1 0 01.948.684l1.498 4.493a1 1 0 01-.502 1.21l-2.257 1.13a11.042 11.042 0 005.516 5.516l1.13-2.257a1 1 0 011.21-.502l4.493 1.498a1 1 0 01.684.949V19a2 2 0 01-2 2h-1C9.716 21 3 14.284 3 6V5z" />
</svg>
Gọi ngay
</Button>
</a>
<Button variant="outline" className="w-full gap-2">
<svg className="h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z" />
</svg>
Nhắn tin
</Button>
{agent && (
<div className="border-t pt-3">
<p className="text-xs text-muted-foreground">Môi giới</p>
{agent.agency && <p className="text-sm font-medium">{agent.agency}</p>}
{listing.commissionPct != null && (
<p className="text-xs text-muted-foreground">Hoa hồng: {listing.commissionPct}%</p>
)}
</div>
)}
</CardContent>
</Card>
{/* AI Estimate */}
<AiEstimateButton listingId={listing.id} />
{/* Stats */}
<Card>
<CardContent className="pt-6">
<div className="grid grid-cols-3 gap-4 text-center">
<div>
<p className="text-lg font-bold">{listing.viewCount}</p>
<p className="text-xs text-muted-foreground">Lượt xem</p>
</div>
<div>
<p className="text-lg font-bold">{listing.saveCount}</p>
<p className="text-xs text-muted-foreground">Lượt lưu</p>
</div>
<div>
<p className="text-lg font-bold">{listing.inquiryCount}</p>
<p className="text-xs text-muted-foreground">Liên hệ</p>
</div>
</div>
{listing.publishedAt && (
<p className="mt-3 border-t pt-3 text-center text-xs text-muted-foreground">
Đăng ngày {new Date(listing.publishedAt).toLocaleDateString('vi-VN')}
</p>
)}
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function QuickStat({ icon, label, value }: { icon: string; label: string; value: string }) {
const icons: Record<string, React.ReactNode> = {
area: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 8V4m0 0h4M4 4l5 5m11-1V4m0 0h-4m4 0l-5 5M4 16v4m0 0h4m-4 0l5-5m11 5l-5-5m5 5v-4m0 4h-4" />
</svg>
),
bed: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M3 7v11m0-7h18M3 18h18M6 14h.01M6 10a2 2 0 012-2h8a2 2 0 012 2v0H6z" />
</svg>
),
bath: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M4 12h16M4 12a2 2 0 00-2 2v2a4 4 0 004 4h12a4 4 0 004-4v-2a2 2 0 00-2-2M4 12V7a3 3 0 013-3h1" />
</svg>
),
floors: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M19 21V5a2 2 0 00-2-2H7a2 2 0 00-2 2v16m14 0h2m-2 0h-5m-9 0H3m2 0h5M9 7h1m-1 4h1m4-4h1m-1 4h1m-5 10v-5a1 1 0 011-1h2a1 1 0 011 1v5m-4 0h4" />
</svg>
),
compass: (
<svg className="h-5 w-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M21 12a9 9 0 11-18 0 9 9 0 0118 0z" />
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={1.5} d="M9 9l3 3m0 0l3-3m-3 3V6m0 6l-3 3m3-3l3 3m-3-3v6" />
</svg>
),
};
return (
<div className="flex items-center gap-2">
<div className="text-muted-foreground">{icons[icon]}</div>
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="text-sm font-semibold">{value}</p>
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="font-medium">{value}</p>
</div>
);
}

View File

@@ -0,0 +1,276 @@
'use client';
import { useTranslations } from 'next-intl';
import * as React from 'react';
import { PropertyCard } from '@/components/search/property-card';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Card, CardContent } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
import { Select } from '@/components/ui/select';
import { Link, useRouter } from '@/i18n/navigation';
import { listingsApi, type ListingDetail } from '@/lib/listings-api';
const DISTRICTS = [
{ name: 'Quận 1', city: 'Hồ Chí Minh', img: null },
{ name: 'Quận 2', city: 'Hồ Chí Minh', img: null },
{ name: 'Quận 7', city: 'Hồ Chí Minh', img: null },
{ name: 'Bình Thạnh', city: 'Hồ Chí Minh', img: null },
{ name: 'Thủ Đức', city: 'Hồ Chí Minh', img: null },
{ name: 'Ba Đình', city: 'Hà Nội', img: null },
{ name: 'Hoàn Kiếm', city: 'Hà Nội', img: null },
{ name: 'Hải Châu', city: 'Đà Nẵng', img: null },
];
type StatKey = 'listings' | 'users' | 'transactions' | 'provinces';
const STATS: { key: StatKey; value: string; icon: string }[] = [
{ key: 'listings', value: '10,000+', icon: '🏠' },
{ key: 'users', value: '50,000+', icon: '👥' },
{ key: 'transactions', value: '2,000+', icon: '✅' },
{ key: 'provinces', value: '63', icon: '📍' },
];
const PROPERTY_TYPE_KEYS = ['APARTMENT', 'HOUSE', 'VILLA', 'LAND', 'OFFICE', 'SHOPHOUSE'] as const;
const TRANSACTION_TYPE_KEYS = ['SALE', 'RENT'] as const;
export default function LandingPage() {
const router = useRouter();
const t = useTranslations();
const [searchQuery, setSearchQuery] = React.useState('');
const [transactionType, setTransactionType] = React.useState('');
const [propertyType, _setPropertyType] = React.useState('');
const [featuredListings, setFeaturedListings] = React.useState<ListingDetail[]>([]);
const [loadingFeatured, setLoadingFeatured] = React.useState(true);
const [featuredError, setFeaturedError] = React.useState(false);
const fetchFeatured = React.useCallback(() => {
setLoadingFeatured(true);
setFeaturedError(false);
listingsApi
.search({ status: 'ACTIVE', limit: 6 })
.then((res) => setFeaturedListings(res.data))
.catch(() => setFeaturedError(true))
.finally(() => setLoadingFeatured(false));
}, []);
React.useEffect(() => {
fetchFeatured();
}, [fetchFeatured]);
const handleSearch = (e: React.FormEvent) => {
e.preventDefault();
const params = new URLSearchParams();
if (searchQuery) params.set('q', searchQuery);
if (transactionType) params.set('transactionType', transactionType);
if (propertyType) params.set('propertyType', propertyType);
router.push(`/search?${params.toString()}`);
};
return (
<div>
{/* Hero Section */}
<section className="relative bg-gradient-to-br from-primary/5 via-background to-primary/10 py-16 md:py-24">
<div className="mx-auto max-w-7xl px-4">
<div className="mx-auto max-w-3xl text-center">
<h1 className="text-4xl font-bold tracking-tight md:text-5xl lg:text-6xl">
{t('landing.heroTitle')}
<span className="text-primary"> {t('landing.heroTitleHighlight')}</span>
</h1>
<p className="mt-4 text-lg text-muted-foreground md:text-xl">
{t('landing.heroSubtitle')}
</p>
{/* Search Bar */}
<form onSubmit={handleSearch} className="mt-8" role="search" aria-label={t('common.search')}>
<div className="mx-auto flex max-w-2xl flex-col gap-3 rounded-xl border bg-white p-3 shadow-lg dark:bg-background sm:flex-row">
<Input
placeholder={t('landing.searchPlaceholder')}
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
className="border-0 shadow-none focus-visible:ring-0"
aria-label={t('landing.searchPlaceholder')}
/>
<div className="flex gap-2">
<Select
value={transactionType}
onChange={(e) => setTransactionType(e.target.value)}
className="w-32 shrink-0"
aria-label={t('landing.transactionTypeLabel')}
>
<option value="">{t('landing.transactionTypeLabel')}</option>
{TRANSACTION_TYPE_KEYS.map((key) => (
<option key={key} value={key}>
{t(`transactionTypes.${key}`)}
</option>
))}
</Select>
<Button type="submit" className="shrink-0 px-6">
<svg
className="mr-2 h-4 w-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
{t('common.search')}
</Button>
</div>
</div>
</form>
{/* Quick property type links */}
<div className="mt-6 flex flex-wrap justify-center gap-2">
{PROPERTY_TYPE_KEYS.map((key) => (
<Link
key={key}
href={`/search?propertyType=${key}`}
>
<Badge variant="outline" className="cursor-pointer px-3 py-1.5 text-sm hover:bg-accent">
{t(`propertyTypes.${key}`)}
</Badge>
</Link>
))}
</div>
</div>
</div>
</section>
{/* Featured Listings */}
<section aria-labelledby="featured-heading" className="py-12 md:py-16">
<div className="mx-auto max-w-7xl px-4">
<div className="flex items-center justify-between">
<div>
<h2 id="featured-heading" className="text-2xl font-bold md:text-3xl">{t('landing.featuredTitle')}</h2>
<p className="mt-1 text-muted-foreground">
{t('landing.featuredSubtitle')}
</p>
</div>
<Link href="/search">
<Button variant="outline">{t('landing.viewAll')}</Button>
</Link>
</div>
{loadingFeatured ? (
<div className="mt-8 flex min-h-[300px] items-center justify-center" role="status" aria-label={t('common.loading')}>
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" aria-hidden="true" />
<span className="sr-only">{t('common.loading')}</span>
</div>
) : featuredError ? (
<div className="mt-8 flex min-h-[200px] flex-col items-center justify-center gap-3 text-muted-foreground" role="alert">
<p>{t('landing.loadError')}</p>
<Button variant="outline" size="sm" onClick={fetchFeatured}>
{t('common.retry')}
</Button>
</div>
) : featuredListings.length > 0 ? (
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{featuredListings.map((listing) => (
<PropertyCard key={listing.id} listing={listing} />
))}
</div>
) : (
<div className="mt-8 flex min-h-[200px] items-center justify-center text-muted-foreground">
<p>{t('landing.noFeatured')}</p>
</div>
)}
</div>
</section>
{/* Districts / Quick Links */}
<section aria-labelledby="districts-heading" className="bg-muted/40 py-12 md:py-16">
<div className="mx-auto max-w-7xl px-4">
<h2 id="districts-heading" className="text-2xl font-bold md:text-3xl">{t('landing.districtsTitle')}</h2>
<p className="mt-1 text-muted-foreground">
{t('landing.districtsSubtitle')}
</p>
<div className="mt-8 grid gap-3 sm:grid-cols-2 md:grid-cols-4">
{DISTRICTS.map((district) => (
<Link
key={`${district.name}-${district.city}`}
href={`/search?district=${encodeURIComponent(district.name)}&city=${encodeURIComponent(district.city)}`}
>
<Card className="group cursor-pointer overflow-hidden transition-shadow hover:shadow-md">
<div className="aspect-[16/9] bg-gradient-to-br from-primary/10 to-primary/5">
<div className="flex h-full items-center justify-center">
<span className="text-3xl" aria-hidden="true">🏙</span>
</div>
</div>
<CardContent className="p-3">
<p className="font-medium group-hover:text-primary">{district.name}</p>
<p className="text-xs text-muted-foreground">{district.city}</p>
</CardContent>
</Card>
</Link>
))}
</div>
</div>
</section>
{/* Market Stats */}
<section aria-labelledby="stats-heading" className="py-12 md:py-16">
<div className="mx-auto max-w-7xl px-4">
<div className="text-center">
<h2 id="stats-heading" className="text-2xl font-bold md:text-3xl">{t('landing.statsTitle')}</h2>
<p className="mt-1 text-muted-foreground">
{t('landing.statsSubtitle')}
</p>
</div>
<div className="mt-8 grid gap-6 sm:grid-cols-2 lg:grid-cols-4">
{STATS.map((stat) => (
<div
key={stat.key}
className="rounded-lg border bg-card p-6 text-center shadow-sm"
>
<span className="text-3xl" aria-hidden="true">{stat.icon}</span>
<p className="mt-2 text-3xl font-bold text-primary">{stat.value}</p>
<p className="mt-1 text-sm text-muted-foreground">{t(`stats.${stat.key}`)}</p>
</div>
))}
</div>
</div>
</section>
{/* CTA Section */}
<section className="bg-primary py-12 md:py-16">
<div className="mx-auto max-w-7xl px-4 text-center">
<h2 className="text-2xl font-bold text-primary-foreground md:text-3xl">
{t('landing.ctaTitle')}
</h2>
<p className="mt-2 text-primary-foreground/80">
{t('landing.ctaSubtitle')}
</p>
<div className="mt-6 flex justify-center gap-3">
<Link href="/register">
<Button
variant="secondary"
size="lg"
className="font-semibold"
>
{t('landing.registerFree')}
</Button>
</Link>
<Link href="/search">
<Button
variant="outline"
size="lg"
className="border-primary-foreground/30 text-primary-foreground hover:bg-primary-foreground/10 hover:text-primary-foreground"
>
{t('landing.searchNow')}
</Button>
</Link>
</div>
</div>
</section>
</div>
);
}

View File

@@ -0,0 +1,166 @@
/* eslint-disable import-x/order */
import { render, screen, waitFor } from '@testing-library/react';
import userEvent from '@testing-library/user-event';
import { beforeEach, describe, expect, it, vi } from 'vitest';
// Mock next-intl with Vietnamese messages
const viMessages = await import('@/messages/vi.json');
vi.mock('next-intl', () => ({
useTranslations: (namespace?: string) => {
const messages = viMessages.default ?? viMessages;
const ns = namespace
? (messages[namespace as keyof typeof messages] as Record<string, unknown> | undefined)
: (messages as unknown as Record<string, unknown>);
return (key: string, params?: Record<string, unknown>) => {
if (!ns) return key;
const parts = key.split('.');
let val: unknown = ns;
for (const p of parts) {
val = (val as Record<string, unknown>)?.[p];
}
if (typeof val === 'string' && params) {
return val.replace(/\{(\w+)\}/g, (_, k: string) => String(params[k] ?? `{${k}}`));
}
return typeof val === 'string' ? val : key;
};
},
useLocale: () => 'vi',
NextIntlClientProvider: ({ children }: { children: React.ReactNode }) => children,
}));
const mockPush = vi.fn();
const mockReplace = vi.fn();
const mockSearchParams = new URLSearchParams();
vi.mock('next/navigation', () => ({
useRouter: () => ({ push: mockPush, replace: mockReplace }),
useSearchParams: () => mockSearchParams,
}));
vi.mock('next/link', () => ({
default: ({ children, href, ...props }: { children: React.ReactNode; href: string; [key: string]: unknown }) => (
<a href={href} {...props}>{children}</a>
),
}));
vi.mock('next/image', () => ({
default: (props: Record<string, unknown>) => <img {...props} />,
}));
// Mock dynamic import for map component
vi.mock('next/dynamic', () => ({
default: () => {
const MockMap = () => <div data-testid="map-placeholder">Map</div>;
MockMap.displayName = 'MockMap';
return MockMap;
},
}));
const mockListings = {
data: [
{
id: '1',
status: 'ACTIVE',
transactionType: 'SALE',
priceVND: '5000000000',
pricePerM2: null,
rentPriceMonthly: null,
commissionPct: null,
viewCount: 10,
saveCount: 2,
inquiryCount: 1,
publishedAt: '2024-01-01',
createdAt: '2024-01-01',
property: {
id: 'p1',
propertyType: 'APARTMENT',
title: 'Căn hộ Quận 7',
description: 'Căn hộ view sông',
address: '123 Nguyễn Hữu Thọ',
ward: 'Phường Tân Hưng',
district: 'Quận 7',
city: 'Hồ Chí Minh',
areaM2: 75,
bedrooms: 2,
bathrooms: 2,
floors: null,
direction: null,
yearBuilt: null,
legalStatus: null,
amenities: null,
projectName: null,
media: [],
},
seller: { id: 's1', fullName: 'Nguyen Van A', phone: '0912345678' },
agent: null,
},
],
total: 1,
page: 1,
limit: 12,
totalPages: 1,
};
vi.mock('@/lib/listings-api', () => ({
listingsApi: {
search: vi.fn(),
},
}));
import { listingsApi } from '@/lib/listings-api';
import SearchPage from '../page';
const mockedListingsApi = vi.mocked(listingsApi);
describe('SearchPage', () => {
beforeEach(() => {
vi.clearAllMocks();
mockedListingsApi.search.mockResolvedValue(mockListings as never);
});
it('renders the search page title', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(screen.getByText('Tìm kiếm bất động sản')).toBeInTheDocument();
});
});
it('renders view mode toggle buttons', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /danh sách/i })).toBeInTheDocument();
expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument();
});
});
it('calls listings API on mount', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(mockedListingsApi.search).toHaveBeenCalled();
});
});
it('displays listing results after loading', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(screen.getByText(/căn hộ quận 7/i)).toBeInTheDocument();
});
});
it('switches to map view when map button is clicked', async () => {
render(<SearchPage />);
await waitFor(() => {
expect(screen.getByRole('button', { name: /bản đồ/i })).toBeInTheDocument();
});
await userEvent.click(screen.getByRole('button', { name: /bản đồ/i }));
await waitFor(() => {
expect(screen.getByTestId('map-placeholder')).toBeInTheDocument();
});
});
});

View File

@@ -0,0 +1,97 @@
'use client';
import { useEffect, useState } from 'react';
export default function SearchError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const [retryCount, setRetryCount] = useState(0);
const [autoRetrying, setAutoRetrying] = useState(false);
useEffect(() => {
console.error('Search error:', error);
}, [error]);
useEffect(() => {
if (retryCount > 0) return;
setAutoRetrying(true);
const timer = setTimeout(() => {
setAutoRetrying(false);
setRetryCount((c) => c + 1);
reset();
}, 3000);
return () => clearTimeout(timer);
}, [error, reset, retryCount]);
const handleRetry = () => {
setRetryCount((c) => c + 1);
reset();
};
return (
<div className="mx-auto max-w-7xl px-4 py-6">
<div className="flex min-h-[400px] flex-col items-center justify-center">
<div className="mx-auto max-w-md text-center">
<div className="mx-auto flex h-14 w-14 items-center justify-center rounded-full bg-destructive/10">
<svg
className="h-7 w-7 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z"
/>
</svg>
</div>
<h2 className="mt-4 text-xl font-semibold">Lỗi tìm kiếm</h2>
<p className="mt-2 text-sm text-muted-foreground">
{autoRetrying
? 'Đang tự động thử lại...'
: 'Không thể thực hiện tìm kiếm. Vui lòng thử lại hoặc thay đổi bộ lọc.'}
</p>
{error.digest && (
<p className="mt-1 text-xs text-muted-foreground"> lỗi: {error.digest}</p>
)}
{retryCount > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
Đã thử lại {retryCount} lần
</p>
)}
<div className="mt-6 flex justify-center gap-3">
<button
onClick={handleRetry}
disabled={autoRetrying}
className="inline-flex h-9 items-center rounded-md bg-primary px-4 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:opacity-50"
>
{autoRetrying ? (
<>
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
Đang thử lại...
</>
) : (
'Thử lại'
)}
</button>
<a
href="/"
className="inline-flex h-9 items-center rounded-md border border-input bg-background px-4 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
>
Về trang chủ
</a>
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,16 @@
import type { Metadata } from 'next';
export const metadata: Metadata = {
title: 'Tìm kiếm bất động sản',
description:
'Tìm kiếm mua bán, cho thuê bất động sản trên toàn quốc — căn hộ, nhà phố, biệt thự, đất nền với bộ lọc thông minh.',
openGraph: {
title: 'Tìm kiếm bất động sản | GoodGo',
description:
'Tìm kiếm mua bán, cho thuê bất động sản trên toàn quốc với GoodGo.',
},
};
export default function SearchLayout({ children }: { children: React.ReactNode }) {
return children;
}

View File

@@ -0,0 +1,72 @@
export default function SearchLoading() {
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Header skeleton */}
<div className="mb-6">
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-80 animate-pulse rounded bg-muted" />
</div>
{/* View mode toggle skeleton */}
<div className="mb-4 flex items-center justify-between">
<div className="flex gap-1 rounded-lg border p-1">
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
<div className="h-8 w-24 animate-pulse rounded bg-muted" />
<div className="hidden h-8 w-24 animate-pulse rounded bg-muted lg:block" />
</div>
<div className="h-8 w-20 animate-pulse rounded bg-muted lg:hidden" />
</div>
{/* Filter bar skeleton (desktop) */}
<div className="mb-4 hidden lg:block">
<div className="flex gap-3 rounded-lg border p-4">
{Array.from({ length: 5 }).map((_, i) => (
<div key={i} className="h-9 w-36 animate-pulse rounded bg-muted" />
))}
<div className="h-9 w-24 animate-pulse rounded-md bg-muted" />
</div>
</div>
{/* Content area skeleton */}
<div className="flex gap-6">
{/* Sidebar skeleton (desktop) */}
<aside className="hidden w-64 shrink-0 lg:block">
<div className="rounded-lg border bg-card p-4">
<div className="space-y-4">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i}>
<div className="h-4 w-20 animate-pulse rounded bg-muted" />
<div className="mt-2 h-9 w-full animate-pulse rounded bg-muted" />
</div>
))}
</div>
</div>
</aside>
{/* Results grid skeleton */}
<div className="min-w-0 flex-1">
<div className="mb-4 flex items-center justify-between">
<div className="h-4 w-32 animate-pulse rounded bg-muted" />
<div className="h-9 w-40 animate-pulse rounded bg-muted" />
</div>
<div className="grid gap-4 sm:grid-cols-2 xl:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card shadow-sm">
<div className="aspect-[16/10] animate-pulse rounded-t-lg bg-muted" />
<div className="p-4">
<div className="h-5 w-3/4 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-1/2 animate-pulse rounded bg-muted" />
<div className="mt-3 flex gap-2">
<div className="h-6 w-16 animate-pulse rounded bg-muted" />
<div className="h-6 w-16 animate-pulse rounded bg-muted" />
</div>
<div className="mt-3 h-5 w-24 animate-pulse rounded bg-muted" />
</div>
</div>
))}
</div>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,294 @@
'use client';
import dynamic from 'next/dynamic';
import { useRouter, useSearchParams } from 'next/navigation';
import * as React from 'react';
import { FilterBar, type SearchFilters } from '@/components/search/filter-bar';
import { SearchResults } from '@/components/search/search-results';
import { Button } from '@/components/ui/button';
import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api';
const ListingMap = dynamic(
() => import('@/components/map/listing-map').then((mod) => mod.ListingMap),
{
ssr: false,
loading: () => (
<div className="flex h-[calc(100vh-220px)] items-center justify-center rounded-lg bg-muted">
<p className="text-sm text-muted-foreground">Đang tải bản đ...</p>
</div>
),
},
);
type ViewMode = 'list' | 'map' | 'split';
const defaultFilters: SearchFilters = {
transactionType: '',
propertyType: '',
city: '',
district: '',
minPrice: '',
maxPrice: '',
minArea: '',
maxArea: '',
bedrooms: '',
sort: '',
};
function SearchContent() {
const router = useRouter();
const searchParams = useSearchParams();
const [filters, setFilters] = React.useState<SearchFilters>(() => ({
...defaultFilters,
transactionType: searchParams.get('transactionType') || '',
propertyType: searchParams.get('propertyType') || '',
city: searchParams.get('city') || '',
district: searchParams.get('district') || '',
minPrice: searchParams.get('minPrice') || '',
maxPrice: searchParams.get('maxPrice') || '',
bedrooms: searchParams.get('bedrooms') || '',
sort: searchParams.get('sort') || '',
}));
const [page, setPage] = React.useState(Number(searchParams.get('page')) || 1);
const [result, setResult] = React.useState<PaginatedResult<ListingDetail> | null>(null);
const [loading, setLoading] = React.useState(true);
const [searchError, setSearchError] = React.useState(false);
const [viewMode, setViewMode] = React.useState<ViewMode>('list');
const [showMobileFilters, setShowMobileFilters] = React.useState(false);
const [selectedListingId, setSelectedListingId] = React.useState<string | undefined>();
const handleMarkerClick = (listing: ListingDetail) => {
setSelectedListingId(listing.id);
};
const fetchListings = React.useCallback(() => {
setLoading(true);
const params: Record<string, string | number> = {
page,
limit: 12,
status: 'ACTIVE',
};
if (filters.transactionType) params['transactionType'] = filters.transactionType;
if (filters.propertyType) params['propertyType'] = filters.propertyType;
if (filters.city) params['city'] = filters.city;
if (filters.district) params['district'] = filters.district;
if (filters.minPrice) params['minPrice'] = filters.minPrice;
if (filters.maxPrice) params['maxPrice'] = filters.maxPrice;
if (filters.minArea) params['minArea'] = Number(filters.minArea);
if (filters.maxArea) params['maxArea'] = Number(filters.maxArea);
if (filters.bedrooms) params['bedrooms'] = Number(filters.bedrooms);
setSearchError(false);
listingsApi
.search(params)
.then(setResult)
.catch(() => {
setResult(null);
setSearchError(true);
})
.finally(() => setLoading(false));
}, [filters, page]);
React.useEffect(() => {
fetchListings();
}, [fetchListings]);
// Sync filters to URL
React.useEffect(() => {
const params = new URLSearchParams();
Object.entries(filters).forEach(([key, value]) => {
if (value) params.set(key, value);
});
if (page > 1) params.set('page', String(page));
const qs = params.toString();
router.replace(`/search${qs ? `?${qs}` : ''}`, { scroll: false });
}, [filters, page, router]);
const handleFilterChange = (newFilters: SearchFilters) => {
setFilters(newFilters);
setPage(1);
};
const handleSearch = () => {
setPage(1);
fetchListings();
};
const activeFilterCount = Object.entries(filters).filter(
([key, value]) => value && key !== 'sort',
).length;
return (
<div className="mx-auto max-w-7xl px-4 py-6">
{/* Header */}
<div className="mb-6">
<h1 className="text-2xl font-bold md:text-3xl">Tìm kiếm bất đng sản</h1>
<p className="mt-1 text-muted-foreground">
Tìm bất đng sản phù hợp với nhu cầu của bạn
</p>
</div>
{/* View Mode Toggle + Mobile Filter Button */}
<div className="mb-4 flex items-center justify-between">
<div className="flex gap-1 rounded-lg border p-1">
<Button
variant={viewMode === 'list' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('list')}
>
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M4 6h16M4 10h16M4 14h16M4 18h16" />
</svg>
Danh sách
</Button>
<Button
variant={viewMode === 'map' ? 'default' : 'ghost'}
size="sm"
onClick={() => setViewMode('map')}
>
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l4.553 2.276A1 1 0 0021 18.382V7.618a1 1 0 00-.553-.894L15 4m0 13V4m0 0L9 7" />
</svg>
Bản đ
</Button>
<Button
variant={viewMode === 'split' ? 'default' : 'ghost'}
size="sm"
className="hidden lg:flex"
onClick={() => setViewMode('split')}
>
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M9 17V7m0 10a2 2 0 01-2 2H5a2 2 0 01-2-2V7a2 2 0 012-2h2a2 2 0 012 2m0 10a2 2 0 002 2h2a2 2 0 002-2M9 7a2 2 0 012-2h2a2 2 0 012 2m0 10V7" />
</svg>
Chia đôi
</Button>
</div>
<Button
variant="outline"
size="sm"
className="lg:hidden"
onClick={() => setShowMobileFilters(!showMobileFilters)}
>
<svg className="mr-1.5 h-4 w-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M3 4a1 1 0 011-1h16a1 1 0 011 1v2.586a1 1 0 01-.293.707l-6.414 6.414a1 1 0 00-.293.707V17l-4 4v-6.586a1 1 0 00-.293-.707L3.293 7.293A1 1 0 013 6.586V4z" />
</svg>
Bộ lọc
{activeFilterCount > 0 && (
<span className="ml-1.5 flex h-5 w-5 items-center justify-center rounded-full bg-primary text-xs text-primary-foreground">
{activeFilterCount}
</span>
)}
</Button>
</div>
{/* Desktop horizontal filter bar */}
<div className="mb-4 hidden lg:block">
<FilterBar
filters={filters}
onChange={handleFilterChange}
onSearch={handleSearch}
layout="horizontal"
/>
</div>
{/* Mobile filter panel */}
{showMobileFilters && (
<div className="mb-4 rounded-lg border bg-card p-4 lg:hidden">
<FilterBar
filters={filters}
onChange={handleFilterChange}
onSearch={() => {
handleSearch();
setShowMobileFilters(false);
}}
layout="sidebar"
/>
</div>
)}
{/* Content Area */}
<div className="flex gap-6">
{/* Sidebar filters (desktop, split/list mode) */}
{viewMode !== 'map' && (
<aside className="hidden w-64 shrink-0 lg:block">
<div className="sticky top-20 rounded-lg border bg-card p-4">
<FilterBar
filters={filters}
onChange={handleFilterChange}
onSearch={handleSearch}
layout="sidebar"
/>
</div>
</aside>
)}
{/* Main content */}
<div className="min-w-0 flex-1">
{viewMode === 'list' && (
<SearchResults
result={result}
loading={loading}
error={searchError}
onRetry={fetchListings}
page={page}
sort={filters.sort}
onPageChange={setPage}
onSortChange={(sort) => handleFilterChange({ ...filters, sort })}
/>
)}
{viewMode === 'map' && (
<ListingMap
listings={result?.data || []}
selectedListingId={selectedListingId}
onMarkerClick={handleMarkerClick}
className="h-[calc(100vh-220px)]"
/>
)}
{viewMode === 'split' && (
<div className="grid gap-4 lg:grid-cols-2">
<div className="overflow-auto" style={{ maxHeight: 'calc(100vh - 220px)' }}>
<SearchResults
result={result}
loading={loading}
error={searchError}
onRetry={fetchListings}
page={page}
sort={filters.sort}
onPageChange={setPage}
onSortChange={(sort) => handleFilterChange({ ...filters, sort })}
/>
</div>
<div className="hidden lg:block">
<ListingMap
listings={result?.data || []}
selectedListingId={selectedListingId}
onMarkerClick={handleMarkerClick}
className="sticky top-20 h-[calc(100vh-220px)]"
/>
</div>
</div>
)}
</div>
</div>
</div>
);
}
export default function SearchPage() {
return (
<React.Suspense
fallback={
<div className="flex min-h-[400px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
}
>
<SearchContent />
</React.Suspense>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { useAuthStore } from '@/lib/auth-store';
export default function GoogleCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { handleOAuthCallback } = useAuthStore();
const processed = useRef(false);
useEffect(() => {
if (processed.current) return;
processed.current = true;
const accessToken = searchParams.get('accessToken');
const refreshToken = searchParams.get('refreshToken');
const expiresIn = searchParams.get('expiresIn');
const error = searchParams.get('error');
if (error) {
router.replace(`/login?error=${encodeURIComponent(error)}`);
return;
}
if (!accessToken || !refreshToken) {
router.replace('/login?error=oauth_failed');
return;
}
handleOAuthCallback(
accessToken,
refreshToken,
expiresIn ? Number(expiresIn) : 900,
)
.then(() => {
const redirect = searchParams.get('redirect') || '/dashboard';
router.replace(redirect);
})
.catch(() => {
router.replace('/login?error=oauth_failed');
});
}, [searchParams, handleOAuthCallback, router]);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
<p className="mt-4 text-sm text-muted-foreground">Đang xử đăng nhập Google...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,55 @@
'use client';
import { Loader2 } from 'lucide-react';
import { useRouter, useSearchParams } from 'next/navigation';
import { useEffect, useRef } from 'react';
import { useAuthStore } from '@/lib/auth-store';
export default function ZaloCallbackPage() {
const router = useRouter();
const searchParams = useSearchParams();
const { handleOAuthCallback } = useAuthStore();
const processed = useRef(false);
useEffect(() => {
if (processed.current) return;
processed.current = true;
const accessToken = searchParams.get('accessToken');
const refreshToken = searchParams.get('refreshToken');
const expiresIn = searchParams.get('expiresIn');
const error = searchParams.get('error');
if (error) {
router.replace(`/login?error=${encodeURIComponent(error)}`);
return;
}
if (!accessToken || !refreshToken) {
router.replace('/login?error=oauth_failed');
return;
}
handleOAuthCallback(
accessToken,
refreshToken,
expiresIn ? Number(expiresIn) : 900,
)
.then(() => {
const redirect = searchParams.get('redirect') || '/dashboard';
router.replace(redirect);
})
.catch(() => {
router.replace('/login?error=oauth_failed');
});
}, [searchParams, handleOAuthCallback, router]);
return (
<div className="flex min-h-screen items-center justify-center">
<div className="text-center">
<Loader2 className="mx-auto h-8 w-8 animate-spin text-primary" />
<p className="mt-4 text-sm text-muted-foreground">Đang xử đăng nhập Zalo...</p>
</div>
</div>
);
}

View File

@@ -0,0 +1,105 @@
'use client';
import * as Sentry from '@sentry/nextjs';
import { useTranslations } from 'next-intl';
import { useEffect, useState } from 'react';
export default function GlobalError({
error,
reset,
}: {
error: Error & { digest?: string };
reset: () => void;
}) {
const [retryCount, setRetryCount] = useState(0);
const [autoRetrying, setAutoRetrying] = useState(false);
const t = useTranslations();
useEffect(() => {
Sentry.captureException(error);
if (process.env.NODE_ENV !== 'production') {
console.error('Unhandled error:', error);
}
}, [error]);
// Auto-retry once after 3 seconds
useEffect(() => {
if (retryCount > 0) return;
setAutoRetrying(true);
const timer = setTimeout(() => {
setAutoRetrying(false);
setRetryCount((c) => c + 1);
reset();
}, 3000);
return () => clearTimeout(timer);
}, [error, reset, retryCount]);
const handleRetry = () => {
setRetryCount((c) => c + 1);
reset();
};
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4" role="alert">
<div className="mx-auto max-w-md text-center">
<div className="mx-auto flex h-16 w-16 items-center justify-center rounded-full bg-destructive/10">
<svg
className="h-8 w-8 text-destructive"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 9v2m0 4h.01m-6.938 4h13.856c1.54 0 2.502-1.667 1.732-2.5L13.732 4c-.77-.833-1.964-.833-2.732 0L4.082 16.5c-.77.833.192 2.5 1.732 2.5z"
/>
</svg>
</div>
<h1 className="mt-4 text-2xl font-bold tracking-tight">
{t('error.title')}
</h1>
<p className="mt-2 text-muted-foreground">
{autoRetrying ? t('error.autoRetrying') : t('error.description')}
</p>
{error.digest && (
<p className="mt-1 text-xs text-muted-foreground">
{t('common.errorCode', { code: error.digest })}
</p>
)}
{retryCount > 0 && (
<p className="mt-1 text-xs text-muted-foreground">
{t('common.retriedCount', { count: retryCount })}
</p>
)}
<div className="mt-8 flex justify-center gap-3">
<button
onClick={handleRetry}
disabled={autoRetrying}
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 disabled:opacity-50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{autoRetrying ? (
<>
<svg className="mr-2 h-4 w-4 animate-spin" viewBox="0 0 24 24" fill="none" aria-hidden="true">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z" />
</svg>
{t('common.retrying')}
</>
) : (
t('common.retry')
)}
</button>
<a
href="/"
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-6 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{t('common.goHome')}
</a>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,38 @@
export default function RootLoading() {
return (
<div className="flex min-h-screen flex-col bg-background">
{/* Header skeleton */}
<div className="border-b">
<div className="mx-auto flex h-16 max-w-7xl items-center justify-between px-4">
<div className="h-8 w-32 animate-pulse rounded bg-muted" />
<div className="flex gap-3">
<div className="h-8 w-20 animate-pulse rounded bg-muted" />
<div className="h-8 w-20 animate-pulse rounded bg-muted" />
</div>
</div>
</div>
{/* Content skeleton */}
<div className="mx-auto w-full max-w-7xl flex-1 px-4 py-8">
<div className="h-8 w-64 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-96 animate-pulse rounded bg-muted" />
<div className="mt-8 grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{Array.from({ length: 6 }).map((_, i) => (
<div key={i} className="rounded-lg border bg-card shadow-sm">
<div className="aspect-[16/10] animate-pulse rounded-t-lg bg-muted" />
<div className="p-4">
<div className="h-5 w-3/4 animate-pulse rounded bg-muted" />
<div className="mt-2 h-4 w-1/2 animate-pulse rounded bg-muted" />
<div className="mt-3 flex gap-2">
<div className="h-6 w-16 animate-pulse rounded bg-muted" />
<div className="h-6 w-16 animate-pulse rounded bg-muted" />
</div>
</div>
</div>
))}
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,34 @@
import { useTranslations } from 'next-intl';
import { Link } from '@/i18n/navigation';
export default function NotFound() {
const t = useTranslations();
return (
<div className="flex min-h-screen flex-col items-center justify-center bg-background px-4">
<div className="mx-auto max-w-md text-center">
<div className="text-8xl font-bold text-primary/20" aria-hidden="true">404</div>
<h1 className="mt-4 text-2xl font-bold tracking-tight">
{t('notFound.title')}
</h1>
<p className="mt-2 text-muted-foreground">
{t('notFound.description')}
</p>
<div className="mt-8 flex justify-center gap-3">
<Link
href="/"
className="inline-flex h-10 items-center justify-center rounded-md bg-primary px-6 text-sm font-medium text-primary-foreground shadow transition-colors hover:bg-primary/90 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{t('common.goHome')}
</Link>
<Link
href="/search"
className="inline-flex h-10 items-center justify-center rounded-md border border-input bg-background px-6 text-sm font-medium shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
>
{t('common.search')}
</Link>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,35 @@
'use client';
import { useLocale, useTranslations } from 'next-intl';
import type { Locale } from '@/i18n/config';
import { usePathname, useRouter } from '@/i18n/navigation';
const localeLabels: Record<Locale, string> = {
vi: '🇻🇳 VI',
en: '🇬🇧 EN',
};
export function LanguageSwitcher() {
const locale = useLocale() as Locale;
const router = useRouter();
const pathname = usePathname();
const t = useTranslations('language');
const switchLocale = (newLocale: Locale) => {
router.replace(pathname, { locale: newLocale });
};
const nextLocale: Locale = locale === 'vi' ? 'en' : 'vi';
return (
<button
type="button"
onClick={() => switchLocale(nextLocale)}
className="inline-flex h-9 items-center gap-1.5 rounded-md px-2.5 text-sm font-medium text-muted-foreground transition-colors hover:bg-accent hover:text-accent-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2"
aria-label={`${t('label')}: ${t(locale)}${t(nextLocale)}`}
>
<span aria-hidden="true">{localeLabels[nextLocale]}</span>
<span className="sr-only">{t(nextLocale)}</span>
</button>
);
}

File diff suppressed because one or more lines are too long