From 207a2013f326ea091487e01167f7f58d0e1bf033 Mon Sep 17 00:00:00 2001 From: Ho Ngoc Hai Date: Wed, 8 Apr 2026 01:54:08 +0700 Subject: [PATCH] feat(listings-frontend): add create/edit form, detail page, and listing components - Multi-step wizard for listing creation (basic info, location, details, pricing, images) - Listing detail page with image gallery, property specs, seller/agent info, stats - Listings index page with filters (transaction type, property type) and pagination - Edit page with tab-based form (read-only until backend PATCH endpoint available) - Drag & drop image upload component with preview and multi-file support - Dashboard layout with navigation bar - New UI primitives: textarea, select, badge, tabs - Listings API client with typed endpoints matching backend contract - Zod validation schemas for all form steps - Status badges with Vietnamese labels for all listing states - Responsive design across all pages Co-Authored-By: Paperclip --- apps/web/app/(dashboard)/layout.tsx | 61 +++++ .../(dashboard)/listings/[id]/edit/page.tsx | 131 +++++++++ .../app/(dashboard)/listings/[id]/page.tsx | 226 ++++++++++++++++ .../web/app/(dashboard)/listings/new/page.tsx | 228 ++++++++++++++++ apps/web/app/(dashboard)/listings/page.tsx | 172 ++++++++++++ apps/web/app/(dashboard)/page.tsx | 54 ++++ apps/web/app/page.tsx | 8 - .../web/components/listings/image-gallery.tsx | 84 ++++++ apps/web/components/listings/image-upload.tsx | 167 ++++++++++++ .../listings/listing-form-steps.tsx | 249 ++++++++++++++++++ .../listings/listing-status-badge.tsx | 12 + apps/web/components/ui/badge.tsx | 33 +++ apps/web/components/ui/select.tsx | 24 ++ apps/web/components/ui/tabs.tsx | 90 +++++++ apps/web/components/ui/textarea.tsx | 22 ++ apps/web/lib/api-client.ts | 3 + apps/web/lib/listings-api.ts | 179 +++++++++++++ apps/web/lib/validations/listings.ts | 99 +++++++ 18 files changed, 1834 insertions(+), 8 deletions(-) create mode 100644 apps/web/app/(dashboard)/layout.tsx create mode 100644 apps/web/app/(dashboard)/listings/[id]/edit/page.tsx create mode 100644 apps/web/app/(dashboard)/listings/[id]/page.tsx create mode 100644 apps/web/app/(dashboard)/listings/new/page.tsx create mode 100644 apps/web/app/(dashboard)/listings/page.tsx create mode 100644 apps/web/app/(dashboard)/page.tsx delete mode 100644 apps/web/app/page.tsx create mode 100644 apps/web/components/listings/image-gallery.tsx create mode 100644 apps/web/components/listings/image-upload.tsx create mode 100644 apps/web/components/listings/listing-form-steps.tsx create mode 100644 apps/web/components/listings/listing-status-badge.tsx create mode 100644 apps/web/components/ui/badge.tsx create mode 100644 apps/web/components/ui/select.tsx create mode 100644 apps/web/components/ui/tabs.tsx create mode 100644 apps/web/components/ui/textarea.tsx create mode 100644 apps/web/lib/listings-api.ts create mode 100644 apps/web/lib/validations/listings.ts diff --git a/apps/web/app/(dashboard)/layout.tsx b/apps/web/app/(dashboard)/layout.tsx new file mode 100644 index 0000000..2700c50 --- /dev/null +++ b/apps/web/app/(dashboard)/layout.tsx @@ -0,0 +1,61 @@ +'use client'; + +import Link from 'next/link'; +import { usePathname } from 'next/navigation'; +import { cn } from '@/lib/utils'; +import { useAuthStore } from '@/lib/auth-store'; +import { Button } from '@/components/ui/button'; + +const navItems = [ + { href: '/', label: 'Trang chủ', icon: '🏠' }, + { href: '/listings', label: 'Tin đăng', icon: '📋' }, + { href: '/listings/new', label: 'Đăng tin', icon: '➕' }, +]; + +export default function DashboardLayout({ children }: { children: React.ReactNode }) { + const pathname = usePathname(); + const { user, logout } = useAuthStore(); + + return ( +
+
+
+ + GoodGo + + + + +
+ {user && ( + + {user.fullName} + + )} + +
+
+
+ +
{children}
+
+ ); +} diff --git a/apps/web/app/(dashboard)/listings/[id]/edit/page.tsx b/apps/web/app/(dashboard)/listings/[id]/edit/page.tsx new file mode 100644 index 0000000..5f1e033 --- /dev/null +++ b/apps/web/app/(dashboard)/listings/[id]/edit/page.tsx @@ -0,0 +1,131 @@ +'use client'; + +import * as React from 'react'; +import { useParams, useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { + StepBasicInfo, + StepLocation, + StepDetails, + StepPricing, +} from '@/components/listings/listing-form-steps'; +import { + createListingSchema, + type CreateListingFormData, +} from '@/lib/validations/listings'; +import { listingsApi, type ListingDetail } from '@/lib/listings-api'; +import { Tabs, TabsList, TabsTrigger, TabsContent } from '@/components/ui/tabs'; + +export default function EditListingPage() { + const { id } = useParams<{ id: string }>(); + const router = useRouter(); + const [listing, setListing] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [activeTab, setActiveTab] = React.useState('basic'); + + const { + register, + reset, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createListingSchema), + mode: 'onTouched', + }); + + React.useEffect(() => { + listingsApi + .getById(id) + .then((data) => { + setListing(data); + const { property } = data; + reset({ + transactionType: data.transactionType, + propertyType: property.propertyType, + title: property.title, + description: property.description, + address: property.address, + ward: property.ward, + district: property.district, + city: property.city, + areaM2: String(property.areaM2), + bedrooms: property.bedrooms != null ? String(property.bedrooms) : '', + bathrooms: property.bathrooms != null ? String(property.bathrooms) : '', + floors: property.floors != null ? String(property.floors) : '', + direction: property.direction ?? '', + yearBuilt: property.yearBuilt != null ? String(property.yearBuilt) : '', + legalStatus: property.legalStatus ?? '', + projectName: property.projectName ?? '', + amenities: property.amenities?.join(', ') ?? '', + priceVND: data.priceVND, + rentPriceMonthly: data.rentPriceMonthly ?? '', + commissionPct: data.commissionPct != null ? String(data.commissionPct) : '', + }); + }) + .catch(() => setListing(null)) + .finally(() => setLoading(false)); + }, [id, reset]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (!listing) { + return ( +
+

Không tìm thấy tin đăng

+ +
+ ); + } + + return ( +
+
+

Chỉnh sửa tin đăng

+ +
+ +

+ Chức năng chỉnh sửa sẽ được hoàn thiện khi backend API hỗ trợ PATCH /listings/:id. + Hiện tại bạn có thể xem lại thông tin đã nhập. +

+ + + + Cơ bản + Vị trí + Chi tiết + Giá cả + + + + + + + + + + + + + + + + + + + +
+ ); +} diff --git a/apps/web/app/(dashboard)/listings/[id]/page.tsx b/apps/web/app/(dashboard)/listings/[id]/page.tsx new file mode 100644 index 0000000..c00ba45 --- /dev/null +++ b/apps/web/app/(dashboard)/listings/[id]/page.tsx @@ -0,0 +1,226 @@ +'use client'; + +import * as React from 'react'; +import Link from 'next/link'; +import { useParams } from 'next/navigation'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { ImageGallery } from '@/components/listings/image-gallery'; +import { ListingStatusBadge } from '@/components/listings/listing-status-badge'; +import { listingsApi, type ListingDetail } from '@/lib/listings-api'; +import { PROPERTY_TYPES, DIRECTIONS, TRANSACTION_TYPES } from '@/lib/validations/listings'; + +function formatPrice(priceVND: string): string { + const num = Number(priceVND); + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`; + return num.toLocaleString('vi-VN'); +} + +function getLabel(list: readonly { value: string; label: string }[], value: string | null) { + if (!value) return '—'; + return list.find((item) => item.value === value)?.label ?? value; +} + +export default function ListingDetailPage() { + const { id } = useParams<{ id: string }>(); + const [listing, setListing] = React.useState(null); + const [loading, setLoading] = React.useState(true); + const [error, setError] = React.useState(null); + + React.useEffect(() => { + listingsApi + .getById(id) + .then(setListing) + .catch((err) => setError(err instanceof Error ? err.message : 'Không tải được tin đăng')) + .finally(() => setLoading(false)); + }, [id]); + + if (loading) { + return ( +
+
+
+ ); + } + + if (error || !listing) { + return ( +
+

{error || 'Không tìm thấy tin đăng'}

+ + + +
+ ); + } + + const { property, seller, agent } = listing; + + return ( +
+ {/* Header */} +
+
+
+ + + {getLabel(TRANSACTION_TYPES, listing.transactionType)} + + + {getLabel(PROPERTY_TYPES, property.propertyType)} + +
+

{property.title}

+

+ {property.address}, {property.ward}, {property.district}, {property.city} +

+
+
+

{formatPrice(listing.priceVND)} VNĐ

+ {listing.pricePerM2 && ( +

+ ~{listing.pricePerM2.toLocaleString('vi-VN')} VNĐ/m² +

+ )} + {listing.rentPriceMonthly && ( +

+ Thuê: {formatPrice(listing.rentPriceMonthly)}/tháng +

+ )} +
+
+ + {/* Image gallery */} + + +
+ {/* Main content */} +
+ {/* Key specs */} + + + Thông tin chung + + +
+ + + + + + + + +
+
+
+ + {/* Description */} + + + Mô tả + + +

{property.description}

+
+
+ + {/* Amenities */} + {property.amenities && property.amenities.length > 0 && ( + + + Tiện ích + + +
+ {property.amenities.map((a) => ( + + {a} + + ))} +
+
+
+ )} +
+ + {/* Sidebar */} +
+ {/* Seller info */} + + + Liên hệ + + +
+

{seller.fullName}

+

{seller.phone}

+
+ + +
+
+ + {/* Agent info */} + {agent && ( + + + Môi giới + + + {agent.agency &&

{agent.agency}

} + {listing.commissionPct != null && ( +

Hoa hồng: {listing.commissionPct}%

+ )} +
+
+ )} + + {/* Stats */} + + + Thống kê + + +
+
+ Lượt xem + {listing.viewCount} +
+
+ Lượt lưu + {listing.saveCount} +
+
+ Liên hệ + {listing.inquiryCount} +
+ {listing.publishedAt && ( +
+ Đăng ngày + + {new Date(listing.publishedAt).toLocaleDateString('vi-VN')} + +
+ )} +
+
+
+
+
+
+ ); +} + +function InfoItem({ label, value }: { label: string; value: string }) { + return ( +
+

{label}

+

{value}

+
+ ); +} diff --git a/apps/web/app/(dashboard)/listings/new/page.tsx b/apps/web/app/(dashboard)/listings/new/page.tsx new file mode 100644 index 0000000..4454a2f --- /dev/null +++ b/apps/web/app/(dashboard)/listings/new/page.tsx @@ -0,0 +1,228 @@ +'use client'; + +import * as React from 'react'; +import { useRouter } from 'next/navigation'; +import { useForm } from 'react-hook-form'; +import { zodResolver } from '@hookform/resolvers/zod'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent } from '@/components/ui/card'; +import { ImageUpload, type ImageFile } from '@/components/listings/image-upload'; +import { + StepBasicInfo, + StepLocation, + StepDetails, + StepPricing, +} from '@/components/listings/listing-form-steps'; +import { + createListingSchema, + listingBasicSchema, + listingLocationSchema, + listingDetailsSchema, + listingPricingSchema, + type CreateListingFormData, +} from '@/lib/validations/listings'; +import { listingsApi, type CreateListingPayload, type Direction } from '@/lib/listings-api'; +import { useAuthStore } from '@/lib/auth-store'; +import { cn } from '@/lib/utils'; + +const STEPS = [ + { title: 'Thông tin', schemaKeys: Object.keys(listingBasicSchema.shape) }, + { title: 'Vị trí', schemaKeys: Object.keys(listingLocationSchema.shape) }, + { title: 'Chi tiết', schemaKeys: Object.keys(listingDetailsSchema.shape) }, + { title: 'Giá cả', schemaKeys: Object.keys(listingPricingSchema.shape) }, + { title: 'Hình ảnh', schemaKeys: null }, +]; + +function toNum(val: string | undefined): number | undefined { + if (!val) return undefined; + const n = Number(val); + return isNaN(n) ? undefined : n; +} + +export default function CreateListingPage() { + const router = useRouter(); + const { tokens } = useAuthStore(); + const [currentStep, setCurrentStep] = React.useState(0); + const [images, setImages] = React.useState([]); + const [isSubmitting, setIsSubmitting] = React.useState(false); + const [error, setError] = React.useState(null); + + const { + register, + handleSubmit, + trigger, + formState: { errors }, + } = useForm({ + resolver: zodResolver(createListingSchema), + mode: 'onTouched', + }); + + const goNext = async () => { + const step = STEPS[currentStep]; + if (step?.schemaKeys) { + const valid = await trigger(step.schemaKeys as Array); + if (!valid) return; + } + setCurrentStep((s) => Math.min(s + 1, STEPS.length - 1)); + }; + + const goBack = () => setCurrentStep((s) => Math.max(s - 1, 0)); + + const onSubmit = async (data: CreateListingFormData) => { + if (!tokens?.accessToken) { + setError('Vui lòng đăng nhập để đăng tin'); + return; + } + + setIsSubmitting(true); + setError(null); + + try { + const payload: CreateListingPayload = { + transactionType: data.transactionType, + propertyType: data.propertyType, + title: data.title, + description: data.description, + address: data.address, + ward: data.ward, + district: data.district, + city: data.city, + latitude: toNum(data.latitude) ?? 0, + longitude: toNum(data.longitude) ?? 0, + areaM2: Number(data.areaM2), + priceVND: data.priceVND, + }; + + const usableAreaM2 = toNum(data.usableAreaM2); + if (usableAreaM2 != null) payload.usableAreaM2 = usableAreaM2; + const bedrooms = toNum(data.bedrooms); + if (bedrooms != null) payload.bedrooms = bedrooms; + const bathrooms = toNum(data.bathrooms); + if (bathrooms != null) payload.bathrooms = bathrooms; + const floors = toNum(data.floors); + if (floors != null) payload.floors = floors; + const floor = toNum(data.floor); + if (floor != null) payload.floor = floor; + const totalFloors = toNum(data.totalFloors); + if (totalFloors != null) payload.totalFloors = totalFloors; + if (data.direction) payload.direction = data.direction as Direction; + const yearBuilt = toNum(data.yearBuilt); + if (yearBuilt != null) payload.yearBuilt = yearBuilt; + if (data.legalStatus) payload.legalStatus = data.legalStatus; + if (data.projectName) payload.projectName = data.projectName; + if (data.amenities) { + payload.amenities = data.amenities.split(',').map((s) => s.trim()).filter(Boolean); + } + if (data.rentPriceMonthly) payload.rentPriceMonthly = data.rentPriceMonthly; + const commissionPct = toNum(data.commissionPct); + if (commissionPct != null) payload.commissionPct = commissionPct; + + const result = await listingsApi.create(tokens.accessToken, payload); + + for (const img of images) { + try { + await listingsApi.uploadMedia(tokens.accessToken, result.listingId, img.file); + } catch { + // Continue with remaining images + } + } + + router.push(`/listings/${result.listingId}`); + } catch (err) { + setError(err instanceof Error ? err.message : 'Có lỗi xảy ra'); + } finally { + setIsSubmitting(false); + } + }; + + return ( +
+

Đăng tin mới

+ + {/* Step indicators */} +
+ {STEPS.map((step, index) => ( +
+ + + {index < STEPS.length - 1 && ( +
+ )} +
+ ))} +
+ + {error && ( +
+ {error} + +
+ )} + +
+ + + {currentStep === 0 && } + {currentStep === 1 && } + {currentStep === 2 && } + {currentStep === 3 && } + {currentStep === 4 && ( +
+

Hình ảnh

+ +
+ )} +
+
+ +
+ + + {currentStep < STEPS.length - 1 ? ( + + ) : ( + + )} +
+
+
+ ); +} diff --git a/apps/web/app/(dashboard)/listings/page.tsx b/apps/web/app/(dashboard)/listings/page.tsx new file mode 100644 index 0000000..6b64d98 --- /dev/null +++ b/apps/web/app/(dashboard)/listings/page.tsx @@ -0,0 +1,172 @@ +'use client'; + +import * as React from 'react'; +import Link from 'next/link'; +import { Card, CardContent } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { Badge } from '@/components/ui/badge'; +import { Select } from '@/components/ui/select'; +import { ListingStatusBadge } from '@/components/listings/listing-status-badge'; +import { listingsApi, type ListingDetail, type PaginatedResult } from '@/lib/listings-api'; +import { PROPERTY_TYPES, TRANSACTION_TYPES } from '@/lib/validations/listings'; + +function formatPrice(priceVND: string): string { + const num = Number(priceVND); + if (num >= 1_000_000_000) return `${(num / 1_000_000_000).toFixed(1)} tỷ`; + if (num >= 1_000_000) return `${(num / 1_000_000).toFixed(0)} triệu`; + return num.toLocaleString('vi-VN'); +} + +export default function ListingsPage() { + const [result, setResult] = React.useState | null>(null); + const [loading, setLoading] = React.useState(true); + const [filters, setFilters] = React.useState({ + transactionType: '', + propertyType: '', + page: 1, + }); + + React.useEffect(() => { + setLoading(true); + const params: Record = { page: filters.page, limit: 12 }; + if (filters.transactionType) params['transactionType'] = filters.transactionType; + if (filters.propertyType) params['propertyType'] = filters.propertyType; + + listingsApi + .search(params) + .then(setResult) + .catch(() => setResult(null)) + .finally(() => setLoading(false)); + }, [filters]); + + return ( +
+
+

Tin đăng

+ + + +
+ + {/* Filters */} +
+ + +
+ + {/* Listing grid */} + {loading ? ( +
+
+
+ ) : !result || result.data.length === 0 ? ( +
+

Chưa có tin đăng nào

+ + + +
+ ) : ( + <> +
+ {result.data.map((listing) => ( + + +
+ {listing.property.media.length > 0 ? ( + {listing.property.title} + ) : ( +
+ Chưa có ảnh +
+ )} +
+ +
+
+ +

+ {formatPrice(listing.priceVND)} VNĐ +

+

{listing.property.title}

+

+ {listing.property.district}, {listing.property.city} +

+
+ + {listing.property.areaM2} m² + + {listing.property.bedrooms != null && ( + + {listing.property.bedrooms} PN + + )} + {listing.property.bathrooms != null && listing.property.bathrooms > 0 && ( + + {listing.property.bathrooms} PT + + )} +
+
+
+ + ))} +
+ + {/* Pagination */} + {result.totalPages > 1 && ( +
+ + + Trang {result.page} / {result.totalPages} + + +
+ )} + + )} +
+ ); +} diff --git a/apps/web/app/(dashboard)/page.tsx b/apps/web/app/(dashboard)/page.tsx new file mode 100644 index 0000000..7ef7384 --- /dev/null +++ b/apps/web/app/(dashboard)/page.tsx @@ -0,0 +1,54 @@ +import Link from 'next/link'; +import { Button } from '@/components/ui/button'; +import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card'; + +export default function HomePage() { + return ( +
+
+

Chào mừng đến GoodGo

+

+ Nền tảng bất động sản thông minh tại Việt Nam +

+
+ +
+ + + Đăng tin mới + Tạo tin đăng bán hoặc cho thuê bất động sản + + + + + + + + + + + Tin đăng của tôi + Quản lý các tin đăng đã tạo + + + + + + + + + + + Tìm kiếm + Tìm bất động sản phù hợp nhu cầu + + + + + + + +
+
+ ); +} diff --git a/apps/web/app/page.tsx b/apps/web/app/page.tsx deleted file mode 100644 index 5e8ea2f..0000000 --- a/apps/web/app/page.tsx +++ /dev/null @@ -1,8 +0,0 @@ -export default function Home() { - return ( -
-

GoodGo Platform

-

Vietnam Real Estate Platform

-
- ); -} diff --git a/apps/web/components/listings/image-gallery.tsx b/apps/web/components/listings/image-gallery.tsx new file mode 100644 index 0000000..900c114 --- /dev/null +++ b/apps/web/components/listings/image-gallery.tsx @@ -0,0 +1,84 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import type { PropertyMedia } from '@/lib/listings-api'; + +interface ImageGalleryProps { + media: PropertyMedia[]; + className?: string; +} + +export function ImageGallery({ media, className }: ImageGalleryProps) { + const images = media.filter((m) => m.type === 'image').sort((a, b) => a.order - b.order); + const [selectedIndex, setSelectedIndex] = React.useState(0); + + if (images.length === 0) { + return ( +
+ Chưa có hình ảnh +
+ ); + } + + return ( +
+ {/* Main image */} +
+ {images[selectedIndex]?.caption + {images.length > 1 && ( + <> + + + + )} +
+ {selectedIndex + 1} / {images.length} +
+
+ + {/* Thumbnails */} + {images.length > 1 && ( +
+ {images.map((img, index) => ( + + ))} +
+ )} +
+ ); +} diff --git a/apps/web/components/listings/image-upload.tsx b/apps/web/components/listings/image-upload.tsx new file mode 100644 index 0000000..8256995 --- /dev/null +++ b/apps/web/components/listings/image-upload.tsx @@ -0,0 +1,167 @@ +'use client'; + +import * as React from 'react'; +import { cn } from '@/lib/utils'; +import { Button } from '@/components/ui/button'; + +interface ImageFile { + file: File; + preview: string; +} + +interface ImageUploadProps { + images: ImageFile[]; + onChange: (images: ImageFile[]) => void; + maxFiles?: number; + className?: string; +} + +export function ImageUpload({ images, onChange, maxFiles = 20, className }: ImageUploadProps) { + const inputRef = React.useRef(null); + const [isDragging, setIsDragging] = React.useState(false); + + const addFiles = React.useCallback( + (files: FileList | File[]) => { + const newImages: ImageFile[] = []; + const allowedTypes = ['image/jpeg', 'image/png', 'image/webp']; + const maxSize = 10 * 1024 * 1024; // 10MB + + Array.from(files).forEach((file) => { + if (!allowedTypes.includes(file.type)) return; + if (file.size > maxSize) return; + if (images.length + newImages.length >= maxFiles) return; + + newImages.push({ + file, + preview: URL.createObjectURL(file), + }); + }); + + if (newImages.length > 0) { + onChange([...images, ...newImages]); + } + }, + [images, onChange, maxFiles], + ); + + const removeImage = React.useCallback( + (index: number) => { + const updated = [...images]; + URL.revokeObjectURL(updated[index]!.preview); + updated.splice(index, 1); + onChange(updated); + }, + [images, onChange], + ); + + const handleDragOver = React.useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = React.useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleDrop = React.useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files.length > 0) { + addFiles(e.dataTransfer.files); + } + }, + [addFiles], + ); + + React.useEffect(() => { + return () => { + images.forEach((img) => URL.revokeObjectURL(img.preview)); + }; + // eslint-disable-next-line react-hooks/exhaustive-deps + }, []); + + return ( +
+
inputRef.current?.click()} + className={cn( + 'flex cursor-pointer flex-col items-center justify-center rounded-lg border-2 border-dashed p-8 transition-colors', + isDragging + ? 'border-primary bg-primary/5' + : 'border-muted-foreground/25 hover:border-primary/50', + )} + > + + + + + +

Kéo thả ảnh vào đây hoặc nhấp để chọn

+

+ JPG, PNG, WebP - Tối đa {maxFiles} ảnh, mỗi ảnh 10MB +

+ { + if (e.target.files) addFiles(e.target.files); + e.target.value = ''; + }} + /> +
+ + {images.length > 0 && ( +
+ {images.map((img, index) => ( +
+ {`Ảnh +
+ +
+ {index === 0 && ( + + Ảnh bìa + + )} +
+ ))} +
+ )} +
+ ); +} + +export type { ImageFile }; diff --git a/apps/web/components/listings/listing-form-steps.tsx b/apps/web/components/listings/listing-form-steps.tsx new file mode 100644 index 0000000..252dfd9 --- /dev/null +++ b/apps/web/components/listings/listing-form-steps.tsx @@ -0,0 +1,249 @@ +'use client'; + +import { Input } from '@/components/ui/input'; +import { Label } from '@/components/ui/label'; +import { Textarea } from '@/components/ui/textarea'; +import { Select } from '@/components/ui/select'; +import { + TRANSACTION_TYPES, + PROPERTY_TYPES, + DIRECTIONS, +} from '@/lib/validations/listings'; +import type { UseFormRegister, FieldErrors } from 'react-hook-form'; +import type { CreateListingFormData } from '@/lib/validations/listings'; + +interface StepProps { + register: UseFormRegister; + errors: FieldErrors; +} + +function FieldError({ message }: { message?: string }) { + if (!message) return null; + return

{message}

; +} + +// ─── Step 1: Basic Info ────────────────────────────────── + +export function StepBasicInfo({ register, errors }: StepProps) { + return ( +
+

Thông tin cơ bản

+ +
+
+ + + +
+ +
+ + + +
+
+ +
+ + + +
+ +
+ +