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 <noreply@paperclip.ing>
This commit is contained in:
Ho Ngoc Hai
2026-04-08 01:54:08 +07:00
parent 8a33aae026
commit 207a2013f3
18 changed files with 1834 additions and 8 deletions

View File

@@ -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 (
<div className="min-h-screen bg-background">
<header className="sticky top-0 z-50 border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
<div className="mx-auto flex h-14 max-w-7xl items-center px-4">
<Link href="/" className="mr-6 flex items-center space-x-2">
<span className="text-lg font-bold text-primary">GoodGo</span>
</Link>
<nav className="flex items-center space-x-1">
{navItems.map((item) => (
<Link
key={item.href}
href={item.href}
className={cn(
'rounded-md px-3 py-2 text-sm font-medium transition-colors hover:bg-accent hover:text-accent-foreground',
pathname === item.href
? 'bg-accent text-accent-foreground'
: 'text-muted-foreground',
)}
>
<span className="mr-1.5">{item.icon}</span>
{item.label}
</Link>
))}
</nav>
<div className="ml-auto flex items-center space-x-3">
{user && (
<span className="text-sm text-muted-foreground">
{user.fullName}
</span>
)}
<Button variant="ghost" size="sm" onClick={logout}>
Đăng xuất
</Button>
</div>
</div>
</header>
<main className="mx-auto max-w-7xl px-4 py-6">{children}</main>
</div>
);
}

View File

@@ -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<ListingDetail | null>(null);
const [loading, setLoading] = React.useState(true);
const [activeTab, setActiveTab] = React.useState('basic');
const {
register,
reset,
formState: { errors },
} = useForm<CreateListingFormData>({
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 (
<div className="flex min-h-[400px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (!listing) {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
<p className="text-destructive">Không tìm thấy tin đăng</p>
<Button variant="outline" onClick={() => router.push('/listings')}>
Quay lại
</Button>
</div>
);
}
return (
<div className="mx-auto max-w-3xl space-y-6">
<div className="flex items-center justify-between">
<h1 className="text-2xl font-bold">Chỉnh sửa tin đăng</h1>
<Button variant="outline" onClick={() => router.push(`/listings/${id}`)}>
Xem tin
</Button>
</div>
<p className="text-sm text-muted-foreground">
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 thể xem lại thông tin đã nhập.
</p>
<Tabs value={activeTab} onValueChange={setActiveTab}>
<TabsList className="grid w-full grid-cols-4">
<TabsTrigger value="basic"> bản</TabsTrigger>
<TabsTrigger value="location">Vị trí</TabsTrigger>
<TabsTrigger value="details">Chi tiết</TabsTrigger>
<TabsTrigger value="pricing">Giá cả</TabsTrigger>
</TabsList>
<Card className="mt-4">
<CardContent className="pt-6">
<TabsContent value="basic">
<StepBasicInfo register={register} errors={errors} />
</TabsContent>
<TabsContent value="location">
<StepLocation register={register} errors={errors} />
</TabsContent>
<TabsContent value="details">
<StepDetails register={register} errors={errors} />
</TabsContent>
<TabsContent value="pricing">
<StepPricing register={register} errors={errors} />
</TabsContent>
</CardContent>
</Card>
</Tabs>
</div>
);
}

View File

