Files
goodgo-platform/codebase_exploration.md
Ho Ngoc Hai 33c2e5ac1d feat(load-tests): add K6 coverage for search, admin, and MCP endpoints
Add three new K6 load test scripts to cover previously untested API surfaces:

- search-advanced.js: Combined geo + text + filter queries, paginated deep
  search, and sort variations against /search and /search/geo (300 peak VUs)
- admin.js: Moderation queue CRUD, bulk moderation, dashboard stats, audit
  logs, and user management endpoints (50 peak VUs)
- mcp.js: MCP server discovery, SSE connection, property-search tool calls,
  valuation/batch-valuation, and feature extraction (120 peak VUs)

Also updates README with new suite documentation, per-suite custom thresholds,
and adds the new suites to the CI workflow_dispatch selector.

Co-Authored-By: Paperclip <noreply@paperclip.ing>
2026-04-11 20:14:52 +07:00

1089 lines
30 KiB
Markdown

# 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<void>;
register: (data: RegisterPayload) => Promise<void>;
handleOAuthCallback: (accessToken: string, refreshToken: string, expiresIn?: number) => Promise<void>;
logout: () => Promise<void>;
refreshToken: () => Promise<boolean>;
fetchProfile: () => Promise<void>;
initialize: () => Promise<void>;
clearError: () => void;
}
export const useAuthStore = create<AuthState>((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<ComparisonState>()(
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 `<AuthState>` 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<T>(endpoint: string, options: RequestOptions = {}): Promise<T> {
// 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: <T>(endpoint: string, headers?: HeadersInit) => ...,
post: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) => ...,
patch: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) => ...,
delete: <T>(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<ListingDetail>(`/listings/${id}`),
search: (params: SearchListingsParams = {}) => {
// Builds URLSearchParams from params object
return apiClient.get<PaginatedResult<ListingDetail>>(`/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<T> {
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<InquiryReadDto>
Auth: Required
// 3. LIST MY INQUIRIES (AGENT)
@Get('agent/me')
GET /inquiries/agent/me
Query Params: page, limit
Response: PaginatedResult<InquiryReadDto>
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<LeadReadDto>
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<string, unknown>; // 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 <h1>{t('title')}</h1>; // "Bảng điều khiển"
}
```
**Navigation Links**:
```typescript
import { Link } from '@/i18n/navigation';
// Automatically includes locale prefix
<Link href="/listings">
{t('nav.listings')}
</Link>
// 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 (
<QueryClientProvider client={queryClient}>
<QueryErrorResetBoundary>
{({ reset }) => (
<QueryErrorBoundaryInner onReset={reset}>
{children}
</QueryErrorBoundaryInner>
)}
</QueryErrorResetBoundary>
</QueryClientProvider>
);
}
```
### 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<typeof createListingSchema>;
```
---
## 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<ViewMode>('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:
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{result.data.map(listing => (
<Card className="h-full">
<Image src={listing.property.media[0]?.url} />
<ListingStatusBadge status={listing.status} />
<h3>{listing.property.title}</h3>
<p className="text-muted-foreground">{listing.property.district}, {listing.property.city}</p>
<p className="font-semibold text-primary">{formatPrice(listing.priceVND)}</p>
{/* Stats on hover */}
{listing.viewCount} lượt xem
{listing.inquiryCount} liên hệ
</Card>
))}
</div>
// 6. Empty State
{!result || result.data.length === 0 && (
<div className="flex min-h-[300px] flex-col items-center justify-center">
<p>Chưa có tin đăng nào</p>
<Link href="/listings/new">
<Button variant="outline" size="sm">Đăng tin đầu tiên</Button>
</Link>
</div>
)}
// 7. Loading State
{loading && (
<div className="flex min-h-[300px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
)}
```
---
## 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 && <Spinner />}`, `{!data && <Empty />}`
### 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<typeof schema>`