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>
14 KiB
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
🖼️ Image Gallery Component Details
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
🧩 Related Components
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
Gallery Local State
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
User Journey - Image Gallery
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
Image Gallery Features
- 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:
- Define new prop in interface
- Update state if needed
- Update render logic
- Test responsive behavior
- 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:
- Update interface in
lib/listings-api.ts - Update API response mapping
- Update gallery component to use new fields
- Update tests
- Update API documentation
📚 Related Documentation
See also:
PROPERTY_DETAIL_PAGE_ANALYSIS.md- Comprehensive analysisPROPERTY_DETAIL_QUICK_REFERENCE.md- Code snippets & patterns