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:
404
docs/explorations/NEXTJS_QUICK_REFERENCE.md
Normal file
404
docs/explorations/NEXTJS_QUICK_REFERENCE.md
Normal file
@@ -0,0 +1,404 @@
|
||||
# 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
|
||||
```
|
||||
Reference in New Issue
Block a user