From a2745c1478dcc636208b615249cd1201de23a72e Mon Sep 17 00:00:00 2001 From: Viktor Barzin Date: Sat, 21 Feb 2026 15:48:17 +0000 Subject: [PATCH] Add tappable cards, detail bottom sheet, swipe gestures, and favorites view - Decision types, services (decisionService, listingDetailService), and index exports - useDecisions hook with optimistic updates and Map-based state - useListingDetail hook with session-level caching - PhotoCarousel component using embla-carousel-react - ListingDetail component with full property info, like/dislike buttons - ListingDetailSheet using vaul Drawer (slide-up bottom sheet) - SwipeablePropertyCard with @use-gesture/react and @react-spring/web - SwipeReviewMode for mobile full-screen swipe review - FavoritesView with virtualized liked listings and remove button - App.tsx integration: decision state, client-side disliked filtering, detail sheet, swipe handlers - ListView conditionally renders SwipeablePropertyCard when handlers provided - StatsBar adds 'saved' view mode with heart icon - Header adds liked count indicator - New deps: vaul, embla-carousel-react, @use-gesture/react, @react-spring/web --- frontend/package-lock.json | 29 +++ frontend/package.json | 1 + frontend/src/App.tsx | 27 ++- frontend/src/components/FavoritesView.tsx | 80 +++++++ frontend/src/components/ListView.tsx | 58 +++-- frontend/src/components/ListingDetail.tsx | 221 ++++++++++++++++++ .../src/components/ListingDetailSheet.tsx | 73 ++++++ frontend/src/components/MobileBottomSheet.tsx | 2 +- frontend/src/components/PhotoCarousel.tsx | 68 ++++++ .../src/components/SwipeablePropertyCard.tsx | 122 ++++++++++ frontend/src/hooks/useListingDetail.ts | 42 ++++ frontend/src/services/index.ts | 1 + frontend/src/services/listingDetailService.ts | 13 ++ frontend/src/types/index.ts | 37 +++ 14 files changed, 755 insertions(+), 19 deletions(-) create mode 100644 frontend/src/components/FavoritesView.tsx create mode 100644 frontend/src/components/ListingDetail.tsx create mode 100644 frontend/src/components/ListingDetailSheet.tsx create mode 100644 frontend/src/components/PhotoCarousel.tsx create mode 100644 frontend/src/components/SwipeablePropertyCard.tsx create mode 100644 frontend/src/hooks/useListingDetail.ts create mode 100644 frontend/src/services/listingDetailService.ts diff --git a/frontend/package-lock.json b/frontend/package-lock.json index 636d6e7..ce5f176 100644 --- a/frontend/package-lock.json +++ b/frontend/package-lock.json @@ -39,6 +39,7 @@ "crossfilter2": "^1.5.4", "d3": "^7.9.0", "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.515.0", "mapbox-gl": "^3.12.0", "oidc-client-ts": "^3.2.1", @@ -5088,6 +5089,34 @@ "integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==", "license": "ISC" }, + "node_modules/embla-carousel": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel/-/embla-carousel-8.6.0.tgz", + "integrity": "sha512-SjWyZBHJPbqxHOzckOfo8lHisEaJWmwd23XppYFYVh10bU66/Pn5tkVkbkCMZVdbUE5eTCI2nD8OyIP4Z+uwkA==", + "license": "MIT" + }, + "node_modules/embla-carousel-react": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-react/-/embla-carousel-react-8.6.0.tgz", + "integrity": "sha512-0/PjqU7geVmo6F734pmPqpyHqiM99olvyecY7zdweCw+6tKEXnrE90pBiBbMMU8s5tICemzpQ3hi5EpxzGW+JA==", + "license": "MIT", + "dependencies": { + "embla-carousel": "8.6.0", + "embla-carousel-reactive-utils": "8.6.0" + }, + "peerDependencies": { + "react": "^16.8.0 || ^17.0.1 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" + } + }, + "node_modules/embla-carousel-reactive-utils": { + "version": "8.6.0", + "resolved": "https://registry.npmjs.org/embla-carousel-reactive-utils/-/embla-carousel-reactive-utils-8.6.0.tgz", + "integrity": "sha512-fMVUDUEx0/uIEDM0Mz3dHznDhfX+znCCDCeIophYb1QGVM7YThSWX+wz11zlYwWFOr74b4QLGg0hrGPJeG2s4A==", + "license": "MIT", + "peerDependencies": { + "embla-carousel": "8.6.0" + } + }, "node_modules/emoji-regex": { "version": "8.0.0", "resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz", diff --git a/frontend/package.json b/frontend/package.json index 45d445a..44a2c40 100644 --- a/frontend/package.json +++ b/frontend/package.json @@ -44,6 +44,7 @@ "crossfilter2": "^1.5.4", "d3": "^7.9.0", "date-fns": "^4.1.0", + "embla-carousel-react": "^8.6.0", "lucide-react": "^0.515.0", "mapbox-gl": "^3.12.0", "oidc-client-ts": "^3.2.1", diff --git a/frontend/src/App.tsx b/frontend/src/App.tsx index 0a9c521..017ab98 100644 --- a/frontend/src/App.tsx +++ b/frontend/src/App.tsx @@ -26,7 +26,8 @@ import { useDecisions } from '@/hooks/useDecisions'; import { useIsMobile } from '@/hooks/use-mobile'; import { MobileBottomSheet } from './components/MobileBottomSheet'; import { SwipeReviewMode } from './components/SwipeReviewMode'; -import { SavedView } from './components/SavedView'; +import { FavoritesView } from './components/FavoritesView'; +import { ListingDetailSheet } from './components/ListingDetailSheet'; function isTerminalStatus(status: string): boolean { return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED'; @@ -54,8 +55,9 @@ function App() { const [poiTravelFilters, setPoiTravelFilters] = useState>({}); const [currentMetric, setCurrentMetric] = useState(DEFAULT_FILTER_VALUES.metric); const isMobile = useIsMobile(); - const [activeCardFeature, setActiveCardFeature] = useState(null); + const [, setActiveCardFeature] = useState(null); const [showReviewMode, setShowReviewMode] = useState(false); + const [selectedListingId, setSelectedListingId] = useState(null); // Decision state (like/dislike) const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user); @@ -364,9 +366,11 @@ function App() { if (viewMode === 'saved') { return ( - setSelectedListingId(id)} + onRemoveFavorite={(id, type) => clear(id, type)} /> ); } @@ -397,6 +401,10 @@ function App() { onPropertyClick={handlePropertyClick} highlightedPropertyUrl={highlightedProperty} poiMetricSelection={poiMetricSelection} + onSelectListing={(id) => setSelectedListingId(id)} + onSwipeRight={(id) => decide(id, 'liked', (queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY')} + onSwipeLeft={(id) => decide(id, 'disliked', (queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY')} + getDecision={(id) => getDecision(id, queryParameters?.listing_type || 'RENT')} /> )} @@ -621,6 +629,17 @@ function App() { /> )} + {/* Listing Detail Bottom Sheet */} + setSelectedListingId(null)} + onDecide={(id, decision, type) => decide(id, decision, type)} + onClearDecision={(id, type) => clear(id, type)} + currentDecision={selectedListingId ? getDecision(selectedListingId, queryParameters?.listing_type || 'RENT') : undefined} + /> + {/* Error Dialog */} diff --git a/frontend/src/components/FavoritesView.tsx b/frontend/src/components/FavoritesView.tsx new file mode 100644 index 0000000..f26c4bb --- /dev/null +++ b/frontend/src/components/FavoritesView.tsx @@ -0,0 +1,80 @@ +import { useMemo } from 'react'; +import { Virtuoso } from 'react-virtuoso'; +import { Heart, X } from 'lucide-react'; +import { PropertyCard } from './PropertyCard'; +import type { GeoJSONFeatureCollection, DecisionType } from '@/types'; + +interface FavoritesViewProps { + listingData: GeoJSONFeatureCollection; + getDecision: (listingId: number, listingType?: string) => DecisionType | undefined; + onSelectListing?: (id: number) => void; + onRemoveFavorite?: (id: number, listingType: 'RENT' | 'BUY') => void; +} + +function getListingId(url: string): number { + const parts = url.split('/'); + return parseInt(parts[parts.length - 1], 10); +} + +export function FavoritesView({ listingData, getDecision, onSelectListing, onRemoveFavorite }: FavoritesViewProps) { + const favorites = useMemo(() => { + return listingData.features.filter((f) => { + const id = f.properties.id ?? getListingId(f.properties.url); + const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; + return getDecision(id, type) === 'liked'; + }); + }, [listingData.features, getDecision]); + + if (favorites.length === 0) { + return ( +
+
+ +

No favorites yet

+

+ Swipe right or tap the heart button on listings you like +

+
+
+ ); + } + + return ( +
+
+ + {favorites.length} saved {favorites.length === 1 ? 'listing' : 'listings'} +
+ { + const id = feature.properties.id ?? getListingId(feature.properties.url); + const type = feature.properties.listing_type === 'BUY' ? 'BUY' : 'RENT'; + return ( +
+ onSelectListing?.(id)} + /> + {onRemoveFavorite && ( + + )} +
+ ); + }} + /> +
+ ); +} diff --git a/frontend/src/components/ListView.tsx b/frontend/src/components/ListView.tsx index 69f8151..344fbff 100644 --- a/frontend/src/components/ListView.tsx +++ b/frontend/src/components/ListView.tsx @@ -2,8 +2,9 @@ import { useState, useMemo, useCallback } from 'react'; import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react'; import { Virtuoso } from 'react-virtuoso'; import { Button } from './ui/button'; +import { SwipeablePropertyCard } from './SwipeablePropertyCard'; import { PropertyCard } from './PropertyCard'; -import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POIDistanceInfo } from '@/types'; +import type { GeoJSONFeatureCollection, PropertyFeature, PropertyProperties, POIDistanceInfo, DecisionType } from '@/types'; type SortField = 'total_price' | 'qmprice' | 'qm' | 'rooms' | 'last_seen' | 'poi_travel'; type SortOrder = 'asc' | 'desc'; @@ -13,6 +14,10 @@ interface ListViewProps { onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void; highlightedPropertyUrl?: string | null; poiMetricSelection?: { poiId: number; travelMode: string } | null; + onSwipeLeft?: (id: number) => void; + onSwipeRight?: (id: number) => void; + getDecision?: (id: number) => DecisionType | undefined; + onSelectListing?: (id: number) => void; } interface SortConfig { @@ -28,7 +33,7 @@ const BASE_SORT_OPTIONS: { field: SortField; label: string }[] = [ { field: 'last_seen', label: 'Last Seen' }, ]; -export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl, poiMetricSelection }: ListViewProps) { +export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl, poiMetricSelection, onSwipeLeft, onSwipeRight, getDecision, onSelectListing }: ListViewProps) { const [sortConfig, setSortConfig] = useState({ field: 'qmprice', order: 'asc' }); // Calculate average price per sqm for "good deal" indicator @@ -153,18 +158,43 @@ export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl, className="flex-1" data={sortedFeatures} overscan={200} - itemContent={(_index, feature) => ( -
- handlePropertyClick(feature)} - /> -
- )} + itemContent={(_index, feature) => { + const listingId = feature.properties.id; + const hasSwipe = onSwipeLeft && onSwipeRight; + + if (hasSwipe && listingId !== undefined) { + return ( +
+ onSwipeLeft(listingId)} + onSwipeRight={() => onSwipeRight(listingId)} + onClick={() => { + onSelectListing?.(listingId); + handlePropertyClick(feature); + }} + /> +
+ ); + } + + return ( +
+ handlePropertyClick(feature)} + /> +
+ ); + }} /> ); diff --git a/frontend/src/components/ListingDetail.tsx b/frontend/src/components/ListingDetail.tsx new file mode 100644 index 0000000..56d1e7f --- /dev/null +++ b/frontend/src/components/ListingDetail.tsx @@ -0,0 +1,221 @@ +import { ExternalLink, Heart, X, Bed, Maximize2, PoundSterling, Building, Clock, MapPin, Footprints, Bike, Train } from 'lucide-react'; +import { Button } from './ui/button'; +import { PhotoCarousel } from './PhotoCarousel'; +import type { ListingDetailData, DecisionType, POIDistanceInfo } from '@/types'; + +interface ListingDetailProps { + detail: ListingDetailData; + onDecide: (decision: DecisionType) => void; + onClearDecision: () => void; +} + +function formatDuration(seconds: number): string { + const minutes = Math.round(seconds / 60); + if (minutes < 60) return `${minutes}m`; + const hours = Math.floor(minutes / 60); + const mins = minutes % 60; + return mins > 0 ? `${hours}h${mins}m` : `${hours}h`; +} + +function TravelModeIcon({ mode }: { mode: string }) { + switch (mode) { + case 'WALK': return ; + case 'BICYCLE': return ; + case 'TRANSIT': return ; + default: return null; + } +} + +export function ListingDetail({ detail, onDecide, onClearDecision }: ListingDetailProps) { + const allPhotos = [ + ...detail.photos, + ...detail.floorplans.map(fp => ({ url: fp.url, caption: fp.caption || 'Floorplan', type: 'FLOORPLAN' as string | null })), + ]; + + return ( +
+ {/* Photo carousel */} + + +
+ {/* Price + address */} +
+
+
+
+ £{detail.price.toLocaleString()} + {detail.listing_type !== 'BUY' && ( + /mo + )} +
+ {detail.display_address && ( +
+ + {detail.display_address} +
+ )} +
+
+
+ + {/* Like/Dislike buttons */} +
+ + +
+ + {/* Key stats */} +
+
+ + {detail.number_of_bedrooms} beds +
+
+ + {detail.square_meters ?? '\u2014'} +
+
+ + {detail.square_meters ? `£${Math.round(detail.price / detail.square_meters)}` : '\u2014'}/m² +
+
+ + {/* Key features */} + {detail.key_features.length > 0 && ( +
+

Key Features

+
    + {detail.key_features.map((f, i) => ( +
  • {f}
  • + ))} +
+
+ )} + + {/* Description */} + {detail.description && ( +
+

Description

+

{detail.description}

+
+ )} + + {/* Property details grid */} +
+

Details

+
+ {detail.property_sub_type && ( +
+ + {detail.property_sub_type} +
+ )} + {detail.furnish_type && ( +
+ Furnishing: + {detail.furnish_type} +
+ )} + {detail.council_tax_band && ( +
+ Council Tax: + Band {detail.council_tax_band} +
+ )} + {detail.available_from && ( +
+ + Available {detail.available_from} +
+ )} + {detail.service_charge != null && ( +
+ Service charge: + £{detail.service_charge.toLocaleString()} +
+ )} + {detail.lease_left != null && ( +
+ Lease: + {detail.lease_left} years +
+ )} +
+
+ + {/* Floorplans */} + {detail.floorplans.length > 0 && ( +
+

Floorplans

+
+ {detail.floorplans.map((fp, i) => ( + {fp.caption + ))} +
+
+ )} + + {/* Price history */} + {detail.price_history.length > 1 && ( +
+

Price History

+
+ {detail.price_history.map((entry) => ( +
+ {entry.last_seen.split('T')[0]} + £{entry.price.toLocaleString()} +
+ ))} +
+
+ )} + + {/* POI distances */} + {detail.poi_distances.length > 0 && ( +
+

Travel Times

+
+ {detail.poi_distances.map((d: POIDistanceInfo) => ( +
+ {d.poi_name}: + + {formatDuration(d.duration_seconds)} +
+ ))} +
+
+ )} + + {/* Agency */} + {detail.agency && ( +
+ + {detail.agency} +
+ )} + + {/* External link */} + +
+
+ ); +} diff --git a/frontend/src/components/ListingDetailSheet.tsx b/frontend/src/components/ListingDetailSheet.tsx new file mode 100644 index 0000000..82b6da0 --- /dev/null +++ b/frontend/src/components/ListingDetailSheet.tsx @@ -0,0 +1,73 @@ +import { Drawer } from 'vaul'; +import { Loader2 } from 'lucide-react'; +import { ListingDetail } from './ListingDetail'; +import type { AuthUser } from '@/auth/types'; +import type { DecisionType } from '@/types'; +import { useListingDetail } from '@/hooks/useListingDetail'; +import { useEffect } from 'react'; + +interface ListingDetailSheetProps { + user: AuthUser; + listingId: number | null; + listingType: 'RENT' | 'BUY'; + onClose: () => void; + onDecide: (listingId: number, decision: DecisionType, listingType: 'RENT' | 'BUY') => void; + onClearDecision: (listingId: number, listingType: 'RENT' | 'BUY') => void; + currentDecision: DecisionType | undefined; +} + +export function ListingDetailSheet({ + user, + listingId, + listingType, + onClose, + onDecide, + onClearDecision, + currentDecision, +}: ListingDetailSheetProps) { + const { detail, isLoading, error, loadDetail, clearDetail } = useListingDetail(user); + + useEffect(() => { + if (listingId) { + loadDetail(listingId, listingType); + } else { + clearDetail(); + } + }, [listingId, listingType, loadDetail, clearDetail]); + + // Override the decision from the detail with the current one from useDecisions (optimistic) + const detailWithDecision = detail ? { ...detail, decision: currentDecision ?? null } : null; + + return ( + { if (!open) onClose(); }} + > + + + +
+
+ {isLoading && ( +
+ +
+ )} + {error && ( +
+ Failed to load listing details: {error} +
+ )} + {detailWithDecision && !isLoading && ( + onDecide(listingId!, decision, listingType)} + onClearDecision={() => onClearDecision(listingId!, listingType)} + /> + )} +
+ + + + ); +} diff --git a/frontend/src/components/MobileBottomSheet.tsx b/frontend/src/components/MobileBottomSheet.tsx index f82f3fd..3a62d54 100644 --- a/frontend/src/components/MobileBottomSheet.tsx +++ b/frontend/src/components/MobileBottomSheet.tsx @@ -25,7 +25,7 @@ export function MobileBottomSheet({ onActiveListingChange, poiMetricSelection, }: MobileBottomSheetProps) { - const [snap, setSnap] = useState("148px"); + const [snap, setSnap] = useState("148px"); const [activeCardIndex, setActiveCardIndex] = useState(0); const features = listingData?.features ?? []; diff --git a/frontend/src/components/PhotoCarousel.tsx b/frontend/src/components/PhotoCarousel.tsx new file mode 100644 index 0000000..34be257 --- /dev/null +++ b/frontend/src/components/PhotoCarousel.tsx @@ -0,0 +1,68 @@ +import { useState, useCallback, useEffect } from 'react'; +import useEmblaCarousel from 'embla-carousel-react'; +import type { ListingDetailPhoto } from '@/types'; + +interface PhotoCarouselProps { + photos: ListingDetailPhoto[]; +} + +export function PhotoCarousel({ photos }: PhotoCarouselProps) { + const [emblaRef, emblaApi] = useEmblaCarousel({ loop: true }); + const [selectedIndex, setSelectedIndex] = useState(0); + + const onSelect = useCallback(() => { + if (!emblaApi) return; + setSelectedIndex(emblaApi.selectedScrollSnap()); + }, [emblaApi]); + + useEffect(() => { + if (!emblaApi) return; + emblaApi.on('select', onSelect); + return () => { emblaApi.off('select', onSelect); }; + }, [emblaApi, onSelect]); + + if (photos.length === 0) { + return ( +
+ No photos available +
+ ); + } + + return ( +
+
+
+ {photos.map((photo, i) => ( +
+ {photo.caption +
+ ))} +
+
+ {/* Counter */} +
+ {selectedIndex + 1} / {photos.length} +
+ {/* Dots */} + {photos.length > 1 && photos.length <= 20 && ( +
+ {photos.map((_, i) => ( +
+ )} +
+ ); +} diff --git a/frontend/src/components/SwipeablePropertyCard.tsx b/frontend/src/components/SwipeablePropertyCard.tsx new file mode 100644 index 0000000..6fccb41 --- /dev/null +++ b/frontend/src/components/SwipeablePropertyCard.tsx @@ -0,0 +1,122 @@ +import { useRef, useState } from 'react'; +import { useDrag } from '@use-gesture/react'; +import { useSpring, animated } from '@react-spring/web'; +import { Heart, X } from 'lucide-react'; +import { PropertyCard } from './PropertyCard'; +import type { PropertyProperties, POI, DecisionType } from '@/types'; + +interface SwipeablePropertyCardProps { + property: PropertyProperties; + variant?: 'compact' | 'full'; + isHighlighted?: boolean; + avgPricePerSqm?: number; + allPOIs?: POI[]; + onClick?: () => void; + onSwipeRight?: () => void; + onSwipeLeft?: () => void; + decision?: DecisionType | null; +} + +const SWIPE_THRESHOLD = 80; + +export function SwipeablePropertyCard({ + property, + variant = 'compact', + isHighlighted = false, + avgPricePerSqm, + allPOIs, + onClick, + onSwipeRight, + onSwipeLeft, + decision, +}: SwipeablePropertyCardProps) { + const containerRef = useRef(null); + const [gone, setGone] = useState(false); + + const [{ x, opacity }, api] = useSpring(() => ({ + x: 0, + opacity: 1, + config: { tension: 200, friction: 20 }, + })); + + const bind = useDrag( + ({ active, movement: [mx], direction: [dx] }) => { + if (gone) return; + + // If dragging vertically more than horizontally, cancel (let scroll work) + if (active && Math.abs(mx) < 15) return; + + if (!active) { + // Released + if (Math.abs(mx) > SWIPE_THRESHOLD) { + // Swipe confirmed + setGone(true); + const dir = dx > 0 ? 1 : -1; + api.start({ + x: dir * 400, + opacity: 0, + onRest: () => { + if (dir > 0) onSwipeRight?.(); + else onSwipeLeft?.(); + }, + }); + } else { + // Spring back + api.start({ x: 0, opacity: 1 }); + } + } else { + api.start({ x: mx, opacity: 1 - Math.abs(mx) / 400, immediate: true }); + } + }, + { + axis: 'x', + filterTaps: true, + from: () => [x.get(), 0], + }, + ); + + if (gone) return null; + + const likedBadge = decision === 'liked'; + + return ( +
+ {/* Background indicators */} +
+
+ +
+
+ +
+
+ + {/* Card */} + +
+ {likedBadge && ( +
+ +
+ )} + +
+
+
+ ); +} diff --git a/frontend/src/hooks/useListingDetail.ts b/frontend/src/hooks/useListingDetail.ts new file mode 100644 index 0000000..7891239 --- /dev/null +++ b/frontend/src/hooks/useListingDetail.ts @@ -0,0 +1,42 @@ +import { useState, useRef, useCallback } from 'react'; +import type { AuthUser } from '@/auth/types'; +import type { ListingDetailData } from '@/types'; +import { fetchListingDetail } from '@/services'; + +export function useListingDetail(user: AuthUser | null) { + const [detail, setDetail] = useState(null); + const [isLoading, setIsLoading] = useState(false); + const [error, setError] = useState(null); + const cache = useRef>(new Map()); + + const loadDetail = useCallback( + async (listingId: number, listingType: 'RENT' | 'BUY' = 'RENT') => { + if (!user) return; + const cacheKey = `${listingId}-${listingType}`; + const cached = cache.current.get(cacheKey); + if (cached) { + setDetail(cached); + return; + } + setIsLoading(true); + setError(null); + try { + const data = await fetchListingDetail(user, listingId, listingType); + cache.current.set(cacheKey, data); + setDetail(data); + } catch (e) { + setError(e instanceof Error ? e.message : String(e)); + } finally { + setIsLoading(false); + } + }, + [user], + ); + + const clearDetail = useCallback(() => { + setDetail(null); + setError(null); + }, []); + + return { detail, isLoading, error, loadDetail, clearDetail }; +} diff --git a/frontend/src/services/index.ts b/frontend/src/services/index.ts index 25113bc..a081b7d 100644 --- a/frontend/src/services/index.ts +++ b/frontend/src/services/index.ts @@ -6,3 +6,4 @@ export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type Can export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService'; export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances } from './poiService'; export { fetchDecisions, setDecision, clearDecision } from './decisionService'; +export { fetchListingDetail } from './listingDetailService'; diff --git a/frontend/src/services/listingDetailService.ts b/frontend/src/services/listingDetailService.ts new file mode 100644 index 0000000..ac5f3af --- /dev/null +++ b/frontend/src/services/listingDetailService.ts @@ -0,0 +1,13 @@ +import type { AuthUser } from '@/auth/types'; +import type { ListingDetailData } from '@/types'; +import { apiRequest } from './apiClient'; + +export async function fetchListingDetail( + user: AuthUser, + listingId: number, + listingType: 'RENT' | 'BUY' = 'RENT', +): Promise { + return apiRequest(user, `/api/listing/${listingId}/detail`, { + params: { listing_type: listingType }, + }); +} diff --git a/frontend/src/types/index.ts b/frontend/src/types/index.ts index 163eee1..e1d09b8 100644 --- a/frontend/src/types/index.ts +++ b/frontend/src/types/index.ts @@ -8,6 +8,7 @@ export interface PropertyPriceHistory { } export interface PropertyProperties { + id: number; url: string; city: string; country: string; @@ -190,3 +191,39 @@ export interface ListingDecision { created_at: string; updated_at: string; } + +// Listing detail types +export interface ListingDetailPhoto { + url: string; + caption: string | null; + type: string | null; +} + +export interface ListingDetailFloorplan { + url: string; + caption: string | null; +} + +export interface ListingDetailData { + id: number; + price: number; + number_of_bedrooms: number; + square_meters: number | null; + agency: string | null; + council_tax_band: string | null; + url: string; + listing_type: 'RENT' | 'BUY'; + description: string | null; + display_address: string | null; + property_sub_type: string | null; + key_features: string[]; + photos: ListingDetailPhoto[]; + floorplans: ListingDetailFloorplan[]; + price_history: PropertyPriceHistory[]; + furnish_type: string | null; + available_from: string | null; + service_charge: number | null; + lease_left: number | null; + decision: DecisionType | null; + poi_distances: POIDistanceInfo[]; +}