From a1cd936bc45a3071163c7e0e541bd0db474d4e3f Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Thu, 23 Jul 2026 21:56:11 +0700 Subject: [PATCH] UX audit round 2: Near Me fix, map height, touch targets, iOS dirs, a11y, mobile layout MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit CRITICAL: - Near Me button: completely rewritten — was silently discarding ALL errors. Now shows toast for denied, timeout, unavailable with actionable copy. Button shows red LocateOff icon when denied so user sees the state at a glance. Raised to 44×44px (Apple minimum). - Map height on mobile: switched to CSS variable system (--header-mobile, --bottom-nav) for pixel-accurate height. Added Layout noPadding prop so the map page gets full bleed without the 80px bottom-padding fighting the map container calc. - Layer toggle pills: raised from 28×28 to 40×40px — was below touch target minimum - Right-side control column: now hides when spot bottom sheet is open (was always visible) iOS/Directions: - SpotDetailPage directions button: now detects Apple platform and opens Apple Maps on iOS/macOS, Google Maps everywhere else (was always Google Maps) - Added aria-label to directions button Accessibility: - LoginDialog: was missing DialogTitle, causing console errors on every login open - CameraCapture: fallback dialog was missing DialogTitle (sr-only) - CommandDialog: added sr-only DialogTitle Layout: - SpotDetailPage: reduced top padding mobile pt-4 (was py-8 = 32px wasted space) - Directions popover: centered in viewport (top-1/2 left-1/2) with zoom-in animation, never clips behind Sheet stacking context CSS: - Added --header-mobile and --bottom-nav CSS variables for consistent calculations - Added .page-fullbleed helper Co-authored-by: shakespeare.diy --- src/components/CameraCapture.tsx | 1 + src/components/Layout.tsx | 8 ++- src/components/auth/LoginDialog.tsx | 5 +- src/components/ui/command.tsx | 3 +- src/index.css | 13 +++++ src/pages/MapPage.tsx | 85 +++++++++++++++++++++++------ src/pages/SpotDetailPage.tsx | 21 ++++--- 7 files changed, 106 insertions(+), 30 deletions(-) diff --git a/src/components/CameraCapture.tsx b/src/components/CameraCapture.tsx index 6d88290..28b4eb4 100644 --- a/src/components/CameraCapture.tsx +++ b/src/components/CameraCapture.tsx @@ -211,6 +211,7 @@ export function CameraCapture({ return ( + Opening Camera
diff --git a/src/components/Layout.tsx b/src/components/Layout.tsx index 8750bab..172e211 100644 --- a/src/components/Layout.tsx +++ b/src/components/Layout.tsx @@ -56,6 +56,9 @@ import { NewsletterOverlay } from '@/components/NewsletterOverlay'; interface LayoutProps { children: React.ReactNode; + /** If true, removes the bottom padding that prevents bottom nav overlap. + * Use for full-bleed pages like the map that manage their own height. */ + noPadding?: boolean; } // Nav item keys that map to translation keys @@ -332,7 +335,7 @@ function MobileNav({ ); } -export function Layout({ children }: LayoutProps) { +export function Layout({ children, noPadding }: LayoutProps) { const location = useLocation(); const [mobileMenuOpen, setMobileMenuOpen] = useState(false); const [showStatusDialog, setShowStatusDialog] = useState(false); @@ -755,7 +758,8 @@ export function Layout({ children }: LayoutProps) { {/* Main Content — extra bottom padding on mobile so the fixed bottom nav never overlaps */} -
+ {/* noPadding: full-bleed pages (map) manage their own height */} +
{children}
diff --git a/src/components/auth/LoginDialog.tsx b/src/components/auth/LoginDialog.tsx index 8a3f21d..faba2d0 100644 --- a/src/components/auth/LoginDialog.tsx +++ b/src/components/auth/LoginDialog.tsx @@ -10,7 +10,7 @@ declare global { import { Shield, UserPlus, Sparkles, Cloud, Smartphone, Check, Loader2, KeyRound } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Input } from '@/components/ui/input'; -import { Dialog, DialogContent, DialogHeader, DialogDescription } from "@/components/ui/dialog"; +import { Dialog, DialogContent, DialogHeader, DialogTitle, DialogDescription } from "@/components/ui/dialog"; import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; import { useLoginActions } from '@/hooks/useLoginActions'; import { useToast } from '@/hooks/useToast'; @@ -232,6 +232,9 @@ export const LoginDialog: React.FC = ({ isOpen, onClose, onLog + + Welcome to SkateSpots + Sign up or log in to continue diff --git a/src/components/ui/command.tsx b/src/components/ui/command.tsx index 69d63a4..0ca13ef 100644 --- a/src/components/ui/command.tsx +++ b/src/components/ui/command.tsx @@ -4,7 +4,7 @@ import { Command as CommandPrimitive } from "cmdk" import { Search } from "lucide-react" import { cn } from "@/lib/utils" -import { Dialog, DialogContent } from "@/components/ui/dialog" +import { Dialog, DialogContent, DialogTitle } from "@/components/ui/dialog" const Command = React.forwardRef< React.ElementRef, @@ -27,6 +27,7 @@ const CommandDialog = ({ children, ...props }: CommandDialogProps) => { return ( + Command Palette {children} diff --git a/src/index.css b/src/index.css index b5d84b8..01811cd 100644 --- a/src/index.css +++ b/src/index.css @@ -348,6 +348,19 @@ padding-bottom: env(safe-area-inset-bottom, 0px); } +/* Bottom navigation height token — used for full-bleed pages (map, etc.) */ +:root { + /* Mobile header: 3.5rem (56px) */ + --header-mobile: 3.5rem; + /* Mobile bottom nav: 58px base + safe area */ + --bottom-nav: calc(3.625rem + env(safe-area-inset-bottom, 0px)); +} + +/* Full-bleed page — removes Layout bottom padding so the page can manage its own height */ +.page-fullbleed > main { + padding-bottom: 0 !important; +} + .safe-area-left { padding-left: env(safe-area-inset-left, 0px); } diff --git a/src/pages/MapPage.tsx b/src/pages/MapPage.tsx index 80608dd..dda150b 100644 --- a/src/pages/MapPage.tsx +++ b/src/pages/MapPage.tsx @@ -66,6 +66,8 @@ import { Navigation, ExternalLink, MapPin, + LocateFixed, + LocateOff, } from 'lucide-react'; import { CameraCapture } from '@/components/CameraCapture'; import { useUploadFile } from '@/hooks/useUploadFile'; @@ -389,8 +391,8 @@ function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label: <> {/* Backdrop */}
setOpen(false)} /> - {/* Menu — fixed so it never clips behind the Sheet stacking context */} -
+ {/* Menu — fixed, centered, never clips behind Sheet stacking context */} +

Open with

@@ -582,18 +584,57 @@ export default function MapPage() { // Near Me fly-to state 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 handleNearMe = () => { - if (!navigator.geolocation) return; + // Clear previous error + setNearMeError(null); + + if (!navigator.geolocation) { + setNearMeError('unavailable'); + toast({ + title: 'Location not available', + description: 'Your browser does not support geolocation. Try Chrome or Safari.', + variant: 'destructive', + }); + return; + } + setNearMeLoading(true); + navigator.geolocation.getCurrentPosition( (pos) => { - setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 13 }); + setFlyTo({ lat: pos.coords.latitude, lng: pos.coords.longitude, zoom: 14 }); setUserLocation({ lat: pos.coords.latitude, lon: pos.coords.longitude }); setNearMeLoading(false); + setNearMeError(null); }, - () => setNearMeLoading(false), - { enableHighAccuracy: true, timeout: 8000 } + (err) => { + 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', + }); + } else if (err.code === err.TIMEOUT) { + setNearMeError('timeout'); + toast({ + title: 'Location timed out', + description: 'Could not get your location in time. Check your signal and try again.', + variant: 'destructive', + }); + } else { + setNearMeError('unavailable'); + toast({ + title: 'Location unavailable', + description: 'Your location could not be determined. Make sure location services are enabled.', + variant: 'destructive', + }); + } + }, + { enableHighAccuracy: true, timeout: 10000, maximumAge: 30000 } ); }; @@ -842,10 +883,10 @@ export default function MapPage() { const totalActiveFilters = activeCategoryCount + (trustFilter !== 'all' ? 1 : 0) + (sortBy !== 'recent' ? 1 : 0); return ( - - {/* Account for mobile header (56px) + bottom nav (~72px+safe-area) on mobile, desktop header (64px) only on desktop */} - {/* Using 100dvh for mobile to handle iOS Safari URL bar correctly */} -
+ + {/* Full-height map container: subtract header + bottom nav on mobile, header only on desktop */} + {/* 100dvh handles iOS Safari URL bar shrink correctly */} +
{/* Toolbar */}
@@ -1401,13 +1442,21 @@ export default function MapPage() { onClick={handleNearMe} disabled={nearMeLoading} size="sm" - className="h-10 w-10 rounded-full bg-black/80 hover:bg-black border border-white/20 shadow-lg backdrop-blur p-0" - title="Near me" + className={cn( + "h-11 w-11 rounded-full shadow-lg backdrop-blur p-0 border transition-all active:scale-90 touch-manipulation", + nearMeError === 'denied' + ? "bg-red-900/80 hover:bg-red-800/90 border-red-500/40" + : "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" > {nearMeLoading ? ( + ) : nearMeError === 'denied' ? ( + ) : ( - + )} {/* Map attribution */} @@ -1424,8 +1473,8 @@ export default function MapPage() { {/* Right-side control column — filter pills + Quick Snap (mobile only, hidden when overlays open) */}
{/* Layer filter pills — compact icon-only */} {[ @@ -1438,12 +1487,14 @@ export default function MapPage() { key={key} onClick={toggle} className={cn( - 'h-7 w-7 rounded-full text-xs flex items-center justify-center shadow-md border transition-all', + // 40×40 ensures 44pt Apple / 48dp Material touch target compliance + 'h-10 w-10 rounded-full text-base flex items-center justify-center shadow-md border transition-all touch-manipulation active:scale-90', active ? `${activeColor} border-white/20 shadow-black/30` - : 'bg-black/60 border-white/8 opacity-35' + : 'bg-black/60 border-white/8 opacity-40' )} title={key} + aria-label={`Toggle ${key}`} > {label} diff --git a/src/pages/SpotDetailPage.tsx b/src/pages/SpotDetailPage.tsx index 755908c..c6a520e 100644 --- a/src/pages/SpotDetailPage.tsx +++ b/src/pages/SpotDetailPage.tsx @@ -165,12 +165,15 @@ export default function SpotDetailPage() { }; const handleDirections = () => { - if (coordinates) { - window.open( - `https://www.google.com/maps/dir/?api=1&destination=${coordinates.lat},${coordinates.lng}`, - '_blank' - ); - } + if (!coordinates) return; + const { lat, lng } = coordinates; + const label = encodeURIComponent(spot?.title ?? 'Skate Spot'); + // Use Apple Maps on iOS/macOS, Google Maps everywhere else + const isApple = /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent) && !/(Windows|Android)/.test(navigator.userAgent); + const url = isApple + ? `https://maps.apple.com/?ll=${lat},${lng}&q=${label}&dirflg=d` + : `https://www.google.com/maps/dir/?api=1&destination=${lat},${lng}`; + window.open(url, '_blank'); }; const handleDelete = async () => { @@ -217,11 +220,11 @@ export default function SpotDetailPage() { return ( -
+
{/* Back link */} Back to Map @@ -331,7 +334,7 @@ export default function SpotDetailPage() { - {/* More options menu */}