- 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>
405 lines
10 KiB
Markdown
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
|
|
```
|