From 628150b7d8619f82dbabcba34c21e67d1f3942eb Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Thu, 9 Apr 2026 09:12:37 +0700 Subject: [PATCH] =?UTF-8?q?refactor(web):=20consolidate=20i18n=20routes=20?= =?UTF-8?q?=E2=80=94=20remove=20non-locale=20route=20duplication?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Remove duplicate root-level route groups ((public)/, (auth)/, (dashboard)/, (admin)/, auth/) that shadowed the [locale]/ i18n-aware versions. All routes now live exclusively under [locale]/ with next-intl middleware handling locale detection and redirect. - Root layout.tsx → pass-through (delegates html/body to [locale]/layout.tsx) - [locale]/layout.tsx now imports globals.css - Root error.tsx, not-found.tsx get html wrapper for safety fallback - Remove redundant root loading.tsx - 38 duplicate route files removed Co-Authored-By: Paperclip --- apps/web/app/(admin)/admin/kyc/page.tsx | 427 ---------------- .../web/app/(admin)/admin/moderation/page.tsx | 428 ---------------- apps/web/app/(admin)/admin/page.tsx | 223 -------- apps/web/app/(admin)/admin/users/page.tsx | 478 ------------------ apps/web/app/(admin)/error.tsx | 58 --- apps/web/app/(admin)/layout.tsx | 138 ----- apps/web/app/(admin)/loading.tsx | 60 --- apps/web/app/(auth)/__tests__/login.spec.tsx | 170 ------- .../app/(auth)/__tests__/register.spec.tsx | 170 ------- apps/web/app/(auth)/error.tsx | 58 --- apps/web/app/(auth)/layout.tsx | 7 - apps/web/app/(auth)/loading.tsx | 40 -- apps/web/app/(auth)/login/page.tsx | 140 ----- apps/web/app/(auth)/register/page.tsx | 172 ------- apps/web/app/(dashboard)/analytics/page.tsx | 421 --------------- .../app/(dashboard)/dashboard/kyc/page.tsx | 315 ------------ apps/web/app/(dashboard)/dashboard/page.tsx | 289 ----------- .../(dashboard)/dashboard/payments/page.tsx | 239 --------- .../(dashboard)/dashboard/profile/page.tsx | 283 ----------- .../dashboard/subscription/page.tsx | 371 -------------- .../(dashboard)/dashboard/valuation/page.tsx | 74 --- apps/web/app/(dashboard)/error.tsx | 96 ---- apps/web/app/(dashboard)/layout.tsx | 86 ---- .../(dashboard)/listings/[id]/edit/page.tsx | 131 ----- .../__tests__/create-listing.spec.tsx | 111 ---- .../web/app/(dashboard)/listings/new/page.tsx | 221 -------- apps/web/app/(dashboard)/listings/page.tsx | 345 ------------- apps/web/app/(dashboard)/loading.tsx | 71 --- apps/web/app/(public)/layout.tsx | 114 ----- apps/web/app/(public)/listings/[id]/page.tsx | 349 ------------- apps/web/app/(public)/page.tsx | 267 ---------- .../(public)/search/__tests__/search.spec.tsx | 147 ------ apps/web/app/(public)/search/error.tsx | 97 ---- apps/web/app/(public)/search/layout.tsx | 16 - apps/web/app/(public)/search/loading.tsx | 72 --- apps/web/app/(public)/search/page.tsx | 294 ----------- apps/web/app/[locale]/layout.tsx | 118 +++++ apps/web/app/auth/callback/google/page.tsx | 55 -- apps/web/app/auth/callback/zalo/page.tsx | 55 -- apps/web/app/error.tsx | 4 + apps/web/app/layout.tsx | 88 +--- apps/web/app/loading.tsx | 38 -- apps/web/app/not-found.tsx | 4 + 43 files changed, 127 insertions(+), 7213 deletions(-) delete mode 100644 apps/web/app/(admin)/admin/kyc/page.tsx delete mode 100644 apps/web/app/(admin)/admin/moderation/page.tsx delete mode 100644 apps/web/app/(admin)/admin/page.tsx delete mode 100644 apps/web/app/(admin)/admin/users/page.tsx delete mode 100644 apps/web/app/(admin)/error.tsx delete mode 100644 apps/web/app/(admin)/layout.tsx delete mode 100644 apps/web/app/(admin)/loading.tsx delete mode 100644 apps/web/app/(auth)/__tests__/login.spec.tsx delete mode 100644 apps/web/app/(auth)/__tests__/register.spec.tsx delete mode 100644 apps/web/app/(auth)/error.tsx delete mode 100644 apps/web/app/(auth)/layout.tsx delete mode 100644 apps/web/app/(auth)/loading.tsx delete mode 100644 apps/web/app/(auth)/login/page.tsx delete mode 100644 apps/web/app/(auth)/register/page.tsx delete mode 100644 apps/web/app/(dashboard)/analytics/page.tsx delete mode 100644 apps/web/app/(dashboard)/dashboard/kyc/page.tsx delete mode 100644 apps/web/app/(dashboard)/dashboard/page.tsx delete mode 100644 apps/web/app/(dashboard)/dashboard/payments/page.tsx delete mode 100644 apps/web/app/(dashboard)/dashboard/profile/page.tsx delete mode 100644 apps/web/app/(dashboard)/dashboard/subscription/page.tsx delete mode 100644 apps/web/app/(dashboard)/dashboard/valuation/page.tsx delete mode 100644 apps/web/app/(dashboard)/error.tsx delete mode 100644 apps/web/app/(dashboard)/layout.tsx delete mode 100644 apps/web/app/(dashboard)/listings/[id]/edit/page.tsx delete mode 100644 apps/web/app/(dashboard)/listings/__tests__/create-listing.spec.tsx delete mode 100644 apps/web/app/(dashboard)/listings/new/page.tsx delete mode 100644 apps/web/app/(dashboard)/listings/page.tsx delete mode 100644 apps/web/app/(dashboard)/loading.tsx delete mode 100644 apps/web/app/(public)/layout.tsx delete mode 100644 apps/web/app/(public)/listings/[id]/page.tsx delete mode 100644 apps/web/app/(public)/page.tsx delete mode 100644 apps/web/app/(public)/search/__tests__/search.spec.tsx delete mode 100644 apps/web/app/(public)/search/error.tsx delete mode 100644 apps/web/app/(public)/search/layout.tsx delete mode 100644 apps/web/app/(public)/search/loading.tsx delete mode 100644 apps/web/app/(public)/search/page.tsx create mode 100644 apps/web/app/[locale]/layout.tsx delete mode 100644 apps/web/app/auth/callback/google/page.tsx delete mode 100644 apps/web/app/auth/callback/zalo/page.tsx delete mode 100644 apps/web/app/loading.tsx diff --git a/apps/web/app/(admin)/admin/kyc/page.tsx b/apps/web/app/(admin)/admin/kyc/page.tsx deleted file mode 100644 index f2fb16f..0000000 --- a/apps/web/app/(admin)/admin/kyc/page.tsx +++ /dev/null @@ -1,427 +0,0 @@ -'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 Đã xác minh; - case 'PENDING': return Chờ duyệt; - case 'REJECTED': return Bị từ chối; - default: return {status}; - } -} - -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 ( -
-
-
-

{item.fullName}

-

{item.phone}

- {item.email && ( -

{item.email}

- )} -
- {kycStatusBadge(item.kycStatus)} -
- -
-
-
Vai trò
-
{item.role}
-
-
-
Ngày gửi
-
- {new Date(item.createdAt).toLocaleDateString('vi-VN')} -
-
-
- - {kycData && ( -
-

Thông tin KYC

- {kycData.idType && ( -
-
Loại giấy tờ
-
{kycData.idType}
-
- )} - {kycData.idNumber && ( -
-
Số giấy tờ
-
{kycData.idNumber}
-
- )} - -
- {kycData.frontImageUrl && ( -
-
Mặt trước
-
- Mặt trước giấy tờ -
-
- )} - {kycData.backImageUrl && ( -
-
Mặt sau
-
- Mặt sau giấy tờ -
-
- )} - {kycData.selfieUrl && ( -
-
Ảnh selfie
-
- Selfie -
-
- )} -
-
- )} - - {item.kycStatus === 'PENDING' && ( -
- - -
- )} -
- ); -} - -export default function AdminKycPage() { - const [result, setResult] = useState | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [page, setPage] = useState(1); - - const [selectedItem, setSelectedItem] = useState(null); - - // Approve dialog - const [approveDialog, setApproveDialog] = useState(null); - const [approveNotes, setApproveNotes] = useState(''); - - // Reject dialog - const [rejectDialog, setRejectDialog] = useState(null); - const [rejectReason, setRejectReason] = useState(''); - - const [actionLoading, setActionLoading] = useState(false); - const [actionError, setActionError] = useState(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 ( -
- {actionError && ( -
- {actionError} - -
- )} - -
-
-

Duyệt KYC

-

- Xác minh danh tính người dùng và đại lý -

-
- -
- -
- {/* Table */} - - - {loading ? ( -
- -
- ) : error ? ( -
-

{error}

- -
- ) : !result || result.data.length === 0 ? ( -
- -

- Không có yêu cầu KYC nào đang chờ -

-
- ) : ( - <> - - - - Họ tên - SĐT - Vai trò - Trạng thái - Ngày gửi - - - - - {result.data.map((item) => ( - setSelectedItem(item)} - > - -
{item.fullName}
- {item.email && ( -
{item.email}
- )} -
- {item.phone} - - {item.role} - - {kycStatusBadge(item.kycStatus)} - - {new Date(item.createdAt).toLocaleDateString('vi-VN')} - - - - -
- ))} -
-
- - {result.totalPages > 1 && ( -
- - Trang {result.page}/{result.totalPages} ({result.total} yêu cầu) - -
- - -
-
- )} - - )} -
-
- - {/* Detail sidebar */} -
- - - {selectedItem ? ( - { - setApproveDialog(selectedItem.userId); - setApproveNotes(''); - }} - onReject={() => { - setRejectDialog(selectedItem.userId); - setRejectReason(''); - }} - /> - ) : ( -
- Chọn yêu cầu KYC để xem chi tiết -
- )} -
-
-
-
- - {/* Approve dialog */} - setApproveDialog(null)}> - - - Duyệt KYC - - Xác nhận danh tính người dùng đã được xác minh thành công. - - - setApproveNotes(e.target.value)} - /> - - - - - - - - {/* Reject dialog */} - setRejectDialog(null)}> - - - Từ chối KYC - - Vui lòng nhập lý do từ chối. Người dùng sẽ cần gửi lại hồ sơ. - - - setRejectReason(e.target.value)} - /> - - - - - - -
- ); -} diff --git a/apps/web/app/(admin)/admin/moderation/page.tsx b/apps/web/app/(admin)/admin/moderation/page.tsx deleted file mode 100644 index 9848c78..0000000 --- a/apps/web/app/(admin)/admin/moderation/page.tsx +++ /dev/null @@ -1,428 +0,0 @@ -'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 N/A; - if (score >= 80) return {score}; - if (score >= 50) return {score}; - return {score}; -} - -export default function AdminModerationPage() { - const [result, setResult] = useState | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [page, setPage] = useState(1); - - // Selected items for bulk - const [selected, setSelected] = useState>(new Set()); - - // Action dialogs - const [approveDialog, setApproveDialog] = useState(null); - const [rejectDialog, setRejectDialog] = useState(null); - const [approveNotes, setApproveNotes] = useState(''); - const [rejectReason, setRejectReason] = useState(''); - const [actionLoading, setActionLoading] = useState(false); - const [actionError, setActionError] = useState(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 ( -
- {actionError && ( -
- {actionError} - -
- )} - -
-
-

