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

17 KiB
Raw Permalink Blame History

GoodGo Platform - Property Detail Page Analysis

Project Overview

  • Framework: Next.js 15.5.14 (App Router)
  • Styling: Tailwind CSS 3.4.0 with CSS variables
  • State Management: Zustand 5.0.12 (with persist middleware)
  • UI Components: Custom built with CVA (class-variance-authority) and Radix patterns
  • Internationalization: next-intl 4.9.0 (Vietnamese/English)
  • Image Handling: Next.js Image component with remote patterns
  • Package Manager: pnpm 10.27.0

1. Property Detail Page Structure

File Location

apps/web/app/[locale]/(public)/listings/[id]/
├── page.tsx          # Server component - fetches data, generates metadata, JSON-LD
└── (referenced) listing-detail-client.tsx  # Client component - handles interactivity

Page Architecture

Server Component (page.tsx):

  • Fetches listing data via fetchListingById(params.id)
  • Generates SEO metadata (Open Graph, Twitter Cards, canonical URLs)
  • Generates JSON-LD structured data (breadcrumbs, property schema)
  • Renders structured data and passes data to client component

Client Component (listing-detail-client.tsx):

  • All interactivity (image gallery state, forms, etc.)
  • Uses dynamic imports for heavy components (ListingMap)
  • Main sections:
    • Breadcrumb navigation
    • Header with title, price, badges
    • Image Gallery (main content area)
    • Quick stats bar (area, bedrooms, bathrooms, floors, direction)
    • Two-column layout:
      • Left (2/3): Description, Details, Amenities, Map, Contact Card
      • Right (1/3): Sticky sidebar with contact info, AI Estimate, Stats

Data Flow

page.tsx (Server)
  └─> fetchListingById() ─> ListingDetail object
  └─> generateMetadata() ─> SEO metadata
  └─> ListingDetailClient (Client)
      └─> ImageGallery component
      └─> AddToCompareButton component
      └─> AiEstimateButton component
      └─> dynamic ListingMap component

2. Property Images - Current Implementation

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

Features:

  • Main Display:

    • Aspect ratio: 16:9 (aspect-video)
    • Uses Next.js Image component with fill layout
    • Object fit: cover
    • Rounded corners
    • Previous/Next navigation buttons (semi-transparent overlay, hover effects)
    • Current image counter (bottom-right: "X / Total")
  • Thumbnail Navigation:

    • Horizontal scrollable row (flex with overflow-x-auto)
    • Each thumbnail: 64px × 64px (h-16 w-16)
    • Border indicates selected state (2px border-primary vs border-transparent with opacity)
    • Smooth transitions
  • Empty State:

    • Falls back to gray placeholder if no images: "Chưa có hình ảnh"

State Management:

  • Local React state (selectedIndex): Tracks which image is displayed
  • One-way: thumbnail click → main image update

Image Handling:

  • Filters media by type === 'image'
  • Sorts by order property
  • Supports captions (from PropertyMedia.caption)
  • Uses next/image with optimized sizes

Technical Details:

interface PropertyMedia {
  id: string;
  url: string;
  type: 'image' | 'video';
  order: number;
  caption: string | null;
}

Image Upload Component

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

Features:

  • Drag & drop zone
  • Click to browse
  • File validation:
    • Allowed types: JPEG, PNG, WebP
    • Max size: 10MB per file
    • Max files: 20
  • Preview grid (2 cols mobile, 3 cols tablet, 4 cols desktop)
  • Delete button on hover
  • First image labeled "Ảnh bìa" (Cover photo)
  • URL.createObjectURL for previews (properly cleaned up on unmount)

State Management:

  • Local state: ImageFile[] (file + preview URL)
  • onChange callback pattern

Current Locations:

apps/web/components/
├── listings/
│   ├── image-gallery.tsx       ✓ Main image display with thumbnails
│   ├── image-upload.tsx        ✓ Upload with drag-drop
│   ├── listing-detail-client.tsx ✓ Uses image gallery
│   └── ...other listing components
├── ui/
│   ├── button.tsx              ✓ Navigation buttons
│   ├── badge.tsx               ✓ Image counter badge
│   ├── dialog.tsx              ✓ Custom modal implementation
│   ├── card.tsx
│   └── ...other UI components
├── search/
│   └── property-card.tsx       ✓ Thumbnail display with images
└── comparison/
    └── ...comparison components

