Listing stream fires immediately on auth without waiting for POI fetch. POI distances are not needed for initial rendering and are only computed when user selects POI metric or sets travel filters. This saves ~200-500ms on initial load and keeps the stream on the cached Redis path.
682 lines
25 KiB
TypeScript
682 lines
25 KiB
TypeScript
import { useEffect, useState, useRef, useCallback, useMemo } from 'react';
|
|
import './App.css';
|
|
import { getUser } from './auth/authService';
|
|
import { getStoredPasskeyUser } from './auth/passkeyService';
|
|
import { fromOidcUser, type AuthUser } from './auth/types';
|
|
import AlertError from './components/AlertError';
|
|
import LoginModal from './components/LoginModal';
|
|
import AuthCallback from './components/AuthCallback';
|
|
import { Map } from './components/Map';
|
|
import { FilterPanel, type ParameterValues, DEFAULT_FILTER_VALUES, Metric } from './components/FilterPanel';
|
|
import { VisualizationCard } from './components/VisualizationCard';
|
|
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';
|
|
import { Button } from './components/ui/button';
|
|
import { Filter, Heart } from 'lucide-react';
|
|
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature, POI, POITravelFilter } from '@/types';
|
|
import { refreshListings, streamListingGeoJSON, fetchUserPOIs, fetchBulkPOIDistances, type StreamingProgress } from '@/services';
|
|
import { setOnUnauthorized } from '@/services/apiClient';
|
|
import { clearPasskeyUser } from './auth/passkeyService';
|
|
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
|
import { useTaskProgress } from '@/hooks/useTaskProgress';
|
|
import { useDecisions } from '@/hooks/useDecisions';
|
|
import { useIsMobile } from '@/hooks/use-mobile';
|
|
import { MobileBottomSheet } from './components/MobileBottomSheet';
|
|
import { SwipeReviewMode } from './components/SwipeReviewMode';
|
|
import { FavoritesView } from './components/FavoritesView';
|
|
import { ListingDetailSheet } from './components/ListingDetailSheet';
|
|
|
|
function isTerminalStatus(status: string): boolean {
|
|
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
|
}
|
|
|
|
function App() {
|
|
const [listingData, setListingData] = useState<GeoJSONFeatureCollection | null>(null);
|
|
const [user, setUser] = useState<AuthUser | null>(null);
|
|
const [queryParameters, setQueryParameters] = useState<ParameterValues | null>(null);
|
|
const [submitError, setSubmitError] = useState<string | null>(null);
|
|
const [alertDialogIsOpen, setAlertDialogIsOpen] = useState(false);
|
|
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);
|
|
const [userPOIs, setUserPOIs] = useState<POI[]>([]);
|
|
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);
|
|
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
|
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
|
const isMobile = useIsMobile();
|
|
const [, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
|
const [showReviewMode, setShowReviewMode] = useState(false);
|
|
const [selectedListingId, setSelectedListingId] = useState<number | null>(null);
|
|
|
|
// Decision state (like/dislike)
|
|
const { decide, clear, getDecision, likedCount, isLoaded: isDecisionsLoaded } = useDecisions(user);
|
|
|
|
// 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]);
|
|
|
|
// Ref to track accumulated features during streaming
|
|
const accumulatedFeaturesRef = useRef<PropertyFeature[]>([]);
|
|
// Ref to track if initial load has been triggered
|
|
const initialLoadTriggeredRef = useRef(false);
|
|
// Ref to abort in-flight streaming requests
|
|
const abortControllerRef = useRef<AbortController | null>(null);
|
|
|
|
// Check if this is the callback route - render dedicated component
|
|
if (window.location.pathname === '/callback') {
|
|
return <AuthCallback />;
|
|
}
|
|
|
|
useEffect(() => {
|
|
// 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));
|
|
}
|
|
});
|
|
}
|
|
}, []);
|
|
|
|
useEffect(() => {
|
|
setOnUnauthorized(() => {
|
|
clearPasskeyUser();
|
|
setUser(null);
|
|
});
|
|
}, []);
|
|
|
|
const handlePasskeyLogin = (passkeyUser: AuthUser) => {
|
|
setUser(passkeyUser);
|
|
};
|
|
|
|
// Load user's POIs
|
|
useEffect(() => {
|
|
if (!user) return;
|
|
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
|
|
}, [user]);
|
|
|
|
// Load listings function - used by both auto-load and manual submit
|
|
const loadListings = useCallback(async (parameters: ParameterValues) => {
|
|
if (!user) return;
|
|
|
|
// Abort any in-flight streaming request
|
|
if (abortControllerRef.current) {
|
|
abortControllerRef.current.abort();
|
|
}
|
|
const controller = new AbortController();
|
|
abortControllerRef.current = controller;
|
|
|
|
setQueryParameters(parameters);
|
|
setMobileFilterOpen(false);
|
|
setIsLoading(true);
|
|
accumulatedFeaturesRef.current = [];
|
|
setStreamingProgress({ count: 0 });
|
|
setListingData(null);
|
|
|
|
// Dedup safety net: track seen URLs to prevent duplicate features
|
|
const seenUrls = new Set<string>();
|
|
|
|
let updateScheduled = false;
|
|
|
|
const flushUpdate = () => {
|
|
updateScheduled = false;
|
|
setListingData({
|
|
type: 'FeatureCollection',
|
|
features: [...accumulatedFeaturesRef.current]
|
|
});
|
|
};
|
|
|
|
const scheduleUpdate = () => {
|
|
if (!updateScheduled) {
|
|
updateScheduled = true;
|
|
requestAnimationFrame(flushUpdate);
|
|
}
|
|
};
|
|
|
|
try {
|
|
for await (const batch of streamListingGeoJSON(user, parameters, (progress) => {
|
|
setStreamingProgress(progress);
|
|
}, { signal: controller.signal })) {
|
|
// 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();
|
|
}
|
|
}
|
|
// Final flush to ensure all data is rendered
|
|
flushUpdate();
|
|
} catch (error) {
|
|
// Silently ignore AbortError — it means we intentionally cancelled
|
|
if (error instanceof DOMException && error.name === 'AbortError') {
|
|
return;
|
|
}
|
|
if (error instanceof Error) {
|
|
setSubmitError(error.message);
|
|
} else {
|
|
setSubmitError(String(error));
|
|
}
|
|
setAlertDialogIsOpen(true);
|
|
} finally {
|
|
// Only clear loading state if this controller is still the current one
|
|
if (abortControllerRef.current === controller) {
|
|
setIsLoading(false);
|
|
setStreamingProgress(null);
|
|
}
|
|
}
|
|
}, [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]);
|
|
|
|
// 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,
|
|
);
|
|
}
|
|
|
|
// 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) {
|
|
features = features.filter((f) => {
|
|
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;
|
|
});
|
|
});
|
|
}
|
|
|
|
// 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';
|
|
});
|
|
}
|
|
|
|
return { ...listingData, features };
|
|
}, [listingData, poiMetricSelection, poiTravelFilters, isDecisionsLoaded, getDecision]);
|
|
|
|
// 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]);
|
|
|
|
// 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]);
|
|
|
|
const handleTaskCompleted = useCallback(() => {
|
|
if (queryParameters) {
|
|
loadListings(queryParameters);
|
|
}
|
|
}, [queryParameters, loadListings]);
|
|
|
|
const handleTaskCancelled = useCallback(() => {
|
|
setExplicitTaskId(null);
|
|
}, []);
|
|
|
|
const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => {
|
|
setActiveCardFeature(feature);
|
|
}, []);
|
|
|
|
if (!user) {
|
|
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
|
}
|
|
|
|
const onSubmit = async (action: 'fetch-data' | 'visualize', parameters: ParameterValues) => {
|
|
if (action === 'visualize') {
|
|
loadListings(parameters);
|
|
} else if (action === 'fetch-data') {
|
|
setQueryParameters(parameters);
|
|
setMobileFilterOpen(false);
|
|
setIsLoading(true);
|
|
try {
|
|
const data = await refreshListings(user!, parameters);
|
|
setExplicitTaskId(data.task_id);
|
|
if (data.task_id) subscribe(data.task_id);
|
|
} catch (error) {
|
|
if (error instanceof Error) {
|
|
setSubmitError(error.message);
|
|
} else {
|
|
setSubmitError(String(error));
|
|
}
|
|
setAlertDialogIsOpen(true);
|
|
} finally {
|
|
setIsLoading(false);
|
|
}
|
|
}
|
|
};
|
|
|
|
const handleMetricChange = (metric: Metric) => {
|
|
setCurrentMetric(metric);
|
|
setQueryParameters(prev => prev ? { ...prev, metric } : null);
|
|
};
|
|
|
|
const handlePropertyClick = (property: PropertyProperties, _coordinates: [number, number]) => {
|
|
setHighlightedProperty(property.url);
|
|
// Optionally: pan map to coordinates
|
|
};
|
|
|
|
const renderMainContent = () => {
|
|
if (!processedListingData) {
|
|
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>
|
|
);
|
|
}
|
|
|
|
if (processedListingData.features.length === 0) {
|
|
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>
|
|
);
|
|
}
|
|
|
|
if (viewMode === 'saved') {
|
|
return (
|
|
<FavoritesView
|
|
listingData={listingData!}
|
|
getDecision={getDecision}
|
|
onSelectListing={(id) => setSelectedListingId(id)}
|
|
onRemoveFavorite={(id, type) => clear(id, type)}
|
|
/>
|
|
);
|
|
}
|
|
|
|
return (
|
|
<>
|
|
{/* Map View */}
|
|
{(viewMode === 'map' || viewMode === 'split') && (
|
|
<div className={`relative ${viewMode === 'split' ? 'w-1/2' : 'flex-1'}`} style={{ minHeight: 0 }}>
|
|
<Map
|
|
listingData={processedListingData}
|
|
queryParameters={queryParameters}
|
|
effectiveMetric={effectiveMetric}
|
|
onPropertyClick={handlePropertyClick}
|
|
pois={userPOIs}
|
|
isPickingPOI={poiPickerActive}
|
|
onPoiLocationPick={handlePoiLocationPick}
|
|
onCancelPoiPicking={handleCancelPoiPicking}
|
|
/>
|
|
</div>
|
|
)}
|
|
|
|
{/* List View */}
|
|
{(viewMode === 'list' || viewMode === 'split') && (
|
|
<div className={`${viewMode === 'split' ? 'w-1/2 border-l' : 'flex-1'}`}>
|
|
<ListView
|
|
listingData={processedListingData}
|
|
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')}
|
|
/>
|
|
</div>
|
|
)}
|
|
</>
|
|
);
|
|
};
|
|
|
|
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">
|
|
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} onCancel={() => abortControllerRef.current?.abort()} />
|
|
</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>
|
|
|
|
{/* Filter & Review FABs */}
|
|
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
|
|
<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>
|
|
<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}
|
|
/>
|
|
)}
|
|
</>
|
|
);
|
|
|
|
const handlePOITaskCreated = (taskId: string) => {
|
|
setExplicitTaskId(taskId);
|
|
if (taskId) subscribe(taskId);
|
|
// Refresh POI list in case new ones were created
|
|
if (user) {
|
|
fetchUserPOIs(user).then(setUserPOIs).catch(() => {});
|
|
}
|
|
};
|
|
|
|
const handleStartPoiPicking = () => {
|
|
setPoiPickerActive(true);
|
|
setPickedPoiLocation(null);
|
|
};
|
|
|
|
const handlePoiLocationPick = (lat: number, lng: number) => {
|
|
setPickedPoiLocation({ lat, lng });
|
|
setPoiPickerActive(false);
|
|
};
|
|
|
|
const handleCancelPoiPicking = () => {
|
|
setPoiPickerActive(false);
|
|
};
|
|
|
|
|
|
return (
|
|
<div className="h-screen flex flex-col overflow-hidden">
|
|
{/* Header */}
|
|
<Header
|
|
user={user}
|
|
tasks={tasks}
|
|
activeTaskId={activeTaskId}
|
|
isConnected={isConnected}
|
|
onCancelTask={cancelTask}
|
|
onClearAllTasks={async () => {
|
|
const result = await clearAllTasks();
|
|
if (result) {
|
|
handleTaskCancelled();
|
|
}
|
|
return result;
|
|
}}
|
|
onTaskCompleted={handleTaskCompleted}
|
|
/>
|
|
|
|
{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>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Main View Area */}
|
|
<div className="flex-1 flex flex-col overflow-hidden min-h-0">
|
|
{/* Streaming Progress Bar */}
|
|
<div className="relative shrink-0">
|
|
<StreamingProgressBar progress={streamingProgress} isLoading={isLoading} onCancel={() => abortControllerRef.current?.abort()} />
|
|
</div>
|
|
|
|
{/* Map/List Container */}
|
|
<div className="flex-1 flex overflow-hidden min-h-0">
|
|
{renderMainContent()}
|
|
</div>
|
|
|
|
{/* Stats Bar */}
|
|
{processedListingData && processedListingData.features.length > 0 && (
|
|
<div className="shrink-0">
|
|
<StatsBar
|
|
listingData={processedListingData}
|
|
viewMode={viewMode}
|
|
onViewModeChange={setViewMode}
|
|
likedCount={likedCount}
|
|
/>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Swipe Review Mode Overlay */}
|
|
{showReviewMode && processedListingData && (
|
|
<SwipeReviewMode
|
|
features={processedListingData.features}
|
|
onDecide={decide}
|
|
onClear={clear}
|
|
onClose={() => setShowReviewMode(false)}
|
|
onSelectListing={(id) => setSelectedListingId(id)}
|
|
getDecision={getDecision}
|
|
/>
|
|
)}
|
|
|
|
{/* 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 */}
|
|
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export default App;
|