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

583 lines
15 KiB
Markdown

# 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
```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
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
```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:
<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)
```html
<!-- 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
```jsx
<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
```typescript
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
```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ể:
<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ể)
```typescript
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
```typescript
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
```typescript
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
```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
<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
```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<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)
```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) => <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**
```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: () => <div>Loading...</div>,
});
```
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