Property Card (Search/Listing View)

File: apps/web/components/search/property-card.tsx

  • Uses first media item (media[0]?.url)
  • Shows badge indicating total media count if > 1
  • Has hover scale effect (group-hover:scale-105)
  • Aspect ratio options: 16/10 (compact) or 4/3 (default)

4. Project Component Structure & Patterns

Design System Approach

  • UI Components: Located in components/ui/
  • Pattern: CVA (class-variance-authority) for variants
  • Example (button.tsx):
    const buttonVariants = cva(
      'inline-flex items-center justify-center ...',
      {
        variants: {
          variant: { default: '...', outline: '...', ghost: '...', ... },
          size: { default: '...', sm: '...', lg: '...', icon: '...' },
        },
        defaultVariants: { variant: 'default', size: 'default' },
      }
    );
    

Composition Pattern

  • Small, focused components
  • Props-based configuration
  • Utility function composition (cn() from @/lib/utils - likely clsx + tailwind-merge)
  • Forward refs for form components

Dialog Component

File: apps/web/components/ui/dialog.tsx

  • Custom implementation (not Radix)
  • Features:
    • Backdrop overlay (fixed, z-50, black/80)
    • Center content positioning
    • Close on backdrop click
    • Body overflow hidden when open
    • Composable parts: Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription, DialogFooter

5. Next.js Image Usage Patterns

Configuration

File: apps/web/next.config.js

images: {
  remotePatterns: [
    { protocol: 'https', hostname: '**' },  // All HTTPS domains allowed
  ],
}

Usage Pattern in Components:

import Image from 'next/image';

// Main image (fill layout)
<Image
  src={images[selectedIndex]?.url ?? ''}
  alt={`Ảnh ${selectedIndex + 1}`}
  fill
  sizes="(max-width: 768px) 100vw, 60vw"
  className="object-cover"
  priority={selectedIndex === 0}
/>

// Thumbnail (fixed size)
<Image
  src={img.url}
  alt={`Thumbnail ${index + 1}`}
  fill
  sizes="64px"
  className="object-cover"
/>

// Property card (fill layout)
<Image
  src={listing.property.media[0]?.url ?? ''}
  alt={`Ảnh bất động sản: ${listing.property.title}`}
  fill
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
  className="object-cover transition-transform group-hover:scale-105"
/>

Best Practices Observed:

✓ Always provide alt text
✓ Use responsive sizes prop
✓ Use fill layout with object-cover
✓ Set priority={true} for above-fold images
✓ Use aspect ratio containers (aspect-video, aspect-square, etc.)


6. State Management Patterns

Using Zustand

Auth Store

File: apps/web/lib/auth-store.ts

const useAuthStore = create<AuthState>((set, get) => ({
  user: null,
  isAuthenticated: false,
  isLoading: false,
  error: null,

  login: async (data) => { /* ... */ },
  logout: async () => { /* ... */ },
  fetchProfile: async () => { /* ... */ },
  initialize: async () => { /* ... */ },
}));

Comparison Store (with persistence)

File: apps/web/lib/comparison-store.ts

export const useComparisonStore = create<ComparisonState>()(
  persist(
    (set, get) => ({
      selectedIds: [],
      listings: [],
      isLoading: false,
      error: null,

      addToCompare: (id: string) => { /* ... */ },
      removeFromCompare: (id: string) => { /* ... */ },
      isSelected: (id: string) => { /* ... */ },
      setListings: (listings: ListingDetail[]) => { /* ... */ },
      setLoading: (loading: boolean) => { /* ... */ },
      setError: (error: string | null) => { /* ... */ },
    }),
    {
      name: 'goodgo-compare',
      partialize: (state) => ({ selectedIds: state.selectedIds }),
    }
  )
);

