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

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

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

  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

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

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

// 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 QueryErrorFallback component
  • 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  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>