feat: make frontend fully responsive with mobile-first layout
Add mobile-responsive design with full feature parity: - Bottom sheet (vaul) with 3 snap points for map+list coexistence - Swipeable property cards with horizontal scroll-snap - Hamburger menu with health, tasks, user info - Full-screen map with repositioned legend (top-left on mobile) - Filter FAB opening Sheet drawer - TaskProgressDrawer from bottom on mobile - All changes gated behind useIsMobile() hook (768px breakpoint) - Desktop layout completely untouched New components: MobileBottomSheet, SwipeableCardRow, PropertyCardCompact, MobileMenu Also fixes: idempotent longitude migration, React hooks order
This commit is contained in:
parent
8f068a581e
commit
a744b33578
14 changed files with 1768 additions and 152 deletions
|
|
@ -22,6 +22,8 @@ import { setOnUnauthorized } from '@/services/apiClient';
|
|||
import { clearPasskeyUser } from './auth/passkeyService';
|
||||
import { poiMetricPropertyName, injectPoiMetricProperty } from '@/utils/poiUtils';
|
||||
import { useTaskProgress } from '@/hooks/useTaskProgress';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
import { MobileBottomSheet } from './components/MobileBottomSheet';
|
||||
|
||||
function isTerminalStatus(status: string): boolean {
|
||||
return status === 'SUCCESS' || status === 'FAILURE' || status === 'REVOKED';
|
||||
|
|
@ -48,6 +50,8 @@ function App() {
|
|||
} | null>(null);
|
||||
const [poiTravelFilters, setPoiTravelFilters] = useState<Record<number, POITravelFilter>>({});
|
||||
const [currentMetric, setCurrentMetric] = useState<Metric>(DEFAULT_FILTER_VALUES.metric);
|
||||
const isMobile = useIsMobile();
|
||||
const [activeCardFeature, setActiveCardFeature] = useState<PropertyFeature | null>(null);
|
||||
|
||||
// Explicit task ID set by fetch-data action (to track as "active")
|
||||
const [explicitTaskId, setExplicitTaskId] = useState<string | null>(null);
|
||||
|
|
@ -258,6 +262,10 @@ function App() {
|
|||
setExplicitTaskId(null);
|
||||
}, []);
|
||||
|
||||
const handleActiveListingChange = useCallback((feature: PropertyFeature | null) => {
|
||||
setActiveCardFeature(feature);
|
||||
}, []);
|
||||
|
||||
if (!user) {
|
||||
return <LoginModal isOpen={user === null} onPasskeyLogin={handlePasskeyLogin} />;
|
||||
}
|
||||
|
|
@ -370,6 +378,99 @@ function App() {
|
|||
);
|
||||
};
|
||||
|
||||
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} />
|
||||
</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 FAB */}
|
||||
<div className="fixed bottom-24 right-4 z-50 flex flex-col gap-2">
|
||||
<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);
|
||||
|
|
@ -393,6 +494,7 @@ function App() {
|
|||
setPoiPickerActive(false);
|
||||
};
|
||||
|
||||
|
||||
return (
|
||||
<div className="h-screen flex flex-col overflow-hidden">
|
||||
{/* Header */}
|
||||
|
|
@ -412,99 +514,65 @@ function App() {
|
|||
onTaskCompleted={handleTaskCompleted}
|
||||
/>
|
||||
|
||||
{/* Main content area */}
|
||||
<div className="flex-1 flex overflow-hidden min-h-0">
|
||||
{/* Filter Panel - Desktop (fixed sidebar) */}
|
||||
<div className="hidden md:block 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>
|
||||
|
||||
{/* Filter Panel - Mobile (sheet) */}
|
||||
<div className="md:hidden fixed bottom-4 right-4 z-50">
|
||||
<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>
|
||||
{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>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
</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} />
|
||||
</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}
|
||||
/>
|
||||
</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} />
|
||||
</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}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Error Dialog */}
|
||||
<AlertError message={submitError} open={alertDialogIsOpen} setIsOpen={setAlertDialogIsOpen} />
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue