Some checks failed
CI / Lint → Typecheck → Test → Build (22) (push) Failing after 29s
CI / E2E Tests (push) Has been skipped
CodeQL Analysis / CodeQL (javascript-typescript) (push) Failing after 2m42s
Deploy / Build Web Image (push) Failing after 27s
Deploy / Build AI Services Image (push) Failing after 29s
E2E Tests / Playwright E2E (push) Failing after 43s
Deploy / Build API Image (push) Failing after 1m31s
Security Scanning / Dependency Audit (pnpm) (push) Failing after 6s
Security Scanning / Trivy Scan — API Image (push) Failing after 5m35s
Security Scanning / Trivy Scan — AI Services Image (push) Failing after 3m45s
Deploy / Deploy to Staging (push) Has been skipped
Deploy / Smoke Test Staging (push) Has been skipped
Deploy / Deploy to Production (push) Has been skipped
Deploy / Smoke Test Production (push) Has been skipped
Deploy / Rollback Staging (push) Has been skipped
Deploy / Rollback Production (push) Has been skipped
Security Scanning / Trivy Scan — Web Image (push) Failing after 13m51s
Security Scanning / Trivy Filesystem Scan (push) Failing after 14m46s
Security Scanning / Security Gate (push) Has been cancelled
602 lines
16 KiB
Markdown
602 lines
16 KiB
Markdown
# 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
|
|
│
|
|
└─ <ListingDetailClient listing={data} />
|
|
│
|
|
↓
|
|
3. Client Component (Đã Hydrate)
|
|
│
|
|
├─ <ImageGallery media={property.media} />
|
|
│ └─ 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: <Button variant="default" size="lg" />
|
|
|
|
ui/badge.tsx
|
|
├─ Biến thể CVA
|
|
│ ├─ default (primary)
|
|
│ ├─ secondary
|
|
│ ├─ outline
|
|
│ └─ ...màu sắc (success, warning, info)
|
|
│
|
|
└─ Sử dụng: <Badge variant="secondary">Text</Badge>
|
|
```
|
|
|
|
---
|
|
|
|
## 📊 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
|
|
|
|
<Image
|
|
onClick={() => 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
|
|
|