fix(map): speed-dial FAB + location help dialog

FAB (Add Spot / Add Shop):
- Removed old bottom-left "Add Spot" pill that sat on top of the bottom nav
- New speed-dial FAB in bottom-left, positioned at
  bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] — always clears
  the iOS/Android safe area + bottom nav height
- Tap the red + button to expand upward: "Add Spot" (red/MapPin) and
  "Add Shop" (purple/Store) pill buttons slide in with animation
- + button rotates 45° when open (becomes ×), closes on map tap
- Logged-out state shows "Log in to add" prompt instead
- Tapping either option closes the speed-dial before navigating

Right-side filter pills:
- Bumped from bottom-20 to bottom-[calc(5rem+env(safe-area-inset-bottom))]
  so they're consistently safe-area aware on devices with home indicator

Near Me — location denied fix:
- PERMISSION_DENIED no longer shows a toast (which was useless — user
  can't act on a disappearing toast)
- Tapping the red LocateOff button now opens a bottom-sheet help dialog
  with per-platform instructions (Android Chrome, iOS Safari, desktop
  Chrome, Firefox) on how to re-enable location permission
- "Got it" dismisses and resets the error state so the button returns
  to normal for a fresh attempt
- handleNearMe still fires normally on first tap (not denied state)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-23 23:05:30 +07:00
parent 886280e369
commit 2500b549f5

View file

@ -68,6 +68,8 @@ import {
MapPin,
LocateFixed,
LocateOff,
Store,
Info,
} from 'lucide-react';
import { CameraCapture } from '@/components/CameraCapture';
import { useUploadFile } from '@/hooks/useUploadFile';
@ -595,6 +597,10 @@ export default function MapPage() {
const [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
const [nearMeLoading, setNearMeLoading] = useState(false);
const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null);
const [showLocationHelp, setShowLocationHelp] = useState(false);
// Speed-dial FAB open state
const [fabOpen, setFabOpen] = useState(false);
const handleNearMe = () => {
// Clear previous error
@ -623,11 +629,7 @@ export default function MapPage() {
setNearMeLoading(false);
if (err.code === err.PERMISSION_DENIED) {
setNearMeError('denied');
toast({
title: 'Location access denied',
description: 'To find spots near you, allow location access in your browser settings, then try again.',
variant: 'destructive',
});
setShowLocationHelp(true);
} else if (err.code === err.TIMEOUT) {
setNearMeError('timeout');
toast({
@ -734,6 +736,8 @@ export default function MapPage() {
);
const handleMapClick = (lat: number, lng: number) => {
// Close speed-dial FAB if open
setFabOpen(false);
if (!user) return;
// Navigate to create spot with coordinates pre-filled
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`);
@ -1420,31 +1424,63 @@ export default function MapPage() {
</div>
)}
{/* Add Spot FAB — primary CTA, bottom-left.
Hidden when Sasquatch panel is open (user is in chat mode).
Hidden when mobile filters or spot sheet is open. */}
{/* Speed-dial FAB — bottom-right, above filter pills, clear of bottom nav.
Tap the + button to expand into Add Spot + Add Shop options.
Hidden when overlays open. Desktop gets a different layout so md:hidden. */}
<div className={cn(
"absolute bottom-4 left-4 z-[1000] transition-all duration-200 md:hidden",
"absolute bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] left-3 z-[1001] md:hidden flex flex-col items-start gap-2 transition-all duration-200",
mobileFiltersOpen || selectedSpot || sasquatchPanelOpen
? "opacity-0 pointer-events-none scale-90"
: "opacity-100 scale-100"
)}>
{user ? (
<Link to="/create-spot">
<button className="flex items-center gap-2 h-12 pl-3 pr-4 rounded-full bg-[#ff0040] shadow-xl shadow-[#ff0040]/40 border-2 border-white/20 text-white font-black text-sm uppercase tracking-wide active:scale-95 transition-transform">
<Plus className="h-5 w-5 flex-shrink-0" />
Add Spot
{/* Expanded options — slide up when fabOpen */}
<div className={cn(
"flex flex-col gap-2 transition-all duration-200 origin-bottom",
fabOpen ? "opacity-100 translate-y-0 pointer-events-auto" : "opacity-0 translate-y-3 pointer-events-none"
)}>
{user ? (
<>
<Link to="/create-shop" onClick={() => setFabOpen(false)}>
<button className="flex items-center gap-2 h-11 pl-3 pr-4 rounded-full bg-purple-600 shadow-lg shadow-purple-600/30 border border-white/20 text-white font-black text-sm uppercase tracking-wide active:scale-95 transition-transform whitespace-nowrap">
<Store className="h-4 w-4 flex-shrink-0" />
Add Shop
</button>
</Link>
<Link to="/create-spot" onClick={() => setFabOpen(false)}>
<button className="flex items-center gap-2 h-11 pl-3 pr-4 rounded-full bg-[#ff0040] shadow-lg shadow-[#ff0040]/30 border border-white/20 text-white font-black text-sm uppercase tracking-wide active:scale-95 transition-transform whitespace-nowrap">
<MapPin className="h-4 w-4 flex-shrink-0" />
Add Spot
</button>
</Link>
</>
) : (
<button
onClick={() => {
setFabOpen(false);
document.querySelector<HTMLElement>('[data-login-trigger]')?.click();
}}
className="flex items-center gap-2 h-11 pl-3 pr-4 rounded-full bg-[#ff0040]/80 shadow-lg shadow-[#ff0040]/20 border border-white/20 text-white font-black text-sm uppercase tracking-wide active:scale-95 transition-transform backdrop-blur whitespace-nowrap"
>
<Plus className="h-4 w-4 flex-shrink-0" />
Log in to add
</button>
</Link>
) : (
<button
onClick={() => document.querySelector<HTMLElement>('[data-login-trigger]')?.click()}
className="flex items-center gap-2 h-12 pl-3 pr-4 rounded-full bg-[#ff0040]/80 shadow-xl shadow-[#ff0040]/30 border-2 border-white/20 text-white font-black text-sm uppercase tracking-wide active:scale-95 transition-transform backdrop-blur"
>
<Plus className="h-5 w-5 flex-shrink-0" />
Add Spot
</button>
)}
)}
</div>
{/* Main FAB trigger */}
<button
onClick={() => setFabOpen(v => !v)}
aria-label={fabOpen ? 'Close add menu' : 'Add spot or shop'}
aria-expanded={fabOpen}
className={cn(
"h-12 w-12 rounded-full shadow-xl border-2 border-white/20 flex items-center justify-center transition-all duration-200 active:scale-95 touch-manipulation",
fabOpen
? "bg-white/15 rotate-45 shadow-white/10"
: "bg-[#ff0040] shadow-[#ff0040]/40"
)}
>
<Plus className="h-5 w-5 text-white" />
</button>
</div>
{/* Near Me button + attribution — floating top-right, hidden when any overlay is open */}
@ -1453,7 +1489,7 @@ export default function MapPage() {
mobileFiltersOpen ? "opacity-0 pointer-events-none" : "opacity-100"
)}>
<Button
onClick={handleNearMe}
onClick={nearMeError === 'denied' ? () => setShowLocationHelp(true) : handleNearMe}
disabled={nearMeLoading}
size="sm"
className={cn(
@ -1463,7 +1499,7 @@ export default function MapPage() {
: "bg-black/80 hover:bg-black/95 border-white/20"
)}
title={nearMeError === 'denied' ? 'Location access denied — tap for help' : 'Find spots near me'}
aria-label="Find spots near my location"
aria-label={nearMeError === 'denied' ? 'Location access denied — tap for help' : 'Find spots near my location'}
>
{nearMeLoading ? (
<span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
@ -1485,9 +1521,54 @@ export default function MapPage() {
</div>
</div>
{/* Location permission help dialog */}
{showLocationHelp && (
<div className="absolute inset-0 z-[2000] flex items-end justify-center pb-[calc(5rem+env(safe-area-inset-bottom,0px))] md:items-center md:pb-0 px-4">
<div
className="absolute inset-0 bg-black/60 backdrop-blur-sm"
onClick={() => setShowLocationHelp(false)}
/>
<div className="relative w-full max-w-sm rounded-2xl bg-[#111] border border-red-500/30 shadow-2xl p-5 space-y-4">
<div className="flex items-start gap-3">
<div className="w-10 h-10 rounded-xl bg-red-500/15 flex items-center justify-center flex-shrink-0">
<LocateOff className="h-5 w-5 text-red-400" />
</div>
<div>
<p className="text-sm font-black text-white uppercase tracking-wide">Location Blocked</p>
<p className="text-xs text-muted-foreground mt-1 leading-relaxed">
Your browser has blocked location access for this site. Here's how to re-enable it:
</p>
</div>
</div>
<div className="rounded-xl border border-white/8 bg-white/[0.03] divide-y divide-white/5 overflow-hidden">
{[
{ platform: '📱 Android Chrome', steps: 'Tap the lock icon in the address bar → Site settings → Location → Allow' },
{ platform: '🍎 iOS Safari', steps: 'Settings app → Safari → Location → Allow (or tap the "AA" button in Safari\'s address bar)' },
{ platform: '💻 Desktop Chrome', steps: 'Click the lock icon → Site settings → Location → Allow' },
{ platform: '🦊 Firefox', steps: 'Click the lock icon → Connection secure → More info → Permissions → Access your location → Allow' },
].map(({ platform, steps }) => (
<div key={platform} className="px-3 py-2.5">
<p className="text-xs font-bold text-white mb-0.5">{platform}</p>
<p className="text-[11px] text-muted-foreground leading-snug">{steps}</p>
</div>
))}
</div>
<button
onClick={() => {
setShowLocationHelp(false);
setNearMeError(null);
}}
className="w-full py-3 rounded-xl bg-white/8 hover:bg-white/12 border border-white/10 text-sm font-bold text-white transition-all active:scale-[0.98]"
>
Got it
</button>
</div>
</div>
)}
{/* Right-side control column — filter pills + Quick Snap (mobile only, hidden when overlays open) */}
<div className={cn(
"absolute bottom-20 right-3 z-[1000] md:hidden flex flex-col items-center gap-2 transition-opacity duration-150",
"absolute bottom-[calc(5rem+env(safe-area-inset-bottom,0px))] right-3 z-[1000] md:hidden flex flex-col items-center gap-2 transition-opacity duration-150",
mobileFiltersOpen || selectedSpot ? "opacity-0 pointer-events-none" : "opacity-100"
)}>
{/* Layer filter pills — compact icon-only */}