BUY listings can come back from the API with null `total_price`, `qm`,
`qmprice`, `rooms`, and `last_seen` even though the TypeScript types
declare them non-nullable. The cards called `.toLocaleString()` and
`.split('T')` on those values, throwing TypeError and tripping the
ErrorBoundary — which is the "BUY part of the page crashes" symptom.
Coerce numeric fields via a `safeNum` helper (0 fallback), gate the
"Nd ago" line on a finite last_seen, and apply the same guards in
PropertyCard, PropertyCardCompact, SwipeCard, and the price-history
section of ListingDetail. Added regression tests asserting both card
variants render with all-null backend fields without throwing.
88 lines
3.4 KiB
TypeScript
88 lines
3.4 KiB
TypeScript
import { Bed, MapPin } from 'lucide-react';
|
|
import type { PropertyProperties } from '@/types';
|
|
|
|
interface PropertyCardCompactProps {
|
|
property: PropertyProperties;
|
|
isActive?: boolean;
|
|
isHighlighted?: boolean;
|
|
avgPricePerSqm?: number;
|
|
onClick?: () => void;
|
|
}
|
|
|
|
export function PropertyCardCompact({
|
|
property,
|
|
isActive = false,
|
|
isHighlighted = false,
|
|
avgPricePerSqm,
|
|
onClick,
|
|
}: PropertyCardCompactProps) {
|
|
// BUY listings may have null numeric fields; coerce so renders don't throw.
|
|
const safeNum = (v: unknown): number => (typeof v === 'number' && Number.isFinite(v) ? v : 0);
|
|
const safeTotalPrice = safeNum(property.total_price);
|
|
const safeQm = safeNum(property.qm);
|
|
const safeQmprice = safeNum(property.qmprice);
|
|
const safeRooms = safeNum(property.rooms);
|
|
|
|
const isGoodDeal = avgPricePerSqm && safeQmprice > 0 && safeQmprice < avgPricePerSqm * 0.9;
|
|
const isExpensive = avgPricePerSqm && safeQmprice > avgPricePerSqm * 1.1;
|
|
|
|
const priceIndicator = isGoodDeal
|
|
? { dotColor: 'bg-[var(--deal-good)]', label: 'Good deal' }
|
|
: isExpensive
|
|
? { dotColor: 'bg-[var(--deal-above)]', label: 'Above avg' }
|
|
: null;
|
|
|
|
return (
|
|
<div
|
|
className={`w-[280px] shrink-0 snap-center rounded-lg border bg-card shadow-sm overflow-hidden cursor-pointer transition-shadow hover:shadow-md ${
|
|
isActive ? 'ring-2 ring-primary scale-[1.02]' : ''
|
|
} ${isHighlighted ? 'ring-2 ring-primary' : ''}`}
|
|
onClick={onClick}
|
|
>
|
|
{/* Thumbnail with 4:3 aspect ratio */}
|
|
<div className="aspect-[4/3] w-full bg-muted">
|
|
{property.photo_thumbnail && (
|
|
<img
|
|
src={property.photo_thumbnail}
|
|
alt={`${safeRooms}-bed, £${safeTotalPrice.toLocaleString()}`}
|
|
className="w-full h-full object-cover"
|
|
/>
|
|
)}
|
|
</div>
|
|
|
|
{/* Details */}
|
|
<div className="p-3 space-y-1">
|
|
{/* Price bold */}
|
|
<div className="flex items-center gap-1.5">
|
|
<span className="font-bold text-base">
|
|
£{safeTotalPrice.toLocaleString()}
|
|
{property.listing_type !== 'BUY' && (
|
|
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
|
)}
|
|
</span>
|
|
{priceIndicator && (
|
|
<span className={`w-2 h-2 rounded-full shrink-0 ${priceIndicator.dotColor}`} title={priceIndicator.label} />
|
|
)}
|
|
</div>
|
|
|
|
{/* Beds and size */}
|
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
<span className="flex items-center gap-1">
|
|
<Bed className="h-3.5 w-3.5" />
|
|
{safeRooms} bed
|
|
</span>
|
|
<span>·</span>
|
|
<span>{safeQm} m²</span>
|
|
</div>
|
|
|
|
{/* Location */}
|
|
{property.city && (
|
|
<div className="flex items-center gap-1 text-xs text-muted-foreground truncate">
|
|
<MapPin className="h-3 w-3 shrink-0" />
|
|
{property.city}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
);
|
|
}
|