# 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:
ui/badge.tsx
├─ Biến thể CVA
│ ├─ default (primary)
│ ├─ secondary
│ ├─ outline
│ └─ ...màu sắc (success, warning, info)
│
└─ Sử dụng: Text
```
---
## 📊 Các Mẫu Quản Lý State
### State Cục Bộ Của Gallery
```
Component: ImageGallery
State: selectedIndex (number)
├─ Khởi tạo: 0
├─ Cập nhật khi: prev, next, nhấn vào thumbnail
└─ Sử dụng: Hiển thị ảnh chính, làm nổi bật thumbnail
```
### State Toàn Cục (Zustand)
```
Auth Store
├─ user: UserProfile | null
├─ isAuthenticated: boolean
├─ isLoading: boolean
├─ error: string | null
└─ Actions: login, logout, fetchProfile
Comparison Store
├─ selectedIds: string[] (được lưu trữ)
├─ listings: ListingDetail[]
├─ isLoading: boolean
├─ error: string | null
└─ Actions: addToCompare, removeFromCompare, v.v.
```
---
## 🔗 Bản Đồ Import
### Tham Chiếu Cấu Trúc Tệp
```
apps/web/
│
├─ app/[locale]/(public)/listings/[id]/
│ └─ page.tsx ──────────────────────────── ĐIỂM VÀO
│ │
│ └─ imports:
│ ├─ ListingDetailClient
│ ├─ JsonLd
│ ├─ fetchListingById
│ └─ các tiện ích định dạng
│
├─ components/
│ │
│ ├─ listings/
│ │ ├─ listing-detail-client.tsx ────────── CLIENT COMPONENT
│ │ │ ├─ imports: ImageGallery
│ │ │ ├─ imports: AddToCompareButton
│ │ │ ├─ imports: AiEstimateButton
│ │ │ └─ dynamic: ListingMap
│ │ │
│ │ ├─ image-gallery.tsx ───────────────── HIỂN THỊ ẢNH CHÍNH
│ │ │ └─ imports: Next.js Image
│ │ │
│ │ └─ image-upload.tsx ──────────────────── TẢI LÊN TỆP
│ │ └─ imports: Button component
│ │
│ ├─ ui/
│ │ ├─ button.tsx
│ │ ├─ badge.tsx
│ │ ├─ card.tsx
│ │ ├─ dialog.tsx
│ │ └─ ...các component UI khác
│ │
│ └─ search/
│ └─ property-card.tsx ──────────────── DẠNG XEM THUMBNAIL
│ └─ imports: ImageGallery pattern
│
└─ lib/
├─ listings-api.ts ────────────────────── KIỂU DỮ LIỆU & HÀM API
│ └─ PropertyMedia interface
│ └─ ListingDetail interface
│
├─ auth-store.ts
├─ comparison-store.ts
└─ utils.ts
```
---
## 📈 Các Mức Độ Phức Tạp Của Component
### Cấp 1: Các Component UI Đơn Giản
```
Badge, Button, Input, Label
├─ Props: props cơ bản + biến thể
├─ State: không có
└─ Tương tác: click, focus, hover
```
### Cấp 2: Các Component Tổng Hợp
```
Card (Header + Content + Footer)
ImageUpload (Kéo-thả + Lưới xem trước)
├─ Props: nội dung children
├─ State: state cục bộ
└─ Tương tác: click, kéo, nhập liệu
```
### Cấp 3: Các Component Tính Năng
```
ImageGallery (Chính + Thumbnails)
PropertyCard (Link + Image + Info)
├─ Props: dữ liệu + callbacks
├─ State: chỉ mục được chọn, trạng thái UI
└─ Tương tác: điều hướng, lọc
```
### Cấp 4: Các Component Trang
```
ListingDetailClient (Bố cục trang đầy đủ)
├─ Props: dữ liệu listing
├─ State: nhiều tính năng
├─ Tương tác: tất cả tương tác người dùng
└─ Children: nhiều component tính năng
```
---
## 🚀 Cân Nhắc Về Hiệu Suất
### Tối Ưu Hóa Ảnh
```
Chiến Lược Component Ảnh:
├─ Next.js Image component
│ ├─ Tự động chọn định dạng (WebP, AVIF)
│ ├─ Phục vụ đáp ứng qua srcset
│ └─ Thay đổi kích thước theo yêu cầu
│
├─ Lazy Loading
│ ├─ Ảnh chính: priority={selectedIndex === 0}
│ └─ Thumbnails: không có priority (lazy)
│
└─ Kích Thước Đáp Ứng
├─ Thuộc tính sizes: cho trình duyệt biết kích thước ảnh
└─ Ngăn chặn thay đổi bố cục
```
### Phân Tách Code
```
Dynamic Imports:
├─ ListingMap (nặng, thư viện bản đồ)
│ ├─ ssr: false (chỉ dành cho client)
│ └─ loading: component giữ chỗ
│
└─ Các component khác: đóng gói cùng trang
```
### Tối Ưu Hóa State
```
Zustand:
├─ Mẫu Selector: useStore(state => state.field)
├─ Chỉ render lại khi state được chọn thay đổi
└─ Middleware Persist: localStorage chỉ cho dữ liệu cần thiết
```
---
## 🔄 Luồng Điều Hướng
### Hành Trình Người Dùng - Thư Viện Ảnh
```
1. Người dùng truy cập /vi/listings/123
└─ Trang tải với ảnh đầu tiên (priority=true)
2. Người dùng tương tác với gallery:
├─ Nhấn vào thumbnail
│ └─ setSelectedIndex(index) → ảnh chính cập nhật
│
├─ Nhấn nút tiếp theo
│ └─ setSelectedIndex(i + 1) → quay về 0
│
└─ Nhấn nút trước đó
└─ setSelectedIndex(i - 1) → quay về cuối
```
### Hành Trình Người Dùng - Tải Lên Tệp (Tạo/Chỉnh Sửa Listing)
```
1. Người dùng mở biểu mẫu listing
└─ Component ImageUpload được gắn kết
2. Người dùng thêm ảnh:
├─ Kéo & thả tệp
│ └─ addFiles() → lọc + kiểm tra → onChange()
│
└─ Nhấn để duyệt
└─ Chọn tệp → giống như kéo & thả
3. Người dùng xóa ảnh:
└─ removeImage(index) → dọn dẹp URL → onChange()
4. Người dùng gửi biểu mẫu:
└─ Ảnh được tải lên qua listingsApi.uploadMedia()
```
---
## 📋 Danh Sách Kiểm Tra Component
### Tính Năng Thư Viện Ảnh
- [x] Hiển thị ảnh chính (đáp ứng)
- [x] Điều hướng Trước/Tiếp theo
- [x] Huy hiệu đếm ảnh
- [x] Điều hướng thumbnail (có thể cuộn)
- [x] Làm nổi bật thumbnail được chọn
- [x] Trạng thái dự phòng khi trống
- [ ] Phóng to lightbox/modal (CHƯA triển khai)
- [ ] Điều hướng bàn phím (CHƯA triển khai)
- [ ] Cử chỉ chạm (CHƯA triển khai)
### Tính Năng Tải Lên Ảnh
- [x] Kéo & thả
- [x] Nhấn để duyệt
- [x] Kiểm tra loại tệp
- [x] Kiểm tra kích thước tệp
- [x] Lưới xem trước
- [x] Nút xóa
- [x] Chỉ báo ảnh bìa
- [x] Dọn dẹp URL khi unmount
- [ ] Thanh tiến trình (CHƯA triển khai)
- [ ] Tiến trình tải lên nhiều tệp (CHƯA triển khai)
### SEO & Metadata
- [x] Ảnh Open Graph
- [x] Ảnh Twitter Card
- [x] Schema JSON-LD
- [x] URL Canonical
- [x] Liên kết ngôn ngữ thay thế
- [x] Văn bản alt mô tả
---
## 🛠️ Hướng Dẫn Bảo Trì
### Thêm Tính Năng Ảnh Mới
**Thêm vào ImageGallery:**
1. Định nghĩa prop mới trong interface
2. Cập nhật state nếu cần
3. Cập nhật logic render
4. Kiểm tra hành vi đáp ứng
5. Cập nhật các kiểu TypeScript
**Ví dụ - Thêm tính năng zoom:**
```typescript
interface ImageGalleryProps {
media: PropertyMedia[];
className?: string;
onImageClick?: (index: number) => void; // MỚI
}
// Trong component:
const [isZoomed, setIsZoomed] = useState(false); // MỚI
setIsZoomed(true)} // MỚI
cursor={isZoomed ? 'zoom-out' : 'zoom-in'} // MỚI
/>
```
### Cập Nhật Cấu Trúc Dữ Liệu Ảnh
**Nếu PropertyMedia thay đổi:**
1. Cập nhật interface trong `lib/listings-api.ts`
2. Cập nhật ánh xạ phản hồi API
3. Cập nhật component gallery để sử dụng các trường mới
4. Cập nhật các bài kiểm thử
5. Cập nhật tài liệu API
---
## 📚 Tài Liệu Liên Quan
Xem thêm:
- `PROPERTY_DETAIL_PAGE_ANALYSIS.md` - Phân tích toàn diện
- `PROPERTY_DETAIL_QUICK_REFERENCE.md` - Đoạn code & các mẫu