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
|
|
@ -2,11 +2,13 @@ import type { AuthUser } from '@/auth/types';
|
|||
import type { TaskState } from '@/types';
|
||||
import { Button } from './ui/button';
|
||||
import { Separator } from './ui/separator';
|
||||
import { LogOut, Home, Filter } from 'lucide-react';
|
||||
import { LogOut, Home } from 'lucide-react';
|
||||
import { logout } from '@/auth/authService';
|
||||
import { clearPasskeyUser } from '@/auth/passkeyService';
|
||||
import { HealthIndicator } from './HealthIndicator';
|
||||
import { TaskIndicator } from './TaskIndicator';
|
||||
import { MobileMenu } from './MobileMenu';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
interface HeaderProps {
|
||||
user: AuthUser;
|
||||
|
|
@ -25,9 +27,6 @@ interface HeaderProps {
|
|||
|
||||
export function Header({
|
||||
user,
|
||||
activeFilterCount = 0,
|
||||
onToggleFilters,
|
||||
showFilterToggle = false,
|
||||
tasks,
|
||||
activeTaskId,
|
||||
isConnected,
|
||||
|
|
@ -35,6 +34,8 @@ export function Header({
|
|||
onClearAllTasks,
|
||||
onTaskCompleted,
|
||||
}: HeaderProps) {
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
const handleLogout = async () => {
|
||||
if (user.provider === 'passkey') {
|
||||
clearPasskeyUser();
|
||||
|
|
@ -45,63 +46,62 @@ export function Header({
|
|||
};
|
||||
|
||||
return (
|
||||
<header className="flex h-14 shrink-0 items-center gap-3 border-b bg-background px-4">
|
||||
<header className={`flex shrink-0 items-center gap-3 border-b bg-background px-4 ${isMobile ? 'h-12' : 'h-14'}`}>
|
||||
{/* Logo / Brand */}
|
||||
<div className="flex items-center gap-2">
|
||||
<Home className="h-5 w-5 text-primary" />
|
||||
<span className="font-semibold text-lg hidden sm:inline">Wrongmove</span>
|
||||
</div>
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
{/* Health Indicator */}
|
||||
<HealthIndicator />
|
||||
|
||||
{/* Task Indicator */}
|
||||
<TaskIndicator
|
||||
tasks={tasks}
|
||||
activeTaskId={activeTaskId}
|
||||
isConnected={isConnected}
|
||||
onCancelTask={onCancelTask}
|
||||
onClearAllTasks={onClearAllTasks}
|
||||
onTaskCompleted={onTaskCompleted}
|
||||
/>
|
||||
|
||||
{/* Filter Toggle (mobile) */}
|
||||
{showFilterToggle && (
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
className="sm:hidden"
|
||||
onClick={onToggleFilters}
|
||||
>
|
||||
<Filter className="h-4 w-4" />
|
||||
{activeFilterCount > 0 && (
|
||||
<span className="ml-1 bg-primary text-primary-foreground text-xs px-1.5 py-0.5 rounded-full">
|
||||
{activeFilterCount}
|
||||
</span>
|
||||
)}
|
||||
</Button>
|
||||
{/* Desktop-only items */}
|
||||
{!isMobile && (
|
||||
<>
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
<HealthIndicator />
|
||||
<TaskIndicator
|
||||
tasks={tasks}
|
||||
activeTaskId={activeTaskId}
|
||||
isConnected={isConnected}
|
||||
onCancelTask={onCancelTask}
|
||||
onClearAllTasks={onClearAllTasks}
|
||||
onTaskCompleted={onTaskCompleted}
|
||||
/>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Spacer */}
|
||||
<div className="flex-1" />
|
||||
|
||||
{/* User Menu */}
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground hidden md:inline">
|
||||
{user.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
<span className="hidden sm:inline">Logout</span>
|
||||
</Button>
|
||||
</div>
|
||||
{/* Mobile: hamburger menu */}
|
||||
{isMobile && (
|
||||
<MobileMenu
|
||||
user={user}
|
||||
tasks={tasks}
|
||||
activeTaskId={activeTaskId}
|
||||
isConnected={isConnected}
|
||||
onCancelTask={onCancelTask}
|
||||
onClearAllTasks={onClearAllTasks}
|
||||
onTaskCompleted={onTaskCompleted}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Desktop: user menu */}
|
||||
{!isMobile && (
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</span>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="sm"
|
||||
onClick={handleLogout}
|
||||
className="gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
127
frontend/src/components/MobileBottomSheet.tsx
Normal file
127
frontend/src/components/MobileBottomSheet.tsx
Normal file
|
|
@ -0,0 +1,127 @@
|
|||
import { useState, useMemo, useCallback } from 'react';
|
||||
import { Drawer } from 'vaul';
|
||||
import { MapPin, PoundSterling } from 'lucide-react';
|
||||
import { SwipeableCardRow } from './SwipeableCardRow';
|
||||
import { ListView } from './ListView';
|
||||
import type { GeoJSONFeatureCollection, PropertyProperties, PropertyFeature } from '@/types';
|
||||
|
||||
interface MobileBottomSheetProps {
|
||||
listingData: GeoJSONFeatureCollection | null;
|
||||
onPropertyClick?: (property: PropertyProperties, coordinates: [number, number]) => void;
|
||||
highlightedPropertyUrl?: string | null;
|
||||
onActiveListingChange?: (feature: PropertyFeature | null) => void;
|
||||
poiMetricSelection?: { poiId: number; travelMode: string } | null;
|
||||
}
|
||||
|
||||
function formatCurrency(value: number): string {
|
||||
if (value >= 1000) return `£${(value / 1000).toFixed(1)}k`;
|
||||
return `£${Math.round(value)}`;
|
||||
}
|
||||
|
||||
export function MobileBottomSheet({
|
||||
listingData,
|
||||
onPropertyClick,
|
||||
highlightedPropertyUrl,
|
||||
onActiveListingChange,
|
||||
poiMetricSelection,
|
||||
}: MobileBottomSheetProps) {
|
||||
const [snap, setSnap] = useState<string | number>("148px");
|
||||
const [activeCardIndex, setActiveCardIndex] = useState(0);
|
||||
|
||||
const features = listingData?.features ?? [];
|
||||
|
||||
const stats = useMemo(() => {
|
||||
if (features.length === 0) return { count: 0, avgPrice: 0 };
|
||||
const validPrices = features
|
||||
.map((f) => f.properties.total_price)
|
||||
.filter((p): p is number => typeof p === 'number' && p > 0);
|
||||
const avgPrice = validPrices.length > 0
|
||||
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
|
||||
: 0;
|
||||
return { count: features.length, avgPrice };
|
||||
}, [features]);
|
||||
|
||||
const avgPricePerSqm = useMemo(() => {
|
||||
const validPrices = features
|
||||
.map((f) => f.properties.qmprice)
|
||||
.filter((p): p is number => typeof p === 'number' && p > 0);
|
||||
return validPrices.length > 0
|
||||
? validPrices.reduce((a, b) => a + b, 0) / validPrices.length
|
||||
: 0;
|
||||
}, [features]);
|
||||
|
||||
const handleActiveIndexChange = useCallback((index: number) => {
|
||||
setActiveCardIndex(index);
|
||||
if (features[index]) {
|
||||
onActiveListingChange?.(features[index]);
|
||||
}
|
||||
}, [features, onActiveListingChange]);
|
||||
|
||||
const handleCardClick = useCallback((feature: PropertyFeature) => {
|
||||
window.open(feature.properties.url, '_blank', 'noopener,noreferrer');
|
||||
onPropertyClick?.(feature.properties, feature.geometry.coordinates);
|
||||
}, [onPropertyClick]);
|
||||
|
||||
const isExpanded = snap === 0.85;
|
||||
|
||||
return (
|
||||
<Drawer.Root
|
||||
open
|
||||
snapPoints={["80px", "148px", 0.85]}
|
||||
activeSnapPoint={snap}
|
||||
setActiveSnapPoint={setSnap}
|
||||
modal={false}
|
||||
>
|
||||
<Drawer.Portal>
|
||||
<Drawer.Content
|
||||
className="fixed inset-x-0 bottom-0 z-40 flex flex-col rounded-t-xl bg-background border-t shadow-lg"
|
||||
style={{ maxHeight: '85vh' }}
|
||||
>
|
||||
{/* Drag handle */}
|
||||
<div className="flex justify-center pt-2 pb-1">
|
||||
<div className="h-1.5 w-10 rounded-full bg-muted-foreground/30" />
|
||||
</div>
|
||||
|
||||
{/* Compact stats (always visible) */}
|
||||
<div className="flex items-center gap-4 px-4 pb-2 text-sm text-muted-foreground">
|
||||
<div className="flex items-center gap-1.5">
|
||||
<MapPin className="h-4 w-4" />
|
||||
<span className="font-medium text-foreground">{stats.count.toLocaleString()}</span>
|
||||
<span>listings</span>
|
||||
</div>
|
||||
{stats.avgPrice > 0 && (
|
||||
<div className="flex items-center gap-1.5">
|
||||
<PoundSterling className="h-4 w-4" />
|
||||
<span>Avg: <span className="font-medium text-foreground">{formatCurrency(stats.avgPrice)}</span></span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Swipeable cards (visible at peek snap) */}
|
||||
{!isExpanded && features.length > 0 && (
|
||||
<SwipeableCardRow
|
||||
features={features}
|
||||
activeIndex={activeCardIndex}
|
||||
onActiveIndexChange={handleActiveIndexChange}
|
||||
avgPricePerSqm={avgPricePerSqm}
|
||||
highlightedPropertyUrl={highlightedPropertyUrl}
|
||||
onCardClick={handleCardClick}
|
||||
/>
|
||||
)}
|
||||
|
||||
{/* Full list view (visible at expanded snap) */}
|
||||
{isExpanded && listingData && features.length > 0 && (
|
||||
<div className="flex-1 min-h-0 overflow-hidden">
|
||||
<ListView
|
||||
listingData={listingData}
|
||||
onPropertyClick={onPropertyClick}
|
||||
highlightedPropertyUrl={highlightedPropertyUrl}
|
||||
poiMetricSelection={poiMetricSelection}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</Drawer.Content>
|
||||
</Drawer.Portal>
|
||||
</Drawer.Root>
|
||||
);
|
||||
}
|
||||
96
frontend/src/components/MobileMenu.tsx
Normal file
96
frontend/src/components/MobileMenu.tsx
Normal file
|
|
@ -0,0 +1,96 @@
|
|||
import type { AuthUser } from '@/auth/types';
|
||||
import type { TaskState } from '@/types';
|
||||
import { Sheet, SheetContent, SheetHeader, SheetTitle, SheetTrigger } from './ui/sheet';
|
||||
import { Button } from './ui/button';
|
||||
import { Menu, LogOut } from 'lucide-react';
|
||||
import { logout } from '@/auth/authService';
|
||||
import { clearPasskeyUser } from '@/auth/passkeyService';
|
||||
import { HealthIndicator } from './HealthIndicator';
|
||||
import { TaskIndicator } from './TaskIndicator';
|
||||
import { Separator } from './ui/separator';
|
||||
|
||||
interface MobileMenuProps {
|
||||
user: AuthUser;
|
||||
tasks: Record<string, TaskState>;
|
||||
activeTaskId: string | null;
|
||||
isConnected: boolean;
|
||||
onCancelTask: (taskId: string) => Promise<boolean>;
|
||||
onClearAllTasks: () => Promise<boolean>;
|
||||
onTaskCompleted?: () => void;
|
||||
}
|
||||
|
||||
export function MobileMenu({
|
||||
user,
|
||||
tasks,
|
||||
activeTaskId,
|
||||
isConnected,
|
||||
onCancelTask,
|
||||
onClearAllTasks,
|
||||
onTaskCompleted,
|
||||
}: MobileMenuProps) {
|
||||
const handleLogout = async () => {
|
||||
if (user.provider === 'passkey') {
|
||||
clearPasskeyUser();
|
||||
window.location.reload();
|
||||
} else {
|
||||
await logout();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Sheet>
|
||||
<SheetTrigger asChild>
|
||||
<Button variant="ghost" size="sm" className="h-10 w-10 p-0">
|
||||
<Menu className="h-5 w-5" />
|
||||
</Button>
|
||||
</SheetTrigger>
|
||||
<SheetContent side="right" className="w-72">
|
||||
<SheetHeader>
|
||||
<SheetTitle>Menu</SheetTitle>
|
||||
</SheetHeader>
|
||||
|
||||
<div className="flex flex-col gap-4 px-4">
|
||||
{/* User info */}
|
||||
<div className="text-sm text-muted-foreground">
|
||||
{user.email}
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Health */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">System Health</span>
|
||||
<HealthIndicator />
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Tasks */}
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-sm font-medium">Tasks</span>
|
||||
<TaskIndicator
|
||||
tasks={tasks}
|
||||
activeTaskId={activeTaskId}
|
||||
isConnected={isConnected}
|
||||
onCancelTask={onCancelTask}
|
||||
onClearAllTasks={onClearAllTasks}
|
||||
onTaskCompleted={onTaskCompleted}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Logout */}
|
||||
<Button
|
||||
variant="outline"
|
||||
onClick={handleLogout}
|
||||
className="w-full gap-2"
|
||||
>
|
||||
<LogOut className="h-4 w-4" />
|
||||
Logout
|
||||
</Button>
|
||||
</div>
|
||||
</SheetContent>
|
||||
</Sheet>
|
||||
);
|
||||
}
|
||||
76
frontend/src/components/PropertyCardCompact.tsx
Normal file
76
frontend/src/components/PropertyCardCompact.tsx
Normal file
|
|
@ -0,0 +1,76 @@
|
|||
import { Bed, Maximize2 } from 'lucide-react';
|
||||
import type { PropertyProperties } from '@/types';
|
||||
|
||||
interface PropertyCardCompactProps {
|
||||
property: PropertyProperties;
|
||||
isActive?: boolean;
|
||||
isHighlighted?: boolean;
|
||||
avgPricePerSqm?: number;
|
||||
onClick?: () => void;
|
||||
}
|
||||
|
||||
export function PropertyCardCompact({
|
||||
property,
|
||||
isActive = false,
|
||||
isHighlighted = false,
|
||||
avgPricePerSqm,
|
||||
onClick,
|
||||
}: PropertyCardCompactProps) {
|
||||
const isGoodDeal = avgPricePerSqm && property.qmprice > 0 && property.qmprice < avgPricePerSqm * 0.9;
|
||||
const isExpensive = avgPricePerSqm && property.qmprice > avgPricePerSqm * 1.1;
|
||||
|
||||
const priceIndicator = isGoodDeal
|
||||
? { color: 'text-green-600 bg-green-50', label: 'Good deal' }
|
||||
: isExpensive
|
||||
? { color: 'text-red-600 bg-red-50', label: 'Above avg' }
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
className={`w-[280px] shrink-0 snap-center rounded-lg border bg-background shadow-sm overflow-hidden cursor-pointer transition-all ${
|
||||
isActive ? 'ring-2 ring-primary scale-[1.02]' : ''
|
||||
} ${isHighlighted ? 'ring-2 ring-primary' : ''}`}
|
||||
onClick={onClick}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="h-28 w-full bg-muted">
|
||||
{property.photo_thumbnail && (
|
||||
<img
|
||||
src={property.photo_thumbnail}
|
||||
alt="Property"
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Details */}
|
||||
<div className="p-3">
|
||||
<div className="flex items-start justify-between gap-2">
|
||||
<div className="font-semibold text-base">
|
||||
£{property.total_price.toLocaleString()}
|
||||
{property.listing_type !== 'BUY' && (
|
||||
<span className="text-muted-foreground font-normal text-sm">/mo</span>
|
||||
)}
|
||||
</div>
|
||||
{priceIndicator && (
|
||||
<span className={`text-xs px-1.5 py-0.5 rounded shrink-0 ${priceIndicator.color}`}>
|
||||
{priceIndicator.label}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3 mt-1.5 text-sm text-muted-foreground">
|
||||
<span className="flex items-center gap-1">
|
||||
<Bed className="h-3.5 w-3.5" />
|
||||
{property.rooms}
|
||||
</span>
|
||||
<span className="flex items-center gap-1">
|
||||
<Maximize2 className="h-3.5 w-3.5" />
|
||||
{property.qm} m²
|
||||
</span>
|
||||
<span>£{property.qmprice}/m²</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
87
frontend/src/components/SwipeableCardRow.tsx
Normal file
87
frontend/src/components/SwipeableCardRow.tsx
Normal file
|
|
@ -0,0 +1,87 @@
|
|||
import { useRef, useEffect, useCallback } from 'react';
|
||||
import { PropertyCardCompact } from './PropertyCardCompact';
|
||||
import type { PropertyFeature } from '@/types';
|
||||
|
||||
interface SwipeableCardRowProps {
|
||||
features: PropertyFeature[];
|
||||
activeIndex: number;
|
||||
onActiveIndexChange: (index: number) => void;
|
||||
avgPricePerSqm: number;
|
||||
highlightedPropertyUrl?: string | null;
|
||||
onCardClick?: (feature: PropertyFeature) => void;
|
||||
}
|
||||
|
||||
export function SwipeableCardRow({
|
||||
features,
|
||||
activeIndex,
|
||||
onActiveIndexChange,
|
||||
avgPricePerSqm,
|
||||
highlightedPropertyUrl,
|
||||
onCardClick,
|
||||
}: SwipeableCardRowProps) {
|
||||
const scrollRef = useRef<HTMLDivElement>(null);
|
||||
const isScrollingProgrammatically = useRef(false);
|
||||
|
||||
// Scroll to active index when it changes externally (e.g., from map marker tap)
|
||||
useEffect(() => {
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const cardWidth = 280 + 12; // card width + gap
|
||||
const targetScroll = activeIndex * cardWidth - (container.clientWidth - 280) / 2;
|
||||
|
||||
isScrollingProgrammatically.current = true;
|
||||
container.scrollTo({ left: targetScroll, behavior: 'smooth' });
|
||||
|
||||
// Reset flag after scroll completes
|
||||
const timer = setTimeout(() => {
|
||||
isScrollingProgrammatically.current = false;
|
||||
}, 500);
|
||||
return () => clearTimeout(timer);
|
||||
}, [activeIndex]);
|
||||
|
||||
// Detect which card is centered after user scrolls
|
||||
const handleScroll = useCallback(() => {
|
||||
if (isScrollingProgrammatically.current) return;
|
||||
const container = scrollRef.current;
|
||||
if (!container) return;
|
||||
|
||||
const cardWidth = 280 + 12;
|
||||
const centerX = container.scrollLeft + container.clientWidth / 2;
|
||||
const newIndex = Math.round((centerX - 280 / 2) / cardWidth);
|
||||
const clampedIndex = Math.max(0, Math.min(newIndex, features.length - 1));
|
||||
|
||||
if (clampedIndex !== activeIndex) {
|
||||
onActiveIndexChange(clampedIndex);
|
||||
}
|
||||
}, [activeIndex, features.length, onActiveIndexChange]);
|
||||
|
||||
// Debounced scroll handler
|
||||
const scrollTimerRef = useRef<number | null>(null);
|
||||
const debouncedScroll = useCallback(() => {
|
||||
if (scrollTimerRef.current) clearTimeout(scrollTimerRef.current);
|
||||
scrollTimerRef.current = window.setTimeout(handleScroll, 100);
|
||||
}, [handleScroll]);
|
||||
|
||||
if (features.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={scrollRef}
|
||||
onScroll={debouncedScroll}
|
||||
className="flex gap-3 overflow-x-auto snap-x snap-mandatory px-4 py-2 scrollbar-none"
|
||||
style={{ WebkitOverflowScrolling: 'touch' }}
|
||||
>
|
||||
{features.map((feature, index) => (
|
||||
<PropertyCardCompact
|
||||
key={feature.properties.url}
|
||||
property={feature.properties}
|
||||
isActive={index === activeIndex}
|
||||
isHighlighted={feature.properties.url === highlightedPropertyUrl}
|
||||
avgPricePerSqm={avgPricePerSqm}
|
||||
onClick={() => onCardClick?.(feature)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -10,6 +10,7 @@ import {
|
|||
import { Button } from './ui/button';
|
||||
import { CheckCircle2, Circle, Loader2, XCircle, MapPin, Search } from 'lucide-react';
|
||||
import { useEffect, useRef, useMemo } from 'react';
|
||||
import { useIsMobile } from '@/hooks/use-mobile';
|
||||
|
||||
interface TaskProgressDrawerProps {
|
||||
open: boolean;
|
||||
|
|
@ -420,6 +421,7 @@ export function TaskProgressDrawer({
|
|||
onSelectTask,
|
||||
}: TaskProgressDrawerProps) {
|
||||
// Determine which task's data to show
|
||||
const isMobile = useIsMobile();
|
||||
const hasMultipleTasks = tasks && Object.keys(tasks).length > 1;
|
||||
const effectiveTaskId = selectedTaskId ?? taskID;
|
||||
|
||||
|
|
@ -446,7 +448,10 @@ export function TaskProgressDrawer({
|
|||
|
||||
return (
|
||||
<Sheet open={open} onOpenChange={onOpenChange}>
|
||||
<SheetContent side="right" className="flex flex-col w-full sm:!max-w-lg">
|
||||
<SheetContent
|
||||
side={isMobile ? "bottom" : "right"}
|
||||
className={`flex flex-col ${isMobile ? 'h-[85vh] rounded-t-xl' : 'w-full sm:!max-w-lg'}`}
|
||||
>
|
||||
<SheetHeader>
|
||||
<div className="flex items-center justify-between pr-6">
|
||||
<SheetTitle>{drawerTitle}</SheetTitle>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue