# GoodGo Platform - Next.js Frontend Structure Guide
## π 1. App Router Structure (`apps/web/app/`)
### Directory Organization
The app follows Next.js 13+ App Router with i18n using `[locale]` dynamic segment and route groups:
```
app/
βββ [locale]/ # i18n dynamic segment
β βββ (public)/ # Public pages
β β βββ du-an/ # Projects listing (residential projects)
β β β βββ page.tsx # Search/grid view
β β β βββ [slug]/page.tsx # Project detail page
β β βββ listings/ # Property listings
β β βββ khu-cong-nghiep/ # Industrial parks
β β βββ agents/ # Agent profiles
β β βββ search/ # Search interface
β β βββ bao-cao/ # Reports
β β βββ ...others
β βββ (auth)/ # Auth pages (login, register)
β βββ (dashboard)/ # Protected dashboard routes
β β βββ projects/ # Project management (DEVELOPER role)
β β β βββ page.tsx # Projects list
β β β βββ new/page.tsx # Create new project
β β β βββ [id]/edit/page.tsx # Edit project
β β βββ listings/ # Listing management
β β βββ dashboard/ # Main dashboard
β β βββ analytics/ # Analytics dashboard
β β βββ leads/ # Leads management
β β βββ ...others
β βββ (admin)/ # Admin-only routes
β β βββ admin/ # Admin panel
β βββ auth/callback/ # OAuth callbacks
β βββ layout.tsx # Root layout with locale
βββ api/ # API routes (edge endpoints)
βββ robots.ts # robots.txt generation
```
### Key Patterns
- **Route Groups** (parentheses): Don't affect URL, used for organizing layouts
- `(public)` - Public pages with public header/footer
- `(auth)` - Auth pages (login/register layout)
- `(dashboard)` - Protected pages behind authentication
- `(admin)` - Admin-only pages
- **Dynamic Segments**: `[locale]`, `[id]`, `[slug]`
- **Server Components by default**, `'use client'` for interactivity
---
## π¨ 2. Existing Page Patterns
### Pattern A: Public Browsing Page (e.g., du-an/page.tsx)
**Server Component** wraps Client Component:
```tsx
// Page: Server Component
export async function generateMetadata() { ... } // SEO metadata
export default async function DuAnDetailPage({ params }) { ... }
// Detail fetching
const project = await fetchProjectBySlug(slug);
return ; // Pass to client
```
**Client Component** handles state & interactivity:
```tsx
'use client';
// Use hooks, state, client APIs
const { data, isLoading } = useProjectsSearch(filters);
// Multiple view modes: grid, list, map
// Filters, pagination
```
### Pattern B: Admin/Dashboard Page (projects/page.tsx)
**Full Client Component** with:
- Authentication check via `useAuthStore`
- React Query for data fetching & mutations
- Filters & search
- CRUD operations
```tsx
'use client';
const { data: result } = useQuery({
queryKey: ['admin-projects', ...],
queryFn: () => duAnApi.searchMine(queryParams),
});
const deleteMutation = useMutation({
mutationFn: duAnApi.delete,
onSuccess: () => queryClient.invalidateQueries(...),
});
```
### Pattern C: Detail Pages with Metadata
**Server-side data fetching** for SEO:
```tsx
export async function generateMetadata({ params }: PageProps) {
const project = await fetchProjectBySlug(slug);
return {
title: `${project.name} β ${project.developer.name}`,
description: project.description?.slice(0, 160),
};
}
export default async function Page({ params }: PageProps) {
const project = await fetchProjectBySlug(slug);
if (!project) notFound();
return ;
}
```
### Pattern D: Dynamic Routes with API Integration
```tsx
export async function generateStaticParams() {
// Pre-render static pages
const projects = await fetchProjects({ limit: 100 });
return projects.map(p => ({ slug: p.slug }));
}
export const revalidate = 3600; // ISR: revalidate every hour
```
---
## π§© 3. UI Components (`apps/web/components/`)
### Component Structure
```
components/
βββ ui/ # shadcn/ui primitives
β βββ button.tsx
β βββ card.tsx
β βββ input.tsx
β βββ label.tsx
β βββ dialog.tsx
β βββ select.tsx
β βββ tabs.tsx
β βββ table.tsx
β βββ textarea.tsx
β βββ badge.tsx
β βββ language-switcher.tsx
βββ du-an/ # Project-specific components
β βββ project-card.tsx # Grid card display
β βββ project-map.tsx # Mapbox map integration
β βββ project-filter-bar.tsx # Search filters
β βββ du-an-detail-client.tsx # Detail page tabs
β βββ project-ai-advice-card.tsx
βββ listings/
β βββ image-gallery.tsx
β βββ ...
βββ map/
β βββ listing-map.tsx
β βββ ...
βββ neighborhood/
β βββ neighborhood-poi-map.tsx
β βββ neighborhood-radar-chart.tsx
β βββ ...
βββ ...others
```
### Component Patterns
**Functional Component** (most common):
```tsx
'use client'; // if interactive
interface ProjectCardProps {
project: ProjectSummary;
}
export function ProjectCard({ project }: ProjectCardProps) {
return (
{/* Content */}
);
}
```
**With Query Hooks** (data fetching):
```tsx
'use client';
import { useQuery } from '@tanstack/react-query';
export function ProjectsList() {
const { data, isLoading } = useQuery({
queryKey: ['projects', params],
queryFn: () => duAnApi.search(params),
});
return ...
}
```
**Image Components** (Next.js):
```tsx
import Image from 'next/image';
```
---
## πΊοΈ 4. Mapbox Integration
### Setup
- **Token**: `NEXT_PUBLIC_MAPBOX_TOKEN` environment variable
- **Library**: `mapbox-gl` (imported with `/* eslint-disable import-x/no-named-as-default-member */`)
- **CSS**: `import 'mapbox-gl/dist/mapbox-gl.css'`
### Example: ProjectMap Component
```tsx
'use client';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
export function ProjectMap({ projects }: ProjectMapProps) {
const mapContainerRef = React.useRef(null);
const mapRef = React.useRef(null);
const markersRef = React.useRef([]);
React.useEffect(() => {
mapboxgl.accessToken = process.env['NEXT_PUBLIC_MAPBOX_TOKEN']!;
const map = new mapboxgl.Map({
container: mapContainerRef.current!,
style: mapStyle, // Dynamic style from useMapboxStyle()
center: [106.6297, 10.8231], // HCMC default
zoom: 12,
});
map.addControl(new mapboxgl.NavigationControl(), 'top-right');
mapRef.current = map;
}, []);
// Add markers with click popups
React.useEffect(() => {
projects.forEach(project => {
const marker = new mapboxgl.Marker(...)
.setLngLat([project.longitude, project.latitude])
.setPopup(popup)
.addTo(map);
});
}, [projects]);
return ;
}
```
### Features
- **Dynamic map styles** via `useMapboxStyle()` hook
- **Custom markers** with HTML elements
- **Popups** with project information
- **Auto-fit bounds** with `fitBounds()`
- **Navigation control** for zooming
### Other Maps in Codebase
- `listing-map.tsx` - Individual property map
- `neighborhood-poi-map.tsx` - POI overlay (restaurants, schools, etc.)
- `park-map.tsx` - Industrial park locations
---
## π¨ 5. Tailwind/Styling Setup
### Configuration
- **File**: `apps/web/tailwind.config.ts`
- **Plugins**: `tailwindcss-animate`
- **Color system**: CSS custom properties (HSL variables)
### Design Tokens
**Colors** (via CSS variables):
```tsx
// Usage in tailwind.config
colors: {
primary: 'hsl(var(--primary))',
secondary: 'hsl(var(--secondary))',
destructive: 'hsl(var(--destructive))',
muted: 'hsl(var(--muted))',
accent: { blue, purple },
background: { DEFAULT, elevated, surface },
signal: { up, down, neutral },
card: 'hsl(var(--card))',
// ...
}
```
**Spacing**:
```tsx
spacing: {
cell: '0.5rem',
row: '2.25rem',
sidebar: '15rem',
'sidebar-collapsed': '3.5rem',
}
```
**Typography**:
```tsx
fontSize: {
ticker: '0.8125rem',
'heading-sm': '0.875rem',
'heading-md': '1.125rem',
'heading-lg': '1.5rem',
'heading-xl': '1.875rem',
}
```
**Shadows**:
```tsx
boxShadow: {
'elevation-1': '0 1px 2px rgba(0,0,0,.30), 0 0 0 1px hsl(var(--border))',
'elevation-2': '0 4px 12px rgba(0,0,0,.40)',
'elevation-3': '0 12px 32px rgba(0,0,0,.50)',
}
```
**Utilities**:
```tsx
cn('rounded-lg', 'border', 'p-4', isHovered && 'shadow-lg')
```
### Dark Mode
```tsx
darkMode: ['class'] // Toggle via class on root element
```
---
## π 6. API Client & Data Fetching Patterns
### API Client (`apps/web/lib/api-client.ts`)
Core fetch wrapper with:
- **CSRF token** handling
- **Auto-refresh** on 401 (expired token)
- **Coalesced refresh** (only refresh once if multiple 401s)
- **Credentials** included (cookies)
```tsx
export const apiClient = {
get: (endpoint: string, headers?: HeadersInit) =>
request(endpoint, { method: 'GET', headers }),
post: (endpoint: string, body?: unknown, headers?: HeadersInit) =>
request(endpoint, { method: 'POST', body, headers }),
patch: (endpoint: string, body?: unknown, headers?: HeadersInit) =>
request(endpoint, { method: 'PATCH', body, headers }),
delete: (endpoint: string, headers?: HeadersInit) =>
request(endpoint, { method: 'DELETE', headers }),
};
```
### Domain APIs
**File: `apps/web/lib/du-an-api.ts`** (Projects API)
```tsx
export const duAnApi = {
search: (params: SearchProjectsParams) =>
apiClient.get>(`/projects?...`),
searchMine: (params) => // DEVELOPER/ADMIN only
apiClient.get>(`/projects/mine/list?...`),
getBySlug: (slug: string) =>
apiClient.get(`/projects/${slug}`),
getLinkedListings: (projectId, params) =>
apiClient.get>(`/projects/${projectId}/listings?...`),
submitInquiry: (projectId, data) =>
apiClient.post<{ inquiryId: string }>(`/projects/${projectId}/inquiries`, data),
create: (payload) =>
apiClient.post<{ id: string; slug: string }>('/projects', payload),
update: (id, payload) =>
apiClient.patch(`/projects/${id}`, payload),
delete: (id) =>
apiClient.delete<{ success: boolean }>(`/projects/${id}`),
};
```
### Query Hooks (`apps/web/lib/hooks/use-du-an.ts`)
React Query hook pattern:
```tsx
export const projectKeys = {
all: ['projects'] as const,
search: (params) => ['projects', 'search', params] as const,
detail: (slug) => ['projects', 'detail', slug] as const,
linkedListings: (projectId, page) =>
['projects', 'listings', projectId, page] as const,
};
export function useProjectsSearch(params: SearchProjectsParams = {}) {
return useQuery({
queryKey: projectKeys.search(params),
queryFn: () => duAnApi.search(params),
});
}
export function useProjectDetail(slug: string) {
return useQuery({
queryKey: projectKeys.detail(slug),
queryFn: () => duAnApi.getBySlug(slug),
enabled: !!slug,
});
}
```
### Server-Side Fetching (`apps/web/lib/du-an-server.ts`)
For `generateMetadata`, `generateStaticParams`, etc. (server components only):
```tsx
export async function fetchProjectBySlug(slug: string): Promise {
try {
const res = await fetch(`${API_BASE_URL}/projects/${slug}`, {
next: { revalidate: 300 }, // ISR: 5 minutes
});
if (!res.ok) return null;
return normalizeProjectDetail(await res.json());
} catch {
return null;
}
}
export async function fetchProjects(params) {
const query = new URLSearchParams({
page: String(params.page ?? 1),
limit: String(params.limit ?? 100),
});
// ...
const res = await fetch(`${API_BASE_URL}/projects?${query}`, {
next: { revalidate: 3600 }, // ISR: 1 hour
});
return res.json();
}
```
**Key: Normalization**
- Backend returns thin projections
- Normalize on frontend to ensure UI never crashes on missing fields
- Handles both string arrays and object arrays for flexible data shapes
### Other Domain APIs
- `listings-api.ts` - Property listings
- `khu-cong-nghiep-server.ts` - Industrial parks
- `agents-api.ts` - Agent profiles
- `inquiries-api.ts` - Inquiries
- `leads-api.ts` - Leads
- `auth-api.ts` - Authentication
- `admin-api.ts` - Admin endpoints
---
## π 7. Prisma Schema: ProjectDevelopment Model
### Model Definition
```prisma
enum ProjectDevelopmentStatus {
PLANNING
UNDER_CONSTRUCTION
COMPLETED
HANDOVER
}
model ProjectDevelopment {
id String @id @default(cuid())
name String
slug String @unique
developer String # Developer name (company)
developerLogo String?
totalUnits Int
completedUnits Int @default(0)
status ProjectDevelopmentStatus @default(PLANNING)
startDate DateTime?
completionDate DateTime?
description String? @db.Text
amenities Json? # Array of amenities
masterPlanUrl String?
location Unsupported("geometry(Point, 4326)") # PostGIS Point
address String
ward String
district String
city String
minPrice BigInt?
maxPrice BigInt?
pricePerM2Range Json?
totalArea Float?
buildingCount Int?
floorCount Int?
unitTypes Json? # Property types
media Json? # Images, videos, documents
documents Json?
tags String[]
suitableFor String[] @default([])
whyThisLocation String? @db.Text
isVerified Boolean @default(false)
ownerId String? # DEVELOPER user who owns this
createdAt DateTime @default(now())
updatedAt DateTime @updatedAt
properties Property[]
owner User? @relation("ProjectOwner", fields: [ownerId], references: [id], onDelete: SetNull)
@@index([status])
@@index([district, city])
@@index([location], type: Gist) # PostGIS spatial index
@@index([ownerId])
@@index([createdAt])
}
```
### Related Models
- **User** (role: DEVELOPER) β owns projects via `ownedProjects` relation
- **Property** β listings within a project
- **IndustrialPark** β separate model for khu-cong-nghiep
### Key Fields
- **location**: PostGIS `geometry(Point, 4326)` for geographic queries
- **media/documents**: Flexible JSON arrays
- **amenities**: Array of amenity objects or strings
- **tags/suitableFor**: String arrays for filtering
---
## π£οΈ 8. Existing du-an Routes
### Public Routes
- `/du-an` - Browse all projects (with filters, search, map view)
- `/du-an/[slug]` - Project detail page
### Dashboard Routes (DEVELOPER role)
- `/projects` - My projects list with CRUD
- `/projects/new` - Create new project
- `/projects/[id]/edit` - Edit project details
### API Routes
- `GET /projects` - Search all projects (public)
- `GET /projects/mine/list` - Get current developer's projects (DEVELOPER/ADMIN only)
- `GET /projects/[slug]` - Get project detail
- `GET /projects/[id]/listings` - Get listings linked to project
- `GET /projects/[id]/stats` - Project stats (admin + owner only)
- `POST /projects` - Create project (DEVELOPER/ADMIN)
- `PATCH /projects/[id]` - Update project
- `DELETE /projects/[id]` - Delete project
- `POST /projects/[id]/inquiries` - Submit project inquiry
### Feature Flag
- `use-residential-projects-flag.ts` - Feature flag for enabling/disabling du-an module
- Check: `useResidentialProjectsFlag()` in client, `isResidentialProjectsEnabledServer()` on server
---
## π Summary Checklist for Building New Pages
Before building new pages, ensure you:
β
**1. Choose the right route group** (`(public)`, `(dashboard)`, `(admin)`)
β
**2. Use Server Components by default**, wrap with Client Component for interactivity
β
**3. For detail pages**: Use `generateMetadata()` + `generateStaticParams()` for SEO
β
**4. Data fetching**:
- Server components: `fetch()` directly or use `du-an-server.ts` functions
- Client components: Use React Query hooks (`useProjectsSearch`, etc.)
β
**5. Use shadcn/ui components** from `components/ui/`
β
**6. Styling**: Tailwind classes + `cn()` utility from `lib/utils`
β
**7. Colors**: Use design token variables (primary, secondary, muted, etc.)
β
**8. Images**: Use `next/image` with `fill` for responsive layouts
β
**9. Links**: Use `Link` from `@/i18n/navigation` for i18n support
β
**10. Maps**: Use Mapbox via `project-map.tsx` component (check token env var)
β
**11. Authentication**: Check role via `useAuthStore().user?.role` in client components
β
**12. Error handling**: Return `notFound()` in pages, error boundaries in components
β
**13. Loading states**: Skeleton loaders, loading.tsx file, suspense boundaries
β
**14. Tests**: Mirror structure with `__tests__/` folder, name `.spec.tsx`