- Move 8 stray .md (+5 .txt) from ~/Desktop into docs/explorations/from-desktop/ - Reorganize 27 .md/.txt at workspace root: - audit reports -> docs/audits/ - exploration reports -> docs/explorations/ - design system -> docs/design-system/ - Keep only README/CHANGELOG/CONTRIBUTING/CLAUDE at repo root - Refresh docs/README.md as canonical index with links to all groups - Note: pre-existing docs/audits/AUDIT_INDEX.md and AUDIT_SUMMARY.md were overwritten by the newer root-level versions during the move Co-Authored-By: Paperclip <noreply@paperclip.ing>
18 KiB
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:
// Page: Server Component
export async function generateMetadata() { ... } // SEO metadata
export default async function DuAnDetailPage({ params }) { ... }
// Detail fetching
const project = await fetchProjectBySlug(slug);
return <DuAnDetailClient project={project} />; // Pass to client
Client Component handles state & interactivity:
'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
'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:
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 <DetailClient project={project} />;
}
Pattern D: Dynamic Routes with API Integration
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):
'use client'; // if interactive
interface ProjectCardProps {
project: ProjectSummary;
}
export function ProjectCard({ project }: ProjectCardProps) {
return (
<Card className="group hover:shadow-lg transition-shadow">
{/* Content */}
</Card>
);
}
With Query Hooks (data fetching):
'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):
import Image from 'next/image';
<Image
src={project.thumbnailUrl}
alt={project.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 50vw"
/>
🗺️ 4. Mapbox Integration
Setup
- Token:
NEXT_PUBLIC_MAPBOX_TOKENenvironment 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
'use client';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
export function ProjectMap({ projects }: ProjectMapProps) {
const mapContainerRef = React.useRef<HTMLDivElement>(null);
const mapRef = React.useRef<mapboxgl.Map | null>(null);
const markersRef = React.useRef<mapboxgl.Marker[]>([]);
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 <div ref={mapContainerRef} className="h-full w-full" />;
}
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 mapneighborhood-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):
// 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:
spacing: {
cell: '0.5rem',
row: '2.25rem',
sidebar: '15rem',
'sidebar-collapsed': '3.5rem',
}
Typography:
fontSize: {
ticker: '0.8125rem',
'heading-sm': '0.875rem',
'heading-md': '1.125rem',
'heading-lg': '1.5rem',
'heading-xl': '1.875rem',
}
Shadows:
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:
cn('rounded-lg', 'border', 'p-4', isHovered && 'shadow-lg')
Dark Mode
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)
export const apiClient = {
get: <T>(endpoint: string, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'GET', headers }),
post: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'POST', body, headers }),
patch: <T>(endpoint: string, body?: unknown, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'PATCH', body, headers }),
delete: <T>(endpoint: string, headers?: HeadersInit) =>
request<T>(endpoint, { method: 'DELETE', headers }),
};
Domain APIs
File: apps/web/lib/du-an-api.ts (Projects API)
export const duAnApi = {
search: (params: SearchProjectsParams) =>
apiClient.get<PaginatedResult<ProjectSummary>>(`/projects?...`),
searchMine: (params) => // DEVELOPER/ADMIN only
apiClient.get<PaginatedResult<ProjectSummary>>(`/projects/mine/list?...`),
getBySlug: (slug: string) =>
apiClient.get<ProjectDetail>(`/projects/${slug}`),
getLinkedListings: (projectId, params) =>
apiClient.get<PaginatedResult<ListingDetail>>(`/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<ProjectDetail>(`/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:
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):
export async function fetchProjectBySlug(slug: string): Promise<ProjectDetail | null> {
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 listingskhu-cong-nghiep-server.ts- Industrial parksagents-api.ts- Agent profilesinquiries-api.ts- Inquiriesleads-api.ts- Leadsauth-api.ts- Authenticationadmin-api.ts- Admin endpoints
📊 7. Prisma Schema: ProjectDevelopment Model
Model Definition
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
ownedProjectsrelation - 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 detailGET /projects/[id]/listings- Get listings linked to projectGET /projects/[id]/stats- Project stats (admin + owner only)POST /projects- Create project (DEVELOPER/ADMIN)PATCH /projects/[id]- Update projectDELETE /projects/[id]- Delete projectPOST /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 usedu-an-server.tsfunctions - Client components: Use React Query hooks (
useProjectsSearch, etc.) ✅ 5. Use shadcn/ui components fromcomponents/ui/✅ 6. Styling: Tailwind classes +cn()utility fromlib/utils✅ 7. Colors: Use design token variables (primary, secondary, muted, etc.) ✅ 8. Images: Usenext/imagewithfillfor responsive layouts ✅ 9. Links: UseLinkfrom@/i18n/navigationfor i18n support ✅ 10. Maps: Use Mapbox viaproject-map.tsxcomponent (check token env var) ✅ 11. Authentication: Check role viauseAuthStore().user?.rolein client components ✅ 12. Error handling: ReturnnotFound()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