Files
goodgo-platform/docs/audits/PROPERTY_DETAIL_COMPONENTS_MAP.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

14 KiB

Property Detail Page - Component Map & Architecture Diagram

🎯 Page Component Hierarchy

PublicListingDetailPage (Server) [apps/web/app/[locale]/(public)/listings/[id]/page.tsx]
│
├─ JSON-LD Structured Data
│  ├─ JsonLd (Breadcrumb)
│  └─ JsonLd (Listing Schema)
│
└─ ListingDetailClient (Client) [apps/web/components/listings/listing-detail-client.tsx]
   │
   ├─ Breadcrumb Navigation
   │  └─ Link components
   │
   ├─ Header Section
   │  ├─ Title & Description
   │  ├─ Badge (SALE/RENT)
   │  ├─ Badge (Property Type)
   │  ├─ Price Display
   │  └─ AddToCompareButton
   │
   ├─ ImageGallery [MAIN FEATURE]
   │  [apps/web/components/listings/image-gallery.tsx]
   │  ├─ Main Image Display
   │  │  ├─ Previous Button
   │  │  ├─ Image (Next.js Image)
   │  │  ├─ Next Button
   │  │  └─ Counter Badge
   │  │
   │  └─ Thumbnail Navigation
   │     ├─ Thumbnail Item 1
   │     ├─ Thumbnail Item 2
   │     └─ Thumbnail Item N
   │
   ├─ Quick Stats Bar
   │  ├─ QuickStat (Area)
   │  ├─ QuickStat (Bedrooms)
   │  ├─ QuickStat (Bathrooms)
   │  ├─ QuickStat (Floors)
   │  └─ QuickStat (Direction)
   │
   ├─ Main Content (2/3 width - lg:col-span-2)
   │  │
   │  ├─ Description Card
   │  │  ├─ CardHeader
   │  │  └─ CardContent
   │  │
   │  ├─ Details Card
   │  │  ├─ CardHeader
   │  │  ├─ InfoItem (Property Type)
   │  │  ├─ InfoItem (Area)
   │  │  ├─ InfoItem (Bedrooms)
   │  │  ├─ InfoItem (Bathrooms)
   │  │  ├─ InfoItem (Floors)
   │  │  ├─ InfoItem (Direction)
   │  │  ├─ InfoItem (Year Built)
   │  │  ├─ InfoItem (Legal Status)
   │  │  └─ InfoItem (Project)
   │  │
   │  ├─ Amenities Card (conditional)
   │  │  ├─ CardHeader
   │  │  └─ Badge(s) for each amenity
   │  │
   │  └─ Map Card
   │     ├─ CardHeader
   │     └─ ListingMap (dynamic import)
   │
   └─ Sidebar (1/3 width - sticky)
      │
      ├─ Contact Card (sticky)
      │  ├─ Seller Avatar & Name
      │  ├─ Seller Phone
      │  ├─ Call Button
      │  ├─ Message Button
      │  └─ Agent Info (conditional)
      │
      ├─ AiEstimateButton
      │
      └─ Stats Card
         ├─ View Count
         ├─ Save Count
         ├─ Inquiry Count
         └─ Published Date

File: apps/web/components/listings/image-gallery.tsx

ImageGallery (Client Component)
│
├─ Props:
│  ├─ media: PropertyMedia[]
│  └─ className?: string
│
├─ State:
│  └─ selectedIndex: number
│
├─ Layout:
│  │
│  ├─ Main Image Container
│  │  ├─ className: "aspect-video"
│  │  ├─ Image (Next.js)
│  │  ├─ Overlay: Previous Button
│  │  ├─ Overlay: Next Button
│  │  └─ Overlay: Counter Badge
│  │
│  └─ Thumbnail Container (if images.length > 1)
│     ├─ className: "flex gap-2 overflow-x-auto"
│     └─ ThumbnailButton (for each image)
│        ├─ Image (Next.js)
│        ├─ Border: selected ? primary : transparent
│        ├─ Opacity: 70% (unselected)
│        └─ Hover: opacity 100%
│
└─ Handlers:
   ├─ handlePrev()
   ├─ handleNext()
   └─ handleSelectIndex(index)

Data Flow

Property.media (from API)
  ↓
Filter by type === 'image'
  ↓
Sort by order property
  ↓
[selectedIndex, setSelectedIndex] → selectedIndex
  ↓
Image.url at index → Main Display
All images → Thumbnails
selectedIndex → Highlighted Thumbnail

📱 Image Upload Component

File: apps/web/components/listings/image-upload.tsx

ImageUpload (Client Component)
│
├─ Props:
│  ├─ images: ImageFile[]
│  ├─ onChange: (images: ImageFile[]) => void
│  ├─ maxFiles?: number (default: 20)
│  └─ className?: string
│
├─ State:
│  └─ isDragging: boolean
│
├─ Drag & Drop Zone
│  ├─ onDragOver → setIsDragging(true)
│  ├─ onDragLeave → setIsDragging(false)
│  ├─ onDrop → addFiles()
│  └─ onClick → inputRef.click()
│
├─ File Input
│  ├─ accept: "image/jpeg,image/png,image/webp"
│  ├─ multiple: true
│  └─ hidden: true
│
└─ Preview Grid
   └─ For each image:
      ├─ Image preview (URL.createObjectURL)
      ├─ Cover badge (first image)
      ├─ Delete button on hover
      └─ Cleanup on unmount (URL.revokeObjectURL)

