From 0b5308200ee94561cbead9b750ddf991b195fbd6 Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sun, 10 May 2026 21:17:41 +0000 Subject: [PATCH] wrongmove: guard property cards against null backend fields (fix BUY crash) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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. --- frontend/src/components/ListingDetail.tsx | 8 ++- frontend/src/components/PropertyCard.tsx | 50 +++++++++++++------ .../src/components/PropertyCardCompact.tsx | 19 ++++--- frontend/src/components/SwipeCard.tsx | 16 ++++-- .../__tests__/PropertyCard.test.tsx | 32 ++++++++++++ frontend/tsconfig.app.tsbuildinfo | 2 +- 6 files changed, 97 insertions(+), 30 deletions(-) diff --git a/frontend/src/components/ListingDetail.tsx b/frontend/src/components/ListingDetail.tsx index 4a5d2d0..2b008f5 100644 --- a/frontend/src/components/ListingDetail.tsx +++ b/frontend/src/components/ListingDetail.tsx @@ -214,8 +214,12 @@ export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDeta
{detail.price_history.map((entry) => (
- {entry.last_seen.split('T')[0]} - £{entry.price.toLocaleString()} + + {typeof entry.last_seen === 'string' ? entry.last_seen.split('T')[0] : '—'} + + + £{typeof entry.price === 'number' && Number.isFinite(entry.price) ? entry.price.toLocaleString() : '—'} +
))}
diff --git a/frontend/src/components/PropertyCard.tsx b/frontend/src/components/PropertyCard.tsx index dd4d9bf..663b088 100644 --- a/frontend/src/components/PropertyCard.tsx +++ b/frontend/src/components/PropertyCard.tsx @@ -145,8 +145,18 @@ export function PropertyCard({ allPOIs, onClick, }: PropertyCardProps) { - const lastSeenDate = property.last_seen.split('T')[0]; - const lastSeenDays = Math.round((Date.now() - new Date(lastSeenDate).getTime()) / (1000 * 60 * 60 * 24)); + // BUY listings may have null numeric / date fields; coerce so renders don't throw. + const lastSeenRaw = property.last_seen; + const lastSeenDate = typeof lastSeenRaw === 'string' ? lastSeenRaw.split('T')[0] : null; + const lastSeenTime = lastSeenDate ? new Date(lastSeenDate).getTime() : NaN; + const lastSeenDays = Number.isFinite(lastSeenTime) + ? Math.round((Date.now() - lastSeenTime) / (1000 * 60 * 60 * 24)) + : null; + 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); // Determine if this is a good deal const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9; @@ -175,7 +185,7 @@ export function PropertyCard({ {(property.photos?.length || property.photo_thumbnail) ? ( ) : null} @@ -185,7 +195,7 @@ export function PropertyCard({ {/* Price */}
- £{property.total_price.toLocaleString()} + £{safeTotalPrice.toLocaleString()} {property.listing_type !== 'BUY' && ( /mo )} @@ -200,18 +210,22 @@ export function PropertyCard({ {/* Key metrics on one line */}
- {property.rooms}bed + {safeRooms}bed · - {property.qm} m² + {safeQm} m² · - £{property.qmprice}/m² + £{safeQmprice}/m²
{/* Agency + freshness */}
{property.agency} - · - {lastSeenDays}d ago + {lastSeenDays !== null && ( + <> + · + {lastSeenDays}d ago + + )}
{/* POI badges */} @@ -234,7 +248,7 @@ export function PropertyCard({ {(property.photos?.length || property.photo_thumbnail) ? ( ) : null} @@ -257,7 +271,7 @@ export function PropertyCard({ {/* Price as dominant element */}
- £{property.total_price.toLocaleString()} + £{safeTotalPrice.toLocaleString()} {property.listing_type !== 'BUY' && ( /mo )} @@ -272,11 +286,11 @@ export function PropertyCard({ {/* Key metrics on one line */}
- {property.rooms}bed + {safeRooms}bed · - {property.qm} m² + {safeQm} m² · - £{property.qmprice}/m² + £{safeQmprice}/m²
{/* Location */} @@ -294,8 +308,12 @@ export function PropertyCard({ {/* Agency + freshness */}
{property.agency} - · - {lastSeenDays}d ago + {lastSeenDays !== null && ( + <> + · + {lastSeenDays}d ago + + )}
diff --git a/frontend/src/components/PropertyCardCompact.tsx b/frontend/src/components/PropertyCardCompact.tsx index 685734a..f6d0d99 100644 --- a/frontend/src/components/PropertyCardCompact.tsx +++ b/frontend/src/components/PropertyCardCompact.tsx @@ -16,8 +16,15 @@ export function PropertyCardCompact({ avgPricePerSqm, onClick, }: PropertyCardCompactProps) { - const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9; - const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1; + // 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' } @@ -37,7 +44,7 @@ export function PropertyCardCompact({ {property.photo_thumbnail && ( {`${property.rooms}-bed, )} @@ -48,7 +55,7 @@ export function PropertyCardCompact({ {/* Price bold */}
- £{property.total_price.toLocaleString()} + £{safeTotalPrice.toLocaleString()} {property.listing_type !== 'BUY' && ( /mo )} @@ -62,10 +69,10 @@ export function PropertyCardCompact({
- {property.rooms} bed + {safeRooms} bed · - {property.qm} m² + {safeQm} m²
{/* Location */} diff --git a/frontend/src/components/SwipeCard.tsx b/frontend/src/components/SwipeCard.tsx index c9883f6..e080fef 100644 --- a/frontend/src/components/SwipeCard.tsx +++ b/frontend/src/components/SwipeCard.tsx @@ -19,6 +19,12 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC const hasSwiped = useRef(false); const p = feature.properties; const photos = p.photos?.length ? p.photos : p.photo_thumbnail ? [p.photo_thumbnail] : []; + // 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(p.total_price); + const safeQm = safeNum(p.qm); + const safeQmprice = safeNum(p.qmprice); + const safeRooms = safeNum(p.rooms); const prefersReducedMotion = useMemo( () => window.matchMedia('(prefers-reduced-motion: reduce)').matches, @@ -150,7 +156,7 @@ export function SwipeCard({ feature, onSwipe, onTap, isTop, stackIndex }: SwipeC
) : photos.length === 1 ? ( - {`${p.rooms}-bed, + {`${safeRooms}-bed, ) : null}