Redesign mobile map controls: Layers popover + Add speed-dial FAB

Replaces the crowded 12-item vertical right rail with a clean, minimal set:
- Right edge now has just two 44px controls: Near Me and a Layers button.
- Layers opens a labelled popover (Spots/Shops/Gear/Tours) with color dots
  and checkmarks, plus a count badge on the button so state is obvious.
- All "add" actions consolidated into a single red speed-dial FAB
  (bottom-right) that fans out to Add Spot / Add Shop / Offer Tour /
  Quick Snap, with a tap-catcher backdrop to dismiss.
- Sasquatch bubble moved to bottom-left on mobile so it no longer competes
  with the Add FAB for the bottom-right corner.
- Larger touch targets (44px) for better native/ZapStore ergonomics.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 10:18:35 +07:00
parent d107c0a860
commit 1d1e6d18b1
2 changed files with 149 additions and 64 deletions

View file

@ -621,7 +621,7 @@ Instructions:
ref={dragRef}
className={cn(
'z-50 touch-none select-none',
dragPos ? '' : 'fixed bottom-[9.5rem] right-4 md:bottom-6 md:right-auto md:left-4',
dragPos ? '' : 'fixed bottom-[9.5rem] left-4 md:bottom-6 md:left-4',
)}
style={bubbleStyle}
onPointerDown={onPointerDown}

View file

