# GoodGo Platform - Codebase Exploration Report ## 1. Next.js App Router Structure & Patterns ### Directory Organization **Location**: `apps/web/app/[locale]/` The application uses a **group-based routing pattern** with locale support: ``` apps/web/app/ ├── [locale]/ │ ├── layout.tsx (root layout for all routes) │ ├── loading.tsx │ ├── (public)/ │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── page.tsx (home page) │ │ ├── listings/[id]/page.tsx │ │ ├── agents/[id]/page.tsx │ │ ├── search/ │ │ │ ├── layout.tsx │ │ │ ├── loading.tsx │ │ │ └── page.tsx │ │ ├── compare/page.tsx │ │ └── pricing/page.tsx │ │ │ ├── (auth)/ │ │ ├── layout.tsx │ │ ├── loading.tsx │ │ ├── login/page.tsx │ │ └── register/page.tsx │ │ │ ├── (dashboard)/ ⭐ AUTHENTICATED ROUTES │ │ ├── layout.tsx (protected dashboard layout with nav) │ │ ├── loading.tsx │ │ ├── error.tsx │ │ ├── dashboard/ │ │ │ ├── page.tsx (main dashboard with stats/charts) │ │ │ ├── profile/page.tsx │ │ │ ├── payments/page.tsx │ │ │ ├── subscription/page.tsx │ │ │ ├── saved-searches/page.tsx │ │ │ ├── valuation/page.tsx │ │ │ └── kyc/page.tsx │ │ ├── listings/ │ │ │ ├── page.tsx (listing management with grid/table views) │ │ │ ├── new/page.tsx (create listing form) │ │ │ └── [id]/edit/page.tsx │ │ └── analytics/page.tsx │ │ │ ├── (admin)/ │ │ ├── layout.tsx │ │ ├── admin/ │ │ │ └── kyc/page.tsx │ │ └── admin/users/... (moderation, kyc, etc) │ │ │ ├── auth/ │ │ └── callback/ │ │ ├── google/page.tsx │ │ └── zalo/page.tsx │ └── [other files] ``` ### Key Pattern Files **Dashboard Layout** (`apps/web/app/[locale]/(dashboard)/layout.tsx`): - **Type**: Client component (`'use client'`) - **Features**: - Mobile-responsive sidebar + desktop header nav - Dynamic navigation items with icons - User profile display - Theme toggle (light/dark) - Language switcher - Active route detection - Logout functionality - **Key Dependencies**: - `next-intl` for translations - `next/navigation` for routing - Custom `useAuthStore` for auth state - `@/components/providers/theme-provider` **Dashboard Page Example** (`apps/web/app/[locale]/(dashboard)/dashboard/page.tsx`): - **Type**: Client component - **Features**: - Stats cards with loading states - Dynamic charts (District Bar Chart) - Market data integration - Recent listings grid - Responsive grid layout (2-4 columns) - **Data Fetching Pattern**: ```typescript 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 }); ``` ### Folder Naming Convention - **Route Groups**: Wrapped in parentheses: `(dashboard)`, `(auth)`, `(public)`, `(admin)` - **Dynamic Routes**: Square brackets: `[locale]`, `[id]` - **Nested Layouts**: Each group can have its own `layout.tsx` - **Special Files**: - `page.tsx` - route page - `layout.tsx` - nested layout - `loading.tsx` - loading skeleton/fallback - `error.tsx` - error boundary - `not-found.tsx` - 404 fallback --- ## 2. Existing Components & Component Library ### Location `apps/web/components/` ### Component Organization ``` components/ ├── ui/ (Base Design System Components) │ ├── badge.tsx │ ├── button.tsx │ ├── card.tsx │ ├── dialog.tsx │ ├── input.tsx │ ├── label.tsx │ ├── select.tsx │ ├── table.tsx │ ├── tabs.tsx │ ├── textarea.tsx │ ├── language-switcher.tsx │ └── __tests__/ │ ├── listings/ (Domain-Specific Components) │ └── listing-status-badge.tsx │ ├── search/ │ ├── filter-bar.tsx │ └── search-results.tsx │ ├── charts/ (Data Visualization) │ └── district-bar-chart.tsx │ ├── agents/ ├── auth/ ├── comparison/ ├── map/ ├── providers/ ├── seo/ └── valuation/ ``` ### Key UI Components #### Badge Component ```typescript // apps/web/components/ui/badge.tsx - Variants: default, secondary, destructive, outline, success, warning, info - Uses CVA (class-variance-authority) for variant patterns - Small pill-shaped badges with Tailwind styling ``` #### Card Components (Compound Pattern) ```typescript export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent }; ``` #### Table Components (Compound Pattern) ```typescript export { Table, TableHeader, TableBody, TableRow, TableHead, TableCell }; ``` #### Button Component - Size variants: default, sm, lg - Variant variants: default, outline, ghost, destructive - Built with Tailwind + CVA #### Input/Select/Textarea - Standard HTML5 form elements with Tailwind styling - Consistent padding and border styling ### Domain-Specific Components **ListingStatusBadge** (`apps/web/components/listings/listing-status-badge.tsx`): ```typescript interface ListingStatusBadgeProps { status: ListingStatus; } // Uses LISTING_STATUSES config for mapping status to badge variant + label ``` **FilterBar** (`apps/web/components/search/filter-bar.tsx`): ```typescript interface SearchFilters { transactionType: string; propertyType: string; city: string; district: string; minPrice: string; maxPrice: string; minArea: string; maxArea: string; bedrooms: string; sort: string; } // Two layouts: horizontal (compact) and sidebar (expanded) // Integrates with dropdowns, inputs, and price range presets ``` ### Component Library Patterns - **Pattern**: Radix UI-inspired compound components - **Styling**: Tailwind CSS + CVA for variants - **Accessibility**: Proper ARIA labels, semantic HTML - **Theming**: CSS variables for light/dark mode - **Reusability**: Props drilling for customization, className composition with `cn()` utility --- ## 3. Zustand Store Patterns ### Location `apps/web/lib/` ### Store Pattern Structure #### Auth Store (`apps/web/lib/auth-store.ts`) ```typescript export interface AuthState { user: UserProfile | null; isAuthenticated: boolean; isLoading: boolean; error: string | null; // Actions login: (data: LoginPayload) => Promise; register: (data: RegisterPayload) => Promise; handleOAuthCallback: (accessToken: string, refreshToken: string, expiresIn?: number) => Promise; logout: () => Promise; refreshToken: () => Promise; fetchProfile: () => Promise; initialize: () => Promise; clearError: () => void; } export const useAuthStore = create((set, get) => ({ // Initial state user: null, isAuthenticated: false, isLoading: false, error: null, // Actions with async handling login: async (data) => { set({ isLoading: true, error: null }); try { await authApi.login(data); set({ isAuthenticated: true, isLoading: false }); await get().fetchProfile(); } catch (e) { const message = e instanceof ApiError ? e.message : 'Đăng nhập thất bại'; set({ isLoading: false, error: message }); throw e; } }, // ... more actions })); ``` #### Comparison Store (`apps/web/lib/comparison-store.ts`) ```typescript export const useComparisonStore = create()( persist( (set, get) => ({ selectedIds: [], listings: [], isLoading: false, error: null, addToCompare: (id: string) => { const { selectedIds } = get(); if (selectedIds.length >= MAX_COMPARE || selectedIds.includes(id)) return false; set({ selectedIds: [...selectedIds, id], error: null }); return true; }, // ... more actions }), { name: 'goodgo-compare', // localStorage key partialize: (state) => ({ selectedIds: state.selectedIds }), // persist only selectedIds }, ), ); ``` ### Store Patterns Observed 1. **Type-Safe State**: Generic `` type parameter 2. **Async Actions**: Direct Promise handling in actions with try/catch 3. **State Updates**: Using `set()` for immutable updates 4. **Selectors**: Via `get()` to access current state 5. **Persistence Middleware**: Optional localStorage persistence with `persist()` 6. **Partial Persistence**: `partialize` to persist only specific fields 7. **Error Handling**: Dedicated error state field 8. **Loading States**: Separate isLoading flag ### Usage Pattern ```typescript // In components const { user, isAuthenticated, login, logout } = useAuthStore(); const { selectedIds, addToCompare, isSelected } = useComparisonStore(); // No selectors - direct property access // Store handles re-renders automatically ``` --- ## 4. API Service Layer ### Location `apps/web/lib/api-*.ts` files ### API Client Foundation (`apps/web/lib/api-client.ts`) ```typescript const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001/api/v1'; export class ApiError extends Error { constructor( public status: number, message: string, ) { ... } } async function request(endpoint: string, options: RequestOptions = {}): Promise { // Handles: // - CSRF token extraction from cookies (XSRF-TOKEN) // - Credentials (include for cookies) // - Content-Type: application/json // - Error handling with ApiError class // - Status checking (throws on !res.ok) } export const apiClient = { get: (endpoint: string, headers?: HeadersInit) => ..., post: (endpoint: string, body?: unknown, headers?: HeadersInit) => ..., patch: (endpoint: string, body?: unknown, headers?: HeadersInit) => ..., delete: (endpoint: string, headers?: HeadersInit) => ..., }; ``` ### Listings API (`apps/web/lib/listings-api.ts`) ```typescript export const listingsApi = { create: (data: CreateListingPayload) => apiClient.post<{ listingId: string; propertyId: string; status: string }>( '/listings', data, ), getById: (id: string) => apiClient.get(`/listings/${id}`), search: (params: SearchListingsParams = {}) => { // Builds URLSearchParams from params object return apiClient.get>(`/listings${qs ? `?${qs}` : ''}`); }, updateStatus: (id: string, status: ListingStatus, moderationNotes?: string) => apiClient.post<{ status: string }>(`/listings/${id}/status`, { status, moderationNotes }), uploadMedia: async (listingId: string, file: File, caption?: string) => { // Special handling for FormData (multipart/form-data) // Manual CSRF token handling // Returns { mediaId: string; url: string } }, }; ``` ### API Types Pattern ```typescript export type ListingStatus = 'DRAFT' | 'PENDING_REVIEW' | 'ACTIVE' | ... ; export type PropertyType = 'APARTMENT' | 'HOUSE' | ... ; export interface ListingDetail { id: string; status: ListingStatus; priceVND: string; // ... other fields property: { id: string; propertyType: PropertyType; // ... nested structure }; } export interface PaginatedResult { data: T[]; total: number; page: number; limit: number; totalPages: number; } export interface SearchListingsParams { status?: ListingStatus; // ... optional filters page?: number; limit?: number; } ``` ### Auth Handling - **Authentication Method**: Cookie-based (JWT in httpOnly cookies) - **CSRF Protection**: - Token extracted from `XSRF-TOKEN` cookie - Sent in `X-CSRF-Token` header for non-safe methods (POST, PATCH, DELETE) - **Credentials**: `credentials: 'include'` in fetch calls - **Token Refresh**: Automatic refresh in `useAuthStore.fetchProfile()` ### Error Handling Pattern ```typescript try { const data = await listingsApi.search(params); } catch (e) { if (e instanceof ApiError) { if (e.status === 401) { // Handle auth error } else if (e.status === 400) { // Handle validation error } } throw e; } ``` --- ## 5. Backend Inquiry & Lead API Endpoints ### Inquiries Module **Location**: `apps/api/src/modules/inquiries/` #### Controller Routes (`inquiries.controller.ts`) ```typescript @Controller('inquiries') // Base: /api/v1/inquiries // 1. CREATE INQUIRY @Post() POST /inquiries Payload: CreateInquiryDto Response: CreateInquiryResult Auth: Required (JwtAuthGuard) // 2. LIST INQUIRIES BY LISTING @Get('listing/:listingId') GET /inquiries/listing/{listingId} Query Params: page (default 1), limit (default 20, max 100) Response: PaginatedResult Auth: Required // 3. LIST MY INQUIRIES (AGENT) @Get('agent/me') GET /inquiries/agent/me Query Params: page, limit Response: PaginatedResult Auth: Required + AGENT role // 4. MARK INQUIRY AS READ (AGENT) @Patch(':id/read') PATCH /inquiries/{id}/read Auth: Required + AGENT role Response: { success: boolean } ``` #### DTOs **CreateInquiryDto**: ```typescript { listingId: string; // Required message: string; // Required, max 2000 chars phone?: string; // Optional } ``` **ListInquiriesDto**: ```typescript { page?: number; // Default 1, Min 1 limit?: number; // Default 20, Min 1, Max 100 } ``` #### Response DTOs **InquiryReadDto**: ```typescript { id: string; listingId: string; listingTitle: string; userId: string; userName: string; userPhone: string; message: string; phone: string | null; isRead: boolean; createdAt: string; } ``` **CreateInquiryResult** (from handler): ```typescript // Check handler for exact structure // Likely: { inquiryId: string; status: string; ... } ``` --- ### Leads Module **Location**: `apps/api/src/modules/leads/` #### Controller Routes (`leads.controller.ts`) ```typescript @Controller('leads') // Base: /api/v1/leads @UseGuards(JwtAuthGuard, RolesGuard) @Roles('AGENT') // All endpoints require AGENT role // 1. CREATE LEAD @Post() POST /leads Payload: CreateLeadDto Response: CreateLeadResult Auth: AGENT role required // 2. LIST LEADS (AGENT'S LEADS) @Get() GET /leads Query Params: status (filter), page (default 1), limit (default 20) Response: PaginatedResult Auth: AGENT role required // 3. GET LEAD STATS @Get('stats') GET /leads/stats Response: LeadStatsData Auth: AGENT role required // 4. UPDATE LEAD STATUS @Patch(':id/status') PATCH /leads/{id}/status Payload: UpdateLeadStatusDto Response: { updated: boolean } Auth: AGENT role required // 5. DELETE LEAD @Delete(':id') DELETE /leads/{id} Response: { deleted: boolean } Auth: AGENT role required ``` #### DTOs **CreateLeadDto**: ```typescript { name: string; // Required (customer name) phone: string; // Required email?: string; // Optional, valid email format source: string; // Required (lead source: 'website', etc) score?: number; // Optional, 0-100 notes?: Record; // Optional JSON } ``` **ListLeadsDto**: ```typescript { status?: 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'NEGOTIATING' | 'CONVERTED' | 'LOST'; page?: number; // Default 1, Min 1 limit?: number; // Default 20, Min 1, Max 100 } ``` **UpdateLeadStatusDto**: ```typescript { status: 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'NEGOTIATING' | 'CONVERTED' | 'LOST'; // Required } ``` #### Response DTOs **LeadReadDto**: ```typescript { id: string; agentId: string; name: string; phone: string; email: string | null; source: string; score: number | null; notes: unknown; status: string; createdAt: string; updatedAt: string; } ``` **LeadStatsData**: ```typescript // Check get-lead-stats query handler for structure // Likely: { total: number; byStatus: { [status]: number }; ... } ``` #### Lead Statuses ```typescript const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST']; ``` --- ## 6. Tailwind & Design System ### Configuration **Location**: `apps/web/tailwind.config.ts` ```typescript const config: Config = { darkMode: ['class'], // Dark mode via CSS class toggle content: [ './app/**/*.{ts,tsx}', './components/**/*.{ts,tsx}', './lib/**/*.{ts,tsx}' ], theme: { extend: { fontFamily: { sans: ['var(--font-inter)', 'system-ui', 'sans-serif'], }, colors: { // All colors use CSS variables border: 'hsl(var(--border))', input: 'hsl(var(--input))', ring: 'hsl(var(--ring))', background: 'hsl(var(--background))', foreground: 'hsl(var(--foreground))', primary: { DEFAULT: 'hsl(var(--primary))', foreground: 'hsl(var(--primary-foreground))', }, secondary: { ... }, destructive: { ... }, muted: { ... }, accent: { ... }, card: { ... }, }, borderRadius: { lg: 'var(--radius)', md: 'calc(var(--radius) - 2px)', sm: 'calc(var(--radius) - 4px)', }, }, }, plugins: [tailwindcssAnimate], }; ``` ### Theme Colors (CSS Variables) **Location**: `apps/web/app/globals.css` ```css :root { --background: 0 0% 100%; /* White */ --foreground: 222.2 84% 4.9%; /* Dark blue-gray */ --card: 0 0% 100%; --card-foreground: 222.2 84% 4.9%; --primary: 142.1 76.2% 36.3%; /* Green */ --primary-foreground: 355.7 100% 97.3%; --secondary: 210 40% 96.1%; /* Light gray-blue */ --secondary-foreground: 222.2 47.4% 11.2%; --muted: 210 40% 96.1%; --muted-foreground: 215.4 16.3% 46.9%; --accent: 210 40% 96.1%; --accent-foreground: 222.2 47.4% 11.2%; --destructive: 0 84.2% 60.2%; /* Red */ --destructive-foreground: 210 40% 98%; --border: 214.3 31.8% 91.4%; --input: 214.3 31.8% 91.4%; --ring: 142.1 76.2% 36.3%; /* Green ring */ --radius: 0.5rem; /* 8px border radius */ } .dark { --background: 222.2 84% 4.9%; /* Dark navy */ --foreground: 210 40% 98%; /* Off white */ --primary: 142.1 70.6% 45.3%; /* Lighter green */ --primary-foreground: 144.9 80.4% 10%; /* ... other dark mode overrides */ } ``` ### Design Tokens Pattern - **HSL Format**: All colors use `hsl(h s% l%)` for easy manipulation - **Semantic Names**: `primary`, `secondary`, `destructive`, `muted`, `accent` - **Foreground Pairs**: Each color has a `-foreground` variant for text - **Responsive**: Uses `sm:`, `md:`, `lg:` breakpoints - **Spacing**: Tailwind default scale (1, 2, 3, 4, 6, 8, 12, 16, 20, 24, 32, etc.) - **Typography**: Inter font family with standard weight scale ### Common Tailwind Patterns ```typescript // Cards className="rounded-lg border bg-card text-card-foreground shadow-sm" // Buttons className="inline-flex items-center rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground" // Grid layouts className="grid gap-4 sm:grid-cols-2 lg:grid-cols-4" // Flex layouts className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between" // Status badges with variants className="border-transparent bg-green-100 text-green-800" // success className="border-transparent bg-yellow-100 text-yellow-800" // warning className="border-transparent bg-red-100 text-red-800" // destructive ``` ### Utility Functions `apps/web/lib/utils.ts`: ```typescript export function cn(...inputs: ClassValue[]): string { // Merges Tailwind classes, removes conflicts, handles conditions // Used everywhere: className={cn('base-class', condition && 'conditional-class')} } ``` --- ## 7. i18n/Locale Patterns ### Configuration **Location**: `apps/web/i18n/` #### Setup Files **config.ts**: ```typescript export const locales = ['vi', 'en'] as const; export type Locale = (typeof locales)[number]; export const defaultLocale: Locale = 'vi'; ``` **navigation.ts**: ```typescript import { createNavigation } from 'next-intl/navigation'; import { routing } from './routing'; export const { Link, redirect, usePathname, useRouter } = createNavigation(routing); // Provides locale-aware navigation utilities ``` **routing.ts**: ```typescript import { defineRouting } from 'next-intl/routing'; export const routing = defineRouting({ locales: ['vi', 'en'], defaultLocale: 'vi', localePrefix: 'as-needed', // /en/page but not /vi/page (default) }); ``` **request.ts** (Server-side i18n setup): ```typescript export default getRequestConfig(async ({ requestLocale }) => { let locale = await requestLocale; if (!locale || !routing.locales.includes(locale)) { locale = routing.defaultLocale; } return { locale, messages: (await import(`../messages/${locale}.json`)).default, }; }); ``` ### Message Files **Location**: `apps/web/messages/` Files: `vi.json`, `en.json` Structure: ```json { "metadata": { "title": "...", "description": "..." }, "common": { "goodgo": "GoodGo", "loading": "Đang tải...", ... }, "nav": { "home": "Trang chủ", "dashboardNav": "Bảng điều khiển", ... }, "dashboard": { "title": "Bảng điều khiển", "listings": "Tin đăng", ... }, "propertyTypes": { "APARTMENT": "Căn hộ", "HOUSE": "Nhà riêng", ... }, "transactionTypes": { "SALE": "Bán", "RENT": "Cho thuê" }, "listingStatuses": { "ACTIVE": "Đang bán", "SOLD": "Đã bán", ... } } ``` ### Usage in Components **Client Components**: ```typescript 'use client'; import { useTranslations } from 'next-intl'; export default function MyComponent() { const t = useTranslations('dashboard'); // Namespace return

