Files
goodgo-platform/docs/audits/PROPERTY_DETAIL_COMPONENTS_MAP.md
Ho Ngoc Hai 11f2bf26e6
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
chore: update project documentation, audit reports, and initialize IDE configuration files
2026-04-19 03:12:54 +07:00

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

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:

  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:

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