- Rewrite prisma/seed.ts to populate all 27 models with realistic Vietnamese real estate data (8 users with login, 10 properties, 10 listings, orders, payments, reviews, notifications, etc.) - Replace all emoji icons with Lucide React SVG icons across frontend for consistent rendering, sizing, and accessibility - Redesign dashboard nav: grouped sidebar with section headers, primary/secondary split on desktop, icon-only secondary items - Replace language switcher flag emoji with Globe icon - Replace SVG theme toggle with Lucide Moon/Sun icons - Fix API startup: graceful fallback for Sentry profiling, Google OAuth, and Zalo OAuth when credentials are not configured - Relax rate limiting in development mode (10k req/min) - Fix listings API to include media[] array in search response - Add optional chaining for property.media across frontend components - Update OAuth strategy tests to match graceful fallback behavior Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
275 lines
8.6 KiB
TypeScript
275 lines
8.6 KiB
TypeScript
'use client';
|
|
|
|
import { X } from 'lucide-react';
|
|
import Image from 'next/image';
|
|
import { useTranslations } from 'next-intl';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Link } from '@/i18n/navigation';
|
|
import { formatPrice, formatPricePerM2 } 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',
|
|
};
|
|
|
|
const DIRECTION_LABELS: Record<string, string> = {
|
|
NORTH: 'Bắc',
|
|
SOUTH: 'Nam',
|
|
EAST: 'Đông',
|
|
WEST: 'Tây',
|
|
NORTHEAST: 'Đông Bắc',
|
|
NORTHWEST: 'Tây Bắc',
|
|
SOUTHEAST: 'Đông Nam',
|
|
SOUTHWEST: 'Tây Nam',
|
|
};
|
|
|
|
interface ComparisonTableProps {
|
|
listings: ListingDetail[];
|
|
onRemove: (id: string) => void;
|
|
}
|
|
|
|
interface ComparisonRowProps {
|
|
label: string;
|
|
values: React.ReactNode[];
|
|
highlight?: boolean;
|
|
}
|
|
|
|
function ComparisonRow({ label, values, highlight }: ComparisonRowProps) {
|
|
return (
|
|
<tr className={highlight ? 'bg-muted/50' : ''}>
|
|
<td className="whitespace-nowrap border-r px-4 py-3 text-sm font-medium text-muted-foreground">
|
|
{label}
|
|
</td>
|
|
{values.map((val, i) => (
|
|
<td key={i} className="border-r px-4 py-3 text-sm last:border-r-0">
|
|
{val}
|
|
</td>
|
|
))}
|
|
</tr>
|
|
);
|
|
}
|
|
|
|
export function ComparisonTable({ listings, onRemove }: ComparisonTableProps) {
|
|
const t = useTranslations('compare');
|
|
|
|
if (listings.length === 0) return null;
|
|
|
|
return (
|
|
<div className="overflow-x-auto rounded-lg border">
|
|
<table className="w-full min-w-[600px] caption-bottom text-sm">
|
|
<thead>
|
|
<tr className="border-b bg-muted/30">
|
|
<th className="w-40 border-r px-4 py-3 text-left text-sm font-medium text-muted-foreground">
|
|
{t('property')}
|
|
</th>
|
|
{listings.map((listing) => (
|
|
<th key={listing.id} className="border-r px-4 py-3 last:border-r-0">
|
|
<div className="flex flex-col items-center gap-2">
|
|
{/* Image */}
|
|
<div className="relative aspect-[4/3] w-full max-w-[200px] overflow-hidden rounded-md bg-muted">
|
|
{(listing.property.media?.length ?? 0) > 0 ? (
|
|
<Image
|
|
src={listing.property.media![0]?.url ?? ''}
|
|
alt={listing.property.title}
|
|
fill
|
|
sizes="200px"
|
|
className="object-cover"
|
|
placeholder="blur"
|
|
blurDataURL={shimmerBlurDataURL()}
|
|
/>
|
|
) : (
|
|
<div className="flex h-full items-center justify-center text-xs text-muted-foreground">
|
|
{t('noImage')}
|
|
</div>
|
|
)}
|
|
</div>
|
|
{/* Title */}
|
|
<Link
|
|
href={`/listings/${listing.id}` as '/listings/[id]'}
|
|
className="line-clamp-2 text-center text-sm font-semibold hover:text-primary"
|
|
>
|
|
{listing.property.title}
|
|
</Link>
|
|
{/* Remove button */}
|
|
<Button
|
|
variant="ghost"
|
|
size="sm"
|
|
className="h-7 gap-1 text-xs text-muted-foreground hover:text-destructive"
|
|
onClick={() => onRemove(listing.id)}
|
|
aria-label={`${t('remove')} ${listing.property.title}`}
|
|
>
|
|
<X className="h-3 w-3" />
|
|
{t('remove')}
|
|
</Button>
|
|
</div>
|
|
</th>
|
|
))}
|
|
</tr>
|
|
</thead>
|
|
<tbody>
|
|
{/* Price */}
|
|
<ComparisonRow
|
|
label={t('price')}
|
|
highlight
|
|
values={listings.map((l) => (
|
|
<span key={l.id} className="font-bold text-primary">
|
|
{formatPrice(l.priceVND)} VND
|
|
{l.transactionType === 'RENT' && l.rentPriceMonthly && (
|
|
<span className="block text-xs font-normal text-muted-foreground">/tháng</span>
|
|
)}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Transaction type */}
|
|
<ComparisonRow
|
|
label={t('transactionType')}
|
|
values={listings.map((l) => (
|
|
<Badge key={l.id} variant="default" className="text-xs">
|
|
{l.transactionType === 'SALE' ? t('sale') : t('rent')}
|
|
</Badge>
|
|
))}
|
|
/>
|
|
|
|
{/* Property type */}
|
|
<ComparisonRow
|
|
label={t('propertyType')}
|
|
highlight
|
|
values={listings.map((l) => (
|
|
<span key={l.id}>
|
|
{PROPERTY_TYPE_LABELS[l.property.propertyType] || l.property.propertyType}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Area */}
|
|
<ComparisonRow
|
|
label={t('area')}
|
|
values={listings.map((l) => (
|
|
<span key={l.id} className="font-medium">{l.property.areaM2} m²</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Price per m² */}
|
|
<ComparisonRow
|
|
label={t('pricePerM2')}
|
|
highlight
|
|
values={listings.map((l) => (
|
|
<span key={l.id}>
|
|
{l.pricePerM2 != null ? formatPricePerM2(l.pricePerM2) : '—'}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Bedrooms */}
|
|
<ComparisonRow
|
|
label={t('bedrooms')}
|
|
values={listings.map((l) => (
|
|
<span key={l.id}>
|
|
{l.property.bedrooms != null ? `${l.property.bedrooms} ${t('rooms')}` : '—'}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Bathrooms */}
|
|
<ComparisonRow
|
|
label={t('bathrooms')}
|
|
highlight
|
|
values={listings.map((l) => (
|
|
<span key={l.id}>
|
|
{l.property.bathrooms != null ? `${l.property.bathrooms} ${t('rooms')}` : '—'}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Direction */}
|
|
<ComparisonRow
|
|
label={t('direction')}
|
|
values={listings.map((l) => (
|
|
<span key={l.id}>
|
|
{l.property.direction ? DIRECTION_LABELS[l.property.direction] || l.property.direction : '—'}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Floors */}
|
|
<ComparisonRow
|
|
label={t('floors')}
|
|
highlight
|
|
values={listings.map((l) => (
|
|
<span key={l.id}>
|
|
{l.property.floors != null ? l.property.floors : '—'}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Year built */}
|
|
<ComparisonRow
|
|
label={t('yearBuilt')}
|
|
values={listings.map((l) => (
|
|
<span key={l.id}>
|
|
{l.property.yearBuilt != null ? l.property.yearBuilt : '—'}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Legal status */}
|
|
<ComparisonRow
|
|
label={t('legalStatus')}
|
|
highlight
|
|
values={listings.map((l) => (
|
|
<span key={l.id}>
|
|
{l.property.legalStatus || '—'}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Location */}
|
|
<ComparisonRow
|
|
label={t('location')}
|
|
values={listings.map((l) => (
|
|
<span key={l.id} className="text-xs">
|
|
{l.property.address}, {l.property.district}, {l.property.city}
|
|
</span>
|
|
))}
|
|
/>
|
|
|
|
{/* Amenities */}
|
|
<ComparisonRow
|
|
label={t('amenities')}
|
|
highlight
|
|
values={listings.map((l) => (
|
|
<div key={l.id} className="flex flex-wrap gap-1">
|
|
{l.property.amenities && l.property.amenities.length > 0
|
|
? l.property.amenities.map((a) => (
|
|
<Badge key={a} variant="outline" className="text-xs">
|
|
{a}
|
|
</Badge>
|
|
))
|
|
: '—'}
|
|
</div>
|
|
))}
|
|
/>
|
|
|
|
{/* Project name */}
|
|
<ComparisonRow
|
|
label={t('projectName')}
|
|
values={listings.map((l) => (
|
|
<span key={l.id}>
|
|
{l.property.projectName || '—'}
|
|
</span>
|
|
))}
|
|
/>
|
|
</tbody>
|
|
</table>
|
|
</div>
|
|
);
|
|
}
|