Files
goodgo-platform/docs/audits/PROPERTY_DETAIL_QUICK_REFERENCE.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

15 KiB

Trang Chi Tiết Bất Động Sản - Tài Liệu Tham Khảo Nhanh & Đoạn Mã

🎯 Điều Hướng Nhanh

Các Route Trang

  • Trang Chi Tiết: apps/web/app/[locale]/(public)/listings/[id]/page.tsx
  • Component Phía Client: apps/web/components/listings/listing-detail-client.tsx
  • Component Thư Viện Ảnh: apps/web/components/listings/image-gallery.tsx
  • Component Tải Ảnh Lên: apps/web/components/listings/image-upload.tsx
  • Thẻ Bất Động Sản: apps/web/components/search/property-card.tsx

Luồng Dữ Liệu

URL: /vi/listings/abc123
   ↓
[id]/page.tsx (Server)
   ├─ fetchListingById('abc123')
   ├─ generateMetadata()
   └─ <ListingDetailClient listing={data} />
      └─ <ImageGallery media={property.media} />

🖼️ Làm Việc Với Hình Ảnh

Tính Năng Thư Viện Ảnh Hiện Tại

// apps/web/components/listings/image-gallery.tsx

interface ImageGalleryProps {
  media: PropertyMedia[];
  className?: string;
}

// Tính năng:
// ✓ Ảnh chính (tỉ lệ khung hình 16:9)
// ✓ Nút Trước/Tiếp theo
// ✓ Huy hiệu đếm ảnh
// ✓ Hình thu nhỏ cuộn ngang (64x64px)
// ✓ Nổi bật trạng thái đã chọn
// ✓ Trạng thái dự phòng khi trống

Cấu Trúc Dữ Liệu

interface PropertyMedia {
  id: string;
  url: string;              // URL đầy đủ đến hình ảnh
  type: 'image' | 'video';  // Bộ lọc loại phương tiện
  order: number;            // Thứ tự sắp xếp (0, 1, 2...)
  caption: string | null;   // Chú thích tùy chọn
}

interface Property {
  // ... các trường khác
  media: PropertyMedia[];   // Mảng hình ảnh/video
}

Sử Dụng Hình Ảnh Trong Các Component

// ✓ Import Next.js Image
import Image from 'next/image';

// ✓ Ảnh chính với bố cục fill (responsive)
<Image
  src={images[index]?.url}
  alt={`Image ${index + 1}`}
  fill
  sizes="(max-width: 768px) 100vw, 60vw"
  className="object-cover"
  priority={index === 0}
/>

// ✓ Hình thu nhỏ kích thước cố định
<Image
  src={img.url}
  alt={`Thumbnail ${i}`}
  fill
  sizes="64px"
  className="object-cover"
/>

// ✓ Thẻ bất động sản responsive
<Image
  src={media[0]?.url}
  alt="Property"
  fill
  sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
  className="object-cover transition-transform group-hover:scale-105"
/>

API Tải Ảnh Lên

// Từ: apps/web/lib/listings-api.ts

const listingsApi = {
  uploadMedia: async (
    listingId: string, 
    file: File, 
    caption?: string
  ) => {
    const formData = new FormData();
    formData.append('file', file);
    if (caption) formData.append('caption', caption);
    
    // POST /api/v1/listings/{listingId}/media
    // Trả về: { mediaId: string; url: string }
  },
};

Component Tải Tệp Lên (để tham khảo)

// apps/web/components/listings/image-upload.tsx

interface ImageFile {
  file: File;
  preview: string;
}

// Cách dùng:
<ImageUpload 
  images={images}
  onChange={(newImages) => setImages(newImages)}
  maxFiles={20}
/>

// Xác thực:
// ✓ Định dạng: JPEG, PNG, WebP
// ✓ Kích thước tối đa: 10MB mỗi tệp
// ✓ Số lượng tối đa: 20 tệp
// ✓ Hỗ trợ kéo và thả
// ✓ Lưới xem trước với nút xóa

🎨 Các Mẫu Tạo Kiểu

Tỉ Lệ Khung Hình (Tailwind)