Validation:
├─ Allowed types: JPEG, PNG, WebP
├─ Max size: 10MB per file
└─ Max count: 20 files

SearchResults & PropertyCard

SearchResults
│
└─ Grid of PropertyCards
   │
   └─ PropertyCard (for each listing)
      │
      ├─ Link to /listings/{id}
      │
      └─ Card
         ├─ Image Container
         │  ├─ Image (media[0])
         │  ├─ Badge: Transaction Type (overlay)
         │  ├─ Badge: Property Type (overlay)
         │  ├─ AddToCompareButton (overlay)
         │  └─ Badge: Media Count (bottom-right)
         │
         └─ Content
            ├─ Price
            ├─ Title
            ├─ Location
            └─ Badges (Area, Bedrooms, etc.)

🌐 Data Flow & API Mapping

Server to Client Flow

1. Browser Request
   URL: /vi/listings/abc123
   │
   ↓
2. Next.js Route Handler
   [locale]/[id]/page.tsx (Server Component)
   │
   ├─ fetchListingById('abc123')
   │  └─ API: GET /api/v1/listings/abc123
   │     ↓
   │     ListingDetail {
   │       id: string
   │       property: {
   │         media: PropertyMedia[]  ← Images
   │       }
   │       seller: {...}
   │       agent: {...}
   │     }
   │
   ├─ generateMetadata()
   │  └─ Uses property.media[0] for OG image
   │
   ├─ generateJsonLd()
   │  └─ Structured data for SEO
   │
   └─ <ListingDetailClient listing={data} />
      │
      ↓
   3. Client Component (Hydrated)
      │
      ├─ <ImageGallery media={property.media} />
      │  └─ Local state: selectedIndex
      │
      └─ Other interactive components

🎨 Styling Architecture

Tailwind CSS Structure

Root CSS Variables (globals.css)
│
├─ Colors (HSL format)
│  ├─ --primary
│  ├─ --secondary
│  ├─ --background
│  ├─ --foreground
│  ├─ --muted
│  ├─ --accent
│  └─ --card
│
├─ Spacing
│  └─ Uses standard Tailwind scale
│
└─ Radius
   └─ --radius

Tailwind Config (tailwind.config.ts)
│
├─ Extends theme
│  ├─ Colors mapped from CSS variables
│  └─ Border radius configuration
│
└─ Plugins
   └─ tailwindcss-animate

Component-Level Patterns

ui/button.tsx
├─ CVA (Class Variance Authority)
│  ├─ Base classes
│  ├─ Variants
│  │  ├─ variant: default, outline, ghost, etc.
│  │  └─ size: sm, default, lg, icon
│  └─ defaultVariants
│
└─ Usage: <Button variant="default" size="lg" />

ui/badge.tsx
├─ CVA variants
│  ├─ default (primary)
│  ├─ secondary
│  ├─ outline
│  └─ ...colors (success, warning, info)
│
└─ Usage: <Badge variant="secondary">Text</Badge>

📊 State Management Patterns

Component: ImageGallery
State: selectedIndex (number)
├─ Initialize: 0
├─ Update on: prev, next, thumbnail click
└─ Use: Display main image, highlight thumbnail

Global State (Zustand)

Auth Store
├─ user: UserProfile | null
├─ isAuthenticated: boolean
├─ isLoading: boolean
├─ error: string | null
└─ Actions: login, logout, fetchProfile

Comparison Store
├─ selectedIds: string[] (persisted)
├─ listings: ListingDetail[]
├─ isLoading: boolean
├─ error: string | null
└─ Actions: addToCompare, removeFromCompare, etc.

🔗 Import Map

File Structure References

apps/web/
│
├─ app/[locale]/(public)/listings/[id]/
│  └─ page.tsx ──────────────────────────── ENTRY POINT
│     │
│     └─ imports:
│        ├─ ListingDetailClient
│        ├─ JsonLd
│        ├─ fetchListingById
│        └─ formatting utilities
│
├─ components/
│  │
│  ├─ listings/
│  │  ├─ listing-detail-client.tsx ────────── CLIENT COMPONENT
│  │  │  ├─ imports: ImageGallery
│  │  │  ├─ imports: AddToCompareButton
│  │  │  ├─ imports: AiEstimateButton
│  │  │  └─ dynamic: ListingMap
│  │  │
│  │  ├─ image-gallery.tsx ───────────────── MAIN IMAGE DISPLAY
│  │  │  └─ imports: Next.js Image
│  │  │
│  │  └─ image-upload.tsx ──────────────────── FILE UPLOAD
│  │     └─ imports: Button component
│  │
│  ├─ ui/
│  │  ├─ button.tsx
│  │  ├─ badge.tsx
│  │  ├─ card.tsx
│  │  ├─ dialog.tsx
│  │  └─ ...other UI components
│  │
│  └─ search/
│     └─ property-card.tsx ──────────────── THUMBNAIL VIEW
│        └─ imports: ImageGallery pattern
│
└─ lib/
   ├─ listings-api.ts ────────────────────── API TYPES & FUNCTIONS
   │  └─ PropertyMedia interface
   │  └─ ListingDetail interface
   │
   ├─ auth-store.ts
   ├─ comparison-store.ts
   └─ utils.ts