@@ -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<ListingDetail | null>(null);
const [loading, setLoading] = React.useState(true);
const [error, setError] = React.useState<string | null>(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 (
<div className="flex min-h-[400px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
);
}
if (error || !listing) {
return (
<div className="flex min-h-[400px] flex-col items-center justify-center space-y-4">
<p className="text-destructive">{error || 'Không tìm thấy tin đăng'}</p>
<Link href="/listings">
<Button variant="outline">Quay lại danh sách</Button>
</Link>
</div>
);
}
const { property, seller, agent } = listing;
return (
<div className="mx-auto max-w-5xl space-y-6">
{/* Header */}
<div className="flex flex-col gap-4 sm:flex-row sm:items-start sm:justify-between">
<div>
<div className="mb-2 flex items-center gap-2">
<ListingStatusBadge status={listing.status} />
<Badge variant="outline">
{getLabel(TRANSACTION_TYPES, listing.transactionType)}
</Badge>
<Badge variant="outline">
{getLabel(PROPERTY_TYPES, property.propertyType)}
</Badge>
</div>
<h1 className="text-2xl font-bold">{property.title}</h1>
<p className="mt-1 text-muted-foreground">
{property.address}, {property.ward}, {property.district}, {property.city}
</p>
</div>
<div className="text-right">
<p className="text-2xl font-bold text-primary">{formatPrice(listing.priceVND)} VNĐ</p>
{listing.pricePerM2 && (
<p className="text-sm text-muted-foreground">
~{listing.pricePerM2.toLocaleString('vi-VN')} VNĐ/m²
</p>
)}
{listing.rentPriceMonthly && (
<p className="text-sm text-muted-foreground">
Thuê: {formatPrice(listing.rentPriceMonthly)}/tháng
</p>
)}
</div>
</div>
{/* Image gallery */}
<ImageGallery media={property.media} />
<div className="grid gap-6 lg:grid-cols-3">
{/* Main content */}
<div className="space-y-6 lg:col-span-2">
{/* Key specs */}
<Card>
<CardHeader>
<CardTitle>Thông tin chung</CardTitle>
</CardHeader>
<CardContent>
<div className="grid grid-cols-2 gap-4 sm:grid-cols-3">
<InfoItem label="Diện tích" value={`${property.areaM2}`} />
<InfoItem label="Phòng ngủ" value={property.bedrooms != null ? `${property.bedrooms}` : '—'} />
<InfoItem label="Phòng tắm" value={property.bathrooms != null ? `${property.bathrooms}` : '—'} />
<InfoItem label="Số tầng" value={property.floors != null ? `${property.floors}` : '—'} />
<InfoItem label="Hướng" value={getLabel(DIRECTIONS, property.direction)} />
<InfoItem label="Năm xây" value={property.yearBuilt ? `${property.yearBuilt}` : '—'} />
<InfoItem label="Pháp lý" value={property.legalStatus || '—'} />
<InfoItem label="Dự án" value={property.projectName || '—'} />
</div>
</CardContent>
</Card>
{/* Description */}
<Card>
<CardHeader>
<CardTitle> tả</CardTitle>
</CardHeader>
<CardContent>
<p className="whitespace-pre-wrap text-sm leading-relaxed">{property.description}</p>
</CardContent>
</Card>
{/* Amenities */}
{property.amenities && property.amenities.length > 0 && (
<Card>
<CardHeader>
<CardTitle>Tiện ích</CardTitle>
</CardHeader>
<CardContent>
<div className="flex flex-wrap gap-2">
{property.amenities.map((a) => (
<Badge key={a} variant="secondary">
{a}
</Badge>
))}
</div>
</CardContent>
</Card>
)}
</div>
{/* Sidebar */}
<div className="space-y-6">
{/* Seller info */}
<Card>
<CardHeader>
<CardTitle>Liên hệ</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
<div>
<p className="font-medium">{seller.fullName}</p>
<p className="text-sm text-muted-foreground">{seller.phone}</p>
</div>
<Button className="w-full">Gọi ngay</Button>
<Button variant="outline" className="w-full">
Nhắn tin
</Button>
</CardContent>
</Card>
{/* Agent info */}
{agent && (
<Card>
<CardHeader>
<CardTitle>Môi giới</CardTitle>
</CardHeader>
<CardContent>
{agent.agency && <p className="text-sm text-muted-foreground">{agent.agency}</p>}
{listing.commissionPct != null && (
<p className="mt-1 text-sm">Hoa hồng: {listing.commissionPct}%</p>
)}
</CardContent>
</Card>
)}
{/* Stats */}
<Card>
<CardHeader>
<CardTitle>Thống </CardTitle>
</CardHeader>
<CardContent>
<div className="space-y-2 text-sm">
<div className="flex justify-between">
<span className="text-muted-foreground">Lượt xem</span>
<span className="font-medium">{listing.viewCount}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Lượt lưu</span>
<span className="font-medium">{listing.saveCount}</span>
</div>
<div className="flex justify-between">
<span className="text-muted-foreground">Liên hệ</span>
<span className="font-medium">{listing.inquiryCount}</span>
</div>
{listing.publishedAt && (
<div className="flex justify-between">
<span className="text-muted-foreground">Đăng ngày</span>
<span className="font-medium">
{new Date(listing.publishedAt).toLocaleDateString('vi-VN')}
</span>
</div>
)}
</div>
</CardContent>
</Card>
</div>
</div>
</div>
);
}
function InfoItem({ label, value }: { label: string; value: string }) {
return (
<div>
<p className="text-xs text-muted-foreground">{label}</p>
<p className="font-medium">{value}</p>
</div>
);
}

View File

