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
583 lines
15 KiB
Markdown
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
|