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>
17 KiB
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
Imagecomponent withfilllayout - 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
orderproperty - Supports captions (from
PropertyMedia.caption) - Uses
next/imagewith 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
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):
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()andget() - 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: 640pxmd: 768pxlg: 1024pxxl: 1280px2xl: 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
- Responsive Images: Uses
sizesprop for responsive serving - Lazy Loading: Non-priority images load on demand
- Performance: Object-fit cover with aspect ratios
- SEO: First image used for OG tags in metadata
- No 3rd-party: Custom gallery implementation = lightweight
Component Architecture
- Separation of Concerns: Server fetch → Client interactivity
- Dynamic Imports: Heavy components (Map) loaded on demand
- Composition: Small, reusable UI components with variants
- Type Safety: Full TypeScript with Zod validation
State Management
- Zustand for Global State: Auth, Comparisons
- React Query: Likely for server state (data fetching)
- Local State: For UI state (gallery index, form inputs)
i18n
- Vietnamese (vi) and English (en) support
- Labels:
@/lib/validations/listingsfor 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)