@@ -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<ImageFile[]>([]);
const [isSubmitting, setIsSubmitting] = React.useState(false);
const [error, setError] = React.useState<string | null>(null);
const {
register,
handleSubmit,
trigger,
formState: { errors },
} = useForm<CreateListingFormData>({
resolver: zodResolver(createListingSchema),
mode: 'onTouched',
});
const goNext = async () => {
const step = STEPS[currentStep];
if (step?.schemaKeys) {
const valid = await trigger(step.schemaKeys as Array<keyof CreateListingFormData>);
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 (
<div className="mx-auto max-w-3xl">
<h1 className="mb-6 text-2xl font-bold">Đăng tin mới</h1>
{/* Step indicators */}
<div className="mb-8 flex items-center justify-between">
{STEPS.map((step, index) => (
<div key={step.title} className="flex items-center">
<button
type="button"
onClick={() => index < currentStep && setCurrentStep(index)}
className={cn(
'flex h-8 w-8 items-center justify-center rounded-full text-sm font-medium transition-colors',
index === currentStep
? 'bg-primary text-primary-foreground'
: index < currentStep
? 'bg-primary/20 text-primary cursor-pointer'
: 'bg-muted text-muted-foreground',
)}
>
{index < currentStep ? '\u2713' : index + 1}
</button>
<span
className={cn(
'ml-2 hidden text-sm sm:inline',
index === currentStep ? 'font-medium' : 'text-muted-foreground',
)}
>
{step.title}
</span>
{index < STEPS.length - 1 && (
<div
className={cn(
'mx-3 h-px w-8 sm:w-12',
index < currentStep ? 'bg-primary' : 'bg-muted',
)}
/>
)}
</div>
))}
</div>
{error && (
<div className="mb-4 rounded-md border border-destructive/50 bg-destructive/10 p-3 text-sm text-destructive">
{error}
<button className="ml-2 font-medium underline" onClick={() => setError(null)}>
Đóng
</button>
</div>
)}
<form onSubmit={handleSubmit(onSubmit)}>
<Card>
<CardContent className="pt-6">
{currentStep === 0 && <StepBasicInfo register={register} errors={errors} />}
{currentStep === 1 && <StepLocation register={register} errors={errors} />}
{currentStep === 2 && <StepDetails register={register} errors={errors} />}
{currentStep === 3 && <StepPricing register={register} errors={errors} />}
{currentStep === 4 && (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Hình nh</h3>
<ImageUpload images={images} onChange={setImages} />
</div>
)}
</CardContent>
</Card>
<div className="mt-6 flex justify-between">
<Button
type="button"
variant="outline"
onClick={goBack}
disabled={currentStep === 0}
>
Quay lại
</Button>
{currentStep < STEPS.length - 1 ? (
<Button type="button" onClick={goNext}>
Tiếp theo
</Button>
) : (
<Button type="submit" disabled={isSubmitting}>
{isSubmitting ? 'Đang đăng...' : 'Đăng tin'}
</Button>
)}
</div>
</form>
</div>
);
}

View File

@@ -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<PaginatedResult<ListingDetail> | null>(null);
const [loading, setLoading] = React.useState(true);
const [filters, setFilters] = React.useState({
transactionType: '',
propertyType: '',
page: 1,
});
React.useEffect(() => {
setLoading(true);
const params: Record<string, string | number> = { 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 (
<div className="space-y-6">
<div className="flex flex-col gap-4 sm:flex-row sm:items-center sm:justify-between">
<h1 className="text-2xl font-bold">Tin đăng</h1>
<Link href="/listings/new">
<Button>Đăng tin mới</Button>
</Link>
</div>
{/* Filters */}
<div className="flex flex-wrap gap-3">
<Select
value={filters.transactionType}
onChange={(e) => setFilters((f) => ({ ...f, transactionType: e.target.value, page: 1 }))}
className="w-40"
>
<option value="">Tất cả giao dịch</option>
{TRANSACTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<Select
value={filters.propertyType}
onChange={(e) => setFilters((f) => ({ ...f, propertyType: e.target.value, page: 1 }))}
className="w-44"
>
<option value="">Tất cả loại BĐS</option>
{PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
</div>
{/* Listing grid */}
{loading ? (
<div className="flex min-h-[300px] items-center justify-center">
<div className="h-8 w-8 animate-spin rounded-full border-4 border-primary border-t-transparent" />
</div>
) : !result || result.data.length === 0 ? (
<div className="flex min-h-[300px] flex-col items-center justify-center text-muted-foreground">
<p>Chưa tin đăng nào</p>
<Link href="/listings/new" className="mt-2">
<Button variant="outline" size="sm">
Đăng tin đu tiên
</Button>
</Link>
</div>
) : (
<>
<div className="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{result.data.map((listing) => (
<Link key={listing.id} href={`/listings/${listing.id}`}>
<Card className="h-full overflow-hidden transition-shadow hover:shadow-md">
<div className="relative aspect-[4/3] bg-muted">
{listing.property.media.length > 0 ? (
<img
src={listing.property.media[0]?.url}
alt={listing.property.title}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full items-center justify-center text-muted-foreground">
Chưa nh
</div>
)}
<div className="absolute left-2 top-2">
<ListingStatusBadge status={listing.status} />
</div>
</div>
<CardContent className="p-4">
<p className="text-lg font-bold text-primary">
{formatPrice(listing.priceVND)} VNĐ
</p>
<h3 className="mt-1 line-clamp-1 font-medium">{listing.property.title}</h3>
<p className="mt-1 line-clamp-1 text-sm text-muted-foreground">
{listing.property.district}, {listing.property.city}
</p>
<div className="mt-3 flex flex-wrap gap-1.5">
<Badge variant="secondary" className="text-xs">
{listing.property.areaM2} m²
</Badge>
{listing.property.bedrooms != null && (
<Badge variant="secondary" className="text-xs">
{listing.property.bedrooms} PN
</Badge>
)}
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
<Badge variant="secondary" className="text-xs">
{listing.property.bathrooms} PT
</Badge>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
{/* Pagination */}
{result.totalPages > 1 && (
<div className="flex items-center justify-center gap-2">
<Button
variant="outline"
size="sm"
disabled={filters.page <= 1}
onClick={() => setFilters((f) => ({ ...f, page: f.page - 1 }))}
>
Trước
</Button>
<span className="text-sm text-muted-foreground">
Trang {result.page} / {result.totalPages}
</span>
<Button
variant="outline"
size="sm"
disabled={filters.page >= result.totalPages}
onClick={() => setFilters((f) => ({ ...f, page: f.page + 1 }))}
>
Tiếp
</Button>
</div>
)}
</>
)}
</div>
);
}

View File

@@ -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 (
<div className="space-y-8">
<div>
<h1 className="text-3xl font-bold">Chào mừng đến GoodGo</h1>
<p className="mt-2 text-muted-foreground">
Nền tảng bất đng sản thông minh tại Việt Nam
</p>
</div>
<div className="grid gap-6 sm:grid-cols-2 lg:grid-cols-3">
<Card>
<CardHeader>
<CardTitle>Đăng tin mới</CardTitle>
<CardDescription>Tạo tin đăng bán hoặc cho thuê bất đng sản</CardDescription>
</CardHeader>
<CardContent>
<Link href="/listings/new">
<Button className="w-full">Đăng tin ngay</Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tin đăng của tôi</CardTitle>
<CardDescription>Quản các tin đăng đã tạo</CardDescription>
</CardHeader>
<CardContent>
<Link href="/listings">
<Button variant="outline" className="w-full">Xem danh sách</Button>
</Link>
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Tìm kiếm</CardTitle>
<CardDescription>Tìm bất đng sản phù hợp nhu cầu</CardDescription>
</CardHeader>
<CardContent>
<Link href="/listings">
<Button variant="outline" className="w-full">Tìm kiếm</Button>
</Link>
</CardContent>
</Card>
</div>
</div>
);
}

View File

@@ -1,8 +0,0 @@
export default function Home() {
return (
<main className="flex min-h-screen flex-col items-center justify-center p-24">
<h1 className="text-4xl font-bold">GoodGo Platform</h1>
<p className="mt-4 text-lg text-gray-600">Vietnam Real Estate Platform</p>
</main>
);
}

View File

@@ -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 (
<div
className={cn(
'flex aspect-video items-center justify-center rounded-lg bg-muted text-muted-foreground',
className,
)}
>
Chưa hình nh
</div>
);
}
return (
<div className={cn('space-y-3', className)}>
{/* Main image */}
<div className="relative aspect-video overflow-hidden rounded-lg bg-muted">
<img
src={images[selectedIndex]?.url}
alt={images[selectedIndex]?.caption || `Ảnh ${selectedIndex + 1}`}
className="h-full w-full object-cover"
/>
{images.length > 1 && (
<>
<button
onClick={() => setSelectedIndex((i) => (i > 0 ? i - 1 : images.length - 1))}
className="absolute left-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
aria-label="Ảnh trước"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m15 18-6-6 6-6"/></svg>
</button>
<button
onClick={() => setSelectedIndex((i) => (i < images.length - 1 ? i + 1 : 0))}
className="absolute right-2 top-1/2 -translate-y-1/2 rounded-full bg-black/50 p-2 text-white transition-colors hover:bg-black/70"
aria-label="Ảnh tiếp"
>
<svg xmlns="http://www.w3.org/2000/svg" width="20" height="20" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round"><path d="m9 18 6-6-6-6"/></svg>
</button>
</>
)}
<div className="absolute bottom-2 right-2 rounded bg-black/60 px-2 py-1 text-xs text-white">
{selectedIndex + 1} / {images.length}
</div>
</div>
{/* Thumbnails */}
{images.length > 1 && (
<div className="flex gap-2 overflow-x-auto pb-1">
{images.map((img, index) => (
<button
key={img.id}
onClick={() => setSelectedIndex(index)}
className={cn(
'h-16 w-16 flex-shrink-0 overflow-hidden rounded-md border-2 transition-colors',
index === selectedIndex ? 'border-primary' : 'border-transparent opacity-70 hover:opacity-100',
)}
>
<img
src={img.url}
alt={img.caption || `Thumbnail ${index + 1}`}
className="h-full w-full object-cover"
/>
</button>
))}
</div>
)}
</div>
);
}

View File

@@ -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<HTMLInputElement>(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 (
<div className={cn('space-y-4', className)}>
<div
onDragOver={handleDragOver}
onDragLeave={handleDragLeave}
onDrop={handleDrop}
onClick={() => 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',
)}
>
<svg
xmlns="http://www.w3.org/2000/svg"
width="40"
height="40"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="1.5"
strokeLinecap="round"
strokeLinejoin="round"
className="mb-3 text-muted-foreground"
>
<rect width="18" height="18" x="3" y="3" rx="2" ry="2" />
<circle cx="9" cy="9" r="2" />
<path d="m21 15-3.086-3.086a2 2 0 0 0-2.828 0L6 21" />
</svg>
<p className="text-sm font-medium">Kéo thả nh vào đây hoặc nhấp đ chọn</p>
<p className="mt-1 text-xs text-muted-foreground">
JPG, PNG, WebP - Tối đa {maxFiles} nh, mỗi nh 10MB
</p>
<input
ref={inputRef}
type="file"
accept="image/jpeg,image/png,image/webp"
multiple
className="hidden"
onChange={(e) => {
if (e.target.files) addFiles(e.target.files);
e.target.value = '';
}}
/>
</div>
{images.length > 0 && (
<div className="grid grid-cols-2 gap-3 sm:grid-cols-3 md:grid-cols-4">
{images.map((img, index) => (
<div key={img.preview} className="group relative aspect-square overflow-hidden rounded-lg border">
<img
src={img.preview}
alt={`Ảnh ${index + 1}`}
className="h-full w-full object-cover"
/>
<div className="absolute inset-0 flex items-center justify-center bg-black/40 opacity-0 transition-opacity group-hover:opacity-100">
<Button
type="button"
variant="destructive"
size="sm"
onClick={(e) => {
e.stopPropagation();
removeImage(index);
}}
>
Xóa
</Button>
</div>
{index === 0 && (
<span className="absolute left-1.5 top-1.5 rounded bg-primary px-1.5 py-0.5 text-[10px] font-semibold text-primary-foreground">
nh bìa
</span>
)}
</div>
))}
</div>
)}
</div>
);
}
export type { ImageFile };

View File

@@ -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<CreateListingFormData>;
errors: FieldErrors<CreateListingFormData>;
}
function FieldError({ message }: { message?: string }) {
if (!message) return null;
return <p className="mt-1 text-xs text-destructive">{message}</p>;
}
// ─── Step 1: Basic Info ──────────────────────────────────
export function StepBasicInfo({ register, errors }: StepProps) {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Thông tin bản</h3>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="transactionType">Loại giao dịch *</Label>
<Select id="transactionType" {...register('transactionType')}>
<option value="">-- Chọn --</option>
{TRANSACTION_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<FieldError message={errors.transactionType?.message} />
</div>
<div>
<Label htmlFor="propertyType">Loại bất đng sản *</Label>
<Select id="propertyType" {...register('propertyType')}>
<option value="">-- Chọn --</option>
{PROPERTY_TYPES.map((t) => (
<option key={t.value} value={t.value}>
{t.label}
</option>
))}
</Select>
<FieldError message={errors.propertyType?.message} />
</div>
</div>
<div>
<Label htmlFor="title">Tiêu đ tin đăng *</Label>
<Input id="title" placeholder="VD: Bán căn hộ 2PN tại Vinhomes Central Park" {...register('title')} />
<FieldError message={errors.title?.message} />
</div>
<div>
<Label htmlFor="description"> tả chi tiết *</Label>
<Textarea
id="description"
rows={5}
placeholder="Mô tả chi tiết về bất động sản..."
{...register('description')}
/>
<FieldError message={errors.description?.message} />
</div>
</div>
);
}
// ─── Step 2: Location ────────────────────────────────────
export function StepLocation({ register, errors }: StepProps) {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Vị trí</h3>
<div>
<Label htmlFor="address">Đa chỉ *</Label>
<Input id="address" placeholder="Số nhà, tên đường" {...register('address')} />
<FieldError message={errors.address?.message} />
</div>
<div className="grid gap-4 sm:grid-cols-3">
<div>
<Label htmlFor="ward">Phường/ *</Label>
<Input id="ward" placeholder="Phường/Xã" {...register('ward')} />
<FieldError message={errors.ward?.message} />
</div>
<div>
<Label htmlFor="district">Quận/Huyện *</Label>
<Input id="district" placeholder="Quận/Huyện" {...register('district')} />
<FieldError message={errors.district?.message} />
</div>
<div>
<Label htmlFor="city">Tỉnh/Thành phố *</Label>
<Input id="city" placeholder="Tỉnh/Thành phố" {...register('city')} />
<FieldError message={errors.city?.message} />
</div>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="latitude"> đ</Label>
<Input
id="latitude"
type="number"
step="any"
placeholder="VD: 10.7769"
{...register('latitude')}
/>
<FieldError message={errors.latitude?.message} />
</div>
<div>
<Label htmlFor="longitude">Kinh đ</Label>
<Input
id="longitude"
type="number"
step="any"
placeholder="VD: 106.7009"
{...register('longitude')}
/>
<FieldError message={errors.longitude?.message} />
</div>
</div>
<div className="rounded-lg border border-dashed p-6 text-center text-sm text-muted-foreground">
Bản đ chọn vị trí sẽ đưc tích hợp trong phiên bản tiếp theo
</div>
</div>
);
}
// ─── Step 3: Details ─────────────────────────────────────
export function StepDetails({ register, errors }: StepProps) {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Thông số chi tiết</h3>
<div className="grid gap-4 sm:grid-cols-2 md:grid-cols-3">
<div>
<Label htmlFor="areaM2">Diện tích (m²) *</Label>
<Input id="areaM2" type="number" step="0.1" placeholder="VD: 75" {...register('areaM2')} />
<FieldError message={errors.areaM2?.message} />
</div>
<div>
<Label htmlFor="usableAreaM2">Diện tích sử dụng (m²)</Label>
<Input id="usableAreaM2" type="number" step="0.1" {...register('usableAreaM2')} />
</div>
<div>
<Label htmlFor="bedrooms">Phòng ngủ</Label>
<Input id="bedrooms" type="number" min="0" {...register('bedrooms')} />
</div>
<div>
<Label htmlFor="bathrooms">Phòng tắm</Label>
<Input id="bathrooms" type="number" min="0" {...register('bathrooms')} />
</div>
<div>
<Label htmlFor="floors">Số tầng</Label>
<Input id="floors" type="number" min="0" {...register('floors')} />
</div>
<div>
<Label htmlFor="floor">Tầng số</Label>
<Input id="floor" type="number" min="0" {...register('floor')} />
</div>
<div>
<Label htmlFor="totalFloors">Tổng số tầng tòa nhà</Label>
<Input id="totalFloors" type="number" min="0" {...register('totalFloors')} />
</div>
<div>
<Label htmlFor="direction">Hướng nhà</Label>
<Select id="direction" {...register('direction')}>
<option value="">-- Không chọn --</option>
{DIRECTIONS.map((d) => (
<option key={d.value} value={d.value}>
{d.label}
</option>
))}
</Select>
</div>
<div>
<Label htmlFor="yearBuilt">Năm xây dựng</Label>
<Input id="yearBuilt" type="number" placeholder="VD: 2020" {...register('yearBuilt')} />
</div>
</div>
<div>
<Label htmlFor="legalStatus">Pháp </Label>
<Input id="legalStatus" placeholder="VD: Sổ hồng, sổ đỏ..." {...register('legalStatus')} />
</div>
<div>
<Label htmlFor="projectName">Tên dự án</Label>
<Input id="projectName" placeholder="VD: Vinhomes Central Park" {...register('projectName')} />
</div>
<div>
<Label htmlFor="amenities">Tiện ích (cách nhau bởi dấu phẩy)</Label>
<Input id="amenities" placeholder="VD: Hồ bơi, Gym, Công viên" {...register('amenities')} />
</div>
</div>
);
}
// ─── Step 4: Pricing ─────────────────────────────────────
export function StepPricing({ register, errors }: StepProps) {
return (
<div className="space-y-4">
<h3 className="text-lg font-semibold">Giá & Hoa hồng</h3>
<div>
<Label htmlFor="priceVND">Giá bán (VNĐ) *</Label>
<Input id="priceVND" placeholder="VD: 5000000000" {...register('priceVND')} />
<FieldError message={errors.priceVND?.message} />
<p className="mt-1 text-xs text-muted-foreground">Nhập số không dấu chấm hoặc dấu phẩy</p>
</div>
<div className="grid gap-4 sm:grid-cols-2">
<div>
<Label htmlFor="rentPriceMonthly">Giá thuê/tháng (VNĐ)</Label>
<Input id="rentPriceMonthly" placeholder="Chỉ áp dụng cho thuê" {...register('rentPriceMonthly')} />
</div>
<div>
<Label htmlFor="commissionPct">Hoa hồng (%)</Label>
<Input
id="commissionPct"
type="number"
step="0.1"
min="0"
max="100"
placeholder="VD: 2.5"
{...register('commissionPct')}
/>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,12 @@
import { Badge } from '@/components/ui/badge';
import { LISTING_STATUSES } from '@/lib/validations/listings';
import type { ListingStatus } from '@/lib/listings-api';
interface ListingStatusBadgeProps {
status: ListingStatus;
}
export function ListingStatusBadge({ status }: ListingStatusBadgeProps) {
const config = LISTING_STATUSES[status] ?? { label: status, variant: 'outline' as const };
return <Badge variant={config.variant}>{config.label}</Badge>;
}

View File

@@ -0,0 +1,33 @@
import * as React from 'react';
import { cva, type VariantProps } from 'class-variance-authority';
import { cn } from '@/lib/utils';
const badgeVariants = cva(
'inline-flex items-center rounded-full border px-2.5 py-0.5 text-xs font-semibold transition-colors focus:outline-none focus:ring-2 focus:ring-ring focus:ring-offset-2',
{
variants: {
variant: {
default: 'border-transparent bg-primary text-primary-foreground',
secondary: 'border-transparent bg-secondary text-secondary-foreground',
destructive: 'border-transparent bg-destructive text-destructive-foreground',
outline: 'text-foreground',
success: 'border-transparent bg-green-100 text-green-800',
warning: 'border-transparent bg-yellow-100 text-yellow-800',
info: 'border-transparent bg-blue-100 text-blue-800',
},
},
defaultVariants: {
variant: 'default',
},
},
);
export interface BadgeProps
extends React.HTMLAttributes<HTMLDivElement>,
VariantProps<typeof badgeVariants> {}
function Badge({ className, variant, ...props }: BadgeProps) {
return <div className={cn(badgeVariants({ variant }), className)} {...props} />;
}
export { Badge, badgeVariants };

View File

@@ -0,0 +1,24 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface SelectProps extends React.SelectHTMLAttributes<HTMLSelectElement> {}
const Select = React.forwardRef<HTMLSelectElement, SelectProps>(
({ className, children, ...props }, ref) => {
return (
<select
className={cn(
'flex h-10 w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
>
{children}
</select>
);
},
);
Select.displayName = 'Select';
export { Select };

View File

@@ -0,0 +1,90 @@
'use client';
import * as React from 'react';
import { cn } from '@/lib/utils';
interface TabsContextValue {
value: string;
onValueChange: (value: string) => void;
}
const TabsContext = React.createContext<TabsContextValue | null>(null);
function useTabs() {
const context = React.useContext(TabsContext);
if (!context) throw new Error('Tabs components must be used within <Tabs>');
return context;
}
interface TabsProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
onValueChange: (value: string) => void;
}
function Tabs({ value, onValueChange, className, ...props }: TabsProps) {
return (
<TabsContext.Provider value={{ value, onValueChange }}>
<div className={cn('w-full', className)} {...props} />
</TabsContext.Provider>
);
}
const TabsList = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement>>(
({ className, ...props }, ref) => (
<div
ref={ref}
className={cn(
'inline-flex h-10 items-center justify-center rounded-md bg-muted p-1 text-muted-foreground',
className,
)}
{...props}
/>
),
);
TabsList.displayName = 'TabsList';
interface TabsTriggerProps extends React.ButtonHTMLAttributes<HTMLButtonElement> {
value: string;
}
const TabsTrigger = React.forwardRef<HTMLButtonElement, TabsTriggerProps>(
({ className, value, ...props }, ref) => {
const { value: selectedValue, onValueChange } = useTabs();
return (
<button
ref={ref}
className={cn(
'inline-flex items-center justify-center whitespace-nowrap rounded-sm px-3 py-1.5 text-sm font-medium ring-offset-background transition-all focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:pointer-events-none disabled:opacity-50',
selectedValue === value
? 'bg-background text-foreground shadow-sm'
: 'hover:bg-background/50',
className,
)}
onClick={() => onValueChange(value)}
{...props}
/>
);
},
);
TabsTrigger.displayName = 'TabsTrigger';
interface TabsContentProps extends React.HTMLAttributes<HTMLDivElement> {
value: string;
}
const TabsContent = React.forwardRef<HTMLDivElement, TabsContentProps>(
({ className, value, ...props }, ref) => {
const { value: selectedValue } = useTabs();
if (selectedValue !== value) return null;
return (
<div
ref={ref}
className={cn('mt-2 ring-offset-background focus-visible:outline-none', className)}
{...props}
/>
);
},
);
TabsContent.displayName = 'TabsContent';
export { Tabs, TabsList, TabsTrigger, TabsContent };

View File

@@ -0,0 +1,22 @@
import * as React from 'react';
import { cn } from '@/lib/utils';
export interface TextareaProps extends React.TextareaHTMLAttributes<HTMLTextAreaElement> {}
const Textarea = React.forwardRef<HTMLTextAreaElement, TextareaProps>(
({ className, ...props }, ref) => {
return (
<textarea
className={cn(
'flex min-h-[80px] w-full rounded-md border border-input bg-background px-3 py-2 text-sm ring-offset-background placeholder:text-muted-foreground focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring focus-visible:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50',
className,
)}
ref={ref}
{...props}
/>
);
},
);
Textarea.displayName = 'Textarea';
export { Textarea };

View File

@@ -53,4 +53,7 @@ export const apiClient = {
authPost: <T>(endpoint: string, token: string, body?: unknown) =>
request<T>(endpoint, { method: 'POST', body, headers: authHeaders(token) }),
authPatch: <T>(endpoint: string, token: string, body?: unknown) =>
request<T>(endpoint, { method: 'PATCH', body, headers: authHeaders(token) }),
};

View File

@@ -0,0 +1,179 @@
import { apiClient } from './api-client';
// ─── Enums ───────────────────────────────────────────────
export type TransactionType = 'SALE' | 'RENT';
export type PropertyType = 'APARTMENT' | 'HOUSE' | 'VILLA' | 'LAND' | 'OFFICE' | 'SHOPHOUSE';
export type ListingStatus =
| 'DRAFT'
| 'PENDING_REVIEW'
| 'ACTIVE'
| 'RESERVED'
| 'SOLD'
| 'RENTED'
| 'EXPIRED'
| 'REJECTED';
export type Direction =
| 'NORTH'
| 'SOUTH'
| 'EAST'
| 'WEST'
| 'NORTHEAST'
| 'NORTHWEST'
| 'SOUTHEAST'
| 'SOUTHWEST';
// ─── Interfaces ──────────────────────────────────────────
export interface PropertyMedia {
id: string;
url: string;
type: 'image' | 'video';
order: number;
caption: string | null;
}
export interface ListingDetail {
id: string;
status: ListingStatus;
transactionType: TransactionType;
priceVND: string;
pricePerM2: number | null;
rentPriceMonthly: string | null;
commissionPct: number | null;
viewCount: number;
saveCount: number;
inquiryCount: number;
publishedAt: string | null;
createdAt: string;
property: {
id: string;
propertyType: PropertyType;
title: string;
description: string;
address: string;
ward: string;
district: string;
city: string;
areaM2: number;
bedrooms: number | null;
bathrooms: number | null;
floors: number | null;
direction: Direction | null;
yearBuilt: number | null;
legalStatus: string | null;
amenities: string[] | null;
projectName: string | null;
media: PropertyMedia[];
};
seller: {
id: string;
fullName: string;
phone: string;
};
agent: {
id: string;
userId: string;
agency: string | null;
} | null;
}
export interface PaginatedResult<T> {
data: T[];
total: number;
page: number;
limit: number;
totalPages: number;
}
export interface CreateListingPayload {
transactionType: TransactionType;
priceVND: string;
propertyType: PropertyType;
title: string;
description: string;
address: string;
ward: string;
district: string;
city: string;
latitude: number;
longitude: number;
areaM2: number;
usableAreaM2?: number;
bedrooms?: number;
bathrooms?: number;
floors?: number;
floor?: number;
totalFloors?: number;
direction?: Direction;
yearBuilt?: number;
legalStatus?: string;
amenities?: string[];
projectName?: string;
rentPriceMonthly?: string;
commissionPct?: number;
}
export interface SearchListingsParams {
status?: ListingStatus;
transactionType?: TransactionType;
propertyType?: PropertyType;
city?: string;
district?: string;
minPrice?: string;
maxPrice?: string;
minArea?: number;
maxArea?: number;
bedrooms?: number;
page?: number;
limit?: number;
}
// ─── API Functions ───────────────────────────────────────
const API_BASE_URL = process.env['NEXT_PUBLIC_API_URL'] || 'http://localhost:3001';
export const listingsApi = {
create: (token: string, data: CreateListingPayload) =>
apiClient.authPost<{ listingId: string; propertyId: string; status: string }>(
'/listings',
token,
data,
),
getById: (id: string) => apiClient.get<ListingDetail>(`/listings/${id}`),
search: (params: SearchListingsParams = {}) => {
const query = new URLSearchParams();
Object.entries(params).forEach(([key, value]) => {
if (value !== undefined && value !== '') query.append(key, String(value));
});
const qs = query.toString();
return apiClient.get<PaginatedResult<ListingDetail>>(`/listings${qs ? `?${qs}` : ''}`);
},
updateStatus: (token: string, id: string, status: ListingStatus, moderationNotes?: string) =>
apiClient.authPost<{ status: string }>(`/listings/${id}/status`, token, {
status,
moderationNotes,
}),
uploadMedia: async (token: string, listingId: string, file: File, caption?: string) => {
const formData = new FormData();
formData.append('file', file);
if (caption) formData.append('caption', caption);
const res = await fetch(`${API_BASE_URL}/listings/${listingId}/media`, {
method: 'POST',
headers: { Authorization: `Bearer ${token}` },
body: formData,
});
if (!res.ok) {
const error = await res.json().catch(() => ({ message: res.statusText }));
throw new Error(error.message || 'Upload failed');
}
return res.json() as Promise<{ mediaId: string; url: string }>;
},
};

View File

@@ -0,0 +1,99 @@
import { z } from 'zod';
export const TRANSACTION_TYPES = [
{ value: 'SALE', label: 'Bán' },
{ value: 'RENT', label: 'Cho thuê' },
] as const;
export const PROPERTY_TYPES = [
{ value: 'APARTMENT', label: 'Căn hộ' },
{ value: 'HOUSE', label: 'Nhà riêng' },
{ value: 'VILLA', label: 'Biệt thự' },
{ value: 'LAND', label: 'Đất nền' },
{ value: 'OFFICE', label: 'Văn phòng' },
{ value: 'SHOPHOUSE', label: 'Shophouse' },
] as const;
export const DIRECTIONS = [
{ value: 'NORTH', label: 'Bắc' },
{ value: 'SOUTH', label: 'Nam' },
{ value: 'EAST', label: 'Đông' },
{ value: 'WEST', label: 'Tây' },
{ value: 'NORTHEAST', label: 'Đông Bắc' },
{ value: 'NORTHWEST', label: 'Tây Bắc' },
{ value: 'SOUTHEAST', label: 'Đông Nam' },
{ value: 'SOUTHWEST', label: 'Tây Nam' },
] as const;
export const LISTING_STATUSES = {
DRAFT: { label: 'Nháp', variant: 'secondary' as const },
PENDING_REVIEW: { label: 'Chờ duyệt', variant: 'warning' as const },
ACTIVE: { label: 'Đang bán', variant: 'success' as const },
RESERVED: { label: 'Đã đặt cọc', variant: 'info' as const },
SOLD: { label: 'Đã bán', variant: 'default' as const },
RENTED: { label: 'Đã cho thuê', variant: 'default' as const },
EXPIRED: { label: 'Hết hạn', variant: 'destructive' as const },
REJECTED: { label: 'Bị từ chối', variant: 'destructive' as const },
};
// ─── Step 1: Basic Info ──────────────────────────────────
export const listingBasicSchema = z.object({
transactionType: z.enum(['SALE', 'RENT'], {
message: 'Vui lòng chọn loại giao dịch',
}),
propertyType: z.enum(['APARTMENT', 'HOUSE', 'VILLA', 'LAND', 'OFFICE', 'SHOPHOUSE'], {
message: 'Vui lòng chọn loại bất động sản',
}),
title: z.string().min(5, 'Tiêu đề tối thiểu 5 ký tự'),
description: z.string().min(10, 'Mô tả tối thiểu 10 ký tự'),
});
// ─── Step 2: Location ────────────────────────────────────
export const listingLocationSchema = z.object({
address: z.string().min(1, 'Vui lòng nhập địa chỉ'),
ward: z.string().min(1, 'Vui lòng nhập phường/xã'),
district: z.string().min(1, 'Vui lòng nhập quận/huyện'),
city: z.string().min(1, 'Vui lòng nhập tỉnh/thành phố'),
latitude: z.string().optional(),
longitude: z.string().optional(),
});
// ─── Step 3: Details ─────────────────────────────────────
export const listingDetailsSchema = z.object({
areaM2: z.string().min(1, 'Diện tích tối thiểu 1 m²'),
usableAreaM2: z.string().optional(),
bedrooms: z.string().optional(),
bathrooms: z.string().optional(),
floors: z.string().optional(),
floor: z.string().optional(),
totalFloors: z.string().optional(),
direction: z.string().optional(),
yearBuilt: z.string().optional(),
legalStatus: z.string().optional(),
amenities: z.string().optional(),
projectName: z.string().optional(),
});
// ─── Step 4: Pricing ─────────────────────────────────────
export const listingPricingSchema = z.object({
priceVND: z.string().min(1, 'Vui lòng nhập giá'),
rentPriceMonthly: z.string().optional(),
commissionPct: z.string().optional(),
});
// ─── Full Schema ─────────────────────────────────────────
export const createListingSchema = listingBasicSchema
.merge(listingLocationSchema)
.merge(listingDetailsSchema)
.merge(listingPricingSchema);
export type ListingBasicData = z.infer<typeof listingBasicSchema>;
export type ListingLocationData = z.infer<typeof listingLocationSchema>;
export type ListingDetailsData = z.infer<typeof listingDetailsSchema>;
export type ListingPricingData = z.infer<typeof listingPricingSchema>;
export type CreateListingFormData = z.infer<typeof createListingSchema>;