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>
671 lines
32 KiB
TypeScript
671 lines
32 KiB
TypeScript
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 & 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>
|
||
);
|
||
}
|