{t('title')}

; // "Bảng điều khiển" } ``` **Navigation Links**: ```typescript import { Link } from '@/i18n/navigation'; // Automatically includes locale prefix {t('nav.listings')} // On /vi -> renders to /vi/listings // On /en -> renders to /en/listings ``` **URL Structure**: - Vietnamese (default): `/` → `/listings`, `/dashboard` - English: `/en`, `/en/listings`, `/en/dashboard` - `localePrefix: 'as-needed'` means default locale (/vi) is omitted from URL ### Translation Namespacing Key paths in JSON are hierarchical: ```typescript t('common.logout') // common.logout t('dashboard.listings') // dashboard.listings t('propertyTypes.APARTMENT') // propertyTypes.APARTMENT t('search.filters') // search.filters ``` ### Pluralization & Interpolation ```typescript t('bedroomsCount', { count: n }) // Pluralization support t('errorCode', { code: '404' }) // Variable interpolation ``` --- ## 8. Data Fetching & React Query ### Setup **Location**: `apps/web/components/providers/query-provider.tsx` ```typescript export function QueryProvider({ children }: { children: React.ReactNode }) { const queryClient = getQueryClient(); return ( {({ reset }) => ( {children} )} ); } ``` ### Query Key Factory Pattern **Location**: `apps/web/lib/hooks/use-listings.ts` ```typescript export const listingsKeys = { all: ['listings'] as const, search: (params: SearchListingsParams) => ['listings', 'search', params] as const, detail: (id: string) => ['listings', 'detail', id] as const, }; // Used in queries: useQuery({ queryKey: listingsKeys.search(params), queryFn: () => listingsApi.search(params), }) ``` ### Hook Pattern ```typescript export function useListingsSearch(params: SearchListingsParams = {}) { return useQuery({ queryKey: listingsKeys.search(params), queryFn: () => listingsApi.search(params), }); } export function useListingDetail(id: string) { return useQuery({ queryKey: listingsKeys.detail(id), queryFn: () => listingsApi.getById(id), enabled: !!id, // Don't fetch if id is falsy }); } // Usage in components: const { data, isLoading, error } = useListingsSearch({ page: 1, limit: 12 }); const { data: listing } = useListingDetail(id); ``` ### Error Boundary with React Query - Provides `QueryErrorFallback` component - Shows error message and retry button - Automatically resets on successful queries --- ## 9. Key Validation Patterns ### Location `apps/web/lib/validations/listings.ts` ```typescript // Using Zod for schema validation import { z } from 'zod'; // Enum-like objects for UI display + values export const TRANSACTION_TYPES = [ { value: 'SALE', label: 'Bán' }, { value: 'RENT', label: 'Cho thuê' }, ] as const; export const LISTING_STATUSES = { DRAFT: { label: 'Nháp', variant: 'secondary' as const }, ACTIVE: { label: 'Đang bán', variant: 'success' as const }, SOLD: { label: 'Đã bán', variant: 'default' as const }, // ... }; // Zod schemas for form validation export const listingBasicSchema = z.object({ transactionType: z.enum(['SALE', 'RENT'], { message: 'Vui lòng chọn loại giao dịch', }), propertyType: z.enum(['APARTMENT', 'HOUSE', 'VILLA', ...], { message: 'Vui lòng chọn loại bất động sản', }), title: z.string().min(5, 'Tiêu đề tối thiểu 5 ký tự'), description: z.string().min(10, 'Mô tả tối thiểu 10 ký tự'), }); // Composable schemas export const createListingSchema = listingBasicSchema .merge(listingLocationSchema) .merge(listingDetailsSchema) .merge(listingPricingSchema); // Type inference export type CreateListingFormData = z.infer; ``` --- ## 10. List Views Pattern (Listings Page Example) ### File `apps/web/app/[locale]/(dashboard)/listings/page.tsx` ### Features Implemented ```typescript 'use client'; // 1. View Mode Toggle (Grid/Table) const [viewMode, setViewMode] = React.useState('grid'); // 2. Filter State Management const [filters, setFilters] = React.useState({ transactionType: '', propertyType: '', status: '', page: 1, }); // 3. Stats Card Section - Total listings count - Active count (with green color) - Pending review count (with yellow color) - Total views count // 4. Filter Controls (Select dropdowns) - Transaction Type (Bán/Cho thuê) - Property Type (Căn hộ, Nhà riêng, ...) - Status (Nháp, Chờ duyệt, Đang bán, ...) - View mode toggle buttons // 5. Data Display // Grid View:
{result.data.map(listing => (

{listing.property.title}

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

{formatPrice(listing.priceVND)}

{/* Stats on hover */} {listing.viewCount} lượt xem {listing.inquiryCount} liên hệ
))}
// 6. Empty State {!result || result.data.length === 0 && (

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

)} // 7. Loading State {loading && (
)} ``` --- ## Summary: Architectural Patterns for Inquiry & Lead Management Pages Based on this exploration, here are the patterns to follow when building new Inquiry & Lead Management UI pages: ### Page Structure 1. **Use `(dashboard)` route group** - Place under `apps/web/app/[locale]/(dashboard)/inquiries/` and `/leads/` 2. **Client component with `'use client'`** - Use React hooks and Zustand 3. **Include layout/navigation** - Inherited from parent dashboard layout 4. **Follow naming**: `/inquiries/page.tsx`, `/inquiries/[id]/page.tsx`, `/leads/page.tsx`, etc. ### Data Management 1. **Create API service files**: `apps/web/lib/inquiries-api.ts`, `apps/web/lib/leads-api.ts` 2. **Follow apiClient pattern** - Use the generic `apiClient.get/post/patch/delete` 3. **Use React Query hooks**: `useQuery` with key factory pattern 4. **Optional Zustand store** - If complex state management needed (like comparison store) ### UI Components 1. **Use existing `Card`, `Badge`, `Button`, `Select`, `Table` components** 2. **Build `InquiryStatusBadge`, `LeadStatusBadge`** similar to `ListingStatusBadge` 3. **Reuse `FilterBar` pattern** - Select dropdowns + inputs for filtering 4. **Use Table component** for lists - Grid for visual layouts 5. **Implement pagination** - Use `page` and `limit` params ### Translations 1. **Add keys to `messages/vi.json` and `messages/en.json`** 2. **Use `useTranslations('inquiries')` and `useTranslations('leads')`** 3. **Group translations by feature** - Keep status labels, field names organized ### Styling 1. **Use Tailwind classes** - `grid`, `gap-4`, `sm:`, `lg:` breakpoints 2. **Leverage color tokens** - `text-primary`, `bg-card`, `text-muted-foreground` 3. **Status colors**: Use badge variants `success` (contacted), `warning` (new), `info` (qualified) 4. **Responsive grid**: `grid gap-4 sm:grid-cols-2 lg:grid-cols-3` ### Loading/Error States 1. **Loading spinner**: Use rotating border div with `animate-spin` 2. **Empty states**: Centered flex container with icon + message + CTA button 3. **Error fallback**: React Query error boundary with retry button 4. **Conditional rendering**: `{loading && }`, `{!data && }` ### Form Validation 1. **Create Zod schemas** - Similar to `listingBasicSchema`, `listingLocationSchema` 2. **Validation messages** - Use Vietnamese language for errors 3. **API DTOs** - Match backend DTOs exactly 4. **Type inference** - Use `z.infer`