# 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'; {project.name} ``` --- ## πŸ—ΊοΈ 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`