Files
goodgo-platform/docs/audits/PROPERTY_DETAIL_QUICK_REFERENCE.md
Ho Ngoc Hai b8512ebff4 docs: consolidate audit and analysis reports into docs/audits/
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>
2026-04-11 01:37:50 +07:00

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

// 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);
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 changes
  • image-upload.tsx - File validation, drag-drop
  • property-card.tsx - Image display, responsive
  • listing-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

  1. Image Priority

    priority={selectedIndex === 0}  // First image loads with page
    
  2. Responsive Sizes

    sizes="(max-width: 768px) 100vw, 60vw"  // Tells browser image width
    
  3. Lazy Loading

    • Thumbnails load on demand (no priority set)
    • Reduces initial page weight
  4. Dynamic Imports

    const ListingMap = dynamic(() => import('@/components/map/listing-map'), {
      ssr: false,
      loading: () => <div>Loading...</div>,
    });
    
  5. Object URLs Cleanup

    React.useEffect(() => {
      return () => {
        images.forEach((img) => URL.revokeObjectURL(img.preview));
      };
    }, []);
    

📋 Common Tasks

Add a New UI Element

  1. Create in components/ui/ComponentName.tsx
  2. Use CVA for variants
  3. Export from the same file
  4. Import and use in feature components

Add a New Feature Component

  1. Create in components/feature-name/ComponentName.tsx
  2. Make 'use client' if interactive
  3. Import UI components
  4. Use Zustand stores if needed global state
  5. Use local state for UI state
  1. Edit components/listings/image-gallery.tsx
  2. Update PropertyMedia interface if needed (in lib/listings-api.ts)
  3. Adjust aspect ratio / sizes as needed
  4. Test responsive behavior

Add Image Lightbox

  1. Choose library (embla-carousel, yet-another-react-lightbox, etc.)
  2. Install: pnpm add package-name -F @goodgo/web
  3. Create wrapper component in components/listings/image-lightbox.tsx
  4. Integrate with image-gallery.tsx
  5. 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
  • Check selectedIndex state 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 fill layout with container

📚 Additional Resources