UX audit fixes: iOS directions, Me tab profile link, onboarding banner, empty states, stats deltas, newsletter pill

- Fix iOS directions: Apple Maps first on Apple platforms, removed broken geo: URI,
  added Waze option, fixed popover z-index clipping behind Sheet component
- Fix 'Me' bottom nav: now deep-links to /settings?section=profile for logged-in users
  so tapping 'Me' shows your profile, not a generic settings menu
- Add SettingsPage URL param support: ?section=X opens sub-screen directly
- Add onboarding welcome banner: dismissible strip at top of map for logged-out users
  with 'Get Started' CTA pointing to /onboarding, stored in localStorage
- Fix Lab empty state: FAQ link + login prompt for logged-out users
- Fix Events empty state: FAQ link added to all empty state instances
- Fix GearSwap empty state: actionable 'Clear Filters' button + FAQ link
- Community stats: add 'this month' delta badges (+N this month) on all stat cards
- Newsletter pill: hide on map page to eliminate bottom sheet overlap conflict

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-23 21:36:33 +07:00
parent 3e6fc305ac
commit dd85644fca
8 changed files with 163 additions and 41 deletions

View file

@ -295,9 +295,9 @@ function MobileNav({
);
})}
{/* Profile / Login tab */}
{/* Profile / Login tab — goes straight to profile section when logged in */}
<Link
to="/settings"
to={isLoggedIn ? "/settings?section=profile" : "/settings"}
className={cn(
'relative flex flex-col items-center justify-center gap-0.5 py-1.5 px-1 rounded-xl transition-all duration-150 flex-1 touch-manipulation active:scale-90',
isActive('/settings') ? 'text-[#ff0040]' : 'text-gray-500'

View file

@ -59,9 +59,11 @@ function LatestDropPill() {
0
);
// Hide on newsletter page itself, if too old, or if already dismissed for this issue
// Hide on newsletter page, map page (to avoid conflicts with map UI), if too old, or if already dismissed
const isMapPage = location.pathname === '/' || location.pathname === '/map';
if (
location.pathname === '/newsletter' ||
isMapPage ||
age > PILL_VISIBLE_DAYS ||
dismissed === latest.number
) {

View file

@ -50,6 +50,7 @@ function StatCard({
trustedValue,
color,
loading,
thisMonthDelta,
}: {
icon: React.ComponentType<{ className?: string }>;
value: number;
@ -59,6 +60,8 @@ function StatCard({
trustedValue?: number;
color: string;
loading: boolean;
/** How many new items were added this month */
thisMonthDelta?: number;
}) {
return (
<Card className="bg-black/80 border" style={{ borderColor: `${color}33` }}>
@ -69,7 +72,18 @@ function StatCard({
{loading ? (
<Skeleton className="h-10 w-20 mb-1" />
) : (
<p className="text-4xl font-black leading-none" style={{ color }}>{value.toLocaleString()}</p>
<div className="flex items-end gap-2 flex-wrap">
<p className="text-4xl font-black leading-none" style={{ color }}>{value.toLocaleString()}</p>
{typeof thisMonthDelta === 'number' && thisMonthDelta > 0 && (
<span
className="inline-flex items-center gap-0.5 text-[10px] font-black uppercase tracking-wide px-1.5 py-0.5 rounded-full mb-0.5"
style={{ background: `${color}22`, color }}
>
<TrendingUp className="h-2.5 w-2.5" />
+{thisMonthDelta} this month
</span>
)}
</div>
)}
{!loading && trustedValue !== undefined && (
<p className="text-xs mt-1.5 flex items-center gap-1" style={{ color: '#555' }}>
@ -155,6 +169,9 @@ export default function CommunityStatsPage() {
const recentMonthly = useMemo(() => stats?.monthly.slice(-12) ?? [], [stats]);
const recentCumulative = useMemo(() => stats?.cumulativeSpots.slice(-12) ?? [], [stats]);
// Current month delta — how many items were added this month
const thisMonth = useMemo(() => stats?.monthly[stats.monthly.length - 1], [stats]);
return (
<Layout>
<div className="container max-w-6xl px-4 py-10 md:py-14">
@ -193,16 +210,16 @@ export default function CommunityStatsPage() {
<TrendingUp className="h-3.5 w-3.5" /> Community at a Glance
</h2>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3">
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" />
<StatCard icon={MapPin} value={stats?.totalSpots ?? 0} label="Skate Spots" color="#ff0040" loading={isLoading} trustedValue={stats?.trustedSpots} thisMonthDelta={thisMonth?.spots} />
<StatCard icon={Store} value={stats?.totalShops ?? 0} label="Listed Shops" color="#4ade80" loading={isLoading} thisMonthDelta={thisMonth?.shops} />
<StatCard icon={Calendar} value={stats?.totalEvents ?? 0} label="Events" color="#ccff00" loading={isLoading} thisMonthDelta={thisMonth?.events} />
<StatCard icon={Users} value={stats?.totalMappers ?? 0} label="Mappers" color="#facc15" loading={isLoading} trustedValue={stats?.trustedMappers} sub="unique contributors" thisMonthDelta={thisMonth?.activeMappers} />
</div>
<div className="grid grid-cols-2 sm:grid-cols-4 gap-3 mt-3">
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} />
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} />
<StatCard icon={GraduationCap} value={stats?.totalCoaches ?? 0} label="Coaches" color="#ff6600" loading={isLoading} thisMonthDelta={thisMonth?.coaches} />
<StatCard icon={Video} value={stats?.totalFilmers ?? 0} label="Filmers" color="#60a5fa" loading={isLoading} thisMonthDelta={thisMonth?.filmers} />
<StatCard icon={Scissors} value={stats?.totalEditors ?? 0} label="Editors" color="#c084fc" loading={isLoading} thisMonthDelta={thisMonth?.editors} />
<StatCard icon={Route} value={stats?.totalTours ?? 0} label="Tours" color="#f472b6" loading={isLoading} thisMonthDelta={thisMonth?.tours} />
</div>
</section>

View file

@ -700,6 +700,9 @@ function EmptyState({
</button>
</Link>
) : null}
<Link to="/faq" className="mt-5 text-xs text-gray-600 hover:text-gray-400 underline underline-offset-2 transition-colors">
Have questions? Check the FAQ →
</Link>
</div>
);
}

View file

@ -411,14 +411,33 @@ export default function GearSwapPage() {
<h3 className="text-xl font-bold text-white mb-2">No listings found</h3>
<p className="text-gray-400 mb-6 max-w-md mx-auto">
{searchQuery || selectedCategory !== 'all' || trustFilter !== 'all'
? 'Try adjusting your filters or search query.'
: 'Be the first to list your gear!'}
? 'No gear matches your current filters. Try clearing them or searching something else.'
: 'Be the first to list your gear — decks, trucks, wheels, shoes, anything skate!'}
</p>
<Link to="/create-listing">
<Button className="gap-2 bg-orange-500 hover:bg-orange-600">
<Plus className="h-4 w-4" />
List Your Gear
</Button>
<div className="flex flex-col sm:flex-row items-center justify-center gap-3">
{(searchQuery || selectedCategory !== 'all' || trustFilter !== 'all') && (
<Button
variant="outline"
className="gap-2 border-orange-500/40 text-orange-400 hover:bg-orange-500/10"
onClick={() => {
setSearchQuery('');
setSelectedCategory('all');
setTrustFilter('all');
}}
>
<Filter className="h-4 w-4" />
Clear Filters
</Button>
)}
<Link to="/create-listing">
<Button className="gap-2 bg-orange-500 hover:bg-orange-600">
<Plus className="h-4 w-4" />
List Your Gear
</Button>
</Link>
</div>
<Link to="/faq" className="mt-5 inline-block text-xs text-gray-600 hover:text-gray-400 underline underline-offset-2 transition-colors">
Have questions? Check the FAQ →
</Link>
</CardContent>
</Card>

View file

@ -744,15 +744,22 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
<Icon className="h-8 w-8 text-gray-600" />
</div>
<h3 className="text-lg font-black mb-2 text-white">{c.title}</h3>
<p className="text-gray-500 text-sm max-w-xs mb-6">{c.body}</p>
{user && (
<p className="text-gray-500 text-sm max-w-xs mb-4">{c.body}</p>
{user ? (
<Link to={c.to}>
<button className={cn('flex items-center gap-2 px-5 py-2.5 rounded-xl font-black text-sm uppercase tracking-wide text-white transition-all active:scale-95', c.color)}>
<Plus className="h-4 w-4" />
{c.cta}
</button>
</Link>
) : (
<p className="text-xs text-gray-600 mb-4">
<Link to="/settings" className="text-[#ccff00] underline underline-offset-2">Log in</Link> to create a profile
</p>
)}
<Link to="/faq" className="mt-4 text-xs text-gray-600 hover:text-gray-400 underline underline-offset-2 transition-colors">
Have questions? Check the FAQ →
</Link>
</div>
);
}

View file

@ -33,6 +33,7 @@ import { useComments } from '@/hooks/useComments';
import { useStash, STASH_CATEGORIES, CONDITION_LABELS, RARITY_TIERS, type StashItem } from '@/hooks/useStash';
import { useAuthor } from '@/hooks/useAuthor';
import { useSatsToUsd } from '@/hooks/useExchangeRate';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { genUserName } from '@/lib/genUserName';
import { useTranslation } from '@/contexts/LanguageContext';
import { SPOT_CATEGORIES, SPOT_TYPE_LABELS } from '@/lib/types';
@ -326,37 +327,53 @@ function StashDrawer() {
);
}
// Universal directions picker — works on GrapheneOS, iOS, any browser
// Detect iOS/macOS for smarter maps ordering
function isApplePlatform(): boolean {
if (typeof navigator === 'undefined') return false;
return /iPhone|iPad|iPod|Macintosh/.test(navigator.userAgent) && !/(Windows|Android)/.test(navigator.userAgent);
}
// Universal directions picker — works on iOS, Android, desktop
function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label: string }) {
const [open, setOpen] = useState(false);
const onApple = isApplePlatform();
const options = [
const allOptions = [
{
name: 'Maps (device default)',
sub: 'Opens your default maps app',
href: `geo:${lat},${lon}?q=${lat},${lon}(${label})`,
emoji: '📍',
},
{
name: 'OpenStreetMap',
sub: 'Privacy-friendly, no account',
href: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=17`,
emoji: '🗺️',
name: 'Apple Maps',
sub: 'iOS & macOS',
href: `https://maps.apple.com/?ll=${lat},${lon}&q=${label}&dirflg=d`,
emoji: '🍎',
appleOnly: true,
},
{
name: 'Google Maps',
sub: 'Opens in browser or app',
href: `https://maps.google.com/?q=${lat},${lon}`,
emoji: '🔵',
appleOnly: false,
},
{
name: 'Apple Maps',
sub: 'iOS & macOS',
href: `https://maps.apple.com/?ll=${lat},${lon}&q=${label}`,
emoji: '🍎',
name: 'OpenStreetMap',
sub: 'Privacy-friendly, no account',
href: `https://www.openstreetmap.org/?mlat=${lat}&mlon=${lon}&zoom=17`,
emoji: '🗺️',
appleOnly: false,
},
{
name: 'Waze',
sub: 'Community-powered navigation',
href: `https://waze.com/ul?ll=${lat},${lon}&navigate=yes`,
emoji: '🔸',
appleOnly: false,
},
];
// On Apple platforms: Apple Maps first. On others: Google first, no Apple.
const options = onApple
? allOptions
: allOptions.filter(o => !o.appleOnly);
return (
<>
<Button
@ -372,8 +389,8 @@ function DirectionsMenu({ lat, lon, label }: { lat: string; lon: string; label:
<>
{/* Backdrop */}
<div className="fixed inset-0 z-[2000]" onClick={() => setOpen(false)} />
{/* Menu */}
<div className="absolute bottom-14 right-0 z-[2001] bg-black/95 border border-white/15 rounded-2xl shadow-2xl overflow-hidden w-64 backdrop-blur">
{/* Menu — fixed so it never clips behind the Sheet stacking context */}
<div className="fixed bottom-[30vh] left-1/2 -translate-x-1/2 z-[2001] bg-black/97 border border-white/15 rounded-2xl shadow-2xl overflow-hidden w-72 backdrop-blur">
<div className="px-4 py-3 border-b border-white/8">
<p className="text-xs font-black uppercase tracking-widest text-white/50">Open with</p>
</div>
@ -550,6 +567,10 @@ export default function MapPage() {
// Mobile filters sheet
const [mobileFiltersOpen, setMobileFiltersOpen] = useState(false);
// Welcome banner — show once to new/logged-out visitors, dismissible
const [welcomeDismissed, setWelcomeDismissed] = useLocalStorage('skatespots_welcome_dismissed', false);
const showWelcomeBanner = !user && !welcomeDismissed;
// Quick camera capture
const [showQuickCamera, setShowQuickCamera] = useState(false);
const { mutateAsync: uploadFile, isPending: isUploadingQuickPhoto } = useUploadFile();
@ -1279,6 +1300,32 @@ export default function MapPage() {
</div>
</div>
{/* Welcome Banner — shown once to logged-out visitors */}
{showWelcomeBanner && (
<div className="relative z-10 bg-gradient-to-r from-[#ff0040]/90 via-black/95 to-[#ccff00]/20 border-b border-[#ff0040]/40 backdrop-blur">
<div className="container px-4 py-2.5 flex items-center gap-3">
<span className="text-xl flex-shrink-0">🛹</span>
<div className="flex-1 min-w-0">
<p className="text-sm font-black text-white uppercase tracking-wide leading-tight">New here?</p>
<p className="text-xs text-white/70 leading-tight">Discover skate spots, events &amp; the community.</p>
</div>
<Link
to="/onboarding"
className="flex-shrink-0 px-3 py-1.5 rounded-full bg-[#ccff00] text-black text-xs font-black uppercase tracking-wide hover:bg-[#ccff00]/90 active:scale-95 transition-transform"
>
Get Started
</Link>
<button
onClick={() => setWelcomeDismissed(true)}
className="flex-shrink-0 w-7 h-7 flex items-center justify-center rounded-full hover:bg-white/10 text-white/40 hover:text-white transition-colors"
aria-label="Dismiss"
>
<X className="h-3.5 w-3.5" />
</button>
</div>
</div>
)}
{/* Main Content */}
<div className="flex-1 relative">
{viewMode === 'map' ? (
@ -1322,6 +1369,29 @@ export default function MapPage() {
</div>
)}
{/* Add Spot FAB — primary CTA, bottom-left, always visible on map */}
<div className={cn(
"absolute bottom-4 left-4 z-[1000] transition-opacity duration-150 md:hidden",
mobileFiltersOpen || selectedSpot ? "opacity-0 pointer-events-none" : "opacity-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
</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>
{/* Near Me button + attribution — floating top-right, hidden when any overlay is open */}
<div className={cn(
"absolute top-4 right-4 z-[1000] flex flex-col items-end gap-1.5 transition-opacity duration-150",

View file

@ -1,5 +1,5 @@
import { useSeoMeta } from '@unhead/react';
import { Link } from 'react-router-dom';
import { Link, useSearchParams } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { EditProfileForm } from '@/components/EditProfileForm';
import { RelayListManager } from '@/components/RelayListManager';
@ -502,7 +502,11 @@ export default function SettingsPage() {
description: 'Manage your SkateSpots account settings.',
});
const [section, setSection] = useState<Section>(null);
const [searchParams] = useSearchParams();
// Allow deep-linking into a specific section via ?section=profile etc.
const initialSection = (searchParams.get('section') as Section) ?? null;
const [section, setSection] = useState<Section>(initialSection);
const handleNavigate = (s: Section) => setSection(s);
const handleBack = () => setSection(null);