📈 Component Complexity Levels

Level 1: Simple UI Components

Badge, Button, Input, Label
├─ Props: basic props + variants
├─ State: none
└─ Interactions: click, focus, hover

Level 2: Composite Components

Card (Header + Content + Footer)
ImageUpload (Drag-drop + Preview grid)
├─ Props: content children
├─ State: local state
└─ Interactions: click, drag, input

Level 3: Feature Components

ImageGallery (Main + Thumbnails)
PropertyCard (Link + Image + Info)
├─ Props: data + callbacks
├─ State: selected index, UI state
└─ Interactions: navigation, filtering

Level 4: Page Components

ListingDetailClient (Full page layout)
├─ Props: listing data
├─ State: multiple features
├─ Interactions: all user interactions
└─ Children: multiple feature components

🚀 Performance Considerations

Image Optimization

Image Component Strategy:
├─ Next.js Image component
│  ├─ Automatic format selection (WebP, AVIF)
│  ├─ Responsive serving via srcset
│  └─ On-demand resizing
│
├─ Lazy Loading
│  ├─ Main image: priority={selectedIndex === 0}
│  └─ Thumbnails: no priority (lazy)
│
└─ Responsive Sizing
   ├─ sizes prop: tells browser image dimensions
   └─ Prevents layout shift

Code Splitting

Dynamic Imports:
├─ ListingMap (heavy, maps library)
│  ├─ ssr: false (client-only)
│  └─ loading: placeholder component
│
└─ Other components: bundled with page

State Optimization

Zustand:
├─ Selector pattern: useStore(state => state.field)
├─ Only re-render on selected state change
└─ Persist middleware: localStorage only on needed data

🔄 Navigation Flow

1. User visits /vi/listings/123
   └─ Page loads with first image (priority=true)
   
2. User interacts with gallery:
   ├─ Click thumbnail
   │  └─ setSelectedIndex(index) → main image updates
   │
   ├─ Click next button
   │  └─ setSelectedIndex(i + 1) → wraps to 0
   │
   └─ Click prev button
      └─ setSelectedIndex(i - 1) → wraps to last

User Journey - File Upload (Create/Edit Listing)

1. User opens listing form
   └─ ImageUpload component mounts

2. User adds images:
   ├─ Drag & drop files
   │  └─ addFiles() → filter + validate → onChange()
   │
   └─ Click to browse
      └─ Select files → same as drag & drop

3. User removes image:
   └─ removeImage(index) → cleanup URL → onChange()

4. User submits form:
   └─ Images uploaded via listingsApi.uploadMedia()

📋 Component Checklist

  • Main image display (responsive)
  • Previous/Next navigation
  • Image counter badge
  • Thumbnail navigation (scrollable)
  • Selected thumbnail highlighting
  • Empty state fallback
  • Lightbox/modal zoom (NOT implemented)
  • Keyboard navigation (NOT implemented)
  • Touch gestures (NOT implemented)

Image Upload Features

  • Drag & drop
  • Click to browse
  • File type validation
  • File size validation
  • Preview grid
  • Delete button
  • Cover photo indicator
  • URL cleanup on unmount
  • Progress bar (NOT implemented)
  • Multiple upload progress (NOT implemented)

SEO & Metadata

  • Open Graph image
  • Twitter Card image
  • JSON-LD schema
  • Canonical URL
  • Alternate language links
  • Descriptive alt text

🛠️ Maintenance Guide

Adding New Image Features

Add to ImageGallery:

  1. Define new prop in interface
  2. Update state if needed
  3. Update render logic
  4. Test responsive behavior
  5. Update TypeScript types

Example - Add zoom feature:

interface ImageGalleryProps {
  media: PropertyMedia[];
  className?: string;
  onImageClick?: (index: number) => void;  // NEW
}

// In component:
const [isZoomed, setIsZoomed] = useState(false);  // NEW

<Image
  onClick={() => setIsZoomed(true)}  // NEW
  cursor={isZoomed ? 'zoom-out' : 'zoom-in'}  // NEW
/>

Updating Image Data Structure

If PropertyMedia changes:

  1. Update interface in lib/listings-api.ts
  2. Update API response mapping
  3. Update gallery component to use new fields
  4. Update tests
  5. Update API documentation

See also:

  • PROPERTY_DETAIL_PAGE_ANALYSIS.md - Comprehensive analysis
  • PROPERTY_DETAIL_QUICK_REFERENCE.md - Code snippets & patterns