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:
parent
886280e369
commit
2500b549f5
1 changed files with 108 additions and 27 deletions
|
|
@ -68,6 +68,8 @@ import {
|
||||||
MapPin,
|
MapPin,
|
||||||
LocateFixed,
|
LocateFixed,
|
||||||
LocateOff,
|
LocateOff,
|
||||||
|
Store,
|
||||||
|
Info,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { CameraCapture } from '@/components/CameraCapture';
|
import { CameraCapture } from '@/components/CameraCapture';
|
||||||
import { useUploadFile } from '@/hooks/useUploadFile';
|
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 [flyTo, setFlyTo] = useState<{ lat: number; lng: number; zoom?: number } | null>(null);
|
||||||
const [nearMeLoading, setNearMeLoading] = useState(false);
|
const [nearMeLoading, setNearMeLoading] = useState(false);
|
||||||
const [nearMeError, setNearMeError] = useState<'denied' | 'unavailable' | 'timeout' | null>(null);
|
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 = () => {
|
const handleNearMe = () => {
|
||||||
// Clear previous error
|
// Clear previous error
|
||||||
|
|
@ -623,11 +629,7 @@ export default function MapPage() {
|
||||||
setNearMeLoading(false);
|
setNearMeLoading(false);
|
||||||
if (err.code === err.PERMISSION_DENIED) {
|
if (err.code === err.PERMISSION_DENIED) {
|
||||||
setNearMeError('denied');
|
setNearMeError('denied');
|
||||||
toast({
|
setShowLocationHelp(true);
|
||||||
title: 'Location access denied',
|
|
||||||
description: 'To find spots near you, allow location access in your browser settings, then try again.',
|
|
||||||
variant: 'destructive',
|
|
||||||
});
|
|
||||||
} else if (err.code === err.TIMEOUT) {
|
} else if (err.code === err.TIMEOUT) {
|
||||||
setNearMeError('timeout');
|
setNearMeError('timeout');
|
||||||
toast({
|
toast({
|
||||||
|
|
@ -734,6 +736,8 @@ export default function MapPage() {
|
||||||
);
|
);
|
||||||
|
|
||||||
const handleMapClick = (lat: number, lng: number) => {
|
const handleMapClick = (lat: number, lng: number) => {
|
||||||
|
// Close speed-dial FAB if open
|
||||||
|
setFabOpen(false);
|
||||||
if (!user) return;
|
if (!user) return;
|
||||||
// Navigate to create spot with coordinates pre-filled
|
// Navigate to create spot with coordinates pre-filled
|
||||||
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`);
|
navigate(`/create-spot?lat=${lat.toFixed(6)}&lng=${lng.toFixed(6)}`);
|
||||||
|
|
@ -1420,40 +1424,72 @@ export default function MapPage() {
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Add Spot FAB — primary CTA, bottom-left.
|
{/* Speed-dial FAB — bottom-right, above filter pills, clear of bottom nav.
|
||||||
Hidden when Sasquatch panel is open (user is in chat mode).
|
Tap the + button to expand into Add Spot + Add Shop options.
|
||||||
Hidden when mobile filters or spot sheet is open. */}
|
Hidden when overlays open. Desktop gets a different layout so md:hidden. */}
|
||||||
<div className={cn(
|
<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
|
mobileFiltersOpen || selectedSpot || sasquatchPanelOpen
|
||||||
? "opacity-0 pointer-events-none scale-90"
|
? "opacity-0 pointer-events-none scale-90"
|
||||||
: "opacity-100 scale-100"
|
: "opacity-100 scale-100"
|
||||||
|
)}>
|
||||||
|
{/* 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 ? (
|
{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">
|
<Link to="/create-shop" onClick={() => setFabOpen(false)}>
|
||||||
<Plus className="h-5 w-5 flex-shrink-0" />
|
<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
|
Add Spot
|
||||||
</button>
|
</button>
|
||||||
</Link>
|
</Link>
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<button
|
<button
|
||||||
onClick={() => document.querySelector<HTMLElement>('[data-login-trigger]')?.click()}
|
onClick={() => {
|
||||||
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"
|
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-5 w-5 flex-shrink-0" />
|
<Plus className="h-4 w-4 flex-shrink-0" />
|
||||||
Add Spot
|
Log in to add
|
||||||
</button>
|
</button>
|
||||||
)}
|
)}
|
||||||
</div>
|
</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 */}
|
{/* Near Me button + attribution — floating top-right, hidden when any overlay is open */}
|
||||||
<div className={cn(
|
<div className={cn(
|
||||||
"absolute top-4 right-4 z-[1000] flex flex-col items-end gap-1.5 transition-opacity duration-150",
|
"absolute top-4 right-4 z-[1000] flex flex-col items-end gap-1.5 transition-opacity duration-150",
|
||||||
mobileFiltersOpen ? "opacity-0 pointer-events-none" : "opacity-100"
|
mobileFiltersOpen ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||||
)}>
|
)}>
|
||||||
<Button
|
<Button
|
||||||
onClick={handleNearMe}
|
onClick={nearMeError === 'denied' ? () => setShowLocationHelp(true) : handleNearMe}
|
||||||
disabled={nearMeLoading}
|
disabled={nearMeLoading}
|
||||||
size="sm"
|
size="sm"
|
||||||
className={cn(
|
className={cn(
|
||||||
|
|
@ -1463,7 +1499,7 @@ export default function MapPage() {
|
||||||
: "bg-black/80 hover:bg-black/95 border-white/20"
|
: "bg-black/80 hover:bg-black/95 border-white/20"
|
||||||
)}
|
)}
|
||||||
title={nearMeError === 'denied' ? 'Location access denied — tap for help' : 'Find spots near me'}
|
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 ? (
|
{nearMeLoading ? (
|
||||||
<span className="h-4 w-4 rounded-full border-2 border-white/30 border-t-white animate-spin" />
|
<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>
|
||||||
</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) */}
|
{/* Right-side control column — filter pills + Quick Snap (mobile only, hidden when overlays open) */}
|
||||||
<div className={cn(
|
<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"
|
mobileFiltersOpen || selectedSpot ? "opacity-0 pointer-events-none" : "opacity-100"
|
||||||
)}>
|
)}>
|
||||||
{/* Layer filter pills — compact icon-only */}
|
{/* Layer filter pills — compact icon-only */}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue