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>
554 lines
17 KiB
Markdown
554 lines
17 KiB
Markdown
# 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)
|
||
|