@ -18,6 +18,7 @@ import { Skeleton } from '@/components/ui/skeleton';
import { Progress } from '@/components/ui/progress';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Tooltip, TooltipContent, TooltipTrigger } from '@/components/ui/tooltip';
import { Popover, PopoverContent, PopoverTrigger } from '@/components/ui/popover';
import { LoginArea } from '@/components/auth/LoginArea';
import { useSkateSpots } from '@/hooks/useSkateSpots';
@ -70,6 +71,7 @@ import {
LocateOff,
Store,
Info,
Layers,
} from 'lucide-react';
import { CameraCapture } from '@/components/CameraCapture';
import { useUploadFile } from '@/hooks/useUploadFile';
@ -585,6 +587,11 @@ export default function MapPage() {
return () => window.removeEventListener('sasquatch:panelchange', handler);
}, []);
// Mobile "Add" speed-dial (fans out Add Spot / Shop / Tour / Quick Snap)
const [addDialOpen, setAddDialOpen] = useState(false);
// Mobile layers popover
const [layersOpen, setLayersOpen] = useState(false);
// Quick camera capture
const [showQuickCamera, setShowQuickCamera] = useState(false);
const { mutateAsync: uploadFile, isPending: isUploadingQuickPhoto } = useUploadFile();
@ -890,6 +897,8 @@ export default function MapPage() {
// Count active layer filters for mobile badge
const activeLayerCount = [showSpots, showShops, showGear, showTours, showBtcMap].filter(Boolean).length;
// Count of the 4 primary layers active — shown as a badge on the mobile Layers button
const mobileLayerCount = [showSpots, showShops, showGear, showTours].filter(Boolean).length;
const activeCategoryCount = selectedCategories.length;
const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0);
@ -1486,20 +1495,23 @@ export default function MapPage() {
</div>
)}
{/* Right-side control strip — layer toggles + Near Me + Snap (mobile only) */}
{/* ── Mobile map controls (redesigned) ──────────────────────────
Right edge holds just TWO small controls: Near Me + Layers.
Primary "Add" action is a speed-dial FAB (bottom-right). This
replaces the old 12-item vertical strip. */}
<div
className={cn(
"fixed right-3 z-[1000] md:hidden flex flex-col items-center gap-1.5 transition-all duration-200",
mobileFiltersOpen || selectedSpot ? "opacity-0 pointer-events-none translate-x-2" : "opacity-100 translate-x-0"
"fixed right-3 z-[1000] md:hidden flex flex-col items-center gap-2 transition-all duration-200",
mobileFiltersOpen || selectedSpot || addDialOpen ? "opacity-0 pointer-events-none translate-x-2" : "opacity-100 translate-x-0"
)}
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 0.75rem)' }}
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 6.5rem)' }}
>
{/* Near Me */}
<button
onClick={nearMeError === 'denied' ? () => setShowLocationHelp(true) : handleNearMe}
disabled={nearMeLoading}
className={cn(
'h-9 w-9 rounded-full flex items-center justify-center shadow-lg border transition-all touch-manipulation active:scale-90',
'h-11 w-11 rounded-full flex items-center justify-center shadow-lg border backdrop-blur transition-all touch-manipulation active:scale-90',
nearMeError === 'denied'
? 'bg-red-900/80 border-red-500/30'
: 'bg-black/70 border-white/15 hover:border-white/30'
@ -1507,73 +1519,146 @@ export default function MapPage() {
aria-label="Find spots near me"
>
{nearMeLoading ? (
<span className="h-3.5 w-3.5 rounded-full border-2 border-white/20 border-t-white animate-spin" />
<span className="h-4 w-4 rounded-full border-2 border-white/20 border-t-white animate-spin" />
) : nearMeError === 'denied' ? (
<LocateOff className="h-3.5 w-3.5 text-red-400" />
<LocateOff className="h-4 w-4 text-red-400" />
) : (
<LocateFixed className="h-3.5 w-3.5 text-[#ccff00]" />
<LocateFixed className="h-4 w-4 text-[#ccff00]" />
)}
</button>
{/* Divider */}
<div className="w-4 h-px bg-white/10" />
{/* Layer toggles — slim pill style */}
{[
{ key: 'spots', emoji: '🛹', active: showSpots, toggle: () => setShowSpots(v => !v) },
{ key: 'shops', emoji: '🏪', active: showShops, toggle: () => setShowShops(v => !v) },
{ key: 'gear', emoji: '🛒', active: showGear, toggle: () => setShowGear(v => !v) },
{ key: 'tours', emoji: '🗺️', active: showTours, toggle: () => setShowTours(v => !v) },
].map(({ key, emoji, active, toggle }) => (
<button
key={key}
onClick={toggle}
className={cn(
'h-9 w-9 rounded-full text-sm flex items-center justify-center border transition-all touch-manipulation active:scale-90',
active
? 'bg-black/80 border-white/25 shadow-md'
: 'bg-black/40 border-white/6 opacity-35'
)}
aria-label={`Toggle ${key}`}
>
{emoji}
</button>
))}
{/* Divider + Add actions + Quick Snap */}
{user && (
<>
<div className="w-4 h-px bg-white/10" />
{/* Add Spot */}
<Link to="/create-spot" aria-label="Add a skate spot">
<button
className="h-9 w-9 rounded-full bg-[#ff0040]/90 border border-[#ff0040]/50 flex items-center justify-center shadow-lg shadow-[#ff0040]/20 touch-manipulation active:scale-90 transition-all"
aria-label="Add spot"
>
<Plus className="h-4 w-4 text-white" strokeWidth={2.5} />
</button>
</Link>
{/* Add Shop */}
<Link to="/create-shop" aria-label="Add a skate shop">
<button
className="h-9 w-9 rounded-full bg-[#00cc66]/90 border border-[#00cc66]/50 flex items-center justify-center shadow-lg shadow-[#00cc66]/20 touch-manipulation active:scale-90 transition-all"
aria-label="Add shop"
>
<Plus className="h-4 w-4 text-black" strokeWidth={2.5} />
</button>
</Link>
<div className="w-4 h-px bg-white/10" />
{/* Quick Snap */}
{/* Layers popover — labelled toggles with color dots */}
<Popover open={layersOpen} onOpenChange={setLayersOpen}>
<PopoverTrigger asChild>
<button
onClick={() => setShowQuickCamera(true)}
className="h-9 w-9 rounded-full bg-black/70 border border-white/15 flex items-center justify-center shadow-lg touch-manipulation active:scale-90 hover:border-[#ff0040]/50 hover:bg-[#ff0040]/10 transition-all"
aria-label="Quick snap"
className={cn(
'relative h-11 w-11 rounded-full flex items-center justify-center shadow-lg border backdrop-blur transition-all touch-manipulation active:scale-90',
layersOpen
? 'bg-[#ff0040]/20 border-[#ff0040]/60'
: 'bg-black/70 border-white/15 hover:border-white/30'
)}
aria-label="Map layers"
>
<Camera className="h-3.5 w-3.5 text-gray-300" />
<Layers className={cn('h-4 w-4', layersOpen ? 'text-[#ff0040]' : 'text-gray-200')} />
{mobileLayerCount < 4 && (
<span className="absolute -top-1 -right-1 min-w-[18px] h-[18px] px-1 text-[9px] font-black bg-[#ccff00] text-black rounded-full flex items-center justify-center leading-none border-2 border-black">
{mobileLayerCount}
</span>
)}
</button>
</>
)}
</PopoverTrigger>
<PopoverContent
side="left"
align="end"
className="w-52 p-2 bg-black/95 border-white/15 backdrop-blur-xl"
>
<p className="text-[10px] font-bold text-muted-foreground uppercase tracking-wider px-1.5 pb-1.5">Show on Map</p>
<div className="space-y-0.5">
{[
{ key: 'spots', label: 'Spots', dot: '#ff0040', active: showSpots, toggle: () => setShowSpots(v => !v) },
{ key: 'shops', label: 'Shops', dot: '#a855f7', active: showShops, toggle: () => setShowShops(v => !v) },
{ key: 'gear', label: 'Gear', dot: '#f97316', active: showGear, toggle: () => setShowGear(v => !v) },
{ key: 'tours', label: 'Tours', dot: '#06b6d4', active: showTours, toggle: () => setShowTours(v => !v) },
].map(({ key, label, dot, active, toggle }) => (
<button
key={key}
onClick={toggle}
className={cn(
'w-full flex items-center gap-2.5 px-1.5 py-2 rounded-lg text-sm font-semibold transition-colors touch-manipulation',
active ? 'text-white hover:bg-white/5' : 'text-gray-500 hover:bg-white/5'
)}
>
<span
className={cn('w-2.5 h-2.5 rounded-full flex-shrink-0 transition-opacity', active ? 'opacity-100' : 'opacity-30')}
style={{ backgroundColor: dot }}
/>
<span className="flex-1 text-left">{label}</span>
<span
className={cn(
'w-4 h-4 rounded flex items-center justify-center flex-shrink-0 border transition-colors',
active ? 'bg-[#ccff00] border-[#ccff00]' : 'border-white/20'
)}
>
{active && <Check className="h-3 w-3 text-black" strokeWidth={3} />}
</span>
</button>
))}
</div>
</PopoverContent>
</Popover>
</div>
{/* ── Add speed-dial FAB (mobile, logged in) ──────────────────── */}
{user && (
<div
className={cn(
'fixed right-4 z-[1001] md:hidden flex flex-col items-end gap-2.5 transition-all duration-200',
mobileFiltersOpen || selectedSpot ? 'opacity-0 pointer-events-none' : 'opacity-100'
)}
style={{ bottom: 'calc(3.5rem + env(safe-area-inset-bottom, 0px) + 1rem)' }}
>
{/* Fan-out actions */}
{addDialOpen && (
<>
{[
{ label: 'Add Spot', to: '/create-spot', bg: 'bg-[#ff0040]', icon: <MapPin className="h-4 w-4 text-white" /> },
{ label: 'Add Shop', to: '/create-shop', bg: 'bg-[#a855f7]', icon: <Store className="h-4 w-4 text-white" /> },
{ label: 'Offer Tour', to: '/create-tour', bg: 'bg-[#06b6d4]', icon: <Route className="h-4 w-4 text-white" /> },
].map((a, i) => (
<Link
key={a.to}
to={a.to}
onClick={() => setAddDialOpen(false)}
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
style={{ animationDelay: `${i * 40}ms` }}
>
<span className="px-2.5 py-1 rounded-lg bg-black/85 backdrop-blur text-white text-xs font-bold shadow-lg border border-white/10">
{a.label}
</span>
<span className={cn('h-11 w-11 rounded-full flex items-center justify-center shadow-lg', a.bg)}>
{a.icon}
</span>
</Link>
))}
{/* Quick Snap */}
<button
onClick={() => { setAddDialOpen(false); setShowQuickCamera(true); }}
className="flex items-center gap-2.5 animate-in fade-in slide-in-from-bottom-2 duration-200"
style={{ animationDelay: '120ms' }}
>
<span className="px-2.5 py-1 rounded-lg bg-black/85 backdrop-blur text-white text-xs font-bold shadow-lg border border-white/10">
Quick Snap
</span>
<span className="h-11 w-11 rounded-full flex items-center justify-center shadow-lg bg-black/80 border border-white/20">
<Camera className="h-4 w-4 text-[#ccff00]" />
</span>
</button>
</>
)}
{/* Main FAB */}
<button
onClick={() => setAddDialOpen(v => !v)}
className={cn(
'h-14 w-14 rounded-full flex items-center justify-center shadow-2xl transition-all duration-300 touch-manipulation active:scale-90',
addDialOpen
? 'bg-white rotate-45'
: 'bg-[#ff0040] shadow-[#ff0040]/40 hover:bg-[#ff0040]/90'
)}
aria-label={addDialOpen ? 'Close add menu' : 'Add spot, shop, tour, or snap'}
>
<Plus className={cn('h-7 w-7 transition-colors', addDialOpen ? 'text-black' : 'text-white')} strokeWidth={2.5} />
</button>
</div>
)}
{/* Tap-catcher to close the speed-dial */}
{addDialOpen && (
<div
className="fixed inset-0 z-[1000] md:hidden"
onClick={() => setAddDialOpen(false)}
/>
)}
</>
) : (
<div className="h-full overflow-auto">