<!-- 16:9 (video, ảnh chính) -->
<div class="aspect-video"><!-- 1.777:1 --></div>

<!-- 4:3 (thẻ bất động sản) -->
<div class="aspect-[4/3]"><!-- 1.333:1 --></div>

<!-- 16:10 (thẻ bất động sản thu gọn) -->
<div class="aspect-[16/10]"><!-- 1.6:1 --></div>

<!-- Vuông (hình thu nhỏ) -->
<div class="aspect-square"><!-- 1:1 --></div>

Mẫu Container Hình Ảnh

<div className="relative aspect-video overflow-hidden rounded-lg bg-muted">
  <Image
    src={url}
    alt="description"
    fill
    sizes="(max-width: 768px) 100vw, 60vw"
    className="object-cover"
    priority={isMainImage}
  />
</div>

Các Class Tailwind Thông Dụng

// Bố cục
aspect-video         # Tỉ lệ 16:9
aspect-square        # Tỉ lệ 1:1
relative / absolute  # Định vị
fill                 # Object-fit với tỉ lệ khung hình

// Tạo kiểu
object-cover         # Vừa khít ảnh (cắt để lấp đầy)
object-contain       # Vừa khít ảnh (giữ nguyên tỉ lệ)
rounded-lg           # Bo góc
bg-muted            # Nền giữ chỗ

// Tương tác
transition-colors    # Chuyển đổi màu mượt mà
group-hover:scale-105 # Hiệu ứng di chuột
opacity-70          # Độ trong suốt một phần

// Lớp phủ
absolute inset-0    # Lớp phủ toàn bộ
bg-black/50         # Đen bán trong suốt
hover:bg-black/70   # Tối hơn khi di chuột

🔄 Quản Lý Trạng Thái

Mẫu Zustand Store

import { create } from 'zustand';
import { persist } from 'zustand/middleware';

// Store đơn giản
const useMyStore = create<MyState>((set, get) => ({
  count: 0,
  increment: () => set((state) => ({ count: state.count + 1 })),
  asyncAction: async () => {
    set({ isLoading: true });
    // ... công việc bất đồng bộ
    set({ data: result, isLoading: false });
  },
}));

// Store với lưu trữ bền vững
const useComparisonStore = create<State>()(
  persist(
    (set, get) => ({
      // logic store
    }),
    {
      name: 'storage-key',
      partialize: (state) => ({ selectedIds: state.selectedIds }),
    }
  )
);

// Sử dụng trong component
const count = useMyStore((state) => state.count);
const increment = useMyStore((state) => state.increment);

Trạng Thái Cục Bộ Thư Viện Ảnh

const [selectedIndex, setSelectedIndex] = React.useState(0);

const handleNext = () => {
  setSelectedIndex((i) => (i < images.length - 1 ? i + 1 : 0));
};

const handlePrev = () => {
  setSelectedIndex((i) => (i > 0 ? i - 1 : images.length - 1));
};

🧩 Các Mẫu Component UI

Component Button (với các biến thể)

// Từ: apps/web/components/ui/button.tsx

import { Button } from '@/components/ui/button';

// Biến thể:
<Button variant="default">Default</Button>      // Chính
<Button variant="outline">Outline</Button>      // Viền
<Button variant="secondary">Secondary</Button>  // Màu phụ
<Button variant="destructive">Delete</Button>   // Đỏ
<Button variant="ghost">Ghost</Button>          // Không có nền
<Button variant="link">Link</Button>            // Liên kết văn bản

// Kích thước:
<Button size="default">Default</Button>  // Cao 40px
<Button size="sm">Small</Button>         // Cao 36px
<Button size="lg">Large</Button>         // Cao 44px
<Button size="icon">Icon</Button>        // Nút vuông

Component Badge (với các biến thể)

import { Badge } from '@/components/ui/badge';

<Badge variant="default">Primary</Badge>
<Badge variant="secondary">Secondary</Badge>
<Badge variant="outline">Outline</Badge>
<Badge variant="destructive">Destructive</Badge>
<Badge variant="success">Success</Badge>
<Badge variant="warning">Warning</Badge>
<Badge variant="info">Info</Badge>

Component Card

import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';

<Card>
  <CardHeader>
    <CardTitle>Title</CardTitle>
  </CardHeader>
  <CardContent>
    {/* nội dung */}
  </CardContent>
</Card>

Mẫu Dialog/Modal

import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';

const [isOpen, setIsOpen] = React.useState(false);

<Dialog open={isOpen} onOpenChange={setIsOpen}>
  <DialogContent>
    <DialogHeader>
      <DialogTitle>Dialog Title</DialogTitle>
    </DialogHeader>
    {/* nội dung */}
  </DialogContent>
</Dialog>

📱 Thiết Kế Responsive

Các Điểm Ngắt

xs: 0px      /* Mặc định */
sm: 640px    /* Ngang màn hình điện thoại */
md: 768px    /* Máy tính bảng */
lg: 1024px   /* Máy tính để bàn */
xl: 1280px   /* Màn hình rộng */
2xl: 1536px  /* Siêu rộng */

Các Mẫu Thông Dụng

// Ưu tiên mobile
<div className="w-full sm:w-1/2 md:w-1/3 lg:w-1/4">
  
// Hiển thị có điều kiện
<div className="hidden md:block">Hiện trên máy tính bảng trở lên</div>
<div className="block md:hidden">Hiện trên điện thoại</div>

// Lưới responsive
<div className="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 gap-4">

// Cỡ chữ responsive
<p className="text-sm md:text-base lg:text-lg">

// Padding responsive
<div className="p-4 sm:p-6 md:p-8">

🔗 Các Import Thông Dụng

Import Thiết Yếu

// Components
import Image from 'next/image';
import Link from 'next/link';
import dynamic from 'next/dynamic';

// UI Components
import { Button } from '@/components/ui/button';
import { Badge } from '@/components/ui/badge';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';

// Tiện ích
import { cn } from '@/lib/utils';
import { formatPrice, formatPricePerM2 } from '@/lib/currency';

// State & API
import { useAuthStore } from '@/lib/auth-store';
import { useComparisonStore } from '@/lib/comparison-store';
import { listingsApi } from '@/lib/listings-api';

// Hooks
import * as React from 'react';
import { useCallback, useEffect, useState } from 'react';

📊 Lấy Dữ Liệu

Lấy Dữ Liệu Phía Server

// apps/web/lib/listings-server.ts
import { fetchListingById } from '@/lib/listings-server';

// Trong page.tsx (Server Component)
const listing = await fetchListingById(params.id);
if (!listing) notFound();

API Phía Client

// apps/web/lib/listings-api.ts
import { listingsApi } from '@/lib/listings-api';

// Cách dùng:
const listing = await listingsApi.getById(id);
const results = await listingsApi.search({ city: 'Ho Chi Minh' });

Sử Dụng React Query (có thể)

// Mẫu thông thường để lấy dữ liệu
import { useQuery } from '@tanstack/react-query';

const { data, isLoading, error } = useQuery({
  queryKey: ['listing', id],
  queryFn: () => listingsApi.getById(id),
});

🌐 Đa Ngôn Ngữ

Hỗ Trợ Ngôn Ngữ

  • Tiếng Việt (vi)
  • Tiếng Anh (en)

Sử Dụng i18n

// Trong các component, dùng nhãn tiếng Việt trực tiếp hoặc từ hằng số
const PROPERTY_TYPES: Record<string, string> = {
  APARTMENT: 'Căn hộ',
  HOUSE: 'Nhà riêng',
  VILLA: 'Biệt thự',
  // ...
};

// Từ @/lib/validations/listings
import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings';

Route Nhận Biết Ngôn Ngữ

/vi/listings/123   # Tiếng Việt
/en/listings/123   # Tiếng Anh

🔐 Tính Năng Bảo Mật

Tiêu Đề CSP (next.config.js)

img-src 'self' data: blob: https://*.mapbox.com https://
font-src 'self' data:

Danh Sách Cho Phép Domain Hình Ảnh

// Cho phép hình ảnh HTTPS từ bất kỳ domain nào
remotePatterns: [
  { protocol: 'https', hostname: '**' }
]

