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>
This commit is contained in:
591
docs/explorations/NEXTJS_FRONTEND_STRUCTURE.md
Normal file
591
docs/explorations/NEXTJS_FRONTEND_STRUCTURE.md
Normal file
@@ -0,0 +1,591 @@
|
||||
# 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`
|
||||
Reference in New Issue
Block a user