- 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>
592 lines
18 KiB
Markdown
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`
|