Store Patterns:

  • Actions as methods in store object
  • Async support with set() and get()
  • Persistence middleware for localStorage (comparison store)
  • Error handling with dedicated error fields
  • Loading states for async operations

Hooks Pattern

File: apps/web/lib/hooks/

use-analytics.ts
use-listings.ts      # Likely wraps API calls
use-payments.ts
use-saved-searches.ts
use-subscription.ts
use-valuation.ts

These likely use React Query + custom Zustand stores


7. Existing third-party Libraries

No Lightbox/Gallery Libraries Installed

The project does NOT currently use:

  • react-lightbox
  • yet-another-react-lightbox
  • photoswipe
  • swiper (gallery carousel)
  • react-image-gallery
  • embla-carousel (for carousels)

Available Dependencies:

{
  "@tanstack/react-query": "^5.96.2",    // Data fetching
  "zustand": "^5.0.12",                  // State management
  "lucide-react": "^1.7.0",              // Icons
  "mapbox-gl": "^3.21.0",                // Maps
  "recharts": "^3.8.1",                  // Charts
  "next-intl": "^4.9.0",                 // i18n
  "class-variance-authority": "^0.7.1",  // CVA for components
  "clsx": "^2.1.1",                      // Conditional classNames
  "tailwind-merge": "^3.5.0",            // Merge Tailwind classes
}

8. Tailwind & Design Tokens

CSS Variable System

File: apps/web/app/globals.css Color tokens available:

  • --border
  • --input
  • --ring
  • --background
  • --foreground
  • --primary / --primary-foreground
  • --secondary / --secondary-foreground
  • --destructive / --destructive-foreground
  • --muted / --muted-foreground
  • --accent / --accent-foreground
  • --card / --card-foreground
  • --radius (border radius)

Responsive Breakpoints (standard Tailwind):

  • sm: 640px
  • md: 768px
  • lg: 1024px
  • xl: 1280px
  • 2xl: 1536px

Animations Available:

From tailwindcss-animate plugin


9. API & Data Types

Listing Detail Type

interface ListingDetail {
  id: string;
  status: ListingStatus;
  transactionType: 'SALE' | 'RENT';
  priceVND: string;
  pricePerM2: number | null;
  rentPriceMonthly: string | null;
  commissionPct: number | null;
  viewCount: number;
  saveCount: number;
  inquiryCount: number;
  publishedAt: string | null;
  createdAt: string;
  property: {
    id: string;
    propertyType: 'APARTMENT' | 'HOUSE' | 'VILLA' | 'LAND' | 'OFFICE' | 'SHOPHOUSE';
    title: string;
    description: string;
    address: string;
    ward: string;
    district: string;
    city: string;
    areaM2: number;
    bedrooms: number | null;
    bathrooms: number | null;
    floors: number | null;
    direction: Direction | null;
    yearBuilt: number | null;
    legalStatus: string | null;
    amenities: string[] | null;
    projectName: string | null;
    latitude: number | null;
    longitude: number | null;
    media: PropertyMedia[];  // ← Array of images/videos
  };
  seller: { id: string; fullName: string; phone: string };
  agent: { id: string; userId: string; agency: string | null } | null;
}

interface PropertyMedia {
  id: string;
  url: string;
  type: 'image' | 'video';
  order: number;
  caption: string | null;
}

API Functions

File: apps/web/lib/listings-api.ts

const listingsApi = {
  create: (data: CreateListingPayload) => { /* POST /listings */ },
  getById: (id: string) => { /* GET /listings/{id} */ },
  search: (params: SearchListingsParams) => { /* GET /listings?... */ },
  updateStatus: (id, status, notes?) => { /* POST /listings/{id}/status */ },
  uploadMedia: async (listingId, file, caption?) => { /* POST /listings/{id}/media */ },
};

10. File Structure Summary

apps/web/
├── app/
│   ├── globals.css                        # Design tokens, CSS variables
│   └── [locale]/
│       ├── layout.tsx                     # Root layout with providers
│       └── (public)/
│           ├── listings/
│           │   └── [id]/
│           │       └── page.tsx           # Property detail page
│           └── page.tsx                   # Home page
├── components/
│   ├── listings/
│   │   ├── listing-detail-client.tsx      # Main detail view
│   │   ├── image-gallery.tsx              # Gallery component
│   │   ├── image-upload.tsx               # Upload component
│   │   ├── listing-form-steps.tsx
│   │   └── listing-status-badge.tsx
│   ├── ui/
│   │   ├── button.tsx                     # Button with variants
│   │   ├── badge.tsx                      # Badge with variants
│   │   ├── card.tsx                       # Card component
│   │   ├── dialog.tsx                     # Modal/Dialog
│   │   ├── input.tsx
│   │   ├── label.tsx
│   │   ├── select.tsx
│   │   ├── table.tsx
│   │   ├── tabs.tsx
│   │   └── textarea.tsx
│   ├── search/
│   │   ├── property-card.tsx              # Listing card with image
│   │   ├── filter-bar.tsx
│   │   └── search-results.tsx
│   ├── comparison/
│   │   ├── add-to-compare-button.tsx
│   │   ├── compare-floating-bar.tsx
│   │   ├── comparison-stats.tsx
│   │   └── comparison-table.tsx
│   ├── map/
│   │   └── listing-map.tsx                # Mapbox integration
│   ├── seo/
│   │   └── json-ld.tsx                    # Schema.org structured data
│   ├── auth/                              # Auth components
│   ├── agents/                            # Agent components
│   ├── valuation/                         # AI valuation
│   ├── charts/                            # Chart components
│   └── providers/                         # Context providers
├── lib/
│   ├── auth-store.ts                      # Zustand auth
│   ├── comparison-store.ts                # Zustand comparison (persisted)
│   ├── auth-api.ts                        # Auth endpoints
│   ├── listings-api.ts                    # Listing endpoints & types
│   ├── listings-server.ts                 # Server-only functions
│   ├── currency.ts                        # Currency formatting
│   ├── api-client.ts                      # Fetch wrapper
│   ├── query-client.ts                    # React Query config
│   ├── utils.ts                           # Helper functions
│   ├── hooks/
│   │   ├── use-listings.ts
│   │   ├── use-analytics.ts
│   │   ├── use-payments.ts
│   │   ├── use-saved-searches.ts
│   │   ├── use-subscription.ts
│   │   └── use-valuation.ts
│   └── validations/
│       └── listings.ts                    # Zod schemas
├── middleware.ts                          # i18n middleware
├── instrumentation.ts                     # Observability (Sentry)
├── tailwind.config.ts                     # Tailwind configuration
├── next.config.js                         # Next.js configuration
└── package.json

11. Key Insights & Best Practices

Image Strategy

  1. Responsive Images: Uses sizes prop for responsive serving
  2. Lazy Loading: Non-priority images load on demand
  3. Performance: Object-fit cover with aspect ratios
  4. SEO: First image used for OG tags in metadata
  5. No 3rd-party: Custom gallery implementation = lightweight

Component Architecture

  1. Separation of Concerns: Server fetch → Client interactivity
  2. Dynamic Imports: Heavy components (Map) loaded on demand
  3. Composition: Small, reusable UI components with variants
  4. Type Safety: Full TypeScript with Zod validation

State Management

  1. Zustand for Global State: Auth, Comparisons
  2. React Query: Likely for server state (data fetching)
  3. Local State: For UI state (gallery index, form inputs)

i18n

  • Vietnamese (vi) and English (en) support
  • Labels: @/lib/validations/listings for property types, directions, etc.

SEO

  • JSON-LD schema for listings and breadcrumbs
  • Open Graph and Twitter Cards
  • Canonical URLs
  • Alternate language links

12. Dependencies Not Present

⚠️ Potential Opportunities (if needed):

  • No full-featured carousel library (could use embla-carousel if complex carousel needed)
  • No lightbox library (current implementation is basic - consider if modal zoom needed)
  • No image optimization service (relying on Next.js Image component)
  • No form builder library (using react-hook-form + manual forms)
  • No animation library (using Tailwind animations)
  • No virtualization (could add if listing 1000s of items)