# 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)
// Thumbnail (fixed size)
// Property card (fill layout)
```
### 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)