Files
goodgo-platform/docs/explorations/NEXTJS_FRONTEND_STRUCTURE.md
Ho Ngoc Hai 08b96f9c2d docs: consolidate exploration & audit reports under docs/ (TEC-3094)
- 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>
2026-04-21 16:29:24 +07:00

592 lines
18 KiB
Markdown

# 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 <DuAnDetailClient project={project} />; // 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 <DetailClient project={project} />;
}
```
### 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 (
<Card className="group hover:shadow-lg transition-shadow">
{/* Content */}
</Card>
);
}
```
**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';
<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_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<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 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: <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)
```tsx
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:
```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<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 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`