# Trang Chi Tiết Bất Động Sản - Bản Đồ Component & Sơ Đồ Kiến Trúc ## 🎯 Phân Cấp Component Của Trang ``` PublicListingDetailPage (Server) [apps/web/app/[locale]/(public)/listings/[id]/page.tsx] │ ├─ JSON-LD Structured Data │ ├─ JsonLd (Breadcrumb) │ └─ JsonLd (Listing Schema) │ └─ ListingDetailClient (Client) [apps/web/components/listings/listing-detail-client.tsx] │ ├─ Breadcrumb Navigation │ └─ Link components │ ├─ Header Section │ ├─ Title & Description │ ├─ Badge (SALE/RENT) │ ├─ Badge (Property Type) │ ├─ Price Display │ └─ AddToCompareButton │ ├─ ImageGallery [TÍNH NĂNG CHÍNH] │ [apps/web/components/listings/image-gallery.tsx] │ ├─ Main Image Display │ │ ├─ Previous Button │ │ ├─ Image (Next.js Image) │ │ ├─ Next Button │ │ └─ Counter Badge │ │ │ └─ Thumbnail Navigation │ ├─ Thumbnail Item 1 │ ├─ Thumbnail Item 2 │ └─ Thumbnail Item N │ ├─ Quick Stats Bar │ ├─ QuickStat (Area) │ ├─ QuickStat (Bedrooms) │ ├─ QuickStat (Bathrooms) │ ├─ QuickStat (Floors) │ └─ QuickStat (Direction) │ ├─ Main Content (2/3 width - lg:col-span-2) │ │ │ ├─ Description Card │ │ ├─ CardHeader │ │ └─ CardContent │ │ │ ├─ Details Card │ │ ├─ CardHeader │ │ ├─ InfoItem (Property Type) │ │ ├─ InfoItem (Area) │ │ ├─ InfoItem (Bedrooms) │ │ ├─ InfoItem (Bathrooms) │ │ ├─ InfoItem (Floors) │ │ ├─ InfoItem (Direction) │ │ ├─ InfoItem (Year Built) │ │ ├─ InfoItem (Legal Status) │ │ └─ InfoItem (Project) │ │ │ ├─ Amenities Card (có điều kiện) │ │ ├─ CardHeader │ │ └─ Badge(s) cho mỗi tiện ích │ │ │ └─ Map Card │ ├─ CardHeader │ └─ ListingMap (dynamic import) │ └─ Sidebar (1/3 width - sticky) │ ├─ Contact Card (sticky) │ ├─ Seller Avatar & Name │ ├─ Seller Phone │ ├─ Call Button │ ├─ Message Button │ └─ Agent Info (có điều kiện) │ ├─ AiEstimateButton │ └─ Stats Card ├─ View Count ├─ Save Count ├─ Inquiry Count └─ Published Date ``` --- ## 🖼️ Chi Tiết Component Thư Viện Ảnh ### Tệp: `apps/web/components/listings/image-gallery.tsx` ``` ImageGallery (Client Component) │ ├─ Props: │ ├─ media: PropertyMedia[] │ └─ className?: string │ ├─ State: │ └─ selectedIndex: number │ ├─ Layout: │ │ │ ├─ Main Image Container │ │ ├─ className: "aspect-video" │ │ ├─ Image (Next.js) │ │ ├─ Overlay: Previous Button │ │ ├─ Overlay: Next Button │ │ └─ Overlay: Counter Badge │ │ │ └─ Thumbnail Container (nếu images.length > 1) │ ├─ className: "flex gap-2 overflow-x-auto" │ └─ ThumbnailButton (cho mỗi ảnh) │ ├─ Image (Next.js) │ ├─ Border: selected ? primary : transparent │ ├─ Opacity: 70% (chưa được chọn) │ └─ Hover: opacity 100% │ └─ Handlers: ├─ handlePrev() ├─ handleNext() └─ handleSelectIndex(index) ``` ### Luồng Dữ Liệu ``` Property.media (từ API) ↓ Lọc theo type === 'image' ↓ Sắp xếp theo thuộc tính order ↓ [selectedIndex, setSelectedIndex] → selectedIndex ↓ Image.url tại index → Hiển Thị Chính Tất cả ảnh → Thumbnails selectedIndex → Thumbnail Được Làm Nổi Bật ``` --- ## 📱 Component Tải Lên Ảnh ### Tệp: `apps/web/components/listings/image-upload.tsx` ``` ImageUpload (Client Component) │ ├─ Props: │ ├─ images: ImageFile[] │ ├─ onChange: (images: ImageFile[]) => void │ ├─ maxFiles?: number (mặc định: 20) │ └─ className?: string │ ├─ State: │ └─ isDragging: boolean │ ├─ Vùng Kéo & Thả │ ├─ onDragOver → setIsDragging(true) │ ├─ onDragLeave → setIsDragging(false) │ ├─ onDrop → addFiles() │ └─ onClick → inputRef.click() │ ├─ File Input │ ├─ accept: "image/jpeg,image/png,image/webp" │ ├─ multiple: true │ └─ hidden: true │ └─ Lưới Xem Trước └─ Cho mỗi ảnh: ├─ Xem trước ảnh (URL.createObjectURL) ├─ Huy hiệu ảnh bìa (ảnh đầu tiên) ├─ Nút xóa khi di chuột qua └─ Dọn dẹp khi unmount (URL.revokeObjectURL) Kiểm Tra Hợp Lệ: ├─ Các loại được phép: JPEG, PNG, WebP ├─ Kích thước tối đa: 10MB mỗi tệp └─ Số lượng tối đa: 20 tệp ``` --- ## 🧩 Các Component Liên Quan ### SearchResults & PropertyCard ``` SearchResults │ └─ Lưới PropertyCards │ └─ PropertyCard (cho mỗi listing) │ ├─ Link đến /listings/{id} │ └─ Card ├─ Image Container │ ├─ Image (media[0]) │ ├─ Badge: Transaction Type (overlay) │ ├─ Badge: Property Type (overlay) │ ├─ AddToCompareButton (overlay) │ └─ Badge: Media Count (bottom-right) │ └─ Content ├─ Price ├─ Title ├─ Location └─ Badges (Area, Bedrooms, v.v.) ``` --- ## 🌐 Luồng Dữ Liệu & Ánh Xạ API ### Luồng Từ Server Đến Client ``` 1. Yêu Cầu Trình Duyệt URL: /vi/listings/abc123 │ ↓ 2. Next.js Route Handler [locale]/[id]/page.tsx (Server Component) │ ├─ fetchListingById('abc123') │ └─ API: GET /api/v1/listings/abc123 │ ↓ │ ListingDetail { │ id: string │ property: { │ media: PropertyMedia[] ← Ảnh │ } │ seller: {...} │ agent: {...} │ } │ ├─ generateMetadata() │ └─ Sử dụng property.media[0] cho ảnh OG │ ├─ generateJsonLd() │ └─ Dữ liệu có cấu trúc cho SEO │ └─ │ ↓ 3. Client Component (Đã Hydrate) │ ├─ │ └─ State cục bộ: selectedIndex │ └─ Các component tương tác khác ``` --- ## 🎨 Kiến Trúc Giao Diện ### Cấu Trúc Tailwind CSS ``` Biến CSS Gốc (globals.css) │ ├─ Màu sắc (định dạng HSL) │ ├─ --primary │ ├─ --secondary │ ├─ --background │ ├─ --foreground │ ├─ --muted │ ├─ --accent │ └─ --card │ ├─ Khoảng cách │ └─ Sử dụng thang chuẩn Tailwind │ └─ Bo góc └─ --radius Tailwind Config (tailwind.config.ts) │ ├─ Mở rộng theme │ ├─ Màu sắc ánh xạ từ biến CSS │ └─ Cấu hình bo góc viền │ └─ Plugins └─ tailwindcss-animate ``` ### Các Mẫu Ở Cấp Component ``` ui/button.tsx ├─ CVA (Class Variance Authority) │ ├─ Các class cơ sở │ ├─ Biến thể │ │ ├─ variant: default, outline, ghost, v.v. │ │ └─ size: sm, default, lg, icon │ └─ defaultVariants │ └─ Sử dụng: