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
This commit is contained in:
parent
9e1beb7495
commit
a2745c1478
14 changed files with 755 additions and 19 deletions
29
frontend/package-lock.json
generated
29
frontend/package-lock.json
generated
|
|
@ -39,6 +39,7 @@
|
||||||
"crossfilter2": "^1.5.4",
|
"crossfilter2": "^1.5.4",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"lucide-react": "^0.515.0",
|
"lucide-react": "^0.515.0",
|
||||||
"mapbox-gl": "^3.12.0",
|
"mapbox-gl": "^3.12.0",
|
||||||
"oidc-client-ts": "^3.2.1",
|
"oidc-client-ts": "^3.2.1",
|
||||||
|
|
@ -5088,6 +5089,34 @@
|
||||||
"integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
|
"integrity": "sha512-0l1/0gOjESMeQyYaK5IDiPNvFeu93Z/cO0TjZh9eZ1vyCtZnA7KMZ8rQggpsJHIbGSdrqYq9OhuveadOVHCshw==",
|
||||||
"license": "ISC"
|
"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": {
|
"node_modules/emoji-regex": {
|
||||||
"version": "8.0.0",
|
"version": "8.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/emoji-regex/-/emoji-regex-8.0.0.tgz",
|
||||||
|
|
|
||||||
|
|
@ -44,6 +44,7 @@
|
||||||
"crossfilter2": "^1.5.4",
|
"crossfilter2": "^1.5.4",
|
||||||
"d3": "^7.9.0",
|
"d3": "^7.9.0",
|
||||||
"date-fns": "^4.1.0",
|
"date-fns": "^4.1.0",
|
||||||
|
"embla-carousel-react": "^8.6.0",
|
||||||
"lucide-react": "^0.515.0",
|
"lucide-react": "^0.515.0",
|
||||||
"mapbox-gl": "^3.12.0",
|
"mapbox-gl": "^3.12.0",
|
||||||
"oidc-client-ts": "^3.2.1",
|
"oidc-client-ts": "^3.2.1",
|
||||||
|
|
|
||||||
|
|
@ -26,7 +26,8 @@ import { useDecisions } from '@/hooks/useDecisions';
|
||||||
import { useIsMobile } from '@/hooks/use-mobile';
|
import { useIsMobile } from '@/hooks/use-mobile';
|
||||||
import { MobileBottomSheet } from './components/MobileBottomSheet';
|
import { MobileBottomSheet } from './components/MobileBottomSheet';
|
||||||
import { SwipeReviewMode } from './components/SwipeReviewMode';
|
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 {
|
function isTerminalStatus(status: string): boolean {
|
||||||
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
||||||
|
|
@ -54,8 +55,9 @@ function App() {
|
||||||
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
||||||
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
const [activeCardFeature, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
||||||
const [showReviewMode, setShowReviewMode] = useState(false);
|
const [showReviewMode, setShowReviewMode] = useState(false);
|
||||||
|
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
|
||||||
|
|
||||||
// Decision state (like/dislike)
|
// Decision state (like/dislike)
|
||||||
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
|
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
|
||||||
|
|
@ -364,9 +366,11 @@ function App() {
|
||||||
|
|
||||||
if (viewMode === 'saved') {
|
if (viewMode === 'saved') {
|
||||||
return (
|
return (
|
||||||
<SavedView
|
<FavoritesView
|
||||||
listingData={processedListingData}
|
listingData={listingData!}
|
||||||
getDecision={getDecision}
|
getDecision={getDecision}
|
||||||
|
onSelectListing={(id) => setSelectedListingId(id)}
|
||||||
|
onRemoveFavorite={(id, type) => clear(id, type)}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
@ -397,6 +401,10 @@ function App() {
|
||||||
onPropertyClick={handlePropertyClick}
|
onPropertyClick={handlePropertyClick}
|
||||||
highlightedPropertyUrl={highlightedProperty}
|
highlightedPropertyUrl={highlightedProperty}
|
||||||
poiMetricSelection={poiMetricSelection}
|
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')}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
@ -621,6 +629,17 @@ function App() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Listing Detail Bottom Sheet */}
|
||||||
|
<ListingDetailSheet
|
||||||
|
user={user}
|
||||||
|
listingId={selectedListingId}
|
||||||
|
listingType={(queryParameters?.listing_type || 'RENT') as 'RENT' | 'BUY'}
|
||||||
|
onClose={() => 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 */}
|
{/* Error Dialog */}
|
||||||
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
80
frontend/src/components/FavoritesView.tsx
Normal file
80
frontend/src/components/FavoritesView.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="h-full flex items-center justify-center">
|
||||||
|
<div className="text-center p-8">
|
||||||
|
<Heart className="h-12 w-12 mx-auto mb-4 text-muted-foreground" />
|
||||||
|
<h2 className="text-lg font-semibold mb-2">No favorites yet</h2>
|
||||||
|
<p className="text-muted-foreground text-sm">
|
||||||
|
Swipe right or tap the heart button on listings you like
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="h-full flex flex-col bg-background">
|
||||||
|
<div className="px-3 py-2 text-sm text-muted-foreground border-b flex items-center gap-2">
|
||||||
|
<Heart className="h-4 w-4 text-green-600 fill-current" />
|
||||||
|
{favorites.length} saved {favorites.length === 1 ? 'listing' : 'listings'}
|
||||||
|
</div>
|
||||||
|
<Virtuoso
|
||||||
|
className="flex-1"
|
||||||
|
data={favorites}
|
||||||
|
overscan={200}
|
||||||
|
itemContent={(_index, feature) => {
|
||||||
|
const id = feature.properties.id ?? getListingId(feature.properties.url);
|
||||||
|
const type = feature.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
|
||||||
|
return (
|
||||||
|
<div className="px-3 pb-2 first:pt-3 relative">
|
||||||
|
<PropertyCard
|
||||||
|
property={feature.properties}
|
||||||
|
variant="compact"
|
||||||
|
onClick={() => onSelectListing?.(id)}
|
||||||
|
/>
|
||||||
|
{onRemoveFavorite && (
|
||||||
|
<button
|
||||||
|
className="absolute top-4 right-4 p-1 rounded-full bg-red-100 text-red-600 hover:bg-red-200 transition-colors"
|
||||||
|
onClick={(e) => {
|
||||||
|
e.stopPropagation();
|
||||||
|
onRemoveFavorite(id, type);
|
||||||
|
}}
|
||||||
|
title="Remove from favorites"
|
||||||
|
>
|
||||||
|
<X className="h-3.5 w-3.5" />
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -2,8 +2,9 @@ import { useState, useMemo, useCallback } from 'react';
|
||||||
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
import { ArrowUpDown, ArrowUp, ArrowDown } from 'lucide-react';
|
||||||
import { Virtuoso } from 'react-virtuoso';
|
import { Virtuoso } from 'react-virtuoso';
|
||||||
import { Button } from './ui/button';
|
import { Button } from './ui/button';
|
||||||
|
import { SwipeablePropertyCard } from './SwipeablePropertyCard';
|
||||||
import { PropertyCard } from './PropertyCard';
|
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 SortField = 'total_price' | 'qmprice' | 'qm' | 'rooms' | 'last_seen' | 'poi_travel';
|
||||||
type SortOrder = 'asc' | 'desc';
|
type SortOrder = 'asc' | 'desc';
|
||||||
|
|
@ -13,6 +14,10 @@ interface ListViewProps {
|
||||||
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
||||||
highlightedPropertyUrl?: string | null;
|
highlightedPropertyUrl?: string | null;
|
||||||
poiMetricSelection?: { poiId: number; travelMode: 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 {
|
interface SortConfig {
|
||||||
|
|
@ -28,7 +33,7 @@ const BASE_SORT_OPTIONS: { field: SortField; label: string }[] = [
|
||||||
{ field: 'last_seen', label: 'Last Seen' },
|
{ 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<SortConfig>({ field: 'qmprice', order: 'asc' });
|
const [sortConfig, setSortConfig] = useState<SortConfig>({ field: 'qmprice', order: 'asc' });
|
||||||
|
|
||||||
// Calculate average price per sqm for "good deal" indicator
|
// Calculate average price per sqm for "good deal" indicator
|
||||||
|
|
@ -153,18 +158,43 @@ export function ListView({ listingData, onPropertyClick, highlightedPropertyUrl,
|
||||||
className="flex-1"
|
className="flex-1"
|
||||||
data={sortedFeatures}
|
data={sortedFeatures}
|
||||||
overscan={200}
|
overscan={200}
|
||||||
itemContent={(_index, feature) => (
|
itemContent={(_index, feature) => {
|
||||||
<div className="px-3 pb-2 first:pt-3">
|
const listingId = feature.properties.id;
|
||||||
<PropertyCard
|
const hasSwipe = onSwipeLeft && onSwipeRight;
|
||||||
key={feature.properties.url}
|
|
||||||
property={feature.properties}
|
if (hasSwipe && listingId !== undefined) {
|
||||||
variant="compact"
|
return (
|
||||||
avgPricePerSqm={avgPricePerSqm}
|
<div className="px-3 pb-2 first:pt-3">
|
||||||
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
<SwipeablePropertyCard
|
||||||
onClick={() => handlePropertyClick(feature)}
|
property={feature.properties}
|
||||||
/>
|
variant="compact"
|
||||||
</div>
|
avgPricePerSqm={avgPricePerSqm}
|
||||||
)}
|
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
||||||
|
decision={getDecision?.(listingId) ?? null}
|
||||||
|
onSwipeLeft={() => onSwipeLeft(listingId)}
|
||||||
|
onSwipeRight={() => onSwipeRight(listingId)}
|
||||||
|
onClick={() => {
|
||||||
|
onSelectListing?.(listingId);
|
||||||
|
handlePropertyClick(feature);
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="px-3 pb-2 first:pt-3">
|
||||||
|
<PropertyCard
|
||||||
|
key={feature.properties.url}
|
||||||
|
property={feature.properties}
|
||||||
|
variant="compact"
|
||||||
|
avgPricePerSqm={avgPricePerSqm}
|
||||||
|
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
||||||
|
onClick={() => handlePropertyClick(feature)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
221
frontend/src/components/ListingDetail.tsx
Normal file
221
frontend/src/components/ListingDetail.tsx
Normal file
|
|
@ -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 <Footprints className="h-3 w-3" />;
|
||||||
|
case 'BICYCLE': return <Bike className="h-3 w-3" />;
|
||||||
|
case 'TRANSIT': return <Train className="h-3 w-3" />;
|
||||||
|
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 (
|
||||||
|
<div className="pb-8">
|
||||||
|
{/* Photo carousel */}
|
||||||
|
<PhotoCarousel photos={allPhotos} />
|
||||||
|
|
||||||
|
<div className="px-4 pt-4 space-y-4">
|
||||||
|
{/* Price + address */}
|
||||||
|
<div>
|
||||||
|
<div className="flex items-start justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-2xl font-bold">
|
||||||
|
£{detail.price.toLocaleString()}
|
||||||
|
{detail.listing_type !== 'BUY' && (
|
||||||
|
<span className="text-muted-foreground font-normal text-base">/mo</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{detail.display_address && (
|
||||||
|
<div className="flex items-center gap-1 text-sm text-muted-foreground mt-1">
|
||||||
|
<MapPin className="h-3.5 w-3.5" />
|
||||||
|
{detail.display_address}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Like/Dislike buttons */}
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<Button
|
||||||
|
variant={detail.decision === 'disliked' ? 'destructive' : 'outline'}
|
||||||
|
className="flex-1"
|
||||||
|
onClick={() => detail.decision === 'disliked' ? onClearDecision() : onDecide('disliked')}
|
||||||
|
>
|
||||||
|
<X className="h-4 w-4 mr-2" />
|
||||||
|
{detail.decision === 'disliked' ? 'Disliked' : 'Dislike'}
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
variant={detail.decision === 'liked' ? 'default' : 'outline'}
|
||||||
|
className={`flex-1 ${detail.decision === 'liked' ? 'bg-green-600 hover:bg-green-700' : ''}`}
|
||||||
|
onClick={() => detail.decision === 'liked' ? onClearDecision() : onDecide('liked')}
|
||||||
|
>
|
||||||
|
<Heart className={`h-4 w-4 mr-2 ${detail.decision === 'liked' ? 'fill-current' : ''}`} />
|
||||||
|
{detail.decision === 'liked' ? 'Liked' : 'Like'}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key stats */}
|
||||||
|
<div className="grid grid-cols-3 gap-3">
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Bed className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span><strong>{detail.number_of_bedrooms}</strong> beds</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<Maximize2 className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span><strong>{detail.square_meters ?? '\u2014'}</strong> m²</span>
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-sm">
|
||||||
|
<PoundSterling className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span><strong>{detail.square_meters ? `£${Math.round(detail.price / detail.square_meters)}` : '\u2014'}</strong>/m²</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Key features */}
|
||||||
|
{detail.key_features.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Key Features</h3>
|
||||||
|
<ul className="list-disc list-inside text-sm text-muted-foreground space-y-1">
|
||||||
|
{detail.key_features.map((f, i) => (
|
||||||
|
<li key={i}>{f}</li>
|
||||||
|
))}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Description */}
|
||||||
|
{detail.description && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Description</h3>
|
||||||
|
<p className="text-sm text-muted-foreground whitespace-pre-line">{detail.description}</p>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Property details grid */}
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Details</h3>
|
||||||
|
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||||
|
{detail.property_sub_type && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Building className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>{detail.property_sub_type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.furnish_type && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Furnishing:</span>
|
||||||
|
<span>{detail.furnish_type}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.council_tax_band && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Council Tax:</span>
|
||||||
|
<span>Band {detail.council_tax_band}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.available_from && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<Clock className="h-4 w-4 text-muted-foreground" />
|
||||||
|
<span>Available {detail.available_from}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.service_charge != null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Service charge:</span>
|
||||||
|
<span>£{detail.service_charge.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detail.lease_left != null && (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-muted-foreground">Lease:</span>
|
||||||
|
<span>{detail.lease_left} years</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Floorplans */}
|
||||||
|
{detail.floorplans.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Floorplans</h3>
|
||||||
|
<div className="space-y-2">
|
||||||
|
{detail.floorplans.map((fp, i) => (
|
||||||
|
<img key={i} src={fp.url} alt={fp.caption || 'Floorplan'} className="w-full rounded-md border" />
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Price history */}
|
||||||
|
{detail.price_history.length > 1 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Price History</h3>
|
||||||
|
<div className="space-y-1">
|
||||||
|
{detail.price_history.map((entry) => (
|
||||||
|
<div key={entry.id} className="text-sm flex justify-between">
|
||||||
|
<span className="text-muted-foreground">{entry.last_seen.split('T')[0]}</span>
|
||||||
|
<span>£{entry.price.toLocaleString()}</span>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* POI distances */}
|
||||||
|
{detail.poi_distances.length > 0 && (
|
||||||
|
<div>
|
||||||
|
<h3 className="text-sm font-semibold mb-2">Travel Times</h3>
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{detail.poi_distances.map((d: POIDistanceInfo) => (
|
||||||
|
<div key={`${d.poi_id}_${d.travel_mode}`} className="flex items-center gap-1 text-xs text-muted-foreground bg-muted/50 px-1.5 py-0.5 rounded">
|
||||||
|
<span className="font-medium">{d.poi_name}:</span>
|
||||||
|
<TravelModeIcon mode={d.travel_mode} />
|
||||||
|
{formatDuration(d.duration_seconds)}
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Agency */}
|
||||||
|
{detail.agency && (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Building className="h-4 w-4" />
|
||||||
|
<span>{detail.agency}</span>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* External link */}
|
||||||
|
<Button asChild variant="outline" className="w-full">
|
||||||
|
<a href={detail.url} target="_blank" rel="noopener noreferrer">
|
||||||
|
View on Rightmove
|
||||||
|
<ExternalLink className="ml-2 h-4 w-4" />
|
||||||
|
</a>
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
73
frontend/src/components/ListingDetailSheet.tsx
Normal file
73
frontend/src/components/ListingDetailSheet.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<Drawer.Root
|
||||||
|
open={listingId !== null}
|
||||||
|
onOpenChange={(open) => { if (!open) onClose(); }}
|
||||||
|
>
|
||||||
|
<Drawer.Portal>
|
||||||
|
<Drawer.Overlay className="fixed inset-0 bg-black/40 z-50" />
|
||||||
|
<Drawer.Content className="fixed bottom-0 left-0 right-0 z-50 flex flex-col bg-background rounded-t-xl max-h-[90vh]">
|
||||||
|
<div className="mx-auto w-12 h-1.5 flex-shrink-0 rounded-full bg-muted-foreground/20 my-3" />
|
||||||
|
<div className="overflow-y-auto flex-1">
|
||||||
|
{isLoading && (
|
||||||
|
<div className="flex items-center justify-center py-16">
|
||||||
|
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{error && (
|
||||||
|
<div className="p-4 text-center text-destructive">
|
||||||
|
Failed to load listing details: {error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{detailWithDecision && !isLoading && (
|
||||||
|
<ListingDetail
|
||||||
|
detail={detailWithDecision}
|
||||||
|
onDecide={(decision) => onDecide(listingId!, decision, listingType)}
|
||||||
|
onClearDecision={() => onClearDecision(listingId!, listingType)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</Drawer.Content>
|
||||||
|
</Drawer.Portal>
|
||||||
|
</Drawer.Root>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -25,7 +25,7 @@ export function MobileBottomSheet({
|
||||||
onActiveListingChange,
|
onActiveListingChange,
|
||||||
poiMetricSelection,
|
poiMetricSelection,
|
||||||
}: MobileBottomSheetProps) {
|
}: MobileBottomSheetProps) {
|
||||||
const [snap, setSnap] = useState<string | number>("148px");
|
const [snap, setSnap] = useState<string | number | null>("148px");
|
||||||
const [activeCardIndex, setActiveCardIndex] = useState(0);
|
const [activeCardIndex, setActiveCardIndex] = useState(0);
|
||||||
|
|
||||||
const features = listingData?.features ?? [];
|
const features = listingData?.features ?? [];
|
||||||
|
|
|
||||||
68
frontend/src/components/PhotoCarousel.tsx
Normal file
68
frontend/src/components/PhotoCarousel.tsx
Normal file
|
|
@ -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 (
|
||||||
|
<div className="w-full h-48 bg-muted flex items-center justify-center text-muted-foreground">
|
||||||
|
No photos available
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative">
|
||||||
|
<div className="overflow-hidden" ref={emblaRef}>
|
||||||
|
<div className="flex">
|
||||||
|
{photos.map((photo, i) => (
|
||||||
|
<div key={i} className="flex-[0_0_100%] min-w-0">
|
||||||
|
<img
|
||||||
|
src={photo.url}
|
||||||
|
alt={photo.caption || `Photo ${i + 1}`}
|
||||||
|
className="w-full h-64 object-cover"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/* Counter */}
|
||||||
|
<div className="absolute bottom-2 right-2 bg-black/60 text-white text-xs px-2 py-1 rounded">
|
||||||
|
{selectedIndex + 1} / {photos.length}
|
||||||
|
</div>
|
||||||
|
{/* Dots */}
|
||||||
|
{photos.length > 1 && photos.length <= 20 && (
|
||||||
|
<div className="flex justify-center gap-1 mt-2">
|
||||||
|
{photos.map((_, i) => (
|
||||||
|
<button
|
||||||
|
key={i}
|
||||||
|
className={`w-1.5 h-1.5 rounded-full transition-colors ${
|
||||||
|
i === selectedIndex ? 'bg-primary' : 'bg-muted-foreground/30'
|
||||||
|
}`}
|
||||||
|
onClick={() => emblaApi?.scrollTo(i)}
|
||||||
|
/>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
122
frontend/src/components/SwipeablePropertyCard.tsx
Normal file
122
frontend/src/components/SwipeablePropertyCard.tsx
Normal file
|
|
@ -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<HTMLDivElement>(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 (
|
||||||
|
<div className="relative overflow-hidden" ref={containerRef}>
|
||||||
|
{/* Background indicators */}
|
||||||
|
<div className="absolute inset-0 flex items-center justify-between px-6 pointer-events-none">
|
||||||
|
<div className="flex items-center gap-2 text-destructive">
|
||||||
|
<X className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-2 text-green-600">
|
||||||
|
<Heart className="h-8 w-8" />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Card */}
|
||||||
|
<animated.div
|
||||||
|
{...bind()}
|
||||||
|
style={{
|
||||||
|
x,
|
||||||
|
opacity,
|
||||||
|
touchAction: 'pan-y',
|
||||||
|
}}
|
||||||
|
className="relative"
|
||||||
|
>
|
||||||
|
<div className="relative">
|
||||||
|
{likedBadge && (
|
||||||
|
<div className="absolute top-1 right-1 z-10 bg-green-600 text-white rounded-full p-1">
|
||||||
|
<Heart className="h-3 w-3 fill-current" />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
<PropertyCard
|
||||||
|
property={property}
|
||||||
|
variant={variant}
|
||||||
|
isHighlighted={isHighlighted}
|
||||||
|
avgPricePerSqm={avgPricePerSqm}
|
||||||
|
allPOIs={allPOIs}
|
||||||
|
onClick={onClick}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</animated.div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
42
frontend/src/hooks/useListingDetail.ts
Normal file
42
frontend/src/hooks/useListingDetail.ts
Normal file
|
|
@ -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<ListingDetailData | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const cache = useRef<Map<string, ListingDetailData>>(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 };
|
||||||
|
}
|
||||||
|
|
@ -6,3 +6,4 @@ export { fetchTasksForUser, fetchTaskStatus, cancelTask, clearAllTasks, type Can
|
||||||
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
|
export { checkBackendHealth, type HealthStatus, type HealthCheckResult } from './healthService';
|
||||||
export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances } from './poiService';
|
export { fetchUserPOIs, createPOI, updatePOI, deletePOI, triggerPOICalculation, fetchPOIDistances } from './poiService';
|
||||||
export { fetchDecisions, setDecision, clearDecision } from './decisionService';
|
export { fetchDecisions, setDecision, clearDecision } from './decisionService';
|
||||||
|
export { fetchListingDetail } from './listingDetailService';
|
||||||
|
|
|
||||||
13
frontend/src/services/listingDetailService.ts
Normal file
13
frontend/src/services/listingDetailService.ts
Normal file
|
|
@ -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<ListingDetailData> {
|
||||||
|
return apiRequest<ListingDetailData>(user, `/api/listing/${listingId}/detail`, {
|
||||||
|
params: { listing_type: listingType },
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -8,6 +8,7 @@ export interface PropertyPriceHistory {
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PropertyProperties {
|
export interface PropertyProperties {
|
||||||
|
id: number;
|
||||||
url: string;
|
url: string;
|
||||||
city: string;
|
city: string;
|
||||||
country: string;
|
country: string;
|
||||||
|
|
@ -190,3 +191,39 @@ export interface ListingDecision {
|
||||||
created_at: string;
|
created_at: string;
|
||||||
updated_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[];
|
||||||
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue