# 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() └─ └─ ``` --- ## 🖼️ Làm Việc Với Hình Ảnh ### Tính Năng Thư Viện Ảnh Hiện Tại ```typescript // 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 ```typescript 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 ```typescript // ✓ Import Next.js Image import Image from 'next/image'; // ✓ Ảnh chính với bố cục fill (responsive) {`Image // ✓ Hình thu nhỏ kích thước cố định {`Thumbnail // ✓ Thẻ bất động sản responsive Property ``` ### API Tải Ảnh Lên ```typescript // 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) ```typescript // apps/web/components/listings/image-upload.tsx interface ImageFile { file: File; preview: string; } // Cách dùng: 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) ```html
``` ### Mẫu Container Hình Ảnh ```jsx
description
``` ### 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 ```typescript import { create } from 'zustand'; import { persist } from 'zustand/middleware'; // Store đơn giản const useMyStore = create((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()( 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 ```typescript 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ể) ```typescript // Từ: apps/web/components/ui/button.tsx import { Button } from '@/components/ui/button'; // Biến thể: // Chính // Viền // Màu phụ // Đỏ // Không có nền // Liên kết văn bản // Kích thước: // Cao 40px // Cao 36px // Cao 44px // Nút vuông ``` ### Component Badge (với các biến thể) ```typescript import { Badge } from '@/components/ui/badge'; Primary Secondary Outline Destructive Success Warning Info ``` ### Component Card ```typescript import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; Title {/* nội dung */} ``` ### Mẫu Dialog/Modal ```typescript import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog'; const [isOpen, setIsOpen] = React.useState(false); Dialog Title {/* nội dung */} ``` --- ## 📱 Thiết Kế Responsive ### Các Điểm Ngắt ```css 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 ```jsx // Ưu tiên mobile
// Hiển thị có điều kiện
Hiện trên máy tính bảng trở lên
Hiện trên điện thoại
// Lưới responsive
// Cỡ chữ responsive

// Padding responsive

``` --- ## 🔗 Các Import Thông Dụng ### Import Thiết Yếu ```typescript // 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 ```typescript // 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 ```typescript // 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ể) ```typescript // 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 ```typescript // 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 = { 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) ```javascript img-src 'self' data: blob: https://*.mapbox.com https:// font-src 'self' data: ``` ### Danh Sách Cho Phép Domain Hình Ảnh ```javascript // 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ử ```typescript // Mock component Next.js Image jest.mock('next/image', () => ({ __esModule: true, default: (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** ```typescript priority={selectedIndex === 0} // Ảnh đầu tiên tải cùng trang ``` 2. **Kích Thước Responsive** ```typescript 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** ```typescript const ListingMap = dynamic(() => import('@/components/map/listing-map'), { ssr: false, loading: () =>
Loading...
, }); ``` 5. **Dọn Dẹp Object URL** ```typescript 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 - **Next.js Image**: https://nextjs.org/docs/app/api-reference/components/image - **Tailwind CSS**: https://tailwindcss.com/docs - **Zustand**: https://github.com/pmndrs/zustand - **CVA**: https://cva.style/docs - **React Query**: https://tanstack.com/query/latest