Files
goodgo-platform/apps/web/components/comparison/comparison-table.tsx
Ho Ngoc Hai a9fa214544 feat: comprehensive seed, Lucide icons, grouped dashboard nav, API fixes
- 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>
2026-04-13 11:13:04 +07:00

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>
);
}