Kiểm duyệt tin đăng

-

- Duyệt hoặc từ chối các tin đăng chờ phê duyệt -

-
-
- {selected.size > 0 && ( - <> - - - - )} - -
-
- - - - {loading ? ( -
- -
- ) : error ? ( -
-

{error}

- -
- ) : !result || result.data.length === 0 ? ( -
- -

- Không có tin nào chờ kiểm duyệt -

-
- ) : ( - <> - - - - - 0} - onChange={toggleSelectAll} - className="rounded border-input" - /> - - Tiêu đề - Loại - Giá - Người đăng - Điểm AI - Ngày đăng - Hành động - - - - {result.data.map((item) => ( - - - toggleSelect(item.listingId)} - className="rounded border-input" - /> - - -
- {item.propertyTitle} -
-
- {item.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'} -
-
- - {item.propertyType} - - - {formatPrice(item.priceVND)} VND - - - {item.sellerName} - - - {moderationScoreBadge(item.moderationScore)} - - - {new Date(item.createdAt).toLocaleDateString('vi-VN')} - - -
- - -
-
-
- ))} -
-
- - {result.totalPages > 1 && ( -
- - Trang {result.page}/{result.totalPages} ({result.total} tin) - -
- - -
-
- )} - - )} -
-
- - {/* Approve dialog */} - setApproveDialog(null)}> - - - Duyệt tin đăng - - Tin đăng sẽ được hiển thị công khai sau khi duyệt. - - - setApproveNotes(e.target.value)} - /> - - - - - - - - {/* Reject dialog */} - setRejectDialog(null)}> - - - Từ chối tin đăng - - Vui lòng nhập lý do từ chối. Người đăng sẽ nhận được thông báo. - - - setRejectReason(e.target.value)} - /> - - - - - - - - {/* Bulk action dialog */} - setBulkAction(null)}> - - - - {bulkAction === 'approve' ? 'Duyệt hàng loạt' : 'Từ chối hàng loạt'} - - - - - Thao tác sẽ áp dụng cho {selected.size} tin đăng đã chọn. - - - - {bulkAction === 'reject' && ( - setBulkReason(e.target.value)} - /> - )} - - - - - - -
- ); -} diff --git a/apps/web/app/(admin)/admin/page.tsx b/apps/web/app/(admin)/admin/page.tsx deleted file mode 100644 index 1f769d4..0000000 --- a/apps/web/app/(admin)/admin/page.tsx +++ /dev/null @@ -1,223 +0,0 @@ -'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 ( - - - {title} - - - -
{value.toLocaleString('vi-VN')}
- {(description || trendValue) && ( -

- {trend === 'up' && } - {trend === 'down' && } - {trendValue && ( - - {trendValue} - - )} - {description && {description}} -

- )} -
-
- ); -} - -function RevenueChart({ data }: { data: RevenueStatsItem[] }) { - if (data.length === 0) { - return ( -
- Chưa có dữ liệu doanh thu -
- ); - } - - const maxRevenue = Math.max(...data.map((d) => d.totalRevenue), 1); - - return ( -
- {data.map((item) => { - const pct = (item.totalRevenue / maxRevenue) * 100; - return ( -
-
- {item.period} - - {item.totalRevenue.toLocaleString('vi-VN')} VND - -
-
-
-
-
- Subscription: {item.subscriptionRevenue.toLocaleString('vi-VN')} - Listing fee: {item.listingFeeRevenue.toLocaleString('vi-VN')} - {item.transactionCount} GD -
-
- ); - })} -
- ); -} - -export default function AdminDashboardPage() { - const [stats, setStats] = useState(null); - const [revenue, setRevenue] = useState([]); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(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 ( -
- -
- ); - } - - if (error) { - return ( -
-

{error}

- -
- ); - } - - if (!stats) return null; - - return ( -
-
-
-

Dashboard

-

- Tổng quan hệ thống GoodGo -

-
- -
- - {/* Stats grid */} -
- - - - -
- -
- - - -
- - {/* Revenue chart */} - - - Doanh thu 6 tháng gần nhất - - - - - -
- ); -} diff --git a/apps/web/app/(admin)/admin/users/page.tsx b/apps/web/app/(admin)/admin/users/page.tsx deleted file mode 100644 index 475bf9f..0000000 --- a/apps/web/app/(admin)/admin/users/page.tsx +++ /dev/null @@ -1,478 +0,0 @@ -'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 ( -
-
-
-

{user.fullName}

-

{user.phone}

- {user.email && ( -

{user.email}

- )} -
- -
- -
-
-
Vai trò
- {user.role} -
-
-
KYC
- {user.kycStatus} -
-
-
Trạng thái
- - {user.isActive ? 'Hoạt động' : 'Bị khóa'} - -
-
-
Ngày tạo
-
- {new Date(user.createdAt).toLocaleDateString('vi-VN')} -
-
-
- -
-
-
{user.listingsCount}
-
Tin đăng
-
-
-
{user.activeListingsCount}
-
Đang hiển thị
-
-
-
{user.transactionsCount}
-
Giao dịch
-
-
- - {user.subscription && ( -
-
Gói đăng ký
-
- {user.subscription.planTier} - - {user.subscription.status} - - - đến {new Date(user.subscription.currentPeriodEnd).toLocaleDateString('vi-VN')} - -
-
- )} - - {user.recentActivity.length > 0 && ( -
-

Hoạt động gần đây

-
- {user.recentActivity.slice(0, 5).map((activity, i) => ( -
-
-
- {activity.description} - - {new Date(activity.createdAt).toLocaleDateString('vi-VN')} - -
-
- ))} -
-
- )} - -
- -
-
- ); -} - -export default function AdminUsersPage() { - const [result, setResult] = useState | null>(null); - const [loading, setLoading] = useState(true); - const [error, setError] = useState(null); - const [page, setPage] = useState(1); - const [search, setSearch] = useState(''); - const [roleFilter, setRoleFilter] = useState(''); - const [statusFilter, setStatusFilter] = useState(''); - - // Detail panel - const [selectedUser, setSelectedUser] = useState(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(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 ( -
-
-

Quản lý người dùng

-

- Danh sách và quản lý tài khoản người dùng -

-
- - {actionError && ( -
- {actionError} - -
- )} - - {/* Filters */} -
-
-
- - setSearch(e.target.value)} - className="pl-9" - /> -
- -
- - - - -
- -
- {/* Table */} - - - {loading ? ( -
- -
- ) : error ? ( -
-

{error}

- -
- ) : !result || result.data.length === 0 ? ( -
- Không tìm thấy người dùng nào -
- ) : ( - <> - - - - Họ tên - SĐT - Vai trò - KYC - Trạng thái - - - - - {result.data.map((user) => ( - openDetail(user.id)} - > - -
{user.fullName}
- {user.email && ( -
{user.email}
- )} -
- {user.phone} - - {user.role} - - - {user.kycStatus} - - - - {user.isActive ? 'Hoạt động' : 'Bị khóa'} - - - - - -
- ))} -
-
- - {/* Pagination */} - {result.totalPages > 1 && ( -
- - Trang {result.page}/{result.totalPages} ({result.total} người dùng) - -
- - -
-
- )} - - )} -
-
- - {/* Detail sidebar */} -
- - - {detailLoading ? ( -
- -
- ) : selectedUser ? ( - setSelectedUser(null)} - onToggleStatus={handleToggleStatus} - /> - ) : ( -
- Chọn người dùng để xem chi tiết -
- )} -
-
-
-
- - {/* Mobile detail dialog */} - setSelectedUser(null)}> - - {selectedUser && ( - setSelectedUser(null)} - onToggleStatus={handleToggleStatus} - /> - )} - - - - {/* Ban/unban confirmation */} - setBanDialog(null)}> - - - - {banDialog?.isActive ? 'Mở khóa tài khoản' : 'Khóa tài khoản'} - - - {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.'} - - - setBanReason(e.target.value)} - /> - - - - - - -
- ); -} diff --git a/apps/web/app/(admin)/error.tsx b/apps/web/app/(admin)/error.tsx deleted file mode 100644 index e32333a..0000000 --- a/apps/web/app/(admin)/error.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'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 ( -
-
-
- - - -
-

Lỗi trang quản trị

-

- Không thể tải trang quản trị. Vui lòng thử lại sau. -

