SkateSpots/src/pages/BecomeSkaterPage.tsx
shakespeare.diy bb6245e53c Skater profile: browse real map spots for home spot + always use kind-0 profile pic
Home spot:
- Add "Browse spots" button opening a searchable dialog of real mapped spots (useSkateSpots)
- Picking a spot stores its title + an `a` (30818:pubkey:d) coordinate; shows a linked-spot chip with unlink
- Profile page renders a linked home spot as a link to /spot/:pubkey/:identifier
- Parse homeSpotRef from the `a` tag; document in NIP.md

Profile pic consistency:
- Remove the skate-specific image override/upload — the skater card now always
  uses the kind-0 profile picture so both sections show the identical photo
- "Profile Pic" section is read-only with a "Change profile photo" link to settings
- Drop the `image` tag from published skater events; note the change in NIP.md

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-02 21:44:12 +07:00

671 lines
32 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useEffect, useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { Layout } from '@/components/Layout';
import { Card, CardContent } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar';
import { Dialog, DialogContent, DialogHeader, DialogTitle } from '@/components/ui/dialog';
import { ScrollArea } from '@/components/ui/scroll-area';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useToast } from '@/hooks/useToast';
import { LoginArea } from '@/components/auth/LoginArea';
import { useUserSkaterProfile, SKATER_D } from '@/hooks/useSkaters';
import { useSkateSpots } from '@/hooks/useSkateSpots';
import {
SKATE_SKATER_KIND,
SKATE_SPOT_KIND,
SKATER_TAG,
SKATER_STANCES,
SKATER_STYLES,
SKATER_LEVELS,
type SkaterStance,
type SkaterStyle,
type SkaterLevel,
type SkateSpot,
} from '@/lib/types';
import { genUserName } from '@/lib/genUserName';
import { cn } from '@/lib/utils';
import { X, Loader2, ArrowLeft, MapPin, PersonStanding, Sparkles, UserRound, ShieldCheck, Zap, FileText, Globe, Map as MapIcon, Search, Check } from 'lucide-react';
export default function BecomeSkaterPage() {
useSeoMeta({
title: 'Join the Skater Directory - SkateSpots',
description: 'Create your skater profile so the community can find you.',
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { toast } = useToast();
const { data: existing, isLoading: loadingExisting } = useUserSkaterProfile(user?.pubkey);
const { data: allSpots, isLoading: loadingSpots } = useSkateSpots(500);
const metadata = author.data?.metadata;
const displayName = metadata?.name ?? (user ? genUserName(user.pubkey) : 'Skater');
const avatar = metadata?.picture;
// kind-0 values we borrow from so we never re-ask for what the profile has.
const kind0About = metadata?.about ?? '';
const kind0Website = metadata?.website ?? '';
const kind0Nip05 = metadata?.nip05 ?? '';
const kind0Lud16 = metadata?.lud16 ?? '';
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [stance, setStance] = useState<SkaterStance | undefined>();
const [styles, setStyles] = useState<SkaterStyle[]>([]);
const [level, setLevel] = useState<SkaterLevel | undefined>();
// When set, the skater is using a free-text level instead of a preset.
const [customLevel, setCustomLevel] = useState('');
const [levelMode, setLevelMode] = useState<'preset' | 'custom'>('preset');
const [years, setYears] = useState('');
const [homeSpot, setHomeSpot] = useState('');
// When the home spot is a real mapped spot, we keep its `a` coordinate here.
const [homeSpotRef, setHomeSpotRef] = useState<string | undefined>();
const [spotPickerOpen, setSpotPickerOpen] = useState(false);
const [spotSearch, setSpotSearch] = useState('');
const [instagram, setInstagram] = useState('');
const [website, setWebsite] = useState('');
const [isGettingLocation, setIsGettingLocation] = useState(false);
// Ensures we only auto-prefill from kind-0 once, and never clobber typing.
const [prefilled, setPrefilled] = useState(false);
// Prefill: existing 35000 wins; otherwise borrow shared fields from kind-0.
useEffect(() => {
if (prefilled) return;
if (existing) {
setBio(existing.bio || '');
setLocation(existing.location || '');
setStance(existing.stance);
setStyles(existing.styles || []);
setLevel(existing.level);
if (existing.customLevel && !existing.level) {
setCustomLevel(existing.customLevel);
setLevelMode('custom');
}
setYears(existing.yearsSkating || '');
setHomeSpot(existing.homeSpot || '');
setHomeSpotRef(existing.homeSpotRef);
setInstagram(existing.instagram || '');
setWebsite(existing.website || kind0Website);
setPrefilled(true);
} else if (author.data) {
// New skater — seed the shared fields from their Nostr profile.
setBio(kind0About);
setWebsite(kind0Website);
setPrefilled(true);
}
}, [existing, author.data, kind0About, kind0Website, prefilled]);
const toggleStyle = (s: SkaterStyle) =>
setStyles((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(pos) => {
setLatitude(pos.coords.latitude.toFixed(6));
setLongitude(pos.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
},
(err) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: err.message, variant: 'destructive' });
},
);
};
// Pick a real mapped spot as the home spot — stores its title + `a` coordinate.
const selectSpot = (spot: SkateSpot) => {
setHomeSpot(spot.title);
setHomeSpotRef(`${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`);
setSpotPickerOpen(false);
setSpotSearch('');
};
const clearSpotLink = () => setHomeSpotRef(undefined);
const filteredSpots = (allSpots ?? []).filter((s) => {
if (!spotSearch.trim()) return true;
const q = spotSearch.toLowerCase();
return (
s.title.toLowerCase().includes(q) ||
(s.location?.toLowerCase().includes(q) ?? false)
);
});
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!user) return;
if (styles.length === 0) {
toast({ title: 'Pick your styles', description: 'Choose at least one thing you skate.', variant: 'destructive' });
return;
}
try {
const tags: string[][] = [
['d', SKATER_D],
['title', displayName],
['t', SKATER_TAG],
['t', 'skateboarding'],
['alt', `SkateSpots skater: ${displayName}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
if (latitude && longitude) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
}
if (stance) tags.push(['stance', stance]);
if (levelMode === 'custom' && customLevel.trim()) {
tags.push(['level_custom', customLevel.trim().slice(0, 40)]);
} else if (levelMode === 'preset' && level) {
tags.push(['level', level]);
}
for (const s of styles) tags.push(['t', s]);
if (years.trim()) tags.push(['years', years.trim()]);
if (homeSpot.trim()) tags.push(['home_spot', homeSpot.trim()]);
// Link to the real mapped spot when one was picked from the map.
if (homeSpotRef) tags.push(['a', homeSpotRef]);
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
// Store shared fields ONLY as overrides — if they match kind-0, leave them
// out so kind-0 stays the single source of truth and nothing goes stale.
// Photo is intentionally NOT stored here: the skater card always uses the
// kind-0 profile picture so the two stay identical (single source of truth).
const websiteTrim = website.trim();
if (websiteTrim && websiteTrim !== kind0Website.trim()) {
tags.push(['website', websiteTrim]);
}
const bioTrim = bio.trim();
const content = bioTrim === kind0About.trim() ? '' : bioTrim;
await createEvent({ kind: SKATE_SKATER_KIND, content, tags });
toast({
title: existing ? 'Profile updated!' : "You're on the map! 🛹",
description: 'Other skaters can now discover you.',
});
navigate('/skaters');
} catch (error) {
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
}
};
if (!user) {
return (
<Layout>
<div className="container px-4 py-12 max-w-xl">
<Card className="bg-black/80 border-2 border-[#ff0040]/40">
<CardContent className="py-14 text-center">
<div className="inline-flex items-center justify-center w-16 h-16 rounded-2xl bg-gradient-to-br from-[#ff0040] to-[#ff6600] mb-5">
<PersonStanding className="h-8 w-8 text-white" />
</div>
<h2 className="text-2xl font-black uppercase mb-2">Log in to join</h2>
<p className="text-gray-400 mb-6">Create your skater profile so the community can find you.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const styleEntries = Object.entries(SKATER_STYLES) as [SkaterStyle, { label: string; emoji: string }][];
const stanceEntries = Object.entries(SKATER_STANCES) as [SkaterStance, { label: string; emoji: string }][];
const levelEntries = Object.entries(SKATER_LEVELS) as [SkaterLevel, { label: string; emoji: string; color: string }][];
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<Link to="/skaters" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Skaters
</Link>
{/* Hero */}
<div className="relative overflow-hidden rounded-2xl border-2 border-[#ff0040]/30 bg-gradient-to-br from-[#ff0040]/10 via-black to-[#ccff00]/5 p-6 mb-6">
<div className="absolute -top-10 -right-10 w-48 h-48 bg-[#ff0040] opacity-10 rounded-full blur-3xl" />
<div className="relative flex items-center gap-4">
<Avatar className="h-16 w-16 border-2 border-[#ff0040]/50">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] font-black">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="min-w-0">
<h1 className="text-2xl font-black uppercase leading-none">
{existing ? 'Edit your' : 'Join the'} <span className="text-[#ff0040]">Skater</span> Directory
</h1>
<p className="text-gray-400 text-sm mt-1.5 truncate">
Publishing as <span className="text-white font-bold">{displayName}</span>
</p>
</div>
</div>
</div>
{/* Linked-to-your-profile notice + read-only summary of borrowed fields */}
<div className="rounded-xl border border-white/10 bg-white/5 p-3.5 mb-6 space-y-3">
<Link
to="/settings?section=profile"
className="group flex items-center gap-3"
>
<UserRound className="h-5 w-5 text-gray-400 flex-shrink-0" />
<p className="text-xs text-gray-400 flex-1 leading-relaxed">
This is your <span className="text-white font-bold">skate layer</span>, built on your Nostr profile.
Your name, photo, bio, lightning &amp; verification come from there — <span className="text-[#ccff00] font-bold group-hover:underline">edit your full profile</span> to change them everywhere.
</p>
</Link>
{/* Chips showing EVERYTHING carried over from your kind-0 profile */}
<div className="flex flex-wrap items-center gap-1.5 pl-8">
{avatar && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
<img src={avatar} alt="" className="h-3.5 w-3.5 rounded-full object-cover" /> Photo
</span>
)}
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
<UserRound className="h-3 w-3 text-[#ff0040]" /> {displayName}
</span>
{kind0About.trim() && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
<FileText className="h-3 w-3 text-gray-400" /> Bio
</span>
)}
{kind0Website.trim() && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
<Globe className="h-3 w-3 text-sky-400" /> Website
</span>
)}
{kind0Nip05 && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
<ShieldCheck className="h-3 w-3 text-[#ccff00]" /> {kind0Nip05}
</span>
)}
{kind0Lud16 && (
<span className="inline-flex items-center gap-1 text-[10px] font-bold px-2 py-1 rounded-full bg-black/40 border border-white/10 text-gray-300">
<Zap className="h-3 w-3 text-yellow-400" /> Zaps on
</span>
)}
</div>
</div>
{loadingExisting ? (
<div className="flex items-center justify-center py-16 text-gray-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading your profile…
</div>
) : (
<form onSubmit={handleSubmit} className="space-y-6">
{/* Bio */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-5">
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="bio" className="text-sm font-bold uppercase tracking-wide text-gray-400">Your skate story</Label>
{bio.trim() && bio.trim() === kind0About.trim() && (
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-500 flex items-center gap-1">
<UserRound className="h-3 w-3" /> From your profile
</span>
)}
</div>
<Textarea
id="bio"
placeholder="How long you've been skating, what you're into, what you're working on…"
value={bio}
onChange={(e) => setBio(e.target.value)}
rows={4}
className="resize-none"
/>
<p className="text-xs text-muted-foreground">
{kind0About.trim()
? 'Pulled from your profile — tweak it for your skater card, or leave it as is.'
: 'A short intro for your skater card.'}
</p>
</div>
{/* Profile Pic — always your Nostr profile photo, no separate override */}
<div className="space-y-2">
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Profile Pic</Label>
<div className="flex items-center gap-4">
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800 flex-shrink-0 ring-2 ring-white/10">
{avatar ? (
<img src={avatar} alt="Profile" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center bg-[#ff0040]/15 text-[#ff0040] text-2xl font-black">
{displayName.slice(0, 2).toUpperCase()}
</div>
)}
<span className="absolute bottom-0 inset-x-0 bg-black/70 text-[9px] font-bold uppercase tracking-wide text-center py-0.5 text-gray-300">
Your profile pic
</span>
</div>
<div className="min-w-0">
<p className="text-xs text-muted-foreground mb-2">
Your skater card uses your Nostr profile photo — same picture everywhere.
</p>
<Link
to="/settings?section=profile"
className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-[#ff0040] hover:underline"
>
<UserRound className="h-3.5 w-3.5" />
Change profile photo
</Link>
</div>
</div>
</div>
</CardContent>
</Card>
{/* Styles */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-3">
<div>
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">What you skate *</Label>
<p className="text-xs text-muted-foreground mt-0.5">Pick everything that fits your style.</p>
</div>
<div className="flex flex-wrap gap-2">
{styleEntries.map(([key, { label, emoji }]) => {
const active = styles.includes(key);
return (
<button
key={key}
type="button"
onClick={() => toggleStyle(key)}
className={cn(
'px-3 py-2 rounded-xl text-sm font-bold border transition-all flex items-center gap-1.5',
active
? 'bg-[#ff0040] border-[#ff0040] text-white shadow-[0_0_16px_rgba(255,0,64,0.35)] scale-105'
: 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ff0040]/40 hover:text-white',
)}
>
<span>{emoji}</span>{label}
</button>
);
})}
</div>
</CardContent>
</Card>
{/* Stance + Level */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-5">
<div className="space-y-3">
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Stance</Label>
<div className="flex flex-wrap gap-2">
{stanceEntries.map(([key, { label, emoji }]) => (
<button
key={key}
type="button"
onClick={() => setStance(stance === key ? undefined : key)}
className={cn(
'px-4 py-2 rounded-xl text-sm font-bold border transition-all flex items-center gap-1.5',
stance === key ? 'bg-[#ccff00] border-[#ccff00] text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ccff00]/40',
)}
>
<span>{emoji}</span>{label}
</button>
))}
</div>
</div>
<div className="space-y-3">
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Level</Label>
<div className="grid grid-cols-2 sm:grid-cols-5 gap-2">
{levelEntries.map(([key, { label, emoji, color }]) => {
const active = levelMode === 'preset' && level === key;
return (
<button
key={key}
type="button"
onClick={() => {
setLevelMode('preset');
setLevel(active ? undefined : key);
}}
className={cn(
'px-3 py-2.5 rounded-xl text-sm font-black border transition-all text-center',
active ? 'text-black' : 'bg-white/5 border-white/10 text-gray-300 hover:border-white/30',
)}
style={active ? { backgroundColor: color, borderColor: color } : undefined}
>
<div className="text-lg leading-none mb-0.5">{emoji}</div>
{label}
</button>
);
})}
{/* Custom level toggle */}
<button
type="button"
onClick={() => {
if (levelMode === 'custom') {
setLevelMode('preset');
} else {
setLevelMode('custom');
setLevel(undefined);
}
}}
className={cn(
'px-3 py-2.5 rounded-xl text-sm font-black border transition-all text-center',
levelMode === 'custom'
? 'bg-[#ccff00] border-[#ccff00] text-black'
: 'bg-white/5 border-white/10 text-gray-300 hover:border-[#ccff00]/40',
)}
>
<div className="text-lg leading-none mb-0.5">✏️</div>
Custom
</button>
</div>
{levelMode === 'custom' && (
<div className="pt-1">
<Input
id="custom-level"
placeholder="e.g. OG, Weekend Warrior, Retired…"
value={customLevel}
maxLength={40}
onChange={(e) => setCustomLevel(e.target.value)}
className="border-[#ccff00]/30 focus-visible:ring-[#ccff00]/40"
/>
<p className="text-xs text-muted-foreground mt-1.5">
Your own label — shown on your skater card instead of a preset.
</p>
</div>
)}
</div>
<div className="space-y-2">
<Label htmlFor="years" className="text-sm font-bold uppercase tracking-wide text-gray-400">Years skating</Label>
<Input id="years" placeholder="e.g. 8" value={years} onChange={(e) => setYears(e.target.value)} className="max-w-[160px]" />
</div>
{/* Home spot — free text OR link a real mapped spot */}
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="home" className="text-sm font-bold uppercase tracking-wide text-gray-400">Home spot</Label>
<button
type="button"
onClick={() => setSpotPickerOpen(true)}
className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-[#ccff00] hover:underline"
>
<MapIcon className="h-3.5 w-3.5" />
Browse spots
</button>
</div>
{homeSpotRef ? (
// A real spot from the map is linked.
<div className="flex items-center gap-2 rounded-xl border border-[#ccff00]/40 bg-[#ccff00]/5 px-3 py-2.5">
<div className="flex-shrink-0 w-8 h-8 rounded-lg bg-[#ccff00]/15 flex items-center justify-center">
<MapPin className="h-4 w-4 text-[#ccff00]" />
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white truncate">{homeSpot || 'Mapped spot'}</p>
<p className="text-[10px] font-bold uppercase tracking-wide text-[#ccff00] flex items-center gap-1">
<Check className="h-3 w-3" /> Linked to a spot on the map
</p>
</div>
<button
type="button"
onClick={clearSpotLink}
className="p-1.5 rounded-full hover:bg-white/10 transition-colors flex-shrink-0"
aria-label="Unlink spot"
>
<X className="h-4 w-4 text-gray-400" />
</button>
</div>
) : (
<Input
id="home"
placeholder="Local park / plaza — or tap “Browse spots”"
value={homeSpot}
onChange={(e) => setHomeSpot(e.target.value)}
/>
)}
<p className="text-xs text-muted-foreground">
Type it freeform, or link a real spot from the map so people can go there.
</p>
</div>
</CardContent>
</Card>
{/* Location + socials */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-5">
<div className="space-y-2">
<Label htmlFor="location" className="text-sm font-bold uppercase tracking-wide text-gray-400">City / area</Label>
<Input id="location" placeholder="Barcelona, ES" value={location} onChange={(e) => setLocation(e.target.value)} />
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-1 border-white/15">
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting…</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
</Button>
<p className="text-xs text-muted-foreground">Helps others find skaters near them. Only a coarse area is shared.</p>
</div>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="ig" className="text-sm font-bold uppercase tracking-wide text-gray-400">Instagram</Label>
<Input id="ig" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} />
</div>
<div className="space-y-2">
<div className="flex items-center justify-between gap-2">
<Label htmlFor="web" className="text-sm font-bold uppercase tracking-wide text-gray-400">Website</Label>
{website.trim() && website.trim() === kind0Website.trim() && (
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-500 flex items-center gap-1">
<UserRound className="h-3 w-3" /> From profile
</span>
)}
</div>
<Input id="web" placeholder="https://…" value={website} onChange={(e) => setWebsite(e.target.value)} />
</div>
</div>
</CardContent>
</Card>
<Button type="submit" className="w-full h-12 bg-[#ff0040] hover:bg-[#ff0040]/90 uppercase font-black text-base" disabled={isPublishing}>
{isPublishing ? (
<><Loader2 className="h-5 w-5 mr-2 animate-spin" />Publishing…</>
) : (
<><Sparkles className="h-5 w-5 mr-2" />{existing ? 'Update my profile' : 'Put me on the map'}</>
)}
</Button>
</form>
)}
</div>
{/* ── Spot picker: browse real mapped spots to set as home spot ── */}
<Dialog open={spotPickerOpen} onOpenChange={setSpotPickerOpen}>
<DialogContent className="max-w-lg bg-black border-white/10 p-0 gap-0 overflow-hidden">
<DialogHeader className="p-5 pb-3 border-b border-white/10">
<DialogTitle className="flex items-center gap-2 uppercase font-black">
<MapIcon className="h-5 w-5 text-[#ccff00]" />
Pick your home spot
</DialogTitle>
</DialogHeader>
<div className="p-4 pb-3">
<div className="relative">
<Search className="absolute left-3 top-1/2 -translate-y-1/2 h-4 w-4 text-gray-500" />
<Input
autoFocus
placeholder="Search spots by name or city…"
value={spotSearch}
onChange={(e) => setSpotSearch(e.target.value)}
className="pl-9"
/>
</div>
</div>
<ScrollArea className="max-h-[50vh] px-4 pb-4">
{loadingSpots ? (
<div className="flex items-center justify-center py-12 text-gray-500">
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading spots…
</div>
) : filteredSpots.length === 0 ? (
<div className="text-center py-12 px-4">
<MapPin className="h-8 w-8 mx-auto text-gray-600 mb-3" />
<p className="text-sm text-gray-400">
{spotSearch.trim() ? 'No spots match your search.' : 'No spots found yet.'}
</p>
<Link to="/map" className="text-xs font-bold uppercase tracking-wide text-[#ccff00] hover:underline mt-2 inline-block">
Open the map
</Link>
</div>
) : (
<div className="space-y-1.5">
{filteredSpots.slice(0, 100).map((spot) => {
const coord = `${SKATE_SPOT_KIND}:${spot.pubkey}:${spot.identifier}`;
const selected = coord === homeSpotRef;
return (
<button
key={spot.id}
type="button"
onClick={() => selectSpot(spot)}
className={cn(
'w-full flex items-center gap-3 p-2.5 rounded-xl border text-left transition-all',
selected
? 'border-[#ccff00] bg-[#ccff00]/10'
: 'border-white/10 bg-white/5 hover:border-[#ccff00]/40 hover:bg-white/[0.07]',
)}
>
<div className="flex-shrink-0 w-12 h-12 rounded-lg overflow-hidden bg-gray-800">
{spot.images[0] ? (
<img src={spot.images[0]} alt="" className="w-full h-full object-cover" />
) : (
<div className="w-full h-full flex items-center justify-center">
<MapPin className="h-5 w-5 text-gray-600" />
</div>
)}
</div>
<div className="min-w-0 flex-1">
<p className="text-sm font-bold text-white truncate">{spot.title}</p>
{spot.location && (
<p className="text-xs text-gray-400 truncate flex items-center gap-1">
<MapPin className="h-3 w-3 flex-shrink-0" /> {spot.location}
</p>
)}
</div>
{selected && <Check className="h-4 w-4 text-[#ccff00] flex-shrink-0" />}
</button>
);
})}
</div>
)}
</ScrollArea>
</DialogContent>
</Dialog>
</Layout>
);
}