-
-
Quản lý người dùng
-
- Danh sách và quản lý tài khoản người dùng
-
-
-
- {actionError && (
-
- {actionError}
- setActionError(null)} className="ml-2">
-
-
-
- )}
-
- {/* Filters */}
-
-
-
-
{ setRoleFilter(e.target.value); setPage(1); }}
- className="w-40"
- >
- Tất cả vai trò
- Người dùng
- Đại lý
- Admin
-
-
-
{ setStatusFilter(e.target.value); setPage(1); }}
- className="w-40"
- >
- Tất cả trạng thái
- Hoạt động
- Bị khóa
-
-
-
-
- {/* Table */}
-
-
- {loading ? (
-
-
-
- ) : error ? (
-
-
{error}
-
- Thử lại
-
-
- ) : !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)
-
-
- setPage((p) => p - 1)}
- >
-
-
- = result.totalPages}
- onClick={() => setPage((p) => p + 1)}
- >
-
-
-
-
- )}
- >
- )}
-
-
-
- {/* 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)}
- />
-
- setBanDialog(null)}>
- Hủy
-
-
- {actionLoading ? 'Đang xử lý...' : 'Xác nhận'}
-
-
-
-
-
- );
-}
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 (
-
- {/* Mobile overlay */}
- {sidebarOpen && (
-
setSidebarOpen(false)}
- />
- )}
-
- {/* Sidebar */}
-
-
- {/* Main content */}
-
-
- setSidebarOpen(true)}>
-
-
- 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}
- )}
-
-
-
- );
-}
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
-
-
-
-
-
-
-
-
-
- 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
-
-
-
-
-
-
-
-
-
- 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) => (
- setCity(c)}
- >
- {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) => (
- setTrendDistrict(d)}
- >
- {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
-
- ) : (
-
-
-
-
- Quận
- Loại BĐS
- Giá trung vị
- Giá/m²
- Tin đăng
- Ngày bán
- YoY
-
-
-
- {districtStats.map((stat, i) => (
-
- {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}
- setError(null)} className="ml-2 font-medium underline">
- Đóng
-
-
- )}
-
- {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 && (
- <>
-
- Loại giấy tờ
- setDocumentType(e.target.value)}
- >
- {DOCUMENT_TYPES.map((dt) => (
-
- {dt.label}
-
- ))}
-
-
-
- Số giấy tờ
- setDocumentNumber(e.target.value)}
- placeholder="Nhập số CCCD/CMND/Hộ chiếu"
- />
-
- >
- )}
-
- {/* Step 2: Upload images */}
- {currentStep === 2 && (
- <>
-
-
Ảnh mặt trước *
-
setFrontImage(e.target.files?.[0] ?? null)}
- />
- {frontImage && (
-
{frontImage.name}
- )}
-
-
-
Ảnh mặt sau
-
setBackImage(e.target.files?.[0] ?? null)}
- />
- {backImage && (
-
{backImage.name}
- )}
-
-
-
Ảnh selfie cầm giấy tờ
-
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 ? (
-
setCurrentStep((s) => s - 1)}>
- Quay lại
-
- ) : (
-
- )}
- {currentStep < 3 ? (
-
{
- if (currentStep === 1 && !documentNumber.trim()) {
- setError('Vui lòng nhập số giấy tờ');
- return;
- }
- setError(null);
- setCurrentStep((s) => s + 1);
- }}
- >
- Tiếp tục
-
- ) : (
-
- {submitting ? 'Đang gửi...' : 'Gửi hồ sơ xác minh'}
-
- )}
-
-
-
- >
- )}
-
- {/* 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
-
-
-
-
Đăng tin mới
-
-
-
- {/* 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}
-
-
-
-
-
- Xem phân tích chi tiết
-
-
-
-
-
-
-
- {/* Recent listings */}
-
-
-
- Tin đăng gần đây
- Danh sách tin đăng mới nhất của bạn
-
-
-
- Xem tất cả
-
-
-
-
- {loading ? (
-
- Đang tải...
-
- ) : !listings || listings.data.length === 0 ? (
-
-
Chưa có tin đăng nào
-
-
- Đăng tin đầu tiên
-
-
-
- ) : (
-
- {listings.data.slice(0, 5).map((listing) => (
-
-
- {listing.property.media.length > 0 ? (
-
- ) : (
-
- 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
-
-
- {
- setStatusFilter(e.target.value);
- setPage(0);
- }}
- >
- Tất cả
- Chờ xử lý
- Đang xử lý
- Thành công
- Thất bại
- Hoàn tiề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)
-
-
- setPage((p) => p - 1)}
- >
- Trước
-
- = totalPages}
- onClick={() => setPage((p) => p + 1)}
- >
- Sau
-
-
-
- )}
- >
- )}
-
-
-
- );
-}
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}
- setError(null)} className="ml-2 font-medium underline">
- Đóng
-
-
- )}
-
- {success && (
-
- {success}
- setSuccess(null)} className="ml-2 font-medium underline">
- Đóng
-
-
- )}
-
-
- {/* Profile info */}
-
-
-
- Thông tin cá nhân
- Thông tin cơ bản trên hồ sơ của bạn
-
- {!editing && (
- setEditing(true)}>
- Chỉnh sửa
-
- )}
-
-
- {loading ? (
-
- Đang tải...
-
- ) : (
- <>
-
-
Họ và tên
- {editing ? (
-
setFormData((p) => ({ ...p, fullName: e.target.value }))}
- />
- ) : (
-
- {user?.fullName ?? '—'}
-
- )}
-
-
-
-
Số điện thoại
-
- {user?.phone ?? '—'}
-
-
- Số điện thoại không thể thay đổi
-
-
-
-
-
Email
- {editing ? (
-
setFormData((p) => ({ ...p, email: e.target.value }))}
- placeholder="email@example.com"
- />
- ) : (
-
- {user?.email ?? 'Chưa cập nhật'}
-
- )}
-
-
-
-
Vai trò
-
- {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 && (
-
-
- {saving ? 'Đang lưu...' : 'Lưu thay đổi'}
-
- {
- setEditing(false);
- if (user) {
- setFormData({
- fullName: user.fullName,
- email: user.email ?? '',
- phone: user.phone,
- });
- }
- }}
- >
- Hủy
-
-
- )}
- >
- )}
-
-
-
- {/* 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' && (
-
-
- {user?.kycStatus === 'NONE' ? 'Bắt đầu xác minh' : 'Xem trạng thái KYC'}
-
-
- )}
-
- 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}
- setError(null)} className="ml-2 font-medium underline">
- Đóng
-
-
- )}
-
- {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 */}
-
- setBillingCycle('monthly')}
- >
- Theo tháng
-
- setBillingCycle('yearly')}
- >
- Theo năm
-
- -17%
-
-
-
-
-
- {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 ? (
-
- Gói hiện tại
-
- ) : isUpgrade ? (
- setUpgradeTarget(plan)}>
- Nâng cấp
-
- ) : (
-
- —
-
- )}
-
-
- );
- })}
-
-
-
- {/* 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,
- )}
-
-
-
-
- setUpgradeTarget(null)}>
- Hủy
-
-
- {processing ? 'Đang xử lý...' : 'Xác nhận nâng cấp'}
-
-
-
-
-
- );
-}
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
-
- )}
-
-
- {autoRetrying ? (
- <>
-
-
-
-
- Đang thử lại...
- >
- ) : (
- 'Thử lại'
- )}
-
-
- 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
-
-
-
- {navItems.map((item) => (
-
- {item.icon}
- {item.label}
-
- ))}
-
-
-
- {user && (
-
- {user.fullName}
-
- )}
-
- {theme === 'light' ? (
-
-
-
- ) : (
-
-
-
- )}
-
-
logout()}>
- Đăng xuất
-
-
-
-
-
-
{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
-
router.push('/listings')}>
- Quay lại
-
-
- );
- }
-
- return (
-
-
-
Chỉnh sửa tin đăng
- router.push(`/listings/${id}`)}>
- Xem tin
-
-
-
-
- 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 }) => (
-
- onChange([])}>Upload Mock
-
- ),
-}));
-
-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 < currentStep && setCurrentStep(index)}
- className={cn(
- 'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors',
- index === currentStep
- ? 'bg-primary text-primary-foreground'
- : index < currentStep
- ? 'bg-primary/20 text-primary cursor-pointer'
- : 'bg-muted text-muted-foreground',
- )}
- >
- {index < currentStep ? '\u2713' : index + 1}
-
-
- {step.title}
-
- {index < STEPS.length - 1 && (
-
- )}
-
- ))}
-
-
- {error && (
-
- {error}
- setError(null)}>
- Đóng
-
-
- )}
-
-
-
- );
-}
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
-
-
-
-
Đăng tin mới
-
-
-
- {/* 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 */}
-
-
- setFilters((f) => ({ ...f, transactionType: e.target.value, page: 1 }))
- }
- className="w-40"
- >
- Tất cả giao dịch
- {TRANSACTION_TYPES.map((t) => (
-
- {t.label}
-
- ))}
-
-
- setFilters((f) => ({ ...f, propertyType: e.target.value, page: 1 }))
- }
- className="w-44"
- >
- Tất cả loại BĐS
- {PROPERTY_TYPES.map((t) => (
-
- {t.label}
-
- ))}
-
-
setFilters((f) => ({ ...f, status: e.target.value, page: 1 }))}
- className="w-40"
- >
- Tất cả trạng thái
- {Object.entries(LISTING_STATUSES).map(([key, { label }]) => (
-
- {label}
-
- ))}
-
-
-
- setViewMode('grid')}
- >
- Lưới
-
- setViewMode('table')}
- >
- Bảng
-
-
-
-
- {/* Content */}
- {loading ? (
-
- ) : !result || result.data.length === 0 ? (
-
-
Chưa có tin đăng nào
-
-
- Đăng tin đầu tiên
-
-
-
- ) : viewMode === 'grid' ? (
- /* Grid View */
-
- {result.data.map((listing) => (
-
-
-
- {listing.property.media.length > 0 ? (
-
- ) : (
-
- 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 */
-
-
-
-
-
-
- Tin đăng
- Loại
- Giá
- Diện tích
- Trạng thái
- Lượt xem
- Liên hệ
- Ngày đăng
-
-
-
- {result.data.map((listing) => (
-
-
-
-
- {listing.property.media.length > 0 ? (
-
- ) : (
-
- 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 && (
-
- setFilters((f) => ({ ...f, page: f.page - 1 }))}
- >
- Trước
-
-
- Trang {result.page} / {result.totalPages}
-
- = result.totalPages}
- onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
- >
- Tiếp
-
-
- )}
-
- );
-}
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 (
-
- );
-}
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: () => (
-
- ),
- },
-);
-
-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'}
-
-
Quay lại tìm kiếm
-
-
- );
- }
-
- const { property, seller, agent } = listing;
- const transactionLabel = getLabel(TRANSACTION_TYPES, listing.transactionType);
- const propertyTypeLabel = getLabel(PROPERTY_TYPES, property.propertyType);
-
- return (
-
- {/* Breadcrumb */}
-
- Trang chủ
- /
- Tìm kiếm
- /
- {property.title}
-
-
- {/* 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}
-
-
-
-
-
-
-
-
- Gọi ngay
-
-
-
-
-
-
- Nhắn tin
-
-
- {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 (
-
- );
-}
-
-function InfoItem({ label, value }: { label: string; value: string }) {
- return (
-
- );
-}
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 */}
-
-
- {/* 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
-
-
-
-
Xem tất cả
-
-
-
- {loadingFeatured ? (
-
- ) : featuredError ? (
-
-
Không thể tải tin đăng. Vui lòng thử lại.
-
- 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
-
-
-
-
- Đăng ký miễn phí
-
-
-
-
- Tìm kiếm ngay
-
-
-
-
-
-
- );
-}
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
-
- )}
-
-
- {autoRetrying ? (
- <>
-
-
-
-
- Đang thử lại...
- >
- ) : (
- 'Thử lại'
- )}
-
-
- 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) */}
-
-
-
- {Array.from({ length: 6 }).map((_, i) => (
-
- ))}
-
-
-
-
- {/* 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: () => (
-
- ),
- },
-);
-
-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 */}
-
-
-
setViewMode('list')}
- >
-
-
-
- Danh sách
-
-
setViewMode('map')}
- >
-
-
-
- Bản đồ
-
-
setViewMode('split')}
- >
-
-
-
- Chia đôi
-
-
-
-
setShowMobileFilters(!showMobileFilters)}
- >
-
-
-
- Bộ lọc
- {activeFilterCount > 0 && (
-
- {activeFilterCount}
-
- )}
-
-
-
- {/* 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() {
+
+
);
}