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:
parent
3e6fc305ac
commit
dd85644fca
8 changed files with 163 additions and 41 deletions
|
|
@ -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'
|
||||
|
|
|
|||
|
|
@ -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
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 & 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",
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Reference in a new issue