- {error.digest && ( -

Mã lỗi: {error.digest}

- )} -
- - - Tải lại trang - -
-
-
- ); -} diff --git a/apps/web/app/(admin)/layout.tsx b/apps/web/app/(admin)/layout.tsx deleted file mode 100644 index d55c5a2..0000000 --- a/apps/web/app/(admin)/layout.tsx +++ /dev/null @@ -1,138 +0,0 @@ -'use client'; - -import { - LayoutDashboard, - Users, - ClipboardList, - ShieldCheck, - LogOut, - Menu, - X, -} from 'lucide-react'; -import Link from 'next/link'; -import { usePathname, useRouter } from 'next/navigation'; -import { useEffect, useState } from 'react'; -import { Button } from '@/components/ui/button'; -import { useAuthStore } from '@/lib/auth-store'; -import { cn } from '@/lib/utils'; - -const adminNavItems = [ - { href: '/admin', label: 'Dashboard', icon: LayoutDashboard }, - { href: '/admin/users', label: 'Qu\u1EA3n l\u00FD ng\u01B0\u1EDDi d\u00F9ng', icon: Users }, - { href: '/admin/moderation', label: 'Ki\u1EC3m duy\u1EC7t tin', icon: ClipboardList }, - { href: '/admin/kyc', label: 'Duy\u1EC7t KYC', icon: ShieldCheck }, -]; - -export default function AdminLayout({ children }: { children: React.ReactNode }) { - const pathname = usePathname(); - const router = useRouter(); - const { user, logout } = useAuthStore(); - const [sidebarOpen, setSidebarOpen] = useState(false); - - useEffect(() => { - if (user && user.role !== 'ADMIN') { - router.replace('/dashboard'); - } - }, [user, router]); - - if (!user) { - return ( -
-
Đang tải...
-
- ); - } - - if (user.role !== 'ADMIN') { - return null; - } - - return ( -
- {/* Mobile overlay */} - {sidebarOpen && ( -
setSidebarOpen(false)} - /> - )} - - {/* Sidebar */} - - - {/* Main content */} -
-
- - GoodGo Admin -
- -
{children}
-
-
- ); -} diff --git a/apps/web/app/(admin)/loading.tsx b/apps/web/app/(admin)/loading.tsx deleted file mode 100644 index 59e7fe3..0000000 --- a/apps/web/app/(admin)/loading.tsx +++ /dev/null @@ -1,60 +0,0 @@ -export default function AdminLoading() { - return ( -
- {/* Header skeleton */} -
-
-
-
-
-
-
- - {/* Stats grid skeleton */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
-
-
-
-
- ))} -
- -
- {Array.from({ length: 3 }).map((_, i) => ( -
-
-
-
-
-
-
- ))} -
- - {/* Revenue chart skeleton */} -
-
-
-
-
-
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
-
-
- ))} -
-
-
-
- ); -} diff --git a/apps/web/app/(auth)/__tests__/login.spec.tsx b/apps/web/app/(auth)/__tests__/login.spec.tsx deleted file mode 100644 index 6b54da8..0000000 --- a/apps/web/app/(auth)/__tests__/login.spec.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/* 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/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 }) => ( - {children} - ), -})); - -// 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; - register: ReturnType; - handleOAuthCallback: ReturnType; - logout: ReturnType; - refreshToken: ReturnType; - fetchProfile: ReturnType; - initialize: ReturnType; - clearError: ReturnType; - }; - - 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; - }); - }); - - it('renders login form with phone and password fields', () => { - render(); - - 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(); - - expect(screen.getByRole('button', { name: /google/i })).toBeInTheDocument(); - expect(screen.getByRole('button', { name: /zalo/i })).toBeInTheDocument(); - }); - - it('renders register link', () => { - render(); - - 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(); - - 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(); - - 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(); - - 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(); - - expect(screen.getByText('Sai mật khẩu')).toBeInTheDocument(); - }); - - it('navigates to home after successful login', async () => { - mockStore.login.mockResolvedValue(undefined); - render(); - - 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('/'); - }); - }); -}); diff --git a/apps/web/app/(auth)/__tests__/register.spec.tsx b/apps/web/app/(auth)/__tests__/register.spec.tsx deleted file mode 100644 index d469969..0000000 --- a/apps/web/app/(auth)/__tests__/register.spec.tsx +++ /dev/null @@ -1,170 +0,0 @@ -/* 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'; - -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 }) => ( - {children} - ), -})); - -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; - register: ReturnType; - handleOAuthCallback: ReturnType; - logout: ReturnType; - refreshToken: ReturnType; - fetchProfile: ReturnType; - initialize: ReturnType; - clearError: ReturnType; - }; - - 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; - }); - }); - - it('renders register form with all fields', () => { - render(); - - expect(screen.getByText('Tạo tài khoản')).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(); - 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(); - - 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(); - - 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(); - - 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(); - 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(); - - 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('/'); - }); - }); -}); diff --git a/apps/web/app/(auth)/error.tsx b/apps/web/app/(auth)/error.tsx deleted file mode 100644 index dac87eb..0000000 --- a/apps/web/app/(auth)/error.tsx +++ /dev/null @@ -1,58 +0,0 @@ -'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 ( -
-
-
- - - -
-

Lỗi xác thực

-

- Đã xảy ra lỗi trong quá trình xác thực. Vui lòng thử lại. -

- {error.digest && ( -

Mã lỗi: {error.digest}

- )} -
- - - Về trang đăng nhập - -
-
-
- ); -} diff --git a/apps/web/app/(auth)/layout.tsx b/apps/web/app/(auth)/layout.tsx deleted file mode 100644 index ff4dbd5..0000000 --- a/apps/web/app/(auth)/layout.tsx +++ /dev/null @@ -1,7 +0,0 @@ -export default function AuthLayout({ children }: { children: React.ReactNode }) { - return ( -
-
{children}
-
- ); -} diff --git a/apps/web/app/(auth)/loading.tsx b/apps/web/app/(auth)/loading.tsx deleted file mode 100644 index 9bef2a0..0000000 --- a/apps/web/app/(auth)/loading.tsx +++ /dev/null @@ -1,40 +0,0 @@ -export default function AuthLoading() { - return ( -
-
- {/* Logo / title skeleton */} -
-
-
-
-
- - {/* Form fields skeleton */} -
-
-
-
-
-
-
-
-
-
- - {/* Submit button skeleton */} -
- - {/* OAuth buttons skeleton */} -
-
-
-
-
-
-
-
-
-
-
- ); -} diff --git a/apps/web/app/(auth)/login/page.tsx b/apps/web/app/(auth)/login/page.tsx deleted file mode 100644 index 3ad24a8..0000000 --- a/apps/web/app/(auth)/login/page.tsx +++ /dev/null @@ -1,140 +0,0 @@ -'use client'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader2 } from 'lucide-react'; -import Link from 'next/link'; -import { useRouter, useSearchParams } from 'next/navigation'; -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 { 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 oauthError = searchParams.get('error'); - const OAUTH_ERROR_MESSAGES: Record = { - oauth_failed: 'Đăng nhập bằng mạng xã hội thất bại. Vui lòng thử lại.', - access_denied: 'Bạn đã từ chối quyền truy cập. Vui lòng thử lại.', - invalid_request: 'Yêu cầu đăng nhập không hợp lệ. Vui lòng thử lại.', - server_error: 'Lỗi máy chủ. Vui lòng thử lại sau.', - temporarily_unavailable: 'Dịch vụ tạm thời không khả dụng. Vui lòng thử lại sau.', - }; - const oauthErrorMessage = oauthError - ? OAUTH_ERROR_MESSAGES[oauthError] ?? 'Đã xảy ra lỗi khi đăng nhập. Vui lòng thử lại.' - : null; - - const { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - resolver: zodResolver(loginSchema), - }); - - const onSubmit = async (data: LoginFormData) => { - try { - await login(data); - router.push('/'); - } catch { - // Error is handled by the store - } - }; - - return ( - - - Đăng nhập - Nhập số điện thoại và mật khẩu để đăng nhập - - -
- {oauthErrorMessage && ( -
- {oauthErrorMessage} -
- )} - {error && ( -
- {error} - -
- )} - -
- - - {errors.phone && ( -

{errors.phone.message}

- )} -
- -
-
- - -
- - {errors.password && ( -

{errors.password.message}

- )} -
- - -
- -
-
- -
-
- Hoặc đăng nhập với -
-
- - -
- -

- Chưa có tài khoản?{' '} - - Đăng ký - -

-
-
- ); -} diff --git a/apps/web/app/(auth)/register/page.tsx b/apps/web/app/(auth)/register/page.tsx deleted file mode 100644 index 3ae5e04..0000000 --- a/apps/web/app/(auth)/register/page.tsx +++ /dev/null @@ -1,172 +0,0 @@ -'use client'; - -import { zodResolver } from '@hookform/resolvers/zod'; -import { Loader2 } from 'lucide-react'; -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -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 { 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 { - register, - handleSubmit, - formState: { errors }, - } = useForm({ - 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 ( - - - Tạo tài khoản - Nhập thông tin để đăng ký tài khoản GoodGo - - -
- {error && ( -
- {error} - -
- )} - -
- - - {errors.fullName && ( -

{errors.fullName.message}

- )} -
- -
- - - {errors.phone && ( -

{errors.phone.message}

- )} -
- -
- - - {errors.email && ( -

{errors.email.message}

- )} -
- -
-
- - -
- - {errors.password && ( -

{errors.password.message}

- )} -
- -
- - - {errors.confirmPassword && ( -

{errors.confirmPassword.message}

- )} -
- - -
- -
-
- -
-
- Hoặc đăng ký với -
-
- - -
- -

- Đã có tài khoản?{' '} - - Đăng nhập - -

-
-
- ); -} diff --git a/apps/web/app/(dashboard)/analytics/page.tsx b/apps/web/app/(dashboard)/analytics/page.tsx deleted file mode 100644 index de888cd..0000000 --- a/apps/web/app/(dashboard)/analytics/page.tsx +++ /dev/null @@ -1,421 +0,0 @@ -'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: () =>
Đang tải biểu đồ...
}, -); - -const PriceTrendChart = dynamic( - () => import('@/components/charts/price-trend-chart').then((mod) => mod.PriceTrendChart), - { ssr: false, loading: () =>
Đang tải biểu đồ...
}, -); - -const DistrictHeatmap = dynamic( - () => import('@/components/charts/district-heatmap').then((mod) => mod.DistrictHeatmap), - { ssr: false, loading: () =>
Đang tải bản đồ nhiệt...
}, -); - -const AgentPerformance = dynamic( - () => import('@/components/charts/agent-performance').then((mod) => mod.AgentPerformance), - { ssr: false, loading: () =>
Đang tải...
}, -); - -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 N/A; - const isPositive = value >= 0; - return ( - - {isPositive ? '+' : ''} - {value.toFixed(1)}% - - ); -} - -export default function AnalyticsPage() { - const [city, setCity] = useState(CITIES[0] ?? 'Ho Chi Minh'); - const period = CURRENT_PERIOD; - const [tab, setTab] = useState('overview'); - const [trendDistrict, setTrendDistrict] = useState(''); - - 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 ( -
-
-
-

Phân tích thị trường

-

- Báo cáo thị trường bất động sản - {period} -

-
-
- {CITIES.map((c) => ( - - ))} -
-
- - {error &&
{error}
} - - {/* Summary Cards */} -
- - - Tổng tin đăng - - {loading ? '...' : totalListings.toLocaleString('vi-VN')} - - - - - - Giá TB/m² - - {loading ? '...' : formatPriceM2(avgPriceM2)} - - - - - - Ngày trung bình để bán - - {loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`} - - - - - - Số quận/huyện - - {loading ? '...' : new Set(marketReport.map((d) => d.district)).size} - - - -
- - {/* Tabs */} - - - Tổng quan - Xu hướng giá - Chi tiết quận - Hiệu suất - - - {/* Overview Tab */} - -
- {/* Bar Chart - Price by District */} - - - Giá trung bình theo quận - Triệu VND/m² tại {city} - - - {loading ? ( -
- Đang tải... -
- ) : barChartData.length === 0 ? ( -
- Chưa có dữ liệu -
- ) : ( - - )} -
-
- - {/* Heatmap - Mapbox Map */} - - - Bản đồ nhiệt giá theo quận - So sánh giá trung bình/m² tại {city} - - - {loading ? ( -
- Đang tải... -
- ) : heatmap.length === 0 ? ( -
- Chưa có dữ liệu -
- ) : ( - { - setTrendDistrict(district); - setTab('trends'); - }} - /> - )} -
-
-
-
- - {/* Trends Tab */} - -
- {/* District selector */} -
- {uniqueDistricts.map((d) => ( - - ))} -
- - - - - Xu hướng giá - {trendDistrict || 'Chọn quận'} - - - Biến động giá trung bình/m² qua các quý (Căn hộ) - - - - {trendLoading ? ( -
- Đang tải... -
- ) : trendChartData.length === 0 ? ( -
- Chưa có dữ liệu xu hướng -
- ) : ( - - )} -
-
-
-
- - {/* District Stats Tab */} - -
- {/* Stats Table */} - - - Thống kê chi tiết theo quận - - Dữ liệu thị trường bất động sản tại {city} - {period} - - - - {loading ? ( -
- Đang tải... -
- ) : districtStats.length === 0 ? ( -
- Chưa có dữ liệu -
- ) : ( -
- - - - - - - - - - - - - - {districtStats.map((stat, i) => ( - - - - - - - - - - ))} - -
QuậnLoại BĐSGiá trung vịGiá/m²Tin đăngNgày bánYoY
{stat.district} - {stat.propertyType} - - {formatPrice(stat.medianPrice)} - - {formatPriceM2(stat.avgPriceM2)} - {stat.totalListings} - {stat.daysOnMarket.toFixed(0)} - - -
-
- )} -
-
- - {/* Market Report Cards */} - - - Báo cáo thị trường - Tổng hợp chỉ số thị trường theo từng quận - - - {loading ? ( -
- Đang tải... -
- ) : marketReport.length === 0 ? ( -
- Chưa có dữ liệu -
- ) : ( -
- {[...new Map(marketReport.map((d) => [d.district, d])).values()].map( - (district) => ( -
-

{district.district}

-
-
- Giá trung vị - - {formatPrice(district.medianPrice)} - -
-
- Giá/m² - {formatPriceM2(district.avgPriceM2)} -
-
- Tin đăng - {district.totalListings} -
-
- Tồn kho - {district.inventoryLevel} -
-
- Thay đổi YoY - -
-
-
- ), - )} -
- )} -
-
-
-
- - {/* Agent Performance Tab */} - -
- -
-
-
-
- ); -} diff --git a/apps/web/app/(dashboard)/dashboard/kyc/page.tsx b/apps/web/app/(dashboard)/dashboard/kyc/page.tsx deleted file mode 100644 index 364021e..0000000 --- a/apps/web/app/(dashboard)/dashboard/kyc/page.tsx +++ /dev/null @@ -1,315 +0,0 @@ -'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 = { - 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(null); - const [success, setSuccess] = useState(false); - - const [documentType, setDocumentType] = useState('CCCD'); - const [documentNumber, setDocumentNumber] = useState(''); - const [frontImage, setFrontImage] = useState(null); - const [backImage, setBackImage] = useState(null); - const [selfieImage, setSelfieImage] = useState(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 ( -
-
-

Xác minh danh tính (KYC)

-

- Xác minh danh tính để sử dụng đầy đủ tính năng của GoodGo -

-
- - {/* KYC Status */} - - -
- Trạng thái xác minh - {kycInfo.label} -
-
- -

{kycInfo.description}

-
-
- - {error && ( -
- {error} - -
- )} - - {success && ( -
- 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. -
- )} - - {/* KYC Form */} - {canSubmit && !success && ( - <> - {/* Step indicator */} -
- {KYC_STEPS.map((s, i) => ( -
-
= s.step - ? 'bg-primary text-primary-foreground' - : 'bg-muted text-muted-foreground' - }`} - > - {s.step} -
- {s.title} - {i < KYC_STEPS.length - 1 && ( -
- )} -
- ))} -
- - - - - {KYC_STEPS[currentStep - 1]?.title} - - - {KYC_STEPS[currentStep - 1]?.description} - - - - {/* Step 1: Document type */} - {currentStep === 1 && ( - <> -
- - -
-
- - setDocumentNumber(e.target.value)} - placeholder="Nhập số CCCD/CMND/Hộ chiếu" - /> -
- - )} - - {/* Step 2: Upload images */} - {currentStep === 2 && ( - <> -
- - setFrontImage(e.target.files?.[0] ?? null)} - /> - {frontImage && ( -

{frontImage.name}

- )} -
-
- - setBackImage(e.target.files?.[0] ?? null)} - /> - {backImage && ( -

{backImage.name}

- )} -
-
- - setSelfieImage(e.target.files?.[0] ?? null)} - /> - {selfieImage && ( -

{selfieImage.name}

- )} -
- - )} - - {/* Step 3: Confirm */} - {currentStep === 3 && ( -
-

Kiểm tra thông tin

-
-
- Loại giấy tờ - {DOCUMENT_TYPES.find((d) => d.value === documentType)?.label} -
-
- Số giấy tờ - {documentNumber} -
-
- Ảnh mặt trước - {frontImage ? frontImage.name : 'Chưa tải'} -
-
- Ảnh mặt sau - {backImage ? backImage.name : 'Không có'} -
-
- Ảnh selfie - {selfieImage ? selfieImage.name : 'Không có'} -
-
-
- )} - - {/* Navigation buttons */} -
- {currentStep > 1 ? ( - - ) : ( -
- )} - {currentStep < 3 ? ( - - ) : ( - - )} -
- - - - )} - - {/* Already verified */} - {kycStatus === 'VERIFIED' && ( - - -
- ✓ -
-

Danh tính đã được xác minh

-

- 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. -

-
-
- )} - - {/* Pending status */} - {kycStatus === 'PENDING' && !success && ( - - -
- ⏳ -
-

Đang xem xét hồ sơ

-

- Độ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. -

-
-
- )} -
- ); -} diff --git a/apps/web/app/(dashboard)/dashboard/page.tsx b/apps/web/app/(dashboard)/dashboard/page.tsx deleted file mode 100644 index 53b6bb0..0000000 --- a/apps/web/app/(dashboard)/dashboard/page.tsx +++ /dev/null @@ -1,289 +0,0 @@ -'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: () =>
Đang tải biểu đồ...
}, -); - -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 ( - - - {title} - {value} - - {(description || trend != null) && ( - -
- {trend != null && ( - = 0 ? 'text-green-600' : 'text-red-600'}`} - > - {trend >= 0 ? '+' : ''} - {trend.toFixed(1)}% - - )} - {description && ( - {description} - )} -
-
- )} -
- ); -} - -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 ( -
-
-
-

Bảng điều khiển

-

- Tổng quan thị trường và tin đăng của bạn -

-
- - - -
- - {/* Stats overview */} -
- - - - -
- - {/* Market overview + quick stats */} -
- {/* Price chart */} - - - Giá trung bình theo quận - {CITY} - {PERIOD} (triệu VND/m²) - - - {loading ? ( -
- Đang tải... -
- ) : chartData.length === 0 ? ( -
- Chưa có dữ liệu -
- ) : ( - [`${value} tr/m²`, 'Giá']} - /> - )} -
-
- - {/* Market summary */} - - - Thị trường {CITY} - Chỉ số chính - {PERIOD} - - -
- Tổng tin đăng - - {loading ? '...' : totalListings.toLocaleString('vi-VN')} - -
-
- Giá TB/m² - - {loading ? '...' : formatPriceM2(avgPriceM2)} - -
-
- Ngày TB để bán - - {loading ? '...' : `${avgDaysOnMarket.toFixed(0)} ngày`} - -
-
- Số quận - - {loading ? '...' : new Set(marketReport.map((d) => d.district)).size} - -
-
- - - -
-
-
-
- - {/* Recent listings */} - - -
- Tin đăng gần đây - Danh sách tin đăng mới nhất của bạn -
- - - -
- - {loading ? ( -
- Đang tải... -
- ) : !listings || listings.data.length === 0 ? ( -
-

Chưa có tin đăng nào

- - - -
- ) : ( -
- {listings.data.slice(0, 5).map((listing) => ( - -
- {listing.property.media.length > 0 ? ( - {listing.property.title} - ) : ( -
- N/A -
- )} -
-
-

{listing.property.title}

-

- {listing.property.district}, {listing.property.city} -

-
-
-

- {formatPrice(listing.priceVND)} -

- -
-
- {listing.viewCount} lượt xem - {listing.inquiryCount} liên hệ -
- - ))} -
- )} -
-
-
- ); -} diff --git a/apps/web/app/(dashboard)/dashboard/payments/page.tsx b/apps/web/app/(dashboard)/dashboard/payments/page.tsx deleted file mode 100644 index e15e938..0000000 --- a/apps/web/app/(dashboard)/dashboard/payments/page.tsx +++ /dev/null @@ -1,239 +0,0 @@ -'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 = { - 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 = { - 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 = { - 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 ( -
-
-

Thanh toán

-

- Lịch sử giao dịch và quản lý thanh toán -

-
- - {/* Summary cards */} -
- - - Tổng giao dịch - - {loading ? '...' : (transactions?.total ?? 0)} - - - - - - Đã thanh toán - - {loading ? '...' : formatVND(completedTotal)} - - - - - - Đang chờ - - {loading - ? '...' - : (transactions?.items.filter((t) => t.status === 'PENDING' || t.status === 'PROCESSING').length ?? 0)} - - - -
- - {/* Transactions table */} - - -
- Lịch sử giao dịch - Tất cả giao dịch thanh toán của bạn -
-
- -
-
- - {loading ? ( -
- Đang tải... -
- ) : !transactions || transactions.items.length === 0 ? ( -
- Chưa có giao dịch nào -
- ) : ( - <> - {/* Desktop table */} -
- - - - Ngày - Loại - Nhà cung cấp - Số tiền - Trạng thái - Mã GD - - - - {transactions.items.map((tx) => { - const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const }; - return ( - - - {new Date(tx.createdAt).toLocaleDateString('vi-VN')} - - - {TYPE_LABELS[tx.type] ?? tx.type} - - - {PROVIDER_LABELS[tx.provider] ?? tx.provider} - - - {formatVND(tx.amountVND)} - - - {statusInfo.label} - - - {tx.providerTxId ? tx.providerTxId.slice(0, 12) + '...' : '—'} - - - ); - })} - -
-
- - {/* Mobile cards */} -
- {transactions.items.map((tx) => { - const statusInfo = STATUS_LABELS[tx.status] ?? { label: tx.status, variant: 'secondary' as const }; - return ( -
-
- - {TYPE_LABELS[tx.type] ?? tx.type} - - {statusInfo.label} -
-
- - {new Date(tx.createdAt).toLocaleDateString('vi-VN')} —{' '} - {PROVIDER_LABELS[tx.provider] ?? tx.provider} - - {formatVND(tx.amountVND)} -
-
- ); - })} -
- - {/* Pagination */} - {totalPages > 1 && ( -
-

- Trang {page + 1}/{totalPages} ({transactions.total} giao dịch) -

-
- - -
-
- )} - - )} -
-
-
- ); -} diff --git a/apps/web/app/(dashboard)/dashboard/profile/page.tsx b/apps/web/app/(dashboard)/dashboard/profile/page.tsx deleted file mode 100644 index f1227bc..0000000 --- a/apps/web/app/(dashboard)/dashboard/profile/page.tsx +++ /dev/null @@ -1,283 +0,0 @@ -'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 = { - 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(null); - const [loading, setLoading] = useState(true); - const [editing, setEditing] = useState(false); - const [saving, setSaving] = useState(false); - const [error, setError] = useState(null); - const [success, setSuccess] = useState(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 ( -
-
-

Hồ sơ cá nhân

-

Quản lý thông tin tài khoản của bạn

-
- - {error && ( -
- {error} - -
- )} - - {success && ( -
- {success} - -
- )} - -
- {/* Profile info */} - - -
- Thông tin cá nhân - Thông tin cơ bản trên hồ sơ của bạn -
- {!editing && ( - - )} -
- - {loading ? ( -
- Đang tải... -
- ) : ( - <> -
- - {editing ? ( - setFormData((p) => ({ ...p, fullName: e.target.value }))} - /> - ) : ( -

- {user?.fullName ?? '—'} -

- )} -
- -
- -

- {user?.phone ?? '—'} -

-

- Số điện thoại không thể thay đổi -

-
- -
- - {editing ? ( - setFormData((p) => ({ ...p, email: e.target.value }))} - placeholder="email@example.com" - /> - ) : ( -

- {user?.email ?? 'Chưa cập nhật'} -

- )} -
- -
- -

- {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'} -

-
- - {editing && ( -
- - -
- )} - - )} -
-
- - {/* Status sidebar */} -
- - - Trạng thái tài khoản - - -
- Tài khoản - - {user?.isActive ? 'Hoạt động' : 'Bị khóa'} - -
-
- Xác minh KYC - {kycInfo.label} -
- {user?.kycStatus !== 'VERIFIED' && ( - - - - )} -
- Tham gia - - {user?.createdAt - ? new Date(user.createdAt).toLocaleDateString('vi-VN') - : '—'} - -
-
-
- - {/* Agent details */} - {agentProfile && ( - - - Thông tin môi giới - - -
- Mã chứng chỉ - - {agentProfile.licenseNumber ?? 'Chưa có'} - -
-
- Công ty - - {agentProfile.agency ?? 'Độc lập'} - -
- {agentProfile.qualityScore != null && ( -
- Điểm chất lượng - - {agentProfile.qualityScore}/100 - -
- )} -
- Xác minh - - {agentProfile.isVerified ? 'Đã xác minh' : 'Chưa xác minh'} - -
- {agentProfile.serviceAreas.length > 0 && ( -
- Khu vực hoạt động -
- {agentProfile.serviceAreas.map((area) => ( - - {area} - - ))} -
-
- )} -
-
- )} -
-
-
- ); -} diff --git a/apps/web/app/(dashboard)/dashboard/subscription/page.tsx b/apps/web/app/(dashboard)/dashboard/subscription/page.tsx deleted file mode 100644 index e491282..0000000 --- a/apps/web/app/(dashboard)/dashboard/subscription/page.tsx +++ /dev/null @@ -1,371 +0,0 @@ -'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 = { - FREE: 'Miễn phí', - AGENT_PRO: 'Môi giới Pro', - INVESTOR: 'Nhà đầu tư', - ENTERPRISE: 'Doanh nghiệp', -}; - -const STATUS_MAP: Record = { - 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(null); - const [billingCycle, setBillingCycle] = useState<'monthly' | 'yearly'>('monthly'); - const [processing, setProcessing] = useState(false); - const [error, setError] = useState(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 ( -
-
-

Gói dịch vụ

-

- Quản lý gói đăng ký và theo dõi hạn mức sử dụng -

-
- - {error && ( -
- {error} - -
- )} - - {loading ? ( -
- Đang tải... -
- ) : ( - - - Gói hiện tại - So sánh gói - Lịch sử thanh toán - - - {/* Current plan tab */} - - - -
-
- - Gói {PLAN_TIER_LABELS[currentTier] ?? currentTier} - - - {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í'} - -
- {subStatus && {subStatus.label}} -
-
- - {/* Quota usage */} - {quotas.length > 0 && ( -
-

Hạn mức sử dụng

- {quotas.map((q) => { - const pct = q.limit > 0 ? (q.used / q.limit) * 100 : 0; - return ( -
-
- - {q.metric === 'listings' ? 'Tin đăng' : q.metric === 'saved_searches' ? 'Tìm kiếm đã lưu' : q.metric} - - - {q.used}/{q.limit} - -
-
-
90 ? 'bg-red-500' : pct > 70 ? 'bg-yellow-500' : 'bg-primary'}`} - style={{ width: `${Math.min(pct, 100)}%` }} - /> -
-
- ); - })} -
- )} - - - - - {/* Plan comparison tab */} - - {/* Billing cycle toggle */} -
- - -
- -
- {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 ( - - - - {PLAN_TIER_LABELS[plan.tier] ?? plan.name} - - - - {formatVND(price)} - - {Number(price) > 0 && ( - - /{billingCycle === 'monthly' ? 'tháng' : 'năm'} - - )} - - - -
-
- Tin đăng - - {plan.maxListings === -1 ? 'Không giới hạn' : plan.maxListings} - -
-
- Tìm kiếm lưu - - {plan.maxSavedSearches === -1 - ? 'Không giới hạn' - : plan.maxSavedSearches} - -
- {plan.features && - Object.entries(plan.features).map(([key, val]) => ( -
- {key} - - {typeof val === 'boolean' ? (val ? '✓' : '✗') : String(val)} - -
- ))} -
- - {isCurrent ? ( - - ) : isUpgrade ? ( - - ) : ( - - )} -
-
- ); - })} -
-
- - {/* Payment history tab */} - - - - Lịch sử thanh toán - Các giao dịch liên quan đến gói dịch vụ - - - {!billing || billing.payments.length === 0 ? ( -
- Chưa có giao dịch nào -
- ) : ( -
- {billing.payments.map((p) => ( -
-
-

{p.type}

-

- {new Date(p.createdAt).toLocaleDateString('vi-VN')} — {p.provider} -

-
-
-

{formatVND(p.amountVND)}

- - {p.status === 'COMPLETED' - ? 'Thành công' - : p.status === 'FAILED' - ? 'Thất bại' - : p.status === 'PENDING' - ? 'Chờ xử lý' - : p.status} - -
-
- ))} -
- )} -
-
-
- - )} - - {/* Upgrade dialog */} - !o && setUpgradeTarget(null)}> - - - - Nâng cấp lên {PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name} - - - 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. - - -
-
- Gói - - {PLAN_TIER_LABELS[upgradeTarget?.tier ?? ''] ?? upgradeTarget?.name} - -
-
- Chu kỳ - - {billingCycle === 'monthly' ? 'Hàng tháng' : 'Hàng năm'} - -
-
- Giá - - {upgradeTarget && - formatVND( - billingCycle === 'monthly' - ? upgradeTarget.priceMonthlyVND - : upgradeTarget.priceYearlyVND, - )} - -
-
- - - - -
-
-
- ); -} diff --git a/apps/web/app/(dashboard)/dashboard/valuation/page.tsx b/apps/web/app/(dashboard)/dashboard/valuation/page.tsx deleted file mode 100644 index 8fee1cb..0000000 --- a/apps/web/app/(dashboard)/dashboard/valuation/page.tsx +++ /dev/null @@ -1,74 +0,0 @@ -'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(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 ( -
-
-

