# 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 ### Image Gallery Component **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: ```typescript 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 --- ## 3. Image-Related Components ### 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): ```typescript 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` ```javascript images: { remotePatterns: [ { protocol: 'https', hostname: '**' }, // All HTTPS domains allowed ], } ``` ### Usage Pattern in Components: ```typescript import Image from 'next/image'; // Main image (fill layout) {`Ảnh // Thumbnail (fixed size) {`Thumbnail // Property card (fill layout) {`Ảnh ``` ### 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` ```typescript const useAuthStore = create((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` ```typescript export const useComparisonStore = create()( 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: ```json { "@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 ```typescript 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` ```typescript 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)