Move 36 root-level audit/analysis documents and 7 web app audit documents into docs/audits/ directory to declutter the project root. Remove stale EXPLORATION_SUMMARY.txt. Co-Authored-By: Paperclip <noreply@paperclip.ing>
13 KiB
13 KiB
Property Detail Page - Quick Reference & Code Snippets
🎯 Quick Navigation
Page Routes
- Detail Page:
apps/web/app/[locale]/(public)/listings/[id]/page.tsx - Client Component:
apps/web/components/listings/listing-detail-client.tsx - Gallery Component:
apps/web/components/listings/image-gallery.tsx - Upload Component:
apps/web/components/listings/image-upload.tsx - Property Card:
apps/web/components/search/property-card.tsx
Data Flow
URL: /vi/listings/abc123
↓
[id]/page.tsx (Server)
├─ fetchListingById('abc123')
├─ generateMetadata()
└─ <ListingDetailClient listing={data} />
└─ <ImageGallery media={property.media} />
🖼️ Working with Images
Current Gallery Features
// apps/web/components/listings/image-gallery.tsx
interface ImageGalleryProps {
media: PropertyMedia[];
className?: string;
}
// Features:
// ✓ Main image (16:9 aspect ratio)
// ✓ Previous/Next buttons
// ✓ Image counter badge
// ✓ Horizontal scrollable thumbnails (64x64px)
// ✓ Selected state highlighting
// ✓ Empty state fallback
Data Structure
interface PropertyMedia {
id: string;
url: string; // Full URL to image
type: 'image' | 'video'; // Media type filter
order: number; // Sort order (0, 1, 2...)
caption: string | null; // Optional caption
}
interface Property {
// ... other fields
media: PropertyMedia[]; // Array of images/videos
}
Using Images in Components
// ✓ Import Next.js Image
import Image from 'next/image';
// ✓ Main image with fill layout (responsive)
<Image
src={images[index]?.url}
alt={`Image ${index + 1}`}
fill
sizes="(max-width: 768px) 100vw, 60vw"
className="object-cover"
priority={index === 0}
/>
// ✓ Fixed-size thumbnail
<Image
src={img.url}
alt={`Thumbnail ${i}`}
fill
sizes="64px"
className="object-cover"
/>
// ✓ Responsive property card
<Image
src={media[0]?.url}
alt="Property"
fill
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
className="object-cover transition-transform group-hover:scale-105"
/>
Image Upload API
// From: apps/web/lib/listings-api.ts
const listingsApi = {
uploadMedia: async (
listingId: string,
file: File,
caption?: string
) => {
const formData = new FormData();
formData.append('file', file);
if (caption) formData.append('caption', caption);
// POST /api/v1/listings/{listingId}/media
// Returns: { mediaId: string; url: string }
},
};
File Upload Component (for reference)
// apps/web/components/listings/image-upload.tsx
interface ImageFile {
file: File;
preview: string;
}
// Usage:
<ImageUpload
images={images}
onChange={(newImages) => setImages(newImages)}
maxFiles={20}
/>
// Validation:
// ✓ Types: JPEG, PNG, WebP
// ✓ Max size: 10MB per file
// ✓ Max count: 20 files
// ✓ Drag & drop support
// ✓ Preview grid with delete button
🎨 Styling Patterns
Aspect Ratios (Tailwind)
<!-- 16:9 (videos, main images) -->
<div class="aspect-video"><!-- 1.777:1 --></div>
<!-- 4:3 (property cards) -->
<div class="aspect-[4/3]"><!-- 1.333:1 --></div>
<!-- 16:10 (compact property cards) -->
<div class="aspect-[16/10]"><!-- 1.6:1 --></div>
<!-- Square (thumbnails) -->
<div class="aspect-square"><!-- 1:1 --></div>
Image Container Pattern
<div className="relative aspect-video overflow-hidden rounded-lg bg-muted">
<Image
src={url}
alt="description"
fill
sizes="(max-width: 768px) 100vw, 60vw"
className="object-cover"
priority={isMainImage}
/>
</div>
Common Tailwind Classes
// Layout
aspect-video # 16:9 ratio
aspect-square # 1:1 ratio
relative / absolute # Positioning
fill # Object-fit with aspect ratio
// Styling
object-cover # Image fit (crop to fill)
object-contain # Image fit (preserve ratio)
rounded-lg # Border radius
bg-muted # Placeholder background
// Interactive
transition-colors # Smooth color changes
group-hover:scale-105 # Hover effect
opacity-70 # Partial transparency
// Overlay
absolute inset-0 # Full coverage overlay
bg-black/50 # Semi-transparent black
hover:bg-black/70 # Darker on hover
🔄 State Management
Zustand Store Pattern
import { create } from 'zustand';
import { persist } from 'zustand/middleware';
// Simple store
const useMyStore = create<MyState>((set, get) => ({
count: 0,
increment: () => set((state) => ({ count: state.count + 1 })),
asyncAction: async () => {
set({ isLoading: true });
// ... async work
set({ data: result, isLoading: false });
},
}));
// Store with persistence
const useComparisonStore = create<State>()(
persist(
(set, get) => ({
// store logic
}),
{
name: 'storage-key',
partialize: (state) => ({ selectedIds: state.selectedIds }),
}
)
);
// Usage in component
const count = useMyStore((state) => state.count);
const increment = useMyStore((state) => state.increment);
Image Gallery Local State
const [selectedIndex, setSelectedIndex] = React.useState(0);
const handleNext = () => {
setSelectedIndex((i) => (i < images.length - 1 ? i + 1 : 0));
};
const handlePrev = () => {
setSelectedIndex((i) => (i > 0 ? i - 1 : images.length - 1));
};
🧩 UI Component Patterns
Button Component (with variants)
// From: apps/web/components/ui/button.tsx
import { Button } from '@/components/ui/button';
// Variants:
<Button variant="default">Default</Button> // Primary
<Button variant="outline">Outline</Button> // Border
<Button variant="secondary">Secondary</Button> // Secondary color
<Button variant="destructive">Delete</Button> // Red
<Button variant="ghost">Ghost</Button> // No background
<Button variant="link">Link</Button> // Text link
// Sizes:
<Button size="default">Default</Button> // 40px height
<Button size="sm">Small</Button> // 36px height
<Button size="lg">Large</Button> // 44px height
<Button size="icon">Icon</Button> // Square button
Badge Component (with variants)
import { Badge } from '@/components/ui/badge';
<Badge variant="default">Primary</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="info">Info</Badge>
Card Component
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
<Card>
<CardHeader>
<CardTitle>Title</CardTitle>
</CardHeader>
<CardContent>
{/* content */}
</CardContent>
</Card>
Dialog/Modal Pattern
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
const [isOpen, setIsOpen] = React.useState(false);
<Dialog open={isOpen} onOpenChange={setIsOpen}>
<DialogContent>
<DialogHeader>
<DialogTitle>Dialog Title</DialogTitle>
</DialogHeader>
{/* content */}
</DialogContent>
</Dialog>
📱 Responsive Design
Breakpoints
xs: 0px /* Default */
sm: 640px /* Mobile landscape */
md: 768px /* Tablet */
lg: 1024px /* Desktop */
xl: 1280px /* Wide desktop */
2xl: 1536px /* Ultra-wide */
Common Patterns
// Mobile-first
<div className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4">
// Conditional display
<div className="hidden md:block">Show on tablet+</div>
<div className="block md:hidden">Show on mobile</div>
// Responsive grid
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
// Responsive text size
<p className="text-sm md:text-base lg:text-lg">
// Responsive padding
<div className="p-4 sm:p-6 md:p-8">
🔗 Common Imports
Essential Imports
// Components
import Image from 'next/image';
import Link from 'next/link';
import dynamic from 'next/dynamic';
// UI Components
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
// Utilities
import { cn } from '@/lib/utils';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
// State & API
import { useAuthStore } from '@/lib/auth-store';
import { useComparisonStore } from '@/lib/comparison-store';
import { listingsApi } from '@/lib/listings-api';
// Hooks
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';
📊 Data Fetching
Server-side Fetching
// apps/web/lib/listings-server.ts
import { fetchListingById } from '@/lib/listings-server';
// In page.tsx (Server Component)
const listing = await fetchListingById(params.id);
if (!listing) notFound();
Client-side API
// apps/web/lib/listings-api.ts
import { listingsApi } from '@/lib/listings-api';
// Usage:
const listing = await listingsApi.getById(id);
const results = await listingsApi.search({ city: 'Ho Chi Minh' });
React Query Usage (likely)
// Typical pattern for fetching
import { useQuery } from '@tanstack/react-query';
const { data, isLoading, error } = useQuery({
queryKey: ['listing', id],
queryFn: () => listingsApi.getById(id),
});
🌐 Internationalization
Language Support
- Vietnamese (vi)
- English (en)
Using i18n
// In components, use Vietnamese labels directly or from constants
const PROPERTY_TYPES: Record<string, string> = {
APARTMENT: 'Căn hộ',
HOUSE: 'Nhà riêng',
VILLA: 'Biệt thự',
// ...
};
// From @/lib/validations/listings
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
Language-aware Routes
/vi/listings/123 # Vietnamese
/en/listings/123 # English
🔐 Security Features
CSP Headers (next.config.js)
img-src 'self' data: blob: https://*.mapbox.com https://
font-src 'self' data:
Image Domain Whitelist
// Allows HTTPS images from any domain
remotePatterns: [
{ protocol: 'https', hostname: '**' }
]
🧪 Testing Considerations
Component Files to Test
image-gallery.tsx- Gallery navigation, state changesimage-upload.tsx- File validation, drag-dropproperty-card.tsx- Image display, responsivelisting-detail-client.tsx- Overall page functionality
Test Patterns
// Mock Next.js Image component
jest.mock('next/image', () => ({
__esModule: true,
default: (props) => <img {...props} />,
}));
// Mock Zustand stores
jest.mock('@/lib/auth-store', () => ({
useAuthStore: jest.fn(),
}));
🚀 Performance Optimization Tips
-
Image Priority
priority={selectedIndex === 0} // First image loads with page -
Responsive Sizes
sizes="(max-width: 768px) 100vw, 60vw" // Tells browser image width -
Lazy Loading
- Thumbnails load on demand (no priority set)
- Reduces initial page weight
-
Dynamic Imports
const ListingMap = dynamic(() => import('@/components/map/listing-map'), { ssr: false, loading: () => <div>Loading...</div>, }); -
Object URLs Cleanup
React.useEffect(() => { return () => { images.forEach((img) => URL.revokeObjectURL(img.preview)); }; }, []);
📋 Common Tasks
Add a New UI Element
- Create in
components/ui/ComponentName.tsx - Use CVA for variants
- Export from the same file
- Import and use in feature components
Add a New Feature Component
- Create in
components/feature-name/ComponentName.tsx - Make 'use client' if interactive
- Import UI components
- Use Zustand stores if needed global state
- Use local state for UI state
Modify Image Gallery
- Edit
components/listings/image-gallery.tsx - Update PropertyMedia interface if needed (in
lib/listings-api.ts) - Adjust aspect ratio / sizes as needed
- Test responsive behavior
Add Image Lightbox
- Choose library (embla-carousel, yet-another-react-lightbox, etc.)
- Install:
pnpm add package-name -F @goodgo/web - Create wrapper component in
components/listings/image-lightbox.tsx - Integrate with
image-gallery.tsx - Test with multiple images
🐛 Common Issues & Solutions
Image Not Loading
- Check URL is valid and HTTPS
- Verify domain in
remotePatterns - Check CSP headers allow the domain
Gallery Navigation Frozen
- Check
selectedIndexstate updates - Verify onClick handlers are properly bound
- Check for JavaScript errors in console
Thumbnail Scroll Issues
- Ensure parent container has
overflow-x-auto - Check flex properties on thumbnails
- Verify width constraints (flex-shrink-0)
Layout Shifting on Image Load
- Use aspect ratio container
- Set explicit width/height
- Use
filllayout with container
📚 Additional Resources
- Next.js Image: https://nextjs.org/docs/app/api-reference/components/image
- Tailwind CSS: https://tailwindcss.com/docs
- Zustand: https://github.com/pmndrs/zustand
- CVA: https://cva.style/docs
- React Query: https://tanstack.com/query/latest