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>
1089 lines
30 KiB
Markdown
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>`
|