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

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_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

'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):

// 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 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

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])
}
  • 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