🧪 Cân Nhắc Khi Kiểm Thử

Các Tệp Component Cần Kiểm Thử

  • image-gallery.tsx - Điều hướng thư viện, thay đổi trạng thái
  • image-upload.tsx - Xác thực tệp, kéo và thả
  • property-card.tsx - Hiển thị hình ảnh, responsive
  • listing-detail-client.tsx - Chức năng tổng thể của trang

Các Mẫu Kiểm Thử

// Mock component Next.js Image
jest.mock('next/image', () => ({
  __esModule: true,
  default: (props) => <img {...props} />,
}));

// Mock Zustand stores
jest.mock('@/lib/auth-store', () => ({
  useAuthStore: jest.fn(),
}));

🚀 Mẹo Tối Ưu Hiệu Suất

  1. Ưu Tiên Hình Ảnh

    priority={selectedIndex === 0}  // Ảnh đầu tiên tải cùng trang
    
  2. Kích Thước Responsive

    sizes="(max-width: 768px) 100vw, 60vw"  // Cho trình duyệt biết chiều rộng ảnh
    
  3. Tải Lười (Lazy Loading)

    • Hình thu nhỏ tải theo yêu cầu (không đặt priority)
    • Giảm tải ban đầu của trang
  4. Dynamic Import

    const ListingMap = dynamic(() => import('@/components/map/listing-map'), {
      ssr: false,
      loading: () => <div>Loading...</div>,
    });
    
  5. Dọn Dẹp Object URL

    React.useEffect(() => {
      return () => {
        images.forEach((img) => URL.revokeObjectURL(img.preview));
      };
    }, []);
    

📋 Các Tác Vụ Thông Dụng

Thêm Một Phần Tử UI Mới

  1. Tạo trong components/ui/ComponentName.tsx
  2. Dùng CVA cho các biến thể
  3. Export từ cùng tệp
  4. Import và sử dụng trong các component tính năng

Thêm Một Component Tính Năng Mới

  1. Tạo trong components/feature-name/ComponentName.tsx
  2. Thêm 'use client' nếu có tương tác
  3. Import các component UI
  4. Dùng Zustand stores nếu cần trạng thái toàn cục
  5. Dùng trạng thái cục bộ cho trạng thái UI

Chỉnh Sửa Thư Viện Ảnh

  1. Chỉnh sửa components/listings/image-gallery.tsx
  2. Cập nhật interface PropertyMedia nếu cần (trong lib/listings-api.ts)
  3. Điều chỉnh tỉ lệ khung hình / kích thước theo nhu cầu
  4. Kiểm tra hành vi responsive

Thêm Lightbox Hình Ảnh

  1. Chọn thư viện (embla-carousel, yet-another-react-lightbox, v.v.)
  2. Cài đặt: pnpm add package-name -F @goodgo/web
  3. Tạo component wrapper trong components/listings/image-lightbox.tsx
  4. Tích hợp với image-gallery.tsx
  5. Kiểm thử với nhiều hình ảnh

🐛 Các Vấn Đề Thường Gặp & Giải Pháp

Hình Ảnh Không Tải Được

  • Kiểm tra URL hợp lệ và là HTTPS
  • Xác minh domain trong remotePatterns
  • Kiểm tra tiêu đề CSP cho phép domain đó

Điều Hướng Thư Viện Bị Đóng Băng

  • Kiểm tra cập nhật trạng thái selectedIndex
  • Xác minh các handler onClick được bind đúng cách
  • Kiểm tra lỗi JavaScript trong console

Vấn Đề Cuộn Hình Thu Nhỏ

  • Đảm bảo container cha có overflow-x-auto
  • Kiểm tra thuộc tính flex trên hình thu nhỏ
  • Xác minh ràng buộc chiều rộng (flex-shrink-0)

Dịch Chuyển Bố Cục Khi Tải Ảnh

  • Dùng container tỉ lệ khung hình
  • Đặt chiều rộng/chiều cao rõ ràng
  • Dùng bố cục fill với container

📚 Tài Nguyên Bổ Sung