Dinh gia AI

-

- Su dung AI de uoc tinh gia tri bat dong san dua tren du lieu thi truong -

-
- -
- {/* Form + Results */} -
- - - {predictMutation.isError && ( -
- Khong the dinh gia. Vui long thu lai sau. -
- )} - - {currentResult && } -
- - {/* History sidebar */} -
- -
-
-
- ); -} diff --git a/apps/web/app/(dashboard)/error.tsx b/apps/web/app/(dashboard)/error.tsx deleted file mode 100644 index 07235c4..0000000 --- a/apps/web/app/(dashboard)/error.tsx +++ /dev/null @@ -1,96 +0,0 @@ -'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 ( -
-
-
- - - -
-

Không thể tải bảng điều khiển

-

- {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.'} -

- {error.digest && ( -

Mã lỗi: {error.digest}

- )} - {retryCount > 0 && ( -

- Đã thử lại {retryCount} lần -

- )} -
- - - Tải lại trang - -
-
-
- ); -} diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx deleted file mode 100644 index 0e71e02..0000000 --- a/apps/web/app/(dashboard)/layout.tsx +++ /dev/null @@ -1,86 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { useTheme } from '@/components/providers/theme-provider'; -import { Button } from '@/components/ui/button'; -import { useAuthStore } from '@/lib/auth-store'; -import { cn } from '@/lib/utils'; - -const navItems = [ - { href: '/dashboard', label: 'Bảng điều khiển', icon: '🏠' }, - { href: '/listings', label: 'Tin đăng', icon: '📋' }, - { href: '/listings/new', label: 'Đăng tin', icon: '➕' }, - { href: '/analytics', label: 'Phân tích', icon: '📊' }, - { href: '/dashboard/valuation', label: 'Định giá AI', icon: '🤖' }, - { href: '/dashboard/profile', label: 'Hồ sơ', icon: '👤' }, - { href: '/dashboard/subscription', label: 'Gói dịch vụ', icon: '💎' }, - { href: '/dashboard/payments', label: 'Thanh toán', icon: '💳' }, -]; - -export default function DashboardLayout({ children }: { children: React.ReactNode }) { - const pathname = usePathname(); - const { user, logout } = useAuthStore(); - const { theme, toggleTheme } = useTheme(); - - return ( -
-
-
- - GoodGo - - - - -
- {user && ( - - {user.fullName} - - )} - - -
-
-
- -
{children}
-
- ); -} diff --git a/apps/web/app/(dashboard)/listings/[id]/edit/page.tsx b/apps/web/app/(dashboard)/listings/[id]/edit/page.tsx deleted file mode 100644 index 591a287..0000000 --- a/apps/web/app/(dashboard)/listings/[id]/edit/page.tsx +++ /dev/null @@ -1,131 +0,0 @@ -'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(null); - const [loading, setLoading] = React.useState(true); - const [activeTab, setActiveTab] = React.useState('basic'); - - const { - register, - reset, - formState: { errors }, - } = useForm({ - 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 ( -
-
-
- ); - } - - if (!listing) { - return ( -
-

Không tìm thấy tin đăng

- -
- ); - } - - return ( -
-
-

Chỉnh sửa tin đăng

- -
- -

- 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. -

- - - - Cơ bản - Vị trí - Chi tiết - Giá cả - - - - - - - - - - - - - - - - - - - -
- ); -} diff --git a/apps/web/app/(dashboard)/listings/__tests__/create-listing.spec.tsx b/apps/web/app/(dashboard)/listings/__tests__/create-listing.spec.tsx deleted file mode 100644 index a19cf73..0000000 --- a/apps/web/app/(dashboard)/listings/__tests__/create-listing.spec.tsx +++ /dev/null @@ -1,111 +0,0 @@ -/* 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 }) => ( -
- -
- ), -})); - -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(); - - 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(); - - 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(); - 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(); - - // 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(); - - 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(); - - // 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(); - }); - }); -}); diff --git a/apps/web/app/(dashboard)/listings/new/page.tsx b/apps/web/app/(dashboard)/listings/new/page.tsx deleted file mode 100644 index 0b79277..0000000 --- a/apps/web/app/(dashboard)/listings/new/page.tsx +++ /dev/null @@ -1,221 +0,0 @@ -'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([]); - const [isSubmitting, setIsSubmitting] = React.useState(false); - const [error, setError] = React.useState(null); - - const { - register, - handleSubmit, - trigger, - formState: { errors }, - } = useForm({ - resolver: zodResolver(createListingSchema), - mode: 'onTouched', - }); - - const goNext = async () => { - const step = STEPS[currentStep]; - if (step?.schemaKeys) { - const valid = await trigger(step.schemaKeys as Array); - 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 ( -
-

Đăng tin mới

- - {/* Step indicators */} -
- {STEPS.map((step, index) => ( -
- - - {index < STEPS.length - 1 && ( -
- )} -
- ))} -
- - {error && ( -
- {error} - -
- )} - -
- - - {currentStep === 0 && } - {currentStep === 1 && } - {currentStep === 2 && } - {currentStep === 3 && } - {currentStep === 4 && ( -
-

Hình ảnh

- -
- )} -
-
- -
- - - {currentStep < STEPS.length - 1 ? ( - - ) : ( - - )} -
-
-
- ); -} diff --git a/apps/web/app/(dashboard)/listings/page.tsx b/apps/web/app/(dashboard)/listings/page.tsx deleted file mode 100644 index bcec7c5..0000000 --- a/apps/web/app/(dashboard)/listings/page.tsx +++ /dev/null @@ -1,345 +0,0 @@ -'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('grid'); - const [filters, setFilters] = React.useState({ - transactionType: '', - propertyType: '', - status: '' as string, - page: 1, - }); - - const searchParams = React.useMemo(() => { - const params: Record = { 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 ( -
- {/* Header */} -
-
-

Quản lý tin đăng

-

- Quản lý, theo dõi và cập nhật các tin đăng của bạn -

-
- - - -
- - {/* Stats */} -
- - - Tổng tin đăng - {loading ? '...' : stats.total} - - - - - Đang hoạt động - - {loading ? '...' : stats.active} - - - - - - Chờ duyệt - - {loading ? '...' : stats.pending} - - - - - - Tổng lượt xem - {loading ? '...' : stats.views} - - -
- - {/* Filters + View Toggle */} -
- - - - -
- - -
-
- - {/* Content */} - {loading ? ( -
-
-
- ) : !result || result.data.length === 0 ? ( -
-

Chưa có tin đăng nào

- - - -
- ) : viewMode === 'grid' ? ( - /* Grid View */ -
- {result.data.map((listing) => ( - - -
- {listing.property.media.length > 0 ? ( - {listing.property.title} - ) : ( -
- Chưa có ảnh -
- )} -
- -
-
- -

- {formatPrice(listing.priceVND)} VND -

-

{listing.property.title}

-

- {listing.property.district}, {listing.property.city} -

-
- - {listing.property.areaM2} m² - - {listing.property.bedrooms != null && ( - - {listing.property.bedrooms} PN - - )} - {listing.property.bathrooms != null && listing.property.bathrooms > 0 && ( - - {listing.property.bathrooms} PT - - )} -
-
- {listing.viewCount} lượt xem - {listing.inquiryCount} liên hệ - {listing.saveCount} đã lưu -
-
-
- - ))} -
- ) : ( - /* Table View */ - - -
- - - - - - - - - - - - - - - {result.data.map((listing) => ( - - - - - - - - - - - ))} - -
Tin đăngLoạiGiáDiện tíchTrạng tháiLượt xemLiên hệNgày đăng
- -
- {listing.property.media.length > 0 ? ( - {listing.property.title} - ) : ( -
- N/A -
- )} -
-
-

- {listing.property.title} -

-

- {listing.property.district}, {listing.property.city} -

-
- -
- {listing.property.propertyType} - - {formatPrice(listing.priceVND)} - {listing.property.areaM2} m² - - {listing.viewCount}{listing.inquiryCount} - {formatDate(listing.publishedAt ?? listing.createdAt)} -
-
-
-
- )} - - {/* Pagination */} - {result && result.totalPages > 1 && ( -
- - - Trang {result.page} / {result.totalPages} - - -
- )} -
- ); -} diff --git a/apps/web/app/(dashboard)/loading.tsx b/apps/web/app/(dashboard)/loading.tsx deleted file mode 100644 index cc75a34..0000000 --- a/apps/web/app/(dashboard)/loading.tsx +++ /dev/null @@ -1,71 +0,0 @@ -export default function DashboardLoading() { - return ( -
- {/* Header skeleton */} -
-
-
-
-
-
-
- - {/* Stats grid skeleton */} -
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
-
- ))} -
- - {/* Chart + sidebar skeleton */} -
-
-
-
-
-
-
-
-
-
- {Array.from({ length: 4 }).map((_, i) => ( -
-
-
-
- ))} -
-
-
- - {/* Recent listings skeleton */} -
-
-
-
-
-
-
- {Array.from({ length: 5 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
- ))} -
-
-
-
- ); -} diff --git a/apps/web/app/(public)/layout.tsx b/apps/web/app/(public)/layout.tsx deleted file mode 100644 index b7a24c4..0000000 --- a/apps/web/app/(public)/layout.tsx +++ /dev/null @@ -1,114 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { usePathname } from 'next/navigation'; -import { Button } from '@/components/ui/button'; -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(); - - return ( -
-
-
- - GoodGo - - - - -
- {user ? ( - <> - - {user.fullName} - - - - - - ) : ( - <> - - - - - - - - )} -
-
-
- -
{children}
- -
-
-
-
-

GoodGo

-

- Nền tảng bất động sản thông minh tại Việt Nam -

-
-
-

Loại BĐS

-
    -
  • Căn hộ
  • -
  • Nhà riêng
  • -
  • Biệt thự
  • -
  • Đất nền
  • -
-
-
-

Khu vực

-
    -
  • TP. Hồ Chí Minh
  • -
  • Hà Nội
  • -
  • Đà Nẵng
  • -
  • Nha Trang
  • -
-
-
-

Hỗ trợ

-
    -
  • Đăng nhập
  • -
  • Đăng ký
  • -
-
-
-
- © 2026 GoodGo. Tất cả quyền được bảo lưu. -
-
-
-
- ); -} diff --git a/apps/web/app/(public)/listings/[id]/page.tsx b/apps/web/app/(public)/listings/[id]/page.tsx deleted file mode 100644 index 129e31b..0000000 --- a/apps/web/app/(public)/listings/[id]/page.tsx +++ /dev/null @@ -1,349 +0,0 @@ -'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: () => ( -
-

Đang tải bản đồ...

-
- ), - }, -); - -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(null); - const [loading, setLoading] = React.useState(true); - const [error, setError] = React.useState(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 ( -
- {/* Skeleton loader */} -
-
-
-
-
-
-
-
-
-
-
-
- ); - } - - if (error || !listing) { - return ( -
- - - -

{error || 'Không tìm thấy tin đăng'}

- - - -
- ); - } - - const { property, seller, agent } = listing; - const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType); - const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType); - - return ( -
- {/* Breadcrumb */} - - - {/* Header */} -
-
-
- {transactionLabel && ( - - {transactionLabel} - - )} - {propertyTypeLabel && {propertyTypeLabel}} -
-

{property.title}

-

- - - - - {property.address}, {property.ward}, {property.district}, {property.city} -

-
-
-

{formatPrice(listing.priceVND)} VND

- {listing.pricePerM2 != null && ( -

- ~{listing.pricePerM2.toLocaleString('vi-VN')} VND/m² -

- )} - {listing.rentPriceMonthly && ( -

- Thuê: {formatPrice(listing.rentPriceMonthly)}/tháng -

- )} -
-
- - {/* Image gallery */} - - - {/* Quick specs bar */} -
- - {property.bedrooms != null && ( - - )} - {property.bathrooms != null && ( - - )} - {property.floors != null && ( - - )} - {property.direction && ( - - )} -
- -
- {/* Main content */} -
- {/* Description */} - - - Mô tả - - -

{property.description}

-
-
- - {/* Details */} - - - Thông tin chi tiết - - -
- - - - - - - - - -
-
-
- - {/* Amenities */} - {property.amenities && property.amenities.length > 0 && ( - - - Tiện ích - - -
- {property.amenities.map((a) => ( - - {a} - - ))} -
-
-
- )} - - {/* Map */} - - - Vị trí trên bản đồ - - - - - -
- - {/* Sidebar */} -
- {/* Contact card */} - - - Liên hệ - - -
-
- - - -
-
-

{seller.fullName}

-

{seller.phone}

-
-
- - - - - - - {agent && ( -
-

Môi giới

- {agent.agency &&

{agent.agency}

} - {listing.commissionPct != null && ( -

Hoa hồng: {listing.commissionPct}%

- )} -
- )} -
-
- - {/* AI Estimate */} - - - {/* Stats */} - - -
-
-

{listing.viewCount}

-

Lượt xem

-
-
-

{listing.saveCount}

-

Lượt lưu

-
-
-

{listing.inquiryCount}

-

Liên hệ

-
-
- {listing.publishedAt && ( -

- Đăng ngày {new Date(listing.publishedAt).toLocaleDateString('vi-VN')} -

- )} -
-
-
-
-
- ); -} - -function QuickStat({ icon, label, value }: { icon: string; label: string; value: string }) { - const icons: Record = { - area: ( - - - - ), - bed: ( - - - - ), - bath: ( - - - - ), - floors: ( - - - - ), - compass: ( - - - - - ), - }; - - return ( -
-
{icons[icon]}
-
-

{label}

-

{value}

-
-
- ); -} - -function InfoItem({ label, value }: { label: string; value: string }) { - return ( -
-

{label}

-

{value}

-
- ); -} diff --git a/apps/web/app/(public)/page.tsx b/apps/web/app/(public)/page.tsx deleted file mode 100644 index 671846c..0000000 --- a/apps/web/app/(public)/page.tsx +++ /dev/null @@ -1,267 +0,0 @@ -'use client'; - -import Link from 'next/link'; -import { useRouter } from 'next/navigation'; -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 { listingsApi, type ListingDetail } from '@/lib/listings-api'; -import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings'; - -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 }, -]; - -const STATS = [ - { label: 'Tin đăng', value: '10,000+', icon: '🏠' }, - { label: 'Người dùng', value: '50,000+', icon: '👥' }, - { label: 'Giao dịch thành công', value: '2,000+', icon: '✅' }, - { label: 'Tỉnh thành', value: '63', icon: '📍' }, -]; - -export default function LandingPage() { - const router = useRouter(); - const [searchQuery, setSearchQuery] = React.useState(''); - const [transactionType, setTransactionType] = React.useState(''); - const [propertyType, _setPropertyType] = React.useState(''); - const [featuredListings, setFeaturedListings] = React.useState([]); - 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 ( -
- {/* Hero Section */} -
-
-
-

