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

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

  1. Use cn() for conditional classes

    className={cn(
      'default-class',
      isActive && 'active-class',
      variant === 'outline' && 'outline-class'
    )}
    
  2. Dynamic imports for heavy components

    const ProjectMap = dynamic(
      () => import('@/components/du-an/project-map').then(m => m.ProjectMap),
      { ssr: false }
    );
    
  3. Format numbers using formatPrice() utility

    import { formatPrice } from '@/lib/currency';
    <p>{formatPrice('1000000')}</p> // "1.000.000 ₫"
    
  4. 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) 
    });
    
  5. Leverage ISR for static pages

    export const revalidate = 3600; // Revalidate every hour