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>
30 KiB
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-intlfor translationsnext/navigationfor routing- Custom
useAuthStorefor 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:
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 pagelayout.tsx- nested layoutloading.tsx- loading skeleton/fallbackerror.tsx- error boundarynot-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
// 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)
export { Card, CardHeader, CardFooter, CardTitle, CardDescription, CardContent };
Table Components (Compound Pattern)
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):
interface ListingStatusBadgeProps {
status: ListingStatus;
}
// Uses LISTING_STATUSES config for mapping status to badge variant + label
FilterBar (apps/web/components/search/filter-bar.tsx):
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)
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)
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
- Type-Safe State: Generic
<AuthState>type parameter - Async Actions: Direct Promise handling in actions with try/catch
- State Updates: Using
set()for immutable updates - Selectors: Via
get()to access current state - Persistence Middleware: Optional localStorage persistence with
persist() - Partial Persistence:
partializeto persist only specific fields - Error Handling: Dedicated error state field
- Loading States: Separate isLoading flag
Usage Pattern
// 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)
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)
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
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-TOKENcookie - Sent in
X-CSRF-Tokenheader for non-safe methods (POST, PATCH, DELETE)
- Token extracted from
- Credentials:
credentials: 'include'in fetch calls - Token Refresh: Automatic refresh in
useAuthStore.fetchProfile()
Error Handling Pattern
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)
@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:
{
listingId: string; // Required
message: string; // Required, max 2000 chars
phone?: string; // Optional
}
ListInquiriesDto:
{
page?: number; // Default 1, Min 1
limit?: number; // Default 20, Min 1, Max 100
}
Response DTOs
InquiryReadDto:
{
id: string;
listingId: string;
listingTitle: string;
userId: string;
userName: string;
userPhone: string;
message: string;
phone: string | null;
isRead: boolean;
createdAt: string;
}
CreateInquiryResult (from handler):
// Check handler for exact structure
// Likely: { inquiryId: string; status: string; ... }
Leads Module
Location: apps/api/src/modules/leads/
Controller Routes (leads.controller.ts)
@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:
{
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:
{
status?: 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'NEGOTIATING' | 'CONVERTED' | 'LOST';
page?: number; // Default 1, Min 1
limit?: number; // Default 20, Min 1, Max 100
}
UpdateLeadStatusDto:
{
status: 'NEW' | 'CONTACTED' | 'QUALIFIED' | 'NEGOTIATING' | 'CONVERTED' | 'LOST'; // Required
}
Response DTOs
LeadReadDto:
{
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:
// Check get-lead-stats query handler for structure
// Likely: { total: number; byStatus: { [status]: number }; ... }
Lead Statuses
const LEAD_STATUSES = ['NEW', 'CONTACTED', 'QUALIFIED', 'NEGOTIATING', 'CONVERTED', 'LOST'];
6. Tailwind & Design System
Configuration
Location: apps/web/tailwind.config.ts
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
: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
-foregroundvariant 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
// 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:
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:
export const locales = ['vi', 'en'] as const;
export type Locale = (typeof locales)[number];
export const defaultLocale: Locale = 'vi';
navigation.ts:
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:
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):
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:
{
"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:
'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:
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:
t('common.logout') // common.logout
t('dashboard.listings') // dashboard.listings
t('propertyTypes.APARTMENT') // propertyTypes.APARTMENT
t('search.filters') // search.filters
Pluralization & Interpolation
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
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
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
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
QueryErrorFallbackcomponent - Shows error message and retry button
- Automatically resets on successful queries
9. Key Validation Patterns
Location
apps/web/lib/validations/listings.ts
// 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
'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
- Use
(dashboard)route group - Place underapps/web/app/[locale]/(dashboard)/inquiries/and/leads/ - Client component with
'use client'- Use React hooks and Zustand - Include layout/navigation - Inherited from parent dashboard layout
- Follow naming:
/inquiries/page.tsx,/inquiries/[id]/page.tsx,/leads/page.tsx, etc.
Data Management
- Create API service files:
apps/web/lib/inquiries-api.ts,apps/web/lib/leads-api.ts - Follow apiClient pattern - Use the generic
apiClient.get/post/patch/delete - Use React Query hooks:
useQuerywith key factory pattern - Optional Zustand store - If complex state management needed (like comparison store)
UI Components
- Use existing
Card,Badge,Button,Select,Tablecomponents - Build
InquiryStatusBadge,LeadStatusBadgesimilar toListingStatusBadge - Reuse
FilterBarpattern - Select dropdowns + inputs for filtering - Use Table component for lists - Grid for visual layouts
- Implement pagination - Use
pageandlimitparams
Translations
- Add keys to
messages/vi.jsonandmessages/en.json - Use
useTranslations('inquiries')anduseTranslations('leads') - Group translations by feature - Keep status labels, field names organized
Styling
- Use Tailwind classes -
grid,gap-4,sm:,lg:breakpoints - Leverage color tokens -
text-primary,bg-card,text-muted-foreground - Status colors: Use badge variants
success(contacted),warning(new),info(qualified) - Responsive grid:
grid gap-4 sm:grid-cols-2 lg:grid-cols-3
Loading/Error States
- Loading spinner: Use rotating border div with
animate-spin - Empty states: Centered flex container with icon + message + CTA button
- Error fallback: React Query error boundary with retry button
- Conditional rendering:
{loading && <Spinner />},{!data && <Empty />}
Form Validation
- Create Zod schemas - Similar to
listingBasicSchema,listingLocationSchema - Validation messages - Use Vietnamese language for errors
- API DTOs - Match backend DTOs exactly
- Type inference - Use
z.infer<typeof schema>