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
16 KiB
16 KiB
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
- Hiển thị ảnh chính (đáp ứng)
- Điều hướng Trước/Tiếp theo
- Huy hiệu đếm ảnh
- Điều hướng thumbnail (có thể cuộn)
- Làm nổi bật thumbnail được chọn
- 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
- Kéo & thả
- Nhấn để duyệt
- Kiểm tra loại tệp
- Kiểm tra kích thước tệp
- Lưới xem trước
- Nút xóa
- Chỉ báo ảnh bìa
- 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
- Ảnh Open Graph
- Ảnh Twitter Card
- Schema JSON-LD
- URL Canonical
- Liên kết ngôn ngữ thay thế
- 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:
- Định nghĩa prop mới trong interface
- Cập nhật state nếu cần
- Cập nhật logic render
- Kiểm tra hành vi đáp ứng
- Cập nhật các kiểu TypeScript
Ví dụ - Thêm tính năng zoom:
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:
- Cập nhật interface trong
lib/listings-api.ts - Cập nhật ánh xạ phản hồi API
- Cập nhật component gallery để sử dụng các trường mới
- Cập nhật các bài kiểm thử
- 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ệnPROPERTY_DETAIL_QUICK_REFERENCE.md- Đoạn code & các mẫu