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

554 lines
17 KiB
Markdown
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
# 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)
<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`
```typescript
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`
```typescript
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:
```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)