- Tìm kiếm bất động sản - hoàn hảo -

-

- Nền tảng bất động sản thông minh tại Việt Nam — mua bán, cho thuê nhà đất dễ dàng -

- - {/* Search Bar */} -
-
- setSearchQuery(e.target.value)} - className="border-0 shadow-none focus-visible:ring-0" - /> -
- - -
-
-
- - {/* Quick property type links */} -
- {PROPERTY_TYPES.map((pt) => ( - - - {pt.label} - - - ))} -
-
-
-
- - {/* Featured Listings */} -
-
-
-
-

Tin đăng nổi bật

-

- Khám phá các bất động sản được quan tâm nhất -

-
- - - -
- - {loadingFeatured ? ( -
-
-
- ) : featuredError ? ( -
-

Không thể tải tin đăng. Vui lòng thử lại.

- -
- ) : featuredListings.length > 0 ? ( -
- {featuredListings.map((listing) => ( - - ))} -
- ) : ( -
-

Chưa có tin đăng nổi bật

-
- )} -
-
- - {/* Districts / Quick Links */} -
-
-

Khu vực nổi bật

-

- Tìm kiếm theo quận huyện phổ biến -

- -
- {DISTRICTS.map((district) => ( - - -
-
- 🏙️ -
-
- -

{district.name}

-

{district.city}

-
-
- - ))} -
-
-
- - {/* Market Stats */} -
-
-
-

GoodGo trong số liệu

-

- Nền tảng bất động sản đáng tin cậy tại Việt Nam -

-
- -
- {STATS.map((stat) => ( -
- {stat.icon} -

{stat.value}

-

{stat.label}

-
- ))} -
-
-
- - {/* CTA Section */} -
-
-

- Bạn có bất động sản muốn đăng? -

-

- Đăng tin miễn phí ngay hôm nay, tiếp cận hàng ngàn người mua tiềm năng -

-
- - - - - - -
-
-
-
- ); -} diff --git a/apps/web/app/(public)/search/__tests__/search.spec.tsx b/apps/web/app/(public)/search/__tests__/search.spec.tsx deleted file mode 100644 index 3efd8af..0000000 --- a/apps/web/app/(public)/search/__tests__/search.spec.tsx +++ /dev/null @@ -1,147 +0,0 @@ -/* 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 (used by FilterBar component) -vi.mock('next-intl', () => ({ - useTranslations: () => (key: string) => key, - 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 }) => ( - {children} - ), -})); - -vi.mock('next/image', () => ({ - default: (props: Record) => , -})); - -// Mock dynamic import for map component -vi.mock('next/dynamic', () => ({ - default: () => { - const MockMap = () =>
Map
; - 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(); - - await waitFor(() => { - expect(screen.getByText('Tìm kiếm bất động sản')).toBeInTheDocument(); - }); - }); - - it('renders view mode toggle buttons', async () => { - render(); - - 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(); - - await waitFor(() => { - expect(mockedListingsApi.search).toHaveBeenCalled(); - }); - }); - - it('displays listing results after loading', async () => { - render(); - - 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(); - - 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(); - }); - }); -}); diff --git a/apps/web/app/(public)/search/error.tsx b/apps/web/app/(public)/search/error.tsx deleted file mode 100644 index 5debd84..0000000 --- a/apps/web/app/(public)/search/error.tsx +++ /dev/null @@ -1,97 +0,0 @@ -'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 ( -
-
-
-
- - - -
-

Lỗi tìm kiếm

-

- {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.'} -

- {error.digest && ( -

Mã lỗi: {error.digest}

- )} - {retryCount > 0 && ( -

- Đã thử lại {retryCount} lần -

- )} -
- - - Về trang chủ - -
-
-
-
- ); -} diff --git a/apps/web/app/(public)/search/layout.tsx b/apps/web/app/(public)/search/layout.tsx deleted file mode 100644 index 41de070..0000000 --- a/apps/web/app/(public)/search/layout.tsx +++ /dev/null @@ -1,16 +0,0 @@ -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; -} diff --git a/apps/web/app/(public)/search/loading.tsx b/apps/web/app/(public)/search/loading.tsx deleted file mode 100644 index 7cd33f2..0000000 --- a/apps/web/app/(public)/search/loading.tsx +++ /dev/null @@ -1,72 +0,0 @@ -export default function SearchLoading() { - return ( -
- {/* Header skeleton */} -
-
-
-
- - {/* View mode toggle skeleton */} -
-
-
-
-
-
-
-
- - {/* Filter bar skeleton (desktop) */} -
-
- {Array.from({ length: 5 }).map((_, i) => ( -
- ))} -
-
-
- - {/* Content area skeleton */} -
- {/* Sidebar skeleton (desktop) */} - - - {/* Results grid skeleton */} -
-
-
-
-
-
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
-
- ))} -
-
-
-
- ); -} diff --git a/apps/web/app/(public)/search/page.tsx b/apps/web/app/(public)/search/page.tsx deleted file mode 100644 index 8b82a32..0000000 --- a/apps/web/app/(public)/search/page.tsx +++ /dev/null @@ -1,294 +0,0 @@ -'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: () => ( -
-

Đang tải bản đồ...

