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
15 KiB
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áiimage-upload.tsx- Xác thực tệp, kéo và thảproperty-card.tsx- Hiển thị hình ảnh, responsivelisting-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
-
Ưu Tiên Hình Ảnh
priority={selectedIndex === 0} // Ảnh đầu tiên tải cùng trang -
Kích Thước Responsive
sizes="(max-width: 768px) 100vw, 60vw" // Cho trình duyệt biết chiều rộng ảnh -
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
-
Dynamic Import
const ListingMap = dynamic(() => import('@/components/map/listing-map'), { ssr: false, loading: () => <div>Loading...</div>, }); -
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
- Tạo trong
components/ui/ComponentName.tsx - Dùng CVA cho các biến thể
- Export từ cùng tệp
- Import và sử dụng trong các component tính năng
Thêm Một Component Tính Năng Mới
- Tạo trong
components/feature-name/ComponentName.tsx - Thêm 'use client' nếu có tương tác
- Import các component UI
- Dùng Zustand stores nếu cần trạng thái toàn cục
- Dùng trạng thái cục bộ cho trạng thái UI
Chỉnh Sửa Thư Viện Ảnh
- Chỉnh sửa
components/listings/image-gallery.tsx - Cập nhật interface PropertyMedia nếu cần (trong
lib/listings-api.ts) - Điều chỉnh tỉ lệ khung hình / kích thước theo nhu cầu
- Kiểm tra hành vi responsive
Thêm Lightbox Hình Ảnh
- Chọn thư viện (embla-carousel, yet-another-react-lightbox, v.v.)
- Cài đặt:
pnpm add package-name -F @goodgo/web - Tạo component wrapper trong
components/listings/image-lightbox.tsx - Tích hợp với
image-gallery.tsx - 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
fillvớ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