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:
427
apps/web/app/[locale]/(admin)/admin/kyc/page.tsx
Normal file
427
apps/web/app/[locale]/(admin)/admin/kyc/page.tsx
Normal 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 và đại lý
|
||||
</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 có 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 lý do từ chối. Người dùng sẽ cần gửi lại hồ sơ.
|
||||
</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>
|
||||
);
|
||||
}
|
||||
428
apps/web/app/[locale]/(admin)/admin/moderation/page.tsx
Normal file
428
apps/web/app/[locale]/(admin)/admin/moderation/page.tsx
Normal 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 có 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 lý 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>
|
||||
);
|
||||
}
|
||||
223
apps/web/app/[locale]/(admin)/admin/page.tsx
Normal file
223
apps/web/app/[locale]/(admin)/admin/page.tsx
Normal 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 có 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>
|
||||
);
|
||||
}
|
||||
478
apps/web/app/[locale]/(admin)/admin/users/page.tsx
Normal file
478
apps/web/app/[locale]/(admin)/admin/users/page.tsx
Normal 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 ký</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 lý người dùng</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Danh sách và quản lý 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 lý</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>
|
||||
);
|
||||
}
|
||||
58
apps/web/app/[locale]/(admin)/error.tsx
Normal file
58
apps/web/app/[locale]/(admin)/error.tsx
Normal 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">Mã 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>
|
||||
);
|
||||
}
|
||||
145
apps/web/app/[locale]/(admin)/layout.tsx
Normal file
145
apps/web/app/[locale]/(admin)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
60
apps/web/app/[locale]/(admin)/loading.tsx
Normal file
60
apps/web/app/[locale]/(admin)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
199
apps/web/app/[locale]/(auth)/__tests__/login.spec.tsx
Normal file
199
apps/web/app/[locale]/(auth)/__tests__/login.spec.tsx
Normal 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('/');
|
||||
});
|
||||
});
|
||||
});
|
||||
199
apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx
Normal file
199
apps/web/app/[locale]/(auth)/__tests__/register.spec.tsx
Normal 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('/');
|
||||
});
|
||||
});
|
||||
});
|
||||
58
apps/web/app/[locale]/(auth)/error.tsx
Normal file
58
apps/web/app/[locale]/(auth)/error.tsx
Normal 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">Mã 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>
|
||||
);
|
||||
}
|
||||
7
apps/web/app/[locale]/(auth)/layout.tsx
Normal file
7
apps/web/app/[locale]/(auth)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
40
apps/web/app/[locale]/(auth)/loading.tsx
Normal file
40
apps/web/app/[locale]/(auth)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
138
apps/web/app/[locale]/(auth)/login/page.tsx
Normal file
138
apps/web/app/[locale]/(auth)/login/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
180
apps/web/app/[locale]/(auth)/register/page.tsx
Normal file
180
apps/web/app/[locale]/(auth)/register/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
421
apps/web/app/[locale]/(dashboard)/analytics/page.tsx
Normal file
421
apps/web/app/[locale]/(dashboard)/analytics/page.tsx
Normal 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 có 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 có 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 có 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 kê 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 có 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 có 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>
|
||||
);
|
||||
}
|
||||
315
apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx
Normal file
315
apps/web/app/[locale]/(dashboard)/dashboard/kyc/page.tsx
Normal 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ồ sơ 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 có 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ồ sơ</h2>
|
||||
<p className="mt-2 text-center text-sm text-muted-foreground">
|
||||
Đội ngũ quản trị đang xem xét hồ sơ của bạn. Thời gian dự kiến: 1-3 ngày làm việc.
|
||||
</p>
|
||||
</CardContent>
|
||||
</Card>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
289
apps/web/app/[locale]/(dashboard)/dashboard/page.tsx
Normal file
289
apps/web/app/[locale]/(dashboard)/dashboard/page.tsx
Normal 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 và 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 có 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 có 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>
|
||||
);
|
||||
}
|
||||
239
apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx
Normal file
239
apps/web/app/[locale]/(dashboard)/dashboard/payments/page.tsx
Normal 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 và quản lý 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ử lý</option>
|
||||
<option value="PROCESSING">Đang xử lý</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 có 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>Mã 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>
|
||||
);
|
||||
}
|
||||
283
apps/web/app/[locale]/(dashboard)/dashboard/profile/page.tsx
Normal file
283
apps/web/app/[locale]/(dashboard)/dashboard/profile/page.tsx
Normal 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ồ sơ cá nhân</h1>
|
||||
<p className="mt-2 text-muted-foreground">Quản lý 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 cá nhân</CardTitle>
|
||||
<CardDescription>Thông tin cơ bản trên hồ sơ 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ọ và 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">Mã 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>
|
||||
);
|
||||
}
|
||||
@@ -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 lý gói đăng ký và theo dõi hạn mức sử dụng
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-lg border border-red-200 bg-red-50 p-4 text-sm text-red-700">
|
||||
{error}
|
||||
<button onClick={() => setError(null)} className="ml-2 font-medium underline">
|
||||
Đóng
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex h-64 items-center justify-center text-muted-foreground">
|
||||
Đang tải...
|
||||
</div>
|
||||
) : (
|
||||
<Tabs value={activeTab} onValueChange={setActiveTab}>
|
||||
<TabsList>
|
||||
<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 có 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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
96
apps/web/app/[locale]/(dashboard)/error.tsx
Normal file
96
apps/web/app/[locale]/(dashboard)/error.tsx
Normal 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">Mã 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>
|
||||
);
|
||||
}
|
||||
93
apps/web/app/[locale]/(dashboard)/layout.tsx
Normal file
93
apps/web/app/[locale]/(dashboard)/layout.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
131
apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx
Normal file
131
apps/web/app/[locale]/(dashboard)/listings/[id]/edit/page.tsx
Normal 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 có 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">Cơ 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>
|
||||
);
|
||||
}
|
||||
@@ -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();
|
||||
});
|
||||
});
|
||||
});
|
||||
221
apps/web/app/[locale]/(dashboard)/listings/new/page.tsx
Normal file
221
apps/web/app/[locale]/(dashboard)/listings/new/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
345
apps/web/app/[locale]/(dashboard)/listings/page.tsx
Normal file
345
apps/web/app/[locale]/(dashboard)/listings/page.tsx
Normal 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 lý tin đăng</h1>
|
||||
<p className="text-sm text-muted-foreground">
|
||||
Quản lý, theo dõi và 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 có 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 có ả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>
|
||||
);
|
||||
}
|
||||
71
apps/web/app/[locale]/(dashboard)/loading.tsx
Normal file
71
apps/web/app/[locale]/(dashboard)/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
125
apps/web/app/[locale]/(public)/layout.tsx
Normal file
125
apps/web/app/[locale]/(public)/layout.tsx
Normal 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">Hà 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>
|
||||
);
|
||||
}
|
||||
349
apps/web/app/[locale]/(public)/listings/[id]/page.tsx
Normal file
349
apps/web/app/[locale]/(public)/listings/[id]/page.tsx
Normal 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>Mô 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>
|
||||
);
|
||||
}
|
||||
276
apps/web/app/[locale]/(public)/page.tsx
Normal file
276
apps/web/app/[locale]/(public)/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
166
apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx
Normal file
166
apps/web/app/[locale]/(public)/search/__tests__/search.spec.tsx
Normal 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();
|
||||
});
|
||||
});
|
||||
});
|
||||
97
apps/web/app/[locale]/(public)/search/error.tsx
Normal file
97
apps/web/app/[locale]/(public)/search/error.tsx
Normal 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">Mã 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>
|
||||
);
|
||||
}
|
||||
16
apps/web/app/[locale]/(public)/search/layout.tsx
Normal file
16
apps/web/app/[locale]/(public)/search/layout.tsx
Normal 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;
|
||||
}
|
||||
72
apps/web/app/[locale]/(public)/search/loading.tsx
Normal file
72
apps/web/app/[locale]/(public)/search/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
294
apps/web/app/[locale]/(public)/search/page.tsx
Normal file
294
apps/web/app/[locale]/(public)/search/page.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
55
apps/web/app/[locale]/auth/callback/google/page.tsx
Normal file
55
apps/web/app/[locale]/auth/callback/google/page.tsx
Normal 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ử lý đăng nhập Google...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
55
apps/web/app/[locale]/auth/callback/zalo/page.tsx
Normal file
55
apps/web/app/[locale]/auth/callback/zalo/page.tsx
Normal 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ử lý đăng nhập Zalo...</p>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
105
apps/web/app/[locale]/error.tsx
Normal file
105
apps/web/app/[locale]/error.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
38
apps/web/app/[locale]/loading.tsx
Normal file
38
apps/web/app/[locale]/loading.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
34
apps/web/app/[locale]/not-found.tsx
Normal file
34
apps/web/app/[locale]/not-found.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user