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>
584 lines
13 KiB
Markdown
584 lines
13 KiB
Markdown
# Property Detail Page - Quick Reference & Code Snippets
|
|
|
|
## 🎯 Quick Navigation
|
|
|
|
### Page Routes
|
|
- **Detail Page**: `apps/web/app/[locale]/(public)/listings/[id]/page.tsx`
|
|
- **Client Component**: `apps/web/components/listings/listing-detail-client.tsx`
|
|
- **Gallery Component**: `apps/web/components/listings/image-gallery.tsx`
|
|
- **Upload Component**: `apps/web/components/listings/image-upload.tsx`
|
|
- **Property Card**: `apps/web/components/search/property-card.tsx`
|
|
|
|
### Data Flow
|
|
```
|
|
URL: /vi/listings/abc123
|
|
↓
|
|
[id]/page.tsx (Server)
|
|
├─ fetchListingById('abc123')
|
|
├─ generateMetadata()
|
|
└─ <ListingDetailClient listing={data} />
|
|
└─ <ImageGallery media={property.media} />
|
|
```
|
|
|
|
---
|
|
|
|
## 🖼️ Working with Images
|
|
|
|
### Current Gallery Features
|
|
```typescript
|
|
// apps/web/components/listings/image-gallery.tsx
|
|
|
|
interface ImageGalleryProps {
|
|
media: PropertyMedia[];
|
|
className?: string;
|
|
}
|
|
|
|
// Features:
|
|
// ✓ Main image (16:9 aspect ratio)
|
|
// ✓ Previous/Next buttons
|
|
// ✓ Image counter badge
|
|
// ✓ Horizontal scrollable thumbnails (64x64px)
|
|
// ✓ Selected state highlighting
|
|
// ✓ Empty state fallback
|
|
```
|
|
|
|
### Data Structure
|
|
```typescript
|
|
interface PropertyMedia {
|
|
id: string;
|
|
url: string; // Full URL to image
|
|
type: 'image' | 'video'; // Media type filter
|
|
order: number; // Sort order (0, 1, 2...)
|
|
caption: string | null; // Optional caption
|
|
}
|
|
|
|
interface Property {
|
|
// ... other fields
|
|
media: PropertyMedia[]; // Array of images/videos
|
|
}
|
|
```
|
|
|
|
### Using Images in Components
|
|
```typescript
|
|
// ✓ Import Next.js Image
|
|
import Image from 'next/image';
|
|
|
|
// ✓ Main image with fill layout (responsive)
|
|
<Image
|
|
src={images[index]?.url}
|
|
alt={`Image ${index + 1}`}
|
|
fill
|
|
sizes="(max-width: 768px) 100vw, 60vw"
|
|
className="object-cover"
|
|
priority={index === 0}
|
|
/>
|
|
|
|
// ✓ Fixed-size thumbnail
|
|
<Image
|
|
src={img.url}
|
|
alt={`Thumbnail ${i}`}
|
|
fill
|
|
sizes="64px"
|
|
className="object-cover"
|
|
/>
|
|
|
|
// ✓ Responsive property card
|
|
<Image
|
|
src={media[0]?.url}
|
|
alt="Property"
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
|
className="object-cover transition-transform group-hover:scale-105"
|
|
/>
|
|
```
|
|
|
|
### Image Upload API
|
|
```typescript
|
|
// From: apps/web/lib/listings-api.ts
|
|
|
|
const listingsApi = {
|
|
uploadMedia: async (
|
|
listingId: string,
|
|
file: File,
|
|
caption?: string
|
|
) => {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
if (caption) formData.append('caption', caption);
|
|
|
|
// POST /api/v1/listings/{listingId}/media
|
|
// Returns: { mediaId: string; url: string }
|
|
},
|
|
};
|
|
```
|
|
|
|
### File Upload Component (for reference)
|
|
```typescript
|
|
// apps/web/components/listings/image-upload.tsx
|
|
|
|
interface ImageFile {
|
|
file: File;
|
|
preview: string;
|
|
}
|
|
|
|
// Usage:
|
|
<ImageUpload
|
|
images={images}
|
|
onChange={(newImages) => setImages(newImages)}
|
|
maxFiles={20}
|
|
/>
|
|
|
|
// Validation:
|
|
// ✓ Types: JPEG, PNG, WebP
|
|
// ✓ Max size: 10MB per file
|
|
// ✓ Max count: 20 files
|
|
// ✓ Drag & drop support
|
|
// ✓ Preview grid with delete button
|
|
```
|
|
|
|
---
|
|
|
|
## 🎨 Styling Patterns
|
|
|
|
### Aspect Ratios (Tailwind)
|
|
```html
|
|
<!-- 16:9 (videos, main images) -->
|
|
<div class="aspect-video"><!-- 1.777:1 --></div>
|
|
|
|
<!-- 4:3 (property cards) -->
|
|
<div class="aspect-[4/3]"><!-- 1.333:1 --></div>
|
|
|
|
<!-- 16:10 (compact property cards) -->
|
|
<div class="aspect-[16/10]"><!-- 1.6:1 --></div>
|
|
|
|
<!-- Square (thumbnails) -->
|
|
<div class="aspect-square"><!-- 1:1 --></div>
|
|
```
|
|
|
|
### Image Container Pattern
|
|
```jsx
|
|
<div className="relative aspect-video overflow-hidden rounded-lg bg-muted">
|
|
<Image
|
|
src={url}
|
|
alt="description"
|
|
fill
|
|
sizes="(max-width: 768px) 100vw, 60vw"
|
|
className="object-cover"
|
|
priority={isMainImage}
|
|
/>
|
|
</div>
|
|
```
|
|
|
|
### Common Tailwind Classes
|
|
```
|
|
// Layout
|
|
aspect-video # 16:9 ratio
|
|
aspect-square # 1:1 ratio
|
|
relative / absolute # Positioning
|
|
fill # Object-fit with aspect ratio
|
|
|
|
// Styling
|
|
object-cover # Image fit (crop to fill)
|
|
object-contain # Image fit (preserve ratio)
|
|
rounded-lg # Border radius
|
|
bg-muted # Placeholder background
|
|
|
|
// Interactive
|
|
transition-colors # Smooth color changes
|
|
group-hover:scale-105 # Hover effect
|
|
opacity-70 # Partial transparency
|
|
|
|
// Overlay
|
|
absolute inset-0 # Full coverage overlay
|
|
bg-black/50 # Semi-transparent black
|
|
hover:bg-black/70 # Darker on hover
|
|
```
|
|
|
|
---
|
|
|
|
## 🔄 State Management
|
|
|
|
### Zustand Store Pattern
|
|
```typescript
|
|
import { create } from 'zustand';
|
|
import { persist } from 'zustand/middleware';
|
|
|
|
// Simple store
|
|
const useMyStore = create<MyState>((set, get) => ({
|
|
count: 0,
|
|
increment: () => set((state) => ({ count: state.count + 1 })),
|
|
asyncAction: async () => {
|
|
set({ isLoading: true });
|
|
// ... async work
|
|
set({ data: result, isLoading: false });
|
|
},
|
|
}));
|
|
|
|
// Store with persistence
|
|
const useComparisonStore = create<State>()(
|
|
persist(
|
|
(set, get) => ({
|
|
// store logic
|
|
}),
|
|
{
|
|
name: 'storage-key',
|
|
partialize: (state) => ({ selectedIds: state.selectedIds }),
|
|
}
|
|
)
|
|
);
|
|
|
|
// Usage in component
|
|
const count = useMyStore((state) => state.count);
|
|
const increment = useMyStore((state) => state.increment);
|
|
```
|
|
|
|
### Image Gallery Local State
|
|
```typescript
|
|
const [selectedIndex, setSelectedIndex] = React.useState(0);
|
|
|
|
const handleNext = () => {
|
|
setSelectedIndex((i) => (i < images.length - 1 ? i + 1 : 0));
|
|
};
|
|
|
|
const handlePrev = () => {
|
|
setSelectedIndex((i) => (i > 0 ? i - 1 : images.length - 1));
|
|
};
|
|
```
|
|
|
|
---
|
|
|
|
## 🧩 UI Component Patterns
|
|
|
|
### Button Component (with variants)
|
|
```typescript
|
|
// From: apps/web/components/ui/button.tsx
|
|
|
|
import { Button } from '@/components/ui/button';
|
|
|
|
// Variants:
|
|
<Button variant="default">Default</Button> // Primary
|
|
<Button variant="outline">Outline</Button> // Border
|
|
<Button variant="secondary">Secondary</Button> // Secondary color
|
|
<Button variant="destructive">Delete</Button> // Red
|
|
<Button variant="ghost">Ghost</Button> // No background
|
|
<Button variant="link">Link</Button> // Text link
|
|
|
|
// Sizes:
|
|
<Button size="default">Default</Button> // 40px height
|
|
<Button size="sm">Small</Button> // 36px height
|
|
<Button size="lg">Large</Button> // 44px height
|
|
<Button size="icon">Icon</Button> // Square button
|
|
```
|
|
|
|
### Badge Component (with variants)
|
|
```typescript
|
|
import { Badge } from '@/components/ui/badge';
|
|
|
|
<Badge variant="default">Primary</Badge>
|
|
<Badge variant="secondary">Secondary</Badge>
|
|
<Badge variant="outline">Outline</Badge>
|
|
<Badge variant="destructive">Destructive</Badge>
|
|
<Badge variant="success">Success</Badge>
|
|
<Badge variant="warning">Warning</Badge>
|
|
<Badge variant="info">Info</Badge>
|
|
```
|
|
|
|
### Card Component
|
|
```typescript
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
|
|
<Card>
|
|
<CardHeader>
|
|
<CardTitle>Title</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
{/* content */}
|
|
</CardContent>
|
|
</Card>
|
|
```
|
|
|
|
### Dialog/Modal Pattern
|
|
```typescript
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
|
|
const [isOpen, setIsOpen] = React.useState(false);
|
|
|
|
<Dialog open={isOpen} onOpenChange={setIsOpen}>
|
|
<DialogContent>
|
|
<DialogHeader>
|
|
<DialogTitle>Dialog Title</DialogTitle>
|
|
</DialogHeader>
|
|
{/* content */}
|
|
</DialogContent>
|
|
</Dialog>
|
|
```
|
|
|
|
---
|
|
|
|
## 📱 Responsive Design
|
|
|
|
### Breakpoints
|
|
```css
|
|
xs: 0px /* Default */
|
|
sm: 640px /* Mobile landscape */
|
|
md: 768px /* Tablet */
|
|
lg: 1024px /* Desktop */
|
|
xl: 1280px /* Wide desktop */
|
|
2xl: 1536px /* Ultra-wide */
|
|
```
|
|
|
|
### Common Patterns
|
|
```jsx
|
|
// Mobile-first
|
|
<div className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4">
|
|
|
|
// Conditional display
|
|
<div className="hidden md:block">Show on tablet+</div>
|
|
<div className="block md:hidden">Show on mobile</div>
|
|
|
|
// Responsive grid
|
|
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">
|
|
|
|
// Responsive text size
|
|
<p className="text-sm md:text-base lg:text-lg">
|
|
|
|
// Responsive padding
|
|
<div className="p-4 sm:p-6 md:p-8">
|
|
```
|
|
|
|
---
|
|
|
|
## 🔗 Common Imports
|
|
|
|
### Essential Imports
|
|
```typescript
|
|
// Components
|
|
import Image from 'next/image';
|
|
import Link from 'next/link';
|
|
import dynamic from 'next/dynamic';
|
|
|
|
// UI Components
|
|
import { Button } from '@/components/ui/button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
|
|
|
|
// Utilities
|
|
import { cn } from '@/lib/utils';
|
|
import { formatPrice, formatPricePerM2 } from '@/lib/currency';
|
|
|
|
// State & API
|
|
import { useAuthStore } from '@/lib/auth-store';
|
|
import { useComparisonStore } from '@/lib/comparison-store';
|
|
import { listingsApi } from '@/lib/listings-api';
|
|
|
|
// Hooks
|
|
import * as React from 'react';
|
|
import { useCallback, useEffect, useState } from 'react';
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 Data Fetching
|
|
|
|
### Server-side Fetching
|
|
```typescript
|
|
// apps/web/lib/listings-server.ts
|
|
import { fetchListingById } from '@/lib/listings-server';
|
|
|
|
// In page.tsx (Server Component)
|
|
const listing = await fetchListingById(params.id);
|
|
if (!listing) notFound();
|
|
```
|
|
|
|
### Client-side API
|
|
```typescript
|
|
// apps/web/lib/listings-api.ts
|
|
import { listingsApi } from '@/lib/listings-api';
|
|
|
|
// Usage:
|
|
const listing = await listingsApi.getById(id);
|
|
const results = await listingsApi.search({ city: 'Ho Chi Minh' });
|
|
```
|
|
|
|
### React Query Usage (likely)
|
|
```typescript
|
|
// Typical pattern for fetching
|
|
import { useQuery } from '@tanstack/react-query';
|
|
|
|
const { data, isLoading, error } = useQuery({
|
|
queryKey: ['listing', id],
|
|
queryFn: () => listingsApi.getById(id),
|
|
});
|
|
```
|
|
|
|
---
|
|
|
|
## 🌐 Internationalization
|
|
|
|
### Language Support
|
|
- Vietnamese (vi)
|
|
- English (en)
|
|
|
|
### Using i18n
|
|
```typescript
|
|
// In components, use Vietnamese labels directly or from constants
|
|
const PROPERTY_TYPES: Record<string, string> = {
|
|
APARTMENT: 'Căn hộ',
|
|
HOUSE: 'Nhà riêng',
|
|
VILLA: 'Biệt thự',
|
|
// ...
|
|
};
|
|
|
|
// From @/lib/validations/listings
|
|
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';
|
|
```
|
|
|
|
### Language-aware Routes
|
|
```
|
|
/vi/listings/123 # Vietnamese
|
|
/en/listings/123 # English
|
|
```
|
|
|
|
---
|
|
|
|
## 🔐 Security Features
|
|
|
|
### CSP Headers (next.config.js)
|
|
```javascript
|
|
img-src 'self' data: blob: https://*.mapbox.com https://
|
|
font-src 'self' data:
|
|
```
|
|
|
|
### Image Domain Whitelist
|
|
```javascript
|
|
// Allows HTTPS images from any domain
|
|
remotePatterns: [
|
|
{ protocol: 'https', hostname: '**' }
|
|
]
|
|
```
|
|
|
|
---
|
|
|
|
## 🧪 Testing Considerations
|
|
|
|
### Component Files to Test
|
|
- `image-gallery.tsx` - Gallery navigation, state changes
|
|
- `image-upload.tsx` - File validation, drag-drop
|
|
- `property-card.tsx` - Image display, responsive
|
|
- `listing-detail-client.tsx` - Overall page functionality
|
|
|
|
### Test Patterns
|
|
```typescript
|
|
// Mock Next.js Image component
|
|
jest.mock('next/image', () => ({
|
|
__esModule: true,
|
|
default: (props) => <img {...props} />,
|
|
}));
|
|
|
|
// Mock Zustand stores
|
|
jest.mock('@/lib/auth-store', () => ({
|
|
useAuthStore: jest.fn(),
|
|
}));
|
|
```
|
|
|
|
---
|
|
|
|
## 🚀 Performance Optimization Tips
|
|
|
|
1. **Image Priority**
|
|
```typescript
|
|
priority={selectedIndex === 0} // First image loads with page
|
|
```
|
|
|
|
2. **Responsive Sizes**
|
|
```typescript
|
|
sizes="(max-width: 768px) 100vw, 60vw" // Tells browser image width
|
|
```
|
|
|
|
3. **Lazy Loading**
|
|
- Thumbnails load on demand (no priority set)
|
|
- Reduces initial page weight
|
|
|
|
4. **Dynamic Imports**
|
|
```typescript
|
|
const ListingMap = dynamic(() => import('@/components/map/listing-map'), {
|
|
ssr: false,
|
|
loading: () => <div>Loading...</div>,
|
|
});
|
|
```
|
|
|
|
5. **Object URLs Cleanup**
|
|
```typescript
|
|
React.useEffect(() => {
|
|
return () => {
|
|
images.forEach((img) => URL.revokeObjectURL(img.preview));
|
|
};
|
|
}, []);
|
|
```
|
|
|
|
---
|
|
|
|
## 📋 Common Tasks
|
|
|
|
### Add a New UI Element
|
|
1. Create in `components/ui/ComponentName.tsx`
|
|
2. Use CVA for variants
|
|
3. Export from the same file
|
|
4. Import and use in feature components
|
|
|
|
### Add a New Feature Component
|
|
1. Create in `components/feature-name/ComponentName.tsx`
|
|
2. Make 'use client' if interactive
|
|
3. Import UI components
|
|
4. Use Zustand stores if needed global state
|
|
5. Use local state for UI state
|
|
|
|
### Modify Image Gallery
|
|
1. Edit `components/listings/image-gallery.tsx`
|
|
2. Update PropertyMedia interface if needed (in `lib/listings-api.ts`)
|
|
3. Adjust aspect ratio / sizes as needed
|
|
4. Test responsive behavior
|
|
|
|
### Add Image Lightbox
|
|
1. Choose library (embla-carousel, yet-another-react-lightbox, etc.)
|
|
2. Install: `pnpm add package-name -F @goodgo/web`
|
|
3. Create wrapper component in `components/listings/image-lightbox.tsx`
|
|
4. Integrate with `image-gallery.tsx`
|
|
5. Test with multiple images
|
|
|
|
---
|
|
|
|
## 🐛 Common Issues & Solutions
|
|
|
|
### Image Not Loading
|
|
- Check URL is valid and HTTPS
|
|
- Verify domain in `remotePatterns`
|
|
- Check CSP headers allow the domain
|
|
|
|
### Gallery Navigation Frozen
|
|
- Check `selectedIndex` state updates
|
|
- Verify onClick handlers are properly bound
|
|
- Check for JavaScript errors in console
|
|
|
|
### Thumbnail Scroll Issues
|
|
- Ensure parent container has `overflow-x-auto`
|
|
- Check flex properties on thumbnails
|
|
- Verify width constraints (flex-shrink-0)
|
|
|
|
### Layout Shifting on Image Load
|
|
- Use aspect ratio container
|
|
- Set explicit width/height
|
|
- Use `fill` layout with container
|
|
|
|
---
|
|
|
|
## 📚 Additional Resources
|
|
|
|
- **Next.js Image**: https://nextjs.org/docs/app/api-reference/components/image
|
|
- **Tailwind CSS**: https://tailwindcss.com/docs
|
|
- **Zustand**: https://github.com/pmndrs/zustand
|
|
- **CVA**: https://cva.style/docs
|
|
- **React Query**: https://tanstack.com/query/latest
|
|
|