-
- ), - }, -); - -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(() => ({ - ...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 | null>(null); - const [loading, setLoading] = React.useState(true); - const [searchError, setSearchError] = React.useState(false); - const [viewMode, setViewMode] = React.useState('list'); - const [showMobileFilters, setShowMobileFilters] = React.useState(false); - const [selectedListingId, setSelectedListingId] = React.useState(); - - const handleMarkerClick = (listing: ListingDetail) => { - setSelectedListingId(listing.id); - }; - - const fetchListings = React.useCallback(() => { - setLoading(true); - const params: Record = { - 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 ( -
- {/* Header */} -
-

Tìm kiếm bất động sản

-

- Tìm bất động sản phù hợp với nhu cầu của bạn -

-
- - {/* View Mode Toggle + Mobile Filter Button */} -
-
- - - -
- - -
- - {/* Desktop horizontal filter bar */} -
- -
- - {/* Mobile filter panel */} - {showMobileFilters && ( -
- { - handleSearch(); - setShowMobileFilters(false); - }} - layout="sidebar" - /> -
- )} - - {/* Content Area */} -
- {/* Sidebar filters (desktop, split/list mode) */} - {viewMode !== 'map' && ( - - )} - - {/* Main content */} -
- {viewMode === 'list' && ( - handleFilterChange({ ...filters, sort })} - /> - )} - - {viewMode === 'map' && ( - - )} - - {viewMode === 'split' && ( -
-
- handleFilterChange({ ...filters, sort })} - /> -
-
- -
-
- )} -
-
-
- ); -} - -export default function SearchPage() { - return ( - -
-
- } - > - -
- ); -} diff --git a/apps/web/app/[locale]/layout.tsx b/apps/web/app/[locale]/layout.tsx new file mode 100644 index 0000000..07db790 --- /dev/null +++ b/apps/web/app/[locale]/layout.tsx @@ -0,0 +1,118 @@ +import type { Metadata, Viewport } from 'next'; +import { notFound } from 'next/navigation'; +import { NextIntlClientProvider } from 'next-intl'; +import { getMessages, getTranslations } from 'next-intl/server'; +import { AuthProvider } from '@/components/providers/auth-provider'; +import { QueryProvider } from '@/components/providers/query-provider'; +import { ThemeProvider } from '@/components/providers/theme-provider'; +import type { Locale } from '@/i18n/config'; +import { routing } from '@/i18n/routing'; +import '../globals.css'; + +const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn'; + +export const viewport: Viewport = { + width: 'device-width', + initialScale: 1, + themeColor: '#15803d', +}; + +export async function generateMetadata({ + params: { locale }, +}: { + params: { locale: string }; +}): Promise { + const t = await getTranslations({ locale, namespace: 'metadata' }); + + return { + metadataBase: new URL(siteUrl), + title: { + default: t('title'), + template: '%s | GoodGo', + }, + description: t('description'), + keywords: [ + 'bất động sản', + 'mua bán nhà đất', + 'cho thuê nhà', + 'goodgo', + 'nhà đất việt nam', + 'real estate vietnam', + ], + authors: [{ name: 'GoodGo' }], + creator: 'GoodGo', + openGraph: { + type: 'website', + locale: locale === 'vi' ? 'vi_VN' : 'en_US', + url: siteUrl, + siteName: 'GoodGo', + title: t('ogTitle'), + description: t('ogDescription'), + images: [ + { + url: '/og-image.png', + width: 1200, + height: 630, + alt: t('ogTitle'), + }, + ], + }, + twitter: { + card: 'summary_large_image', + title: t('ogTitle'), + description: t('ogDescription'), + images: ['/og-image.png'], + }, + robots: { + index: true, + follow: true, + googleBot: { + index: true, + follow: true, + 'max-video-preview': -1, + 'max-image-preview': 'large', + 'max-snippet': -1, + }, + }, + }; +} + +export function generateStaticParams() { + return routing.locales.map((locale) => ({ locale })); +} + +export default async function LocaleLayout({ + children, + params: { locale }, +}: { + children: React.ReactNode; + params: { locale: string }; +}) { + // Validate locale + if (!routing.locales.includes(locale as Locale)) { + notFound(); + } + + const messages = await getMessages(); + const t = await getTranslations({ locale, namespace: 'common' }); + + return ( + + + + {t('skipToContent')} + + + + + {children} + + + + + + ); +} diff --git a/apps/web/app/auth/callback/google/page.tsx b/apps/web/app/auth/callback/google/page.tsx deleted file mode 100644 index 024a871..0000000 --- a/apps/web/app/auth/callback/google/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'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 ( -
-
- -

Đang xử lý đăng nhập Google...

-
-
- ); -} diff --git a/apps/web/app/auth/callback/zalo/page.tsx b/apps/web/app/auth/callback/zalo/page.tsx deleted file mode 100644 index cc76a57..0000000 --- a/apps/web/app/auth/callback/zalo/page.tsx +++ /dev/null @@ -1,55 +0,0 @@ -'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 ( -
-
- -

Đang xử lý đăng nhập Zalo...

-
-
- ); -} diff --git a/apps/web/app/error.tsx b/apps/web/app/error.tsx index ad11011..a67144e 100644 --- a/apps/web/app/error.tsx +++ b/apps/web/app/error.tsx @@ -38,6 +38,8 @@ export default function GlobalError({ }; return ( + +
@@ -100,5 +102,7 @@ export default function GlobalError({
+ + ); } diff --git a/apps/web/app/layout.tsx b/apps/web/app/layout.tsx index 19e2033..3f6b127 100644 --- a/apps/web/app/layout.tsx +++ b/apps/web/app/layout.tsx @@ -1,91 +1,5 @@ -import type { Metadata, Viewport } from 'next'; -import { AuthProvider } from '@/components/providers/auth-provider'; -import { QueryProvider } from '@/components/providers/query-provider'; -import { ThemeProvider } from '@/components/providers/theme-provider'; import './globals.css'; -const siteUrl = process.env['NEXT_PUBLIC_SITE_URL'] || 'https://goodgo.vn'; - -export const viewport: Viewport = { - width: 'device-width', - initialScale: 1, - themeColor: '#15803d', -}; - -export const metadata: Metadata = { - metadataBase: new URL(siteUrl), - title: { - default: 'GoodGo \u2014 N\u1ec1n t\u1ea3ng B\u1ea5t \u0111\u1ed9ng s\u1ea3n Vi\u1ec7t Nam', - template: '%s | GoodGo', - }, - description: - 'GoodGo \u2014 n\u1ec1n t\u1ea3ng b\u1ea5t \u0111\u1ed9ng s\u1ea3n th\u00f4ng minh t\u1ea1i Vi\u1ec7t Nam. Mua b\u00e1n, cho thu\u00ea nh\u00e0 \u0111\u1ea5t d\u1ec5 d\u00e0ng v\u1edbi h\u01a1n 10,000+ tin \u0111\u0103ng tr\u00ean to\u00e0n qu\u1ed1c.', - keywords: [ - 'b\u1ea5t \u0111\u1ed9ng s\u1ea3n', - 'mua b\u00e1n nh\u00e0 \u0111\u1ea5t', - 'cho thu\u00ea nh\u00e0', - 'goodgo', - 'nh\u00e0 \u0111\u1ea5t vi\u1ec7t nam', - 'chung c\u01b0', - 'bi\u1ec7t th\u1ef1', - 'nh\u00e0 ph\u1ed1', - '\u0111\u1ea5t n\u1ec1n', - ], - authors: [{ name: 'GoodGo' }], - creator: 'GoodGo', - openGraph: { - type: 'website', - locale: 'vi_VN', - url: siteUrl, - siteName: 'GoodGo', - title: 'GoodGo \u2014 N\u1ec1n t\u1ea3ng B\u1ea5t \u0111\u1ed9ng s\u1ea3n Vi\u1ec7t Nam', - description: - 'Mua b\u00e1n, cho thu\u00ea b\u1ea5t \u0111\u1ed9ng s\u1ea3n d\u1ec5 d\u00e0ng v\u1edbi GoodGo \u2014 n\u1ec1n t\u1ea3ng th\u00f4ng minh, uy t\u00edn h\u00e0ng \u0111\u1ea7u Vi\u1ec7t Nam.', - images: [ - { - url: '/og-image.png', - width: 1200, - height: 630, - alt: 'GoodGo \u2014 N\u1ec1n t\u1ea3ng B\u1ea5t \u0111\u1ed9ng s\u1ea3n Vi\u1ec7t Nam', - }, - ], - }, - twitter: { - card: 'summary_large_image', - title: 'GoodGo \u2014 N\u1ec1n t\u1ea3ng B\u1ea5t \u0111\u1ed9ng s\u1ea3n Vi\u1ec7t Nam', - description: - 'Mua b\u00e1n, cho thu\u00ea b\u1ea5t \u0111\u1ed9ng s\u1ea3n d\u1ec5 d\u00e0ng v\u1edbi GoodGo.', - images: ['/og-image.png'], - }, - robots: { - index: true, - follow: true, - googleBot: { - index: true, - follow: true, - 'max-video-preview': -1, - 'max-image-preview': 'large', - 'max-snippet': -1, - }, - }, -}; - export default function RootLayout({ children }: { children: React.ReactNode }) { - return ( - - - - Chuyển đến nội dung chính - - - - {children} - - - - - ); + return children; } diff --git a/apps/web/app/loading.tsx b/apps/web/app/loading.tsx deleted file mode 100644 index db7c7a5..0000000 --- a/apps/web/app/loading.tsx +++ /dev/null @@ -1,38 +0,0 @@ -export default function RootLoading() { - return ( -
- {/* Header skeleton */} -
-
-
-
-
-
-
-
-
- - {/* Content skeleton */} -
-
-
- -
- {Array.from({ length: 6 }).map((_, i) => ( -
-
-
-
-
-
-
-
-
-
-
- ))} -
-
-
- ); -} diff --git a/apps/web/app/not-found.tsx b/apps/web/app/not-found.tsx index f717858..63660f8 100644 --- a/apps/web/app/not-found.tsx +++ b/apps/web/app/not-found.tsx @@ -2,6 +2,8 @@ import Link from 'next/link'; export default function NotFound() { return ( + +
404
@@ -27,5 +29,7 @@ export default function NotFound() {
+ + ); }