feat(search-frontend): add public landing page, search page with map view, filters, and property cards
- Create (public) route group with landing page (hero, featured listings, district links, stats, CTA) - Create search page with filter sidebar, list/map/split view modes, URL-synced filters, pagination - Build ListingMap component with CSS-based marker visualization and popup details - Build FilterBar with transaction type, property type, city, price range, area, bedrooms filters - Build PropertyCard and SearchResults components with responsive grid layout - Update middleware to allow public access to / and /search routes - Move dashboard home to /dashboard to avoid route conflict - All content in Vietnamese, mobile responsive Co-Authored-By: Paperclip <noreply@paperclip.ing>
This commit is contained in:
101
apps/web/components/search/property-card.tsx
Normal file
101
apps/web/components/search/property-card.tsx
Normal file
@@ -0,0 +1,101 @@
|
||||
'use client';
|
||||
|
||||
import Link from 'next/link';
|
||||
import { Card, CardContent } from '@/components/ui/card';
|
||||
import { Badge } from '@/components/ui/badge';
|
||||
import type { ListingDetail } from '@/lib/listings-api';
|
||||
|
||||
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');
|
||||
}
|
||||
|
||||
const PROPERTY_TYPE_LABELS: Record<string, string> = {
|
||||
APARTMENT: 'Căn hộ',
|
||||
HOUSE: 'Nhà riêng',
|
||||
VILLA: 'Biệt thự',
|
||||
LAND: 'Đất nền',
|
||||
OFFICE: 'Văn phòng',
|
||||
SHOPHOUSE: 'Shophouse',
|
||||
};
|
||||
|
||||
interface PropertyCardProps {
|
||||
listing: ListingDetail;
|
||||
compact?: boolean;
|
||||
}
|
||||
|
||||
export function PropertyCard({ listing, compact }: PropertyCardProps) {
|
||||
return (
|
||||
<Link href={`/listings/${listing.id}`}>
|
||||
<Card className="group h-full overflow-hidden transition-shadow hover:shadow-md">
|
||||
<div className={`relative bg-muted ${compact ? 'aspect-[16/10]' : 'aspect-[4/3]'}`}>
|
||||
{listing.property.media.length > 0 ? (
|
||||
<img
|
||||
src={listing.property.media[0]?.url}
|
||||
alt={listing.property.title}
|
||||
className="h-full w-full object-cover transition-transform group-hover:scale-105"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full items-center justify-center text-muted-foreground">
|
||||
Chưa có ảnh
|
||||
</div>
|
||||
)}
|
||||
<div className="absolute left-2 top-2 flex gap-1">
|
||||
<Badge variant="default" className="text-xs">
|
||||
{listing.transactionType === 'SALE' ? 'Bán' : 'Cho thuê'}
|
||||
</Badge>
|
||||
<Badge variant="secondary" className="text-xs">
|
||||
{PROPERTY_TYPE_LABELS[listing.property.propertyType] || listing.property.propertyType}
|
||||
</Badge>
|
||||
</div>
|
||||
{listing.property.media.length > 1 && (
|
||||
<div className="absolute bottom-2 right-2">
|
||||
<Badge variant="outline" className="bg-black/50 text-xs text-white border-none">
|
||||
{listing.property.media.length} ảnh
|
||||
</Badge>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<CardContent className="p-4">
|
||||
<p className="text-lg font-bold text-primary">
|
||||
{formatPrice(listing.priceVND)} VNĐ
|
||||
{listing.transactionType === 'RENT' && listing.rentPriceMonthly && (
|
||||
<span className="text-sm font-normal text-muted-foreground">/tháng</span>
|
||||
)}
|
||||
</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.address}, {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>
|
||||
)}
|
||||
{listing.property.direction && (
|
||||
<Badge variant="outline" className="text-xs">
|
||||
Hướng {listing.property.direction === 'NORTH' ? 'Bắc' :
|
||||
listing.property.direction === 'SOUTH' ? 'Nam' :
|
||||
listing.property.direction === 'EAST' ? 'Đông' :
|
||||
listing.property.direction === 'WEST' ? 'Tây' :
|
||||
listing.property.direction}
|
||||
</Badge>
|
||||
)}
|
||||
</div>
|
||||
</CardContent>
|
||||
</Card>
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user