2026-02-08 15:11:21 +00:00
|
|
|
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
2025-06-14 15:36:38 +00:00
|
|
|
import './App.css';
|
2026-02-02 20:08:03 +00:00
|
|
|
import { getUser } from './auth/authService';
|
2026-02-07 00:34:47 +00:00
|
|
|
import { getStoredPasskeyUser } from './auth/passkeyService';
|
|
|
|
|
import { fromOidcUser, type AuthUser } from './auth/types';
|
2025-06-21 17:26:45 +00:00
|
|
|
import AlertError from './components/AlertError';
|
2025-06-15 13:49:34 +00:00
|
|
|
import LoginModal from './components/LoginModal';
|
2026-02-02 20:08:03 +00:00
|
|
|
import AuthCallback from './components/AuthCallback';
|
2025-06-14 15:36:38 +00:00
|
|
|
import { Map } from './components/Map';
|
2026-02-07 22:47:13 +00:00
|
|
|
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel';
|
2026-02-08 18:50:06 +00:00
|
|
|
import { VisualizationCard } from './components/VisualizationCard';
|
2026-02-01 17:28:37 +00:00
|
|
|
import { Header } from './components/Header';
|
|
|
|
|
import { StatsBar, type ViewMode } from './components/StatsBar';
|
|
|
|
|
import { ListView } from './components/ListView';
|
|
|
|
|
import { StreamingProgressBar } from './components/StreamingProgressBar';
|
|
|
|
|
import { Sheet, SheetContent, SheetTrigger } from './components/ui/sheet';
|
2025-06-15 21:06:10 +00:00
|
|
|
import { Button } from './components/ui/button';
|
2026-02-21 13:55:05 +00:00
|
|
|
import { Filter, Heart } from 'lucide-react';
|
2026-02-08 16:02:46 +00:00
|
|
|
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
|
2026-02-22 13:29:54 +00:00
|
|
|
import { refreshListings, streamListingGeoJSON, fetchUserPOIs, fetchBulkPOIDistances, type StreamingProgress } from '@/services';
|
2026-02-13 21:16:53 +00:00
|
|
|
import { setOnUnauthorized } from '@/services/apiClient';
|
|
|
|
|
import { clearPasskeyUser } from './auth/passkeyService';
|
2026-02-08 15:11:21 +00:00
|
|
|
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
2026-02-09 23:02:24 +00:00
|
|
|
import { useTaskProgress } from '@/hooks/useTaskProgress';
|
2026-02-21 13:55:05 +00:00
|
|
|
import { useDecisions } from '@/hooks/useDecisions';
|
2026-02-21 11:34:53 +00:00
|
|
|
import { useIsMobile } from '@/hooks/use-mobile';
|
|
|
|
|
import { MobileBottomSheet } from './components/MobileBottomSheet';
|
2026-02-21 13:55:05 +00:00
|
|
|
import { SwipeReviewMode } from './components/SwipeReviewMode';
|
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
2026-02-21 15:48:17 +00:00
|
|
|
import { FavoritesView } from './components/FavoritesView';
|
|
|
|
|
import { ListingDetailSheet } from './components/ListingDetailSheet';
|
2026-02-09 23:02:24 +00:00
|
|
|
|
|
|
|
|
function isTerminalStatus(status: string): boolean {
|
|
|
|
|
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
|
|
|
|
}
|
2025-07-06 12:02:25 +00:00
|
|
|
|
2025-06-14 15:36:38 +00:00
|
|
|
function App() {
|
2026-02-01 17:28:37 +00:00
|
|
|
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
2026-02-07 00:34:47 +00:00
|
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
2025-06-21 17:26:45 +00:00
|
|
|
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
|
|
|
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
|
|
|
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
2026-02-01 17:28:37 +00:00
|
|
|
const [isLoading, setIsLoading] = useState(false);
|
|
|
|
|
const [viewMode, setViewMode] = useState<ViewMode>('map');
|
|
|
|
|
const [mobileFilterOpen, setMobileFilterOpen] = useState(false);
|
|
|
|
|
const [highlightedProperty, setHighlightedProperty] = useState<string | null>(null);
|
|
|
|
|
const [streamingProgress, setStreamingProgress] = useState<StreamingProgress | null>(null);
|
2026-02-08 13:16:32 +00:00
|
|
|
const [userPOIs, setUserPOIs] = useState<POI[]>([]);
|
2026-02-08 15:11:21 +00:00
|
|
|
const [poiPickerActive, setPoiPickerActive] = useState(false);
|
|
|
|
|
const [pickedPoiLocation, setPickedPoiLocation] = useState<{ lat: number; lng: number } | null>(null);
|
|
|
|
|
const [poiMetricSelection, setPoiMetricSelection] = useState<{
|
|
|
|
|
poiId: number;
|
|
|
|
|
poiName: string;
|
|
|
|
|
travelMode: 'WALK' | 'BICYCLE' | 'TRANSIT';
|
|
|
|
|
} | null>(null);
|
2026-02-08 16:02:46 +00:00
|
|
|
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
2026-02-08 18:50:06 +00:00
|
|
|
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
2026-02-21 11:34:53 +00:00
|
|
|
const isMobile = useIsMobile();
|
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
2026-02-21 15:48:17 +00:00
|
|
|
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
2026-02-21 13:55:05 +00:00
|
|
|
const [showReviewMode, setShowReviewMode] = useState(false);
|
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
2026-02-21 15:48:17 +00:00
|
|
|
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
|
2026-02-21 13:55:05 +00:00
|
|
|
|
|
|
|
|
// Decision state (like/dislike)
|
|
|
|
|
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
|
2026-02-01 17:28:37 +00:00
|
|
|
|
2026-02-09 23:02:24 +00:00
|
|
|
// Explicit task ID set by fetch-data action (to track as "active")
|
|
|
|
|
const [explicitTaskId, setExplicitTaskId] = useState<string | null>(null);
|
|
|
|
|
|
|
|
|
|
// Unified task progress: WS primary, polling fallback
|
|
|
|
|
const { tasks, isConnected, subscribe, cancelTask, clearAllTasks } = useTaskProgress(user);
|
|
|
|
|
|
|
|
|
|
// Derive activeTaskId: explicit ID if set, else most recent non-terminal task
|
|
|
|
|
const activeTaskId = useMemo(() => {
|
|
|
|
|
if (explicitTaskId && tasks[explicitTaskId]) return explicitTaskId;
|
|
|
|
|
// Fall back to any non-terminal task
|
|
|
|
|
const nonTerminal = Object.entries(tasks).filter(
|
|
|
|
|
([, t]) => !isTerminalStatus(t.status),
|
|
|
|
|
);
|
|
|
|
|
if (nonTerminal.length > 0) return nonTerminal[0][0];
|
|
|
|
|
// Fall back to explicit even if terminal (to show final status)
|
|
|
|
|
if (explicitTaskId && tasks[explicitTaskId]) return explicitTaskId;
|
|
|
|
|
// Show most recent task if any
|
|
|
|
|
const allIds = Object.keys(tasks);
|
|
|
|
|
return allIds.length > 0 ? allIds[allIds.length - 1] : null;
|
|
|
|
|
}, [explicitTaskId, tasks]);
|
2026-02-09 21:17:30 +00:00
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
// Ref to track accumulated features during streaming
|
|
|
|
|
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
|
|
|
|
// Ref to track if initial load has been triggered
|
|
|
|
|
const initialLoadTriggeredRef = useRef(false);
|
2026-02-09 21:17:30 +00:00
|
|
|
// Ref to abort in-flight streaming requests
|
|
|
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
2025-06-14 15:36:38 +00:00
|
|
|
|
2026-02-02 20:08:03 +00:00
|
|
|
// Check if this is the callback route - render dedicated component
|
|
|
|
|
if (window.location.pathname === '/callback') {
|
|
|
|
|
return <AuthCallback />;
|
|
|
|
|
}
|
2025-06-14 15:36:38 +00:00
|
|
|
|
2026-02-02 20:08:03 +00:00
|
|
|
useEffect(() => {
|
2026-02-07 00:34:47 +00:00
|
|
|
// Check passkey user first, then fall back to OIDC
|
|
|
|
|
const passkeyUser = getStoredPasskeyUser();
|
|
|
|
|
if (passkeyUser) {
|
|
|
|
|
setUser(passkeyUser);
|
|
|
|
|
} else {
|
|
|
|
|
getUser().then((oidcUser) => {
|
|
|
|
|
if (oidcUser) {
|
|
|
|
|
setUser(fromOidcUser(oidcUser));
|
|
|
|
|
}
|
|
|
|
|
});
|
|
|
|
|
}
|
2025-06-14 15:36:38 +00:00
|
|
|
}, []);
|
|
|
|
|
|
2026-02-13 21:16:53 +00:00
|
|
|
useEffect(() => {
|
|
|
|
|
setOnUnauthorized(() => {
|
|
|
|
|
clearPasskeyUser();
|
|
|
|
|
setUser(null);
|
|
|
|
|
});
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-07 00:34:47 +00:00
|
|
|
const handlePasskeyLogin = (passkeyUser: AuthUser) => {
|
|
|
|
|
setUser(passkeyUser);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-08 13:16:32 +00:00
|
|
|
// Load user's POIs
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!user) return;
|
|
|
|
|
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
|
|
|
|
|
}, [user]);
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
// Load listings function - used by both auto-load and manual submit
|
|
|
|
|
const loadListings = useCallback(async (parameters: ParameterValues) => {
|
|
|
|
|
if (!user) return;
|
|
|
|
|
|
2026-02-09 21:17:30 +00:00
|
|
|
// Abort any in-flight streaming request
|
|
|
|
|
if (abortControllerRef.current) {
|
|
|
|
|
abortControllerRef.current.abort();
|
|
|
|
|
}
|
|
|
|
|
const controller = new AbortController();
|
|
|
|
|
abortControllerRef.current = controller;
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
setQueryParameters(parameters);
|
|
|
|
|
setMobileFilterOpen(false);
|
|
|
|
|
setIsLoading(true);
|
|
|
|
|
accumulatedFeaturesRef.current = [];
|
|
|
|
|
setStreamingProgress({ count: 0 });
|
|
|
|
|
setListingData(null);
|
|
|
|
|
|
2026-02-09 21:17:30 +00:00
|
|
|
// Dedup safety net: track seen URLs to prevent duplicate features
|
|
|
|
|
const seenUrls = new Set<string>();
|
|
|
|
|
|
2026-02-06 20:34:50 +00:00
|
|
|
let updateScheduled = false;
|
|
|
|
|
|
|
|
|
|
const flushUpdate = () => {
|
|
|
|
|
updateScheduled = false;
|
|
|
|
|
setListingData({
|
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
|
features: [...accumulatedFeaturesRef.current]
|
|
|
|
|
});
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const scheduleUpdate = () => {
|
|
|
|
|
if (!updateScheduled) {
|
|
|
|
|
updateScheduled = true;
|
|
|
|
|
requestAnimationFrame(flushUpdate);
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
try {
|
|
|
|
|
for await (const batch of streamListingGeoJSON(user, parameters, (progress) => {
|
|
|
|
|
setStreamingProgress(progress);
|
2026-02-22 13:29:54 +00:00
|
|
|
}, { signal: controller.signal })) {
|
2026-02-09 21:17:30 +00:00
|
|
|
// Deduplicate features by URL
|
|
|
|
|
const uniqueBatch = batch.filter((feature) => {
|
|
|
|
|
const url = feature.properties?.url;
|
|
|
|
|
if (!url || seenUrls.has(url)) return false;
|
|
|
|
|
seenUrls.add(url);
|
|
|
|
|
return true;
|
|
|
|
|
});
|
|
|
|
|
if (uniqueBatch.length > 0) {
|
|
|
|
|
accumulatedFeaturesRef.current.push(...uniqueBatch);
|
|
|
|
|
scheduleUpdate();
|
|
|
|
|
}
|
2025-07-06 12:02:25 +00:00
|
|
|
}
|
2026-02-06 20:34:50 +00:00
|
|
|
// Final flush to ensure all data is rendered
|
|
|
|
|
flushUpdate();
|
2026-02-01 17:28:37 +00:00
|
|
|
} catch (error) {
|
2026-02-09 21:17:30 +00:00
|
|
|
// Silently ignore AbortError — it means we intentionally cancelled
|
|
|
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
|
|
|
return;
|
|
|
|
|
}
|
2026-02-01 17:28:37 +00:00
|
|
|
if (error instanceof Error) {
|
|
|
|
|
setSubmitError(error.message);
|
|
|
|
|
} else {
|
|
|
|
|
setSubmitError(String(error));
|
|
|
|
|
}
|
|
|
|
|
setAlertDialogIsOpen(true);
|
|
|
|
|
} finally {
|
2026-02-09 21:17:30 +00:00
|
|
|
// Only clear loading state if this controller is still the current one
|
|
|
|
|
if (abortControllerRef.current === controller) {
|
|
|
|
|
setIsLoading(false);
|
|
|
|
|
setStreamingProgress(null);
|
|
|
|
|
}
|
2026-02-01 17:28:37 +00:00
|
|
|
}
|
2026-02-22 13:29:54 +00:00
|
|
|
}, [user]);
|
|
|
|
|
|
|
|
|
|
// Merge POI distances into listing data after both are available
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!user || !listingData || userPOIs.length === 0 || !queryParameters) return;
|
|
|
|
|
|
|
|
|
|
let cancelled = false;
|
|
|
|
|
fetchBulkPOIDistances(user, (queryParameters.listing_type as 'RENT' | 'BUY') ?? 'RENT')
|
|
|
|
|
.then((distanceLookup) => {
|
|
|
|
|
if (cancelled) return;
|
|
|
|
|
const updatedFeatures = accumulatedFeaturesRef.current.map(feature => {
|
|
|
|
|
const id = feature.properties?.id;
|
|
|
|
|
if (id && distanceLookup[id]) {
|
|
|
|
|
return {
|
|
|
|
|
...feature,
|
|
|
|
|
properties: {
|
|
|
|
|
...feature.properties,
|
|
|
|
|
poi_distances: distanceLookup[id],
|
|
|
|
|
},
|
|
|
|
|
};
|
|
|
|
|
}
|
|
|
|
|
return feature;
|
|
|
|
|
});
|
|
|
|
|
accumulatedFeaturesRef.current = updatedFeatures;
|
|
|
|
|
setListingData({
|
|
|
|
|
type: 'FeatureCollection',
|
|
|
|
|
features: [...updatedFeatures],
|
|
|
|
|
});
|
|
|
|
|
})
|
|
|
|
|
.catch(() => {}); // POI distances are best-effort
|
|
|
|
|
|
|
|
|
|
return () => { cancelled = true; };
|
|
|
|
|
}, [user, listingData?.features.length, userPOIs.length, queryParameters]);
|
2026-02-08 15:11:21 +00:00
|
|
|
|
|
|
|
|
// Compute processed listing data: inject synthetic POI metric property & apply max travel filter
|
|
|
|
|
const processedListingData = useMemo(() => {
|
|
|
|
|
if (!listingData) return null;
|
|
|
|
|
|
|
|
|
|
let features = listingData.features;
|
|
|
|
|
|
|
|
|
|
// Inject synthetic flat property for the selected POI metric
|
|
|
|
|
if (poiMetricSelection) {
|
|
|
|
|
features = injectPoiMetricProperty(
|
|
|
|
|
features,
|
|
|
|
|
poiMetricSelection.poiId,
|
|
|
|
|
poiMetricSelection.travelMode,
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 16:02:46 +00:00
|
|
|
// Filter by per-POI max travel time (AND logic: listing must satisfy all active filters)
|
|
|
|
|
const activeFilters = Object.entries(poiTravelFilters)
|
|
|
|
|
.filter(([, f]) => f.maxMinutes !== undefined)
|
|
|
|
|
.map(([id, f]) => ({ poiId: Number(id), travelMode: f.travelMode, maxMinutes: f.maxMinutes! }));
|
|
|
|
|
|
|
|
|
|
if (activeFilters.length > 0) {
|
2026-02-08 15:11:21 +00:00
|
|
|
features = features.filter((f) => {
|
2026-02-08 16:02:46 +00:00
|
|
|
const distances = f.properties.poi_distances;
|
|
|
|
|
if (!distances) return false;
|
|
|
|
|
return activeFilters.every((filter) => {
|
|
|
|
|
const dist = distances.find(
|
|
|
|
|
(d) => d.poi_id === filter.poiId && d.travel_mode === filter.travelMode,
|
|
|
|
|
);
|
|
|
|
|
return dist !== undefined && dist.duration_seconds <= filter.maxMinutes * 60;
|
|
|
|
|
});
|
2026-02-08 15:11:21 +00:00
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 13:55:05 +00:00
|
|
|
// Filter out disliked listings (client-side for instant feedback)
|
|
|
|
|
if (isDecisionsLoaded) {
|
|
|
|
|
features = features.filter((f) => {
|
|
|
|
|
const parts = f.properties.url.split('/');
|
|
|
|
|
const id = parseInt(parts[parts.length - 1], 10);
|
|
|
|
|
const type = f.properties.listing_type === 'BUY' ? 'BUY' : 'RENT';
|
|
|
|
|
return getDecision(id, type) !== 'disliked';
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 15:11:21 +00:00
|
|
|
return { ...listingData, features };
|
2026-02-21 13:55:05 +00:00
|
|
|
}, [listingData, poiMetricSelection, poiTravelFilters, isDecisionsLoaded, getDecision]);
|
2026-02-08 15:11:21 +00:00
|
|
|
|
|
|
|
|
// Compute the effective metric string for the heatmap
|
|
|
|
|
const effectiveMetric = useMemo(() => {
|
|
|
|
|
if (queryParameters?.metric === Metric.poi_travel && poiMetricSelection) {
|
|
|
|
|
return poiMetricPropertyName(poiMetricSelection.poiId, poiMetricSelection.travelMode);
|
|
|
|
|
}
|
|
|
|
|
return queryParameters?.metric;
|
|
|
|
|
}, [queryParameters?.metric, poiMetricSelection]);
|
2026-02-01 17:28:37 +00:00
|
|
|
|
|
|
|
|
// Auto-load data with default filters when user is authenticated
|
|
|
|
|
useEffect(() => {
|
|
|
|
|
if (!user || initialLoadTriggeredRef.current) {
|
|
|
|
|
return;
|
|
|
|
|
}
|
|
|
|
|
initialLoadTriggeredRef.current = true;
|
|
|
|
|
|
|
|
|
|
const defaultParams: ParameterValues = {
|
|
|
|
|
...DEFAULT_FILTER_VALUES,
|
|
|
|
|
available_from: new Date(),
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
loadListings(defaultParams);
|
|
|
|
|
}, [user, loadListings]);
|
2025-07-06 12:02:25 +00:00
|
|
|
|
2026-02-08 15:11:21 +00:00
|
|
|
const handleTaskCompleted = useCallback(() => {
|
|
|
|
|
if (queryParameters) {
|
|
|
|
|
loadListings(queryParameters);
|
|
|
|
|
}
|
|
|
|
|
}, [queryParameters, loadListings]);
|
|
|
|
|
|
2026-02-09 23:02:24 +00:00
|
|
|
const handleTaskCancelled = useCallback(() => {
|
|
|
|
|
setExplicitTaskId(null);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2026-02-21 11:34:53 +00:00
|
|
|
const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => {
|
|
|
|
|
setActiveCardFeature(feature);
|
|
|
|
|
}, []);
|
|
|
|
|
|
2025-06-21 17:26:45 +00:00
|
|
|
if (!user) {
|
2026-02-07 00:34:47 +00:00
|
|
|
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
2025-06-21 17:26:45 +00:00
|
|
|
}
|
2025-06-18 20:54:47 +00:00
|
|
|
|
2025-06-21 12:49:04 +00:00
|
|
|
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {
|
|
|
|
|
if (action === 'visualize') {
|
2026-02-01 17:28:37 +00:00
|
|
|
loadListings(parameters);
|
2025-06-21 12:49:04 +00:00
|
|
|
} else if (action === 'fetch-data') {
|
2026-02-01 17:28:37 +00:00
|
|
|
setQueryParameters(parameters);
|
|
|
|
|
setMobileFilterOpen(false);
|
|
|
|
|
setIsLoading(true);
|
2025-06-21 17:26:45 +00:00
|
|
|
try {
|
2026-02-01 17:28:37 +00:00
|
|
|
const data = await refreshListings(user!, parameters);
|
2026-02-09 23:02:24 +00:00
|
|
|
setExplicitTaskId(data.task_id);
|
|
|
|
|
if (data.task_id) subscribe(data.task_id);
|
2025-06-21 17:26:45 +00:00
|
|
|
} catch (error) {
|
2026-02-01 17:28:37 +00:00
|
|
|
if (error instanceof Error) {
|
|
|
|
|
setSubmitError(error.message);
|
|
|
|
|
} else {
|
|
|
|
|
setSubmitError(String(error));
|
|
|
|
|
}
|
|
|
|
|
setAlertDialogIsOpen(true);
|
2025-06-22 21:20:42 +00:00
|
|
|
} finally {
|
2026-02-01 17:28:37 +00:00
|
|
|
setIsLoading(false);
|
2025-06-21 12:49:04 +00:00
|
|
|
}
|
2025-06-15 21:06:10 +00:00
|
|
|
}
|
2026-02-01 17:28:37 +00:00
|
|
|
};
|
2025-06-15 21:06:10 +00:00
|
|
|
|
2026-02-07 22:47:13 +00:00
|
|
|
const handleMetricChange = (metric: Metric) => {
|
2026-02-08 18:50:06 +00:00
|
|
|
setCurrentMetric(metric);
|
2026-02-07 22:47:13 +00:00
|
|
|
setQueryParameters(prev => prev ? { ...prev, metric } : null);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
const handlePropertyClick = (property: PropertyProperties, _coordinates: [number, number]) => {
|
|
|
|
|
setHighlightedProperty(property.url);
|
|
|
|
|
// Optionally: pan map to coordinates
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const renderMainContent = () => {
|
2026-02-08 15:11:21 +00:00
|
|
|
if (!processedListingData) {
|
2026-02-01 17:28:37 +00:00
|
|
|
return (
|
|
|
|
|
<div className="flex-1 flex items-center justify-center bg-muted/20">
|
|
|
|
|
<div className="text-center p-8 max-w-md">
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="text-6xl mb-4 animate-pulse">🏠</div>
|
|
|
|
|
<h2 className="text-xl font-semibold mb-2">Loading Properties...</h2>
|
|
|
|
|
<p className="text-muted-foreground mb-4">
|
|
|
|
|
Fetching listings with default filters. You can adjust filters on the left.
|
|
|
|
|
</p>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="text-6xl mb-4">🏠</div>
|
|
|
|
|
<h2 className="text-xl font-semibold mb-2">Welcome to Property Explorer</h2>
|
|
|
|
|
<p className="text-muted-foreground mb-4">
|
|
|
|
|
Use the filters on the left to find properties. Apply filters to visualize existing data or refresh to fetch new listings.
|
|
|
|
|
</p>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-08 15:11:21 +00:00
|
|
|
if (processedListingData.features.length === 0) {
|
2026-02-01 17:28:37 +00:00
|
|
|
return (
|
|
|
|
|
<div className="flex-1 flex items-center justify-center">
|
|
|
|
|
<div className="text-center p-8">
|
|
|
|
|
<div className="text-6xl mb-4">🔍</div>
|
|
|
|
|
<h2 className="text-xl font-semibold mb-2">No listings found</h2>
|
|
|
|
|
<p className="text-muted-foreground">
|
|
|
|
|
Try adjusting the filters or run a data refresh to fetch new listings.
|
|
|
|
|
</p>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-21 13:55:05 +00:00
|
|
|
if (viewMode === 'saved') {
|
|
|
|
|
return (
|
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
2026-02-21 15:48:17 +00:00
|
|
|
<FavoritesView
|
|
|
|
|
listingData={listingData!}
|
2026-02-21 13:55:05 +00:00
|
|
|
getDecision={getDecision}
|
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
2026-02-21 15:48:17 +00:00
|
|
|
onSelectListing={(id) => setSelectedListingId(id)}
|
|
|
|
|
onRemoveFavorite={(id, type) => clear(id, type)}
|
2026-02-21 13:55:05 +00:00
|
|
|
/>
|
|
|
|
|
);
|
|
|
|
|
}
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
return (
|
|
|
|
|
<>
|
|
|
|
|
{/* Map View */}
|
|
|
|
|
{(viewMode === 'map' || viewMode === 'split') && (
|
|
|
|
|
<div className={`relative ${viewMode === 'split' ? 'w-1/2' : 'flex-1'}`} style={{ minHeight: 0 }}>
|
|
|
|
|
<Map
|
2026-02-08 15:11:21 +00:00
|
|
|
listingData={processedListingData}
|
2026-02-01 17:28:37 +00:00
|
|
|
queryParameters={queryParameters}
|
2026-02-08 15:11:21 +00:00
|
|
|
effectiveMetric={effectiveMetric}
|
2026-02-01 17:28:37 +00:00
|
|
|
onPropertyClick={handlePropertyClick}
|
2026-02-08 13:16:32 +00:00
|
|
|
pois={userPOIs}
|
2026-02-08 15:11:21 +00:00
|
|
|
isPickingPOI={poiPickerActive}
|
|
|
|
|
onPoiLocationPick={handlePoiLocationPick}
|
|
|
|
|
onCancelPoiPicking={handleCancelPoiPicking}
|
2025-06-15 21:06:10 +00:00
|
|
|
/>
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
|
|
|
|
)}
|
2025-06-22 21:20:42 +00:00
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
{/* List View */}
|
|
|
|
|
{(viewMode === 'list' || viewMode === 'split') && (
|
|
|
|
|
<div className={`${viewMode === 'split' ? 'w-1/2 border-l' : 'flex-1'}`}>
|
|
|
|
|
<ListView
|
2026-02-08 15:11:21 +00:00
|
|
|
listingData={processedListingData}
|
2026-02-01 17:28:37 +00:00
|
|
|
onPropertyClick={handlePropertyClick}
|
|
|
|
|
highlightedPropertyUrl={highlightedProperty}
|
2026-02-08 15:11:21 +00:00
|
|
|
poiMetricSelection={poiMetricSelection}
|
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
2026-02-21 15:48:17 +00:00
|
|
|
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')}
|
2026-02-01 17:28:37 +00:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-21 11:34:53 +00:00
|
|
|
const renderMobileLayout = () => (
|
|
|
|
|
<>
|
|
|
|
|
{/* Full-screen map */}
|
|
|
|
|
<div className="flex-1 relative min-h-0">
|
|
|
|
|
{/* Streaming Progress Bar */}
|
|
|
|
|
<div className="absolute top-0 left-0 right-0 z-10">
|
2026-02-22 02:04:56 +00:00
|
|
|
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} onCancel={() => abortControllerRef.current?.abort()} />
|
2026-02-21 11:34:53 +00:00
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{processedListingData && processedListingData.features.length > 0 ? (
|
|
|
|
|
<Map
|
|
|
|
|
listingData={processedListingData}
|
|
|
|
|
queryParameters={queryParameters}
|
|
|
|
|
effectiveMetric={effectiveMetric}
|
|
|
|
|
onPropertyClick={handlePropertyClick}
|
|
|
|
|
pois={userPOIs}
|
|
|
|
|
isPickingPOI={poiPickerActive}
|
|
|
|
|
onPoiLocationPick={handlePoiLocationPick}
|
|
|
|
|
onCancelPoiPicking={handleCancelPoiPicking}
|
|
|
|
|
/>
|
|
|
|
|
) : (
|
|
|
|
|
<div className="flex-1 flex items-center justify-center bg-muted/20 h-full">
|
|
|
|
|
<div className="text-center p-8 max-w-md">
|
|
|
|
|
{isLoading ? (
|
|
|
|
|
<>
|
|
|
|
|
<div className="text-4xl mb-4 animate-pulse">🏠</div>
|
|
|
|
|
<h2 className="text-lg font-semibold mb-2">Loading...</h2>
|
|
|
|
|
</>
|
|
|
|
|
) : (
|
|
|
|
|
<>
|
|
|
|
|
<div className="text-4xl mb-4">🏠</div>
|
|
|
|
|
<h2 className="text-lg font-semibold mb-2">Property Explorer</h2>
|
|
|
|
|
<p className="text-muted-foreground text-sm">
|
|
|
|
|
Use the filter button to find properties.
|
|
|
|
|
</p>
|
|
|
|
|
</>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-21 13:55:05 +00:00
|
|
|
{/* Filter & Review FABs */}
|
2026-02-21 11:34:53 +00:00
|
|
|
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
|
2026-02-21 13:55:05 +00:00
|
|
|
<Button
|
|
|
|
|
size="lg"
|
|
|
|
|
variant="outline"
|
|
|
|
|
className="rounded-full shadow-lg h-14 w-14 bg-background"
|
|
|
|
|
onClick={() => setShowReviewMode(true)}
|
|
|
|
|
disabled={!processedListingData || processedListingData.features.length === 0}
|
|
|
|
|
>
|
|
|
|
|
<Heart className="h-6 w-6" />
|
|
|
|
|
</Button>
|
2026-02-21 11:34:53 +00:00
|
|
|
<Sheet open={mobileFilterOpen} onOpenChange={setMobileFilterOpen}>
|
|
|
|
|
<SheetTrigger asChild>
|
|
|
|
|
<Button size="lg" className="rounded-full shadow-lg h-14 w-14">
|
|
|
|
|
<Filter className="h-6 w-6" />
|
|
|
|
|
</Button>
|
|
|
|
|
</SheetTrigger>
|
|
|
|
|
<SheetContent side="left" className="w-80 p-0">
|
|
|
|
|
<div className="h-full flex flex-col">
|
|
|
|
|
<div className="flex-1 min-h-0">
|
|
|
|
|
<FilterPanel
|
|
|
|
|
onSubmit={onSubmit}
|
|
|
|
|
currentMetric={currentMetric}
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
listingCount={processedListingData?.features.length}
|
|
|
|
|
user={user}
|
|
|
|
|
onTaskCreated={handlePOITaskCreated}
|
|
|
|
|
onStartPoiPicking={handleStartPoiPicking}
|
|
|
|
|
pickedPoiLocation={pickedPoiLocation}
|
|
|
|
|
userPOIs={userPOIs}
|
|
|
|
|
poiTravelFilters={poiTravelFilters}
|
|
|
|
|
onPoiTravelFiltersChange={setPoiTravelFilters}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="shrink-0 p-4">
|
|
|
|
|
<VisualizationCard
|
|
|
|
|
metric={currentMetric}
|
|
|
|
|
onMetricChange={handleMetricChange}
|
|
|
|
|
userPOIs={userPOIs}
|
|
|
|
|
onPoiMetricChange={setPoiMetricSelection}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
</SheetContent>
|
|
|
|
|
</Sheet>
|
|
|
|
|
</div>
|
|
|
|
|
|
|
|
|
|
{/* Bottom Sheet */}
|
|
|
|
|
{processedListingData && processedListingData.features.length > 0 && (
|
|
|
|
|
<MobileBottomSheet
|
|
|
|
|
listingData={processedListingData}
|
|
|
|
|
onPropertyClick={handlePropertyClick}
|
|
|
|
|
highlightedPropertyUrl={highlightedProperty}
|
|
|
|
|
onActiveListingChange={handleActiveListingChange}
|
|
|
|
|
poiMetricSelection={poiMetricSelection}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
</>
|
|
|
|
|
);
|
|
|
|
|
|
2026-02-08 13:16:32 +00:00
|
|
|
const handlePOITaskCreated = (taskId: string) => {
|
2026-02-09 23:02:24 +00:00
|
|
|
setExplicitTaskId(taskId);
|
|
|
|
|
if (taskId) subscribe(taskId);
|
2026-02-08 13:16:32 +00:00
|
|
|
// Refresh POI list in case new ones were created
|
|
|
|
|
if (user) {
|
|
|
|
|
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-08 15:11:21 +00:00
|
|
|
const handleStartPoiPicking = () => {
|
|
|
|
|
setPoiPickerActive(true);
|
|
|
|
|
setPickedPoiLocation(null);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handlePoiLocationPick = (lat: number, lng: number) => {
|
|
|
|
|
setPickedPoiLocation({ lat, lng });
|
|
|
|
|
setPoiPickerActive(false);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const handleCancelPoiPicking = () => {
|
|
|
|
|
setPoiPickerActive(false);
|
|
|
|
|
};
|
|
|
|
|
|
2026-02-21 11:34:53 +00:00
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
return (
|
|
|
|
|
<div className="h-screen flex flex-col overflow-hidden">
|
|
|
|
|
{/* Header */}
|
|
|
|
|
<Header
|
|
|
|
|
user={user}
|
2026-02-09 23:02:24 +00:00
|
|
|
tasks={tasks}
|
|
|
|
|
activeTaskId={activeTaskId}
|
|
|
|
|
isConnected={isConnected}
|
|
|
|
|
onCancelTask={cancelTask}
|
|
|
|
|
onClearAllTasks={async () => {
|
|
|
|
|
const result = await clearAllTasks();
|
|
|
|
|
if (result) {
|
|
|
|
|
handleTaskCancelled();
|
|
|
|
|
}
|
|
|
|
|
return result;
|
|
|
|
|
}}
|
2026-02-08 15:11:21 +00:00
|
|
|
onTaskCompleted={handleTaskCompleted}
|
2026-02-01 17:28:37 +00:00
|
|
|
/>
|
|
|
|
|
|
2026-02-21 11:34:53 +00:00
|
|
|
{isMobile ? (
|
|
|
|
|
renderMobileLayout()
|
|
|
|
|
) : (
|
|
|
|
|
/* Desktop layout */
|
|
|
|
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
|
|
|
|
{/* Filter Panel - Desktop (fixed sidebar) */}
|
|
|
|
|
<div className="w-80 shrink-0 h-full overflow-hidden">
|
|
|
|
|
<div className="h-full flex flex-col">
|
|
|
|
|
<div className="flex-1 min-h-0">
|
|
|
|
|
<FilterPanel
|
|
|
|
|
onSubmit={onSubmit}
|
|
|
|
|
currentMetric={currentMetric}
|
|
|
|
|
isLoading={isLoading}
|
|
|
|
|
listingCount={processedListingData?.features.length}
|
|
|
|
|
user={user}
|
|
|
|
|
onTaskCreated={handlePOITaskCreated}
|
|
|
|
|
onStartPoiPicking={handleStartPoiPicking}
|
|
|
|
|
pickedPoiLocation={pickedPoiLocation}
|
|
|
|
|
userPOIs={userPOIs}
|
|
|
|
|
poiTravelFilters={poiTravelFilters}
|
|
|
|
|
onPoiTravelFiltersChange={setPoiTravelFilters}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
<div className="shrink-0 p-4 border-r">
|
|
|
|
|
<VisualizationCard
|
|
|
|
|
metric={currentMetric}
|
|
|
|
|
onMetricChange={handleMetricChange}
|
|
|
|
|
userPOIs={userPOIs}
|
|
|
|
|
onPoiMetricChange={setPoiMetricSelection}
|
|
|
|
|
/>
|
|
|
|
|
</div>
|
2026-02-08 18:50:06 +00:00
|
|
|
</div>
|
|
|
|
|
</div>
|
|
|
|
|
|
2026-02-21 11:34:53 +00:00
|
|
|
{/* Main View Area */}
|
|
|
|
|
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
|
|
|
{/* Streaming Progress Bar */}
|
|
|
|
|
<div className="relative shrink-0">
|
2026-02-22 02:04:56 +00:00
|
|
|
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} onCancel={() => abortControllerRef.current?.abort()} />
|
2026-02-21 11:34:53 +00:00
|
|
|
</div>
|
2026-02-01 17:28:37 +00:00
|
|
|
|
2026-02-21 11:34:53 +00:00
|
|
|
{/* Map/List Container */}
|
|
|
|
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
|
|
|
|
{renderMainContent()}
|
|
|
|
|
</div>
|
2026-02-01 17:28:37 +00:00
|
|
|
|
2026-02-21 11:34:53 +00:00
|
|
|
{/* Stats Bar */}
|
|
|
|
|
{processedListingData && processedListingData.features.length > 0 && (
|
|
|
|
|
<div className="shrink-0">
|
|
|
|
|
<StatsBar
|
|
|
|
|
listingData={processedListingData}
|
|
|
|
|
viewMode={viewMode}
|
|
|
|
|
onViewModeChange={setViewMode}
|
2026-02-21 13:55:05 +00:00
|
|
|
likedCount={likedCount}
|
2026-02-21 11:34:53 +00:00
|
|
|
/>
|
|
|
|
|
</div>
|
|
|
|
|
)}
|
2025-06-15 21:06:10 +00:00
|
|
|
</div>
|
2026-02-01 17:28:37 +00:00
|
|
|
</div>
|
2026-02-21 11:34:53 +00:00
|
|
|
)}
|
2026-02-01 17:28:37 +00:00
|
|
|
|
2026-02-21 13:55:05 +00:00
|
|
|
{/* Swipe Review Mode Overlay */}
|
|
|
|
|
{showReviewMode && processedListingData && (
|
|
|
|
|
<SwipeReviewMode
|
|
|
|
|
features={processedListingData.features}
|
|
|
|
|
onDecide={decide}
|
|
|
|
|
onClear={clear}
|
|
|
|
|
onClose={() => setShowReviewMode(false)}
|
2026-02-21 21:13:32 +00:00
|
|
|
onSelectListing={(id) => setSelectedListingId(id)}
|
2026-02-21 13:55:05 +00:00
|
|
|
getDecision={getDecision}
|
|
|
|
|
/>
|
|
|
|
|
)}
|
|
|
|
|
|
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
2026-02-21 15:48:17 +00:00
|
|
|
{/* 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}
|
|
|
|
|
/>
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
{/* Error Dialog */}
|
|
|
|
|
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
|
|
|
|
</div>
|
|
|
|
|
);
|
2025-06-14 15:36:38 +00:00
|
|
|
}
|
|
|
|
|
|
2026-02-01 17:28:37 +00:00
|
|
|
export default App;
|