- 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>
10 KiB
10 KiB
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
// 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
// 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
// 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
interface ProjectSummary { id, name, slug, ... }
interface ProjectDetail extends ProjectSummary { media, amenities, ... }
✅ Create API methods in du-an-api.ts
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
export function useProjectsSearch(params) {
return useQuery({
queryKey: projectKeys.search(params),
queryFn: () => duAnApi.search(params),
});
}
✅ Use in client components
'use client';
const { data, isLoading } = useProjectsSearch(filters);
✅ Server-side fetching for metadata
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
<Button variant="outline" size="sm">Click me</Button>
<Card><CardHeader><CardTitle>Title</CardTitle></CardHeader></Card>
<Input placeholder="Search..." />
✅ Tailwind classes for layout
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3 mx-auto max-w-7xl px-4">
✅ Design tokens for colors
className="text-primary" // primary color
className="bg-muted text-muted-foreground" // muted background
className="border border-border" // borders
✅ Icons from lucide-react
import { Building2, MapPin, Plus, Trash2 } from 'lucide-react';
<MapPin className="h-4 w-4" />
✅ Images with next/image
<Image
src={project.thumbnailUrl}
alt={project.name}
fill
className="object-cover"
sizes="(max-width: 768px) 100vw, 33vw"
/>
✅ Responsive utilities
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
// .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
// 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
// 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
// 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
// ❌ 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
// ❌ 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
// ❌ 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
// ❌ Wrong - fires even when slug is empty
useQuery({
queryFn: () => duAnApi.getBySlug(slug),
});
// ✅ Right
useQuery({
queryFn: () => duAnApi.getBySlug(slug),
enabled: !!slug,
});
💡 Pro Tips
-
Use
cn()for conditional classesclassName={cn( 'default-class', isActive && 'active-class', variant === 'outline' && 'outline-class' )} -
Dynamic imports for heavy components
const ProjectMap = dynamic( () => import('@/components/du-an/project-map').then(m => m.ProjectMap), { ssr: false } ); -
Format numbers using
formatPrice()utilityimport { formatPrice } from '@/lib/currency'; <p>{formatPrice('1000000')}</p> // "1.000.000 ₫" -
Use query keys factory pattern for invalidation
export const projectKeys = { all: ['projects'] as const, search: (params) => ['projects', 'search', params] as const, }; // Then invalidate specific queries queryClient.invalidateQueries({ queryKey: projectKeys.search(oldFilters) }); -
Leverage ISR for static pages
export const revalidate = 3600; // Revalidate every hour