- property-card.tsx: add ward between address and district in both
card (line 189) and list (line 95) layouts
- transfer-listing-card.tsx: conditionally prepend ward to
district/city when ward is non-null
- property-card.spec.tsx: update address test to assert ward is shown,
add list-layout ward regression test (21/21 pass)
Standard format: {address}, {ward}, {district}, {city}
Compact (project-card, industrial-listing-card): district/city only —
intentional; ProjectSummary has no ward field.
Co-Authored-By: Paperclip <noreply@paperclip.ing>
225 lines
9.3 KiB
TypeScript
225 lines
9.3 KiB
TypeScript
import Image from 'next/image';
|
|
import Link from 'next/link';
|
|
import { AddToCompareButton } from '@/components/comparison/add-to-compare-button';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Card, CardContent } from '@/components/ui/card';
|
|
import { formatPrice } from '@/lib/currency';
|
|
import { shimmerBlurDataURL } from '@/lib/image-blur';
|
|
import type { ListingDetail } from '@/lib/listings-api';
|
|
|
|
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;
|
|
/** 'card' (default, vertical) or 'list' (horizontal row) */
|
|
layout?: 'card' | 'list';
|
|
}
|
|
|
|
export function PropertyCard({ listing, compact, layout = 'card' }: PropertyCardProps) {
|
|
const transactionLabel = listing.transactionType === 'SALE' ? 'Bán' : 'Cho thuê';
|
|
const propertyTypeLabel = PROPERTY_TYPE_LABELS[listing.property.propertyType] || listing.property.propertyType;
|
|
const mediaCount = listing.property.media?.length ?? 0;
|
|
const firstImage = listing.property.media?.[0]?.url;
|
|
const directionLabel =
|
|
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;
|
|
|
|
const ariaLabel = `${listing.property.title} — ${transactionLabel} ${propertyTypeLabel}, ${formatPrice(listing.priceVND)} VNĐ`;
|
|
|
|
if (layout === 'list') {
|
|
return (
|
|
<article aria-label={ariaLabel}>
|
|
<Link href={`/listings/${listing.id}`} className="group block">
|
|
<Card className="overflow-hidden transition-shadow hover:shadow-md">
|
|
<div className="flex flex-col sm:flex-row">
|
|
<div className="relative h-44 w-full shrink-0 bg-muted sm:h-36 sm:w-56">
|
|
{firstImage ? (
|
|
<Image
|
|
src={firstImage}
|
|
alt={`Ảnh bất động sản: ${listing.property.title}`}
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, 224px"
|
|
className="object-cover transition-transform group-hover:scale-105"
|
|
placeholder="blur"
|
|
blurDataURL={shimmerBlurDataURL()}
|
|
/>
|
|
) : (
|
|
<div
|
|
className="flex h-full items-center justify-center text-sm text-muted-foreground"
|
|
aria-hidden="true"
|
|
>
|
|
Chưa có ảnh
|
|
</div>
|
|
)}
|
|
<div className="absolute left-2 top-2 flex gap-1">
|
|
<Badge variant="default" className="text-xs">
|
|
{transactionLabel}
|
|
</Badge>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{propertyTypeLabel}
|
|
</Badge>
|
|
</div>
|
|
{mediaCount > 1 && (
|
|
<div className="absolute bottom-2 right-2">
|
|
<Badge
|
|
variant="outline"
|
|
className="border-none bg-black/50 text-xs text-white"
|
|
aria-label={`${mediaCount} ảnh`}
|
|
>
|
|
{mediaCount} ảnh
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
<div className="absolute right-2 top-2">
|
|
<AddToCompareButton listingId={listing.id} compact />
|
|
</div>
|
|
</div>
|
|
<CardContent className="flex flex-1 flex-col gap-2 p-4">
|
|
<div className="flex flex-wrap items-start justify-between gap-2">
|
|
<div className="min-w-0 flex-1">
|
|
<h3 className="line-clamp-1 font-semibold group-hover:text-primary">
|
|
{listing.property.title}
|
|
</h3>
|
|
<p className="mt-0.5 line-clamp-1 text-sm text-muted-foreground">
|
|
{listing.property.address}, {listing.property.ward}, {listing.property.district}, {listing.property.city}
|
|
</p>
|
|
</div>
|
|
<p className="shrink-0 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>
|
|
</div>
|
|
<ul className="flex flex-wrap gap-1.5" aria-label="Thông tin bất động sản">
|
|
<li>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{listing.property.areaM2} m²
|
|
</Badge>
|
|
</li>
|
|
{listing.property.bedrooms != null && (
|
|
<li>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{listing.property.bedrooms} PN
|
|
</Badge>
|
|
</li>
|
|
)}
|
|
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
|
|
<li>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{listing.property.bathrooms} PT
|
|
</Badge>
|
|
</li>
|
|
)}
|
|
{listing.property.direction && (
|
|
<li>
|
|
<Badge variant="outline" className="text-xs">
|
|
Hướng {directionLabel}
|
|
</Badge>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</CardContent>
|
|
</div>
|
|
</Card>
|
|
</Link>
|
|
</article>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<article aria-label={ariaLabel}>
|
|
<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]'}`}>
|
|
{firstImage ? (
|
|
<Image
|
|
src={firstImage}
|
|
alt={`Ảnh bất động sản: ${listing.property.title}`}
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, (max-width: 1024px) 50vw, 33vw"
|
|
className="object-cover transition-transform group-hover:scale-105"
|
|
placeholder="blur"
|
|
blurDataURL={shimmerBlurDataURL()}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-muted-foreground" aria-hidden="true">
|
|
Chưa có ảnh
|
|
</div>
|
|
)}
|
|
<div className="absolute left-2 top-2 flex gap-1">
|
|
<Badge variant="default" className="text-xs">
|
|
{transactionLabel}
|
|
</Badge>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{propertyTypeLabel}
|
|
</Badge>
|
|
</div>
|
|
{mediaCount > 1 && (
|
|
<div className="absolute bottom-2 right-2">
|
|
<Badge variant="outline" className="bg-black/50 text-xs text-white border-none" aria-label={`${mediaCount} ảnh`}>
|
|
{mediaCount} ảnh
|
|
</Badge>
|
|
</div>
|
|
)}
|
|
<div className="absolute right-2 top-2">
|
|
<AddToCompareButton listingId={listing.id} compact />
|
|
</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.ward}, {listing.property.district}, {listing.property.city}
|
|
</p>
|
|
<ul className="mt-3 flex flex-wrap gap-1.5" aria-label="Thông tin bất động sản">
|
|
<li>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{listing.property.areaM2} m²
|
|
</Badge>
|
|
</li>
|
|
{listing.property.bedrooms != null && (
|
|
<li>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{listing.property.bedrooms} PN
|
|
</Badge>
|
|
</li>
|
|
)}
|
|
{listing.property.bathrooms != null && listing.property.bathrooms > 0 && (
|
|
<li>
|
|
<Badge variant="secondary" className="text-xs">
|
|
{listing.property.bathrooms} PT
|
|
</Badge>
|
|
</li>
|
|
)}
|
|
{listing.property.direction && (
|
|
<li>
|
|
<Badge variant="outline" className="text-xs">
|
|
Hướng {directionLabel}
|
|
</Badge>
|
|
</li>
|
|
)}
|
|
</ul>
|
|
</CardContent>
|
|
</Card>
|
|
</Link>
|
|
</article>
|
|
);
|
|
}
|