Files
goodgo-platform/docs/explorations/NEXTJS_QUICK_REFERENCE.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

405 lines
10 KiB
Markdown

# Next.js Frontend - Quick Reference
## 🗂️ File Organization
```
apps/web/
├── app/[locale]/ # Pages
│ ├── (public)/du-an/ # Public project browsing
│ ├── (dashboard)/projects/ # Developer project management
│ └── layout.tsx # Root layout
├── components/ # UI components
│ ├── ui/ # Primitives (button, card, input...)
│ ├── du-an/ # Project-specific components
│ └── map/ # Mapbox integrations
├── lib/
│ ├── api-client.ts # Fetch wrapper (CSRF, refresh)
│ ├── du-an-api.ts # Project API methods
│ ├── du-an-server.ts # Server-side fetch for metadata
│ ├── hooks/use-du-an.ts # React Query hooks
│ └── utils.ts # Helpers (cn, formatPrice, etc.)
└── tailwind.config.ts # Design tokens
```
---
## 🚀 Common Patterns
### 1. Public Browse Page with Filters & Map
```tsx
// pages/du-an/page.tsx
'use client';
export default function DuAnPage() {
const [filters, setFilters] = useState<SearchProjectsParams>({ page: 1 });
const [viewMode, setViewMode] = useState<'grid' | 'list' | 'map'>('grid');
const { data, isLoading } = useProjectsSearch(filters);
return (
<div className="mx-auto max-w-7xl px-4 py-6">
<ProjectFilterBar filters={filters} onFilterChange={setFilters} />
{viewMode === 'map' ? (
<ProjectMap projects={data?.data || []} />
) : viewMode === 'list' ? (
data?.data.map(p => <ProjectListItem key={p.id} project={p} />)
) : (
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
{data?.data.map(p => <ProjectCard key={p.id} project={p} />)}
</div>
)}
</div>
);
}
```
### 2. Detail Page with Server-Side Rendering
```tsx
// pages/du-an/[slug]/page.tsx
import type { Metadata } from 'next';
export async function generateMetadata({ params }): Promise<Metadata> {
const { slug } = await params;
const project = await fetchProjectBySlug(slug);
if (!project) return { title: 'Not found' };
return {
title: `${project.name}${project.developer.name}`,
description: project.description?.slice(0, 160),
};
}
export default async function Page({ params }) {
const { slug } = await params;
const project = await fetchProjectBySlug(slug);
if (!project) notFound();
return <DuAnDetailClient project={project} />;
}
```
### 3. Admin CRUD Page
```tsx
// pages/projects/page.tsx
'use client';
export default function ProjectsPage() {
const isDeveloper = useAuthStore(s => s.user?.role === 'DEVELOPER');
const { data } = useQuery({
queryKey: ['projects', isDeveloper],
queryFn: () => isDeveloper ? duAnApi.searchMine() : duAnApi.search(),
});
const deleteMutation = useMutation({
mutationFn: duAnApi.delete,
onSuccess: () => queryClient.invalidateQueries({ queryKey: ['projects'] }),
});
return (
<div>
<Link href="/projects/new">
<Button><Plus /> Thêm dự án</Button>
</Link>
{data?.data.map(p => (
<div key={p.id}>
<h3>{p.name}</h3>
<Link href={`/projects/${p.id}/edit`}><Pencil /></Link>
<Button onClick={() => deleteMutation.mutate(p.id)}>
<Trash2 />
</Button>
</div>
))}
</div>
);
}
```
---
## 📚 Key Files to Reference
| Task | File | Notes |
|------|------|-------|
| Browse projects | `app/[locale]/(public)/du-an/page.tsx` | Search + filter + map |
| Project detail | `app/[locale]/(public)/du-an/[slug]/page.tsx` | Server-side + tabs |
| Manage projects | `app/[locale]/(dashboard)/projects/page.tsx` | CRUD + React Query |
| Project card UI | `components/du-an/project-card.tsx` | Reusable component |
| Map integration | `components/du-an/project-map.tsx` | Mapbox with markers |
| Filter bar | `components/du-an/project-filter-bar.tsx` | Search + select filters |
| API methods | `lib/du-an-api.ts` | All endpoints |
| React Query hooks | `lib/hooks/use-du-an.ts` | Prefilled query keys |
| Server fetch | `lib/du-an-server.ts` | For metadata + static generation |
| Fetch wrapper | `lib/api-client.ts` | CSRF + auto-refresh |
| UI components | `components/ui/` | Button, Card, Input, Badge, etc. |
| Styling | `tailwind.config.ts` | Design tokens (colors, spacing, etc.) |
---
## 🔌 API Integration Checklist
✅ Define types in `du-an-api.ts`
```tsx
interface ProjectSummary { id, name, slug, ... }
interface ProjectDetail extends ProjectSummary { media, amenities, ... }
```
✅ Create API methods in `du-an-api.ts`
```tsx
export const duAnApi = {
search: (params) => apiClient.get<PaginatedResult>(`/projects?...`),
getBySlug: (slug) => apiClient.get<ProjectDetail>(`/projects/${slug}`),
};
```
✅ Create React Query hooks in `lib/hooks/use-du-an.ts`
```tsx
export function useProjectsSearch(params) {
return useQuery({
queryKey: projectKeys.search(params),
queryFn: () => duAnApi.search(params),
});
}
```
✅ Use in client components
```tsx
'use client';
const { data, isLoading } = useProjectsSearch(filters);
```
✅ Server-side fetching for metadata
```tsx
import { fetchProjectBySlug } from '@/lib/du-an-server';
export async function generateMetadata({ params }) {
const project = await fetchProjectBySlug(slug);
return { title: project.name };
}
```
---
## 🎨 UI & Styling Checklist
✅ Use shadcn/ui components
```tsx
<Button variant="outline" size="sm">Click me</Button>
<Card><CardHeader><CardTitle>Title</CardTitle></CardHeader></Card>
<Input placeholder="Search..." />
```
✅ Tailwind classes for layout
```tsx
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 mx-auto max-w-7xl px-4">
```
✅ Design tokens for colors
```tsx
className="text-primary" // primary color
className="bg-muted text-muted-foreground" // muted background
className="border border-border" // borders
```
✅ Icons from lucide-react
```tsx
import { Building2, MapPin, Plus, Trash2 } from 'lucide-react';
<MapPin className="h-4 w-4" />
```
✅ Images with next/image
```tsx
<Image
src={project.thumbnailUrl}
alt={project.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 33vw"
/>
```
✅ Responsive utilities
```tsx
className="grid sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4"
className="text-sm md:text-base lg:text-lg"
className="px-4 sm:px-6 lg:px-8"
```
---
## 🗺️ Mapbox Setup
```tsx
// .env.local
NEXT_PUBLIC_MAPBOX_TOKEN=pk_...
// Import in component
'use client';
import mapboxgl from 'mapbox-gl';
import 'mapbox-gl/dist/mapbox-gl.css';
// Use existing component
import { ProjectMap } from '@/components/du-an/project-map';
<ProjectMap projects={projects} />
```
---
## 🔐 Authentication
```tsx
// Check role in client component
'use client';
import { useAuthStore } from '@/lib/auth-store';
const role = useAuthStore(s => s.user?.role);
if (role !== 'DEVELOPER') return <NotAuthorized />;
```
---
## 📊 React Query Patterns
```tsx
// Query
const { data, isLoading, isError } = useQuery({
queryKey: ['projects', filters],
queryFn: () => duAnApi.search(filters),
staleTime: 30_000,
});
// Mutation
const mutation = useMutation({
mutationFn: (projectId) => duAnApi.delete(projectId),
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['projects'] });
toast.success('Deleted!');
},
onError: (error) => {
toast.error(error.message);
},
});
mutation.mutate(projectId);
```
---
## 🧪 Testing
```tsx
// Mirror the directory structure
components/du-an/project-card.tsx
components/du-an/__tests__/project-card.spec.tsx
// Test file
import { ProjectCard } from '../project-card';
import { render, screen } from '@testing-library/react';
describe('ProjectCard', () => {
it('renders project name', () => {
render(<ProjectCard project={mockProject} />);
expect(screen.getByText(mockProject.name)).toBeInTheDocument();
});
});
```
---
## 🚨 Common Mistakes to Avoid
**Don't** use `<a>` tags → use `Link` from `@/i18n/navigation`
```tsx
// ❌ Wrong
<a href="/du-an/123">Project</a>
// ✅ Right
import { Link } from '@/i18n/navigation';
<Link href="/du-an/123">Project</Link>
```
**Don't** fetch API inside `generateMetadata` without normalization
```tsx
// ❌ Wrong - might crash if fields missing
const project = await fetch(...);
return { title: project.name }; // Error if project is null
// ✅ Right - normalize first
const project = normalizeProjectDetail(raw);
if (!project) return { title: 'Not found' };
return { title: project.name };
```
**Don't** mix Server Components and Client Components incorrectly
```tsx
// ❌ Wrong - can't use hooks in server component
export default async function Page() {
const data = useQuery(...); // ERROR!
}
// ✅ Right - server fetches, client interacts
export default async function Page() {
const data = await fetch(...);
return <ClientComponent data={data} />;
}
```
**Don't** forget to add `enabled` to conditional queries
```tsx
// ❌ Wrong - fires even when slug is empty
useQuery({
queryFn: () => duAnApi.getBySlug(slug),
});
// ✅ Right
useQuery({
queryFn: () => duAnApi.getBySlug(slug),
enabled: !!slug,
});
```
---
## 💡 Pro Tips
1. **Use `cn()` for conditional classes**
```tsx
className={cn(
'default-class',
isActive && 'active-class',
variant === 'outline' && 'outline-class'
)}
```
2. **Dynamic imports for heavy components**
```tsx
const ProjectMap = dynamic(
() => import('@/components/du-an/project-map').then(m => m.ProjectMap),
{ ssr: false }
);
```
3. **Format numbers using `formatPrice()` utility**
```tsx
import { formatPrice } from '@/lib/currency';
<p>{formatPrice('1000000')}</p> // "1.000.000 ₫"
```
4. **Use query keys factory pattern for invalidation**
```tsx
export const projectKeys = {
all: ['projects'] as const,
search: (params) => ['projects', 'search', params] as const,
};
// Then invalidate specific queries
queryClient.invalidateQueries({
queryKey: projectKeys.search(oldFilters)
});
```
5. **Leverage ISR for static pages**
```tsx
export const revalidate = 3600; // Revalidate every hour
```