Fuse skater profile (35000) with Nostr identity (kind 0)
Make the skater layer and the universal Nostr profile feel like one person,
with kind-0 as the single source of truth for shared fields.
1. Public profile (NIP19Page): render the skate layer inline — stance, level,
styles, years, home spot as a branded card right under the About. Own-profile
nudge ("Add your skate details") when no 35000 exists. Website in the sidebar
now falls back to the skater profile when kind-0 has none.
2. Become-Skater: prefill bio/website from kind-0 and default the photo to the
kind-0 avatar (with "using your profile photo" affordance). Store shared
fields ONLY as overrides — matching values are dropped so kind-0 stays
authoritative and nothing goes stale. "From your profile" hints + a
linked-to-your-profile banner make the relationship explicit.
3. Fallback-on-render for website/image/bio wherever the skater field is empty.
4. Bidirectional cross-links: new SkaterDirectoryCard in Settings > Profile
shows directory status (level + style chips) linking to edit, or nudges the
user to join; Become-Skater links back to full-profile editing.
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
parent
b42c234709
commit
67d8a24d73
4 changed files with 311 additions and 25 deletions
84
src/components/SkaterDirectoryCard.tsx
Normal file
84
src/components/SkaterDirectoryCard.tsx
Normal file
|
|
@ -0,0 +1,84 @@
|
||||||
|
import { Link } from 'react-router-dom';
|
||||||
|
import { PersonStanding, ChevronRight, Sparkles, Check } from 'lucide-react';
|
||||||
|
import { useCurrentUser } from '@/hooks/useCurrentUser';
|
||||||
|
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||||||
|
import { SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Settings/profile companion card that surfaces the user's SkateSpots "skater
|
||||||
|
* layer" (kind 35000) right next to their kind-0 profile — the reverse of the
|
||||||
|
* cross-link on the Become-Skater page. Shows status + a link to edit, or a
|
||||||
|
* nudge to join the directory if they haven't yet.
|
||||||
|
*/
|
||||||
|
export function SkaterDirectoryCard() {
|
||||||
|
const { user } = useCurrentUser();
|
||||||
|
const { data: skater, isLoading } = useUserSkaterProfile(user?.pubkey);
|
||||||
|
|
||||||
|
if (!user || isLoading) return null;
|
||||||
|
|
||||||
|
// Not in the directory yet — gentle nudge.
|
||||||
|
if (!skater) {
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/become-skater"
|
||||||
|
className="group flex items-center gap-3 p-4 rounded-2xl border-2 border-dashed border-[#ff0040]/30 bg-[#ff0040]/5 hover:border-[#ff0040]/60 hover:bg-[#ff0040]/10 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-[#ff0040]/15 flex items-center justify-center group-hover:bg-[#ff0040]/25 transition-colors">
|
||||||
|
<PersonStanding className="h-5 w-5 text-[#ff0040]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-bold text-sm leading-tight">Join the Skater Directory</p>
|
||||||
|
<p className="text-xs text-muted-foreground mt-0.5">
|
||||||
|
Add your stance, styles & level so skaters can find you.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Sparkles className="h-4 w-4 text-[#ff0040] flex-shrink-0" />
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Already in the directory — show a compact summary + edit affordance.
|
||||||
|
const level = skater.level ? SKATER_LEVELS[skater.level] : undefined;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
to="/become-skater"
|
||||||
|
className="group flex items-center gap-3 p-4 rounded-2xl border border-[#ff0040]/30 bg-gradient-to-r from-[#ff0040]/10 via-transparent to-transparent hover:border-[#ff0040]/50 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-10 h-10 rounded-xl bg-[#ff0040]/15 flex items-center justify-center">
|
||||||
|
<PersonStanding className="h-5 w-5 text-[#ff0040]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-bold text-sm leading-tight flex items-center gap-1.5">
|
||||||
|
<Check className="h-3.5 w-3.5 text-[#ccff00]" />
|
||||||
|
You're in the Skater Directory
|
||||||
|
</p>
|
||||||
|
<div className="flex flex-wrap items-center gap-1.5 mt-1.5">
|
||||||
|
{level && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full text-black"
|
||||||
|
style={{ backgroundColor: level.color }}
|
||||||
|
>
|
||||||
|
{level.emoji} {level.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{skater.styles.slice(0, 3).map((s) => (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
className="text-[10px] font-bold px-2 py-0.5 rounded-full bg-muted text-muted-foreground border border-border"
|
||||||
|
>
|
||||||
|
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
{skater.styles.length > 3 && (
|
||||||
|
<span className="text-[10px] font-bold text-muted-foreground">+{skater.styles.length - 3}</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span className="flex items-center gap-0.5 text-xs font-bold uppercase tracking-wider text-[#ff0040] flex-shrink-0">
|
||||||
|
Edit
|
||||||
|
<ChevronRight className="h-4 w-4" />
|
||||||
|
</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -28,7 +28,7 @@ import {
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Upload, X, Loader2, ArrowLeft, MapPin, PersonStanding, Sparkles } from 'lucide-react';
|
import { Upload, X, Loader2, ArrowLeft, MapPin, PersonStanding, Sparkles, UserRound } from 'lucide-react';
|
||||||
|
|
||||||
export default function BecomeSkaterPage() {
|
export default function BecomeSkaterPage() {
|
||||||
useSeoMeta({
|
useSeoMeta({
|
||||||
|
|
@ -47,6 +47,9 @@ export default function BecomeSkaterPage() {
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
const displayName = metadata?.name ?? (user ? genUserName(user.pubkey) : 'Skater');
|
const displayName = metadata?.name ?? (user ? genUserName(user.pubkey) : 'Skater');
|
||||||
const avatar = metadata?.picture;
|
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 [bio, setBio] = useState('');
|
const [bio, setBio] = useState('');
|
||||||
const [location, setLocation] = useState('');
|
const [location, setLocation] = useState('');
|
||||||
|
|
@ -59,11 +62,16 @@ export default function BecomeSkaterPage() {
|
||||||
const [homeSpot, setHomeSpot] = useState('');
|
const [homeSpot, setHomeSpot] = useState('');
|
||||||
const [instagram, setInstagram] = useState('');
|
const [instagram, setInstagram] = useState('');
|
||||||
const [website, setWebsite] = useState('');
|
const [website, setWebsite] = useState('');
|
||||||
|
// `image` is an explicit skate-specific override; when null we fall back to
|
||||||
|
// the kind-0 avatar (shown in the UI as "using your profile photo").
|
||||||
const [image, setImage] = useState<string | null>(null);
|
const [image, setImage] = useState<string | null>(null);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
// Ensures we only auto-prefill from kind-0 once, and never clobber typing.
|
||||||
|
const [prefilled, setPrefilled] = useState(false);
|
||||||
|
|
||||||
// Prefill from an existing profile when editing.
|
// Prefill: existing 35000 wins; otherwise borrow shared fields from kind-0.
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
|
if (prefilled) return;
|
||||||
if (existing) {
|
if (existing) {
|
||||||
setBio(existing.bio || '');
|
setBio(existing.bio || '');
|
||||||
setLocation(existing.location || '');
|
setLocation(existing.location || '');
|
||||||
|
|
@ -73,10 +81,16 @@ export default function BecomeSkaterPage() {
|
||||||
setYears(existing.yearsSkating || '');
|
setYears(existing.yearsSkating || '');
|
||||||
setHomeSpot(existing.homeSpot || '');
|
setHomeSpot(existing.homeSpot || '');
|
||||||
setInstagram(existing.instagram || '');
|
setInstagram(existing.instagram || '');
|
||||||
setWebsite(existing.website || '');
|
setWebsite(existing.website || kind0Website);
|
||||||
if (existing.image) setImage(existing.image);
|
if (existing.image) setImage(existing.image);
|
||||||
|
setPrefilled(true);
|
||||||
|
} else if (author.data) {
|
||||||
|
// New skater — seed the shared fields from their Nostr profile.
|
||||||
|
setBio(kind0About);
|
||||||
|
setWebsite(kind0Website);
|
||||||
|
setPrefilled(true);
|
||||||
}
|
}
|
||||||
}, [existing]);
|
}, [existing, author.data, kind0About, kind0Website, prefilled]);
|
||||||
|
|
||||||
const toggleStyle = (s: SkaterStyle) =>
|
const toggleStyle = (s: SkaterStyle) =>
|
||||||
setStyles((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
|
setStyles((prev) => (prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]));
|
||||||
|
|
@ -147,10 +161,18 @@ export default function BecomeSkaterPage() {
|
||||||
if (years.trim()) tags.push(['years', years.trim()]);
|
if (years.trim()) tags.push(['years', years.trim()]);
|
||||||
if (homeSpot.trim()) tags.push(['home_spot', homeSpot.trim()]);
|
if (homeSpot.trim()) tags.push(['home_spot', homeSpot.trim()]);
|
||||||
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
|
||||||
if (website.trim()) tags.push(['website', website.trim()]);
|
// Store shared fields ONLY as overrides — if they match kind-0, leave them
|
||||||
if (image) tags.push(['image', image]);
|
// out so kind-0 stays the single source of truth and nothing goes stale.
|
||||||
|
const websiteTrim = website.trim();
|
||||||
|
if (websiteTrim && websiteTrim !== kind0Website.trim()) {
|
||||||
|
tags.push(['website', websiteTrim]);
|
||||||
|
}
|
||||||
|
if (image && image !== avatar) tags.push(['image', image]);
|
||||||
|
|
||||||
await createEvent({ kind: SKATE_SKATER_KIND, content: bio.trim(), tags });
|
const bioTrim = bio.trim();
|
||||||
|
const content = bioTrim === kind0About.trim() ? '' : bioTrim;
|
||||||
|
|
||||||
|
await createEvent({ kind: SKATE_SKATER_KIND, content, tags });
|
||||||
|
|
||||||
toast({
|
toast({
|
||||||
title: existing ? 'Profile updated!' : "You're on the map! 🛹",
|
title: existing ? 'Profile updated!' : "You're on the map! 🛹",
|
||||||
|
|
@ -214,6 +236,18 @@ export default function BecomeSkaterPage() {
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Linked-to-your-profile notice */}
|
||||||
|
<Link
|
||||||
|
to="/settings?section=profile"
|
||||||
|
className="group flex items-center gap-3 rounded-xl border border-white/10 bg-white/5 p-3.5 mb-6 hover:border-white/25 transition-colors"
|
||||||
|
>
|
||||||
|
<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 come from there — <span className="text-[#ccff00] font-bold group-hover:underline">edit your full profile</span> to change them everywhere.
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
|
||||||
{loadingExisting ? (
|
{loadingExisting ? (
|
||||||
<div className="flex items-center justify-center py-16 text-gray-500">
|
<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…
|
<Loader2 className="h-5 w-5 animate-spin mr-2" /> Loading your profile…
|
||||||
|
|
@ -224,7 +258,14 @@ export default function BecomeSkaterPage() {
|
||||||
<Card className="bg-black/60 border-white/10">
|
<Card className="bg-black/60 border-white/10">
|
||||||
<CardContent className="p-5 space-y-5">
|
<CardContent className="p-5 space-y-5">
|
||||||
<div className="space-y-2">
|
<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>
|
<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
|
<Textarea
|
||||||
id="bio"
|
id="bio"
|
||||||
placeholder="How long you've been skating, what you're into, what you're working on…"
|
placeholder="How long you've been skating, what you're into, what you're working on…"
|
||||||
|
|
@ -233,24 +274,55 @@ export default function BecomeSkaterPage() {
|
||||||
rows={4}
|
rows={4}
|
||||||
className="resize-none"
|
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>
|
</div>
|
||||||
|
|
||||||
{/* Photo */}
|
{/* Photo */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Action shot (optional)</Label>
|
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Action shot</Label>
|
||||||
|
<div className="flex items-center gap-4">
|
||||||
{image ? (
|
{image ? (
|
||||||
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800">
|
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800 flex-shrink-0">
|
||||||
<img src={image} alt="Profile" className="w-full h-full object-cover" />
|
<img src={image} alt="Profile" className="w-full h-full object-cover" />
|
||||||
<button type="button" onClick={() => setImage(null)} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/80 hover:bg-red-500 transition-colors">
|
<button type="button" onClick={() => setImage(null)} className="absolute top-1.5 right-1.5 p-1 rounded-full bg-black/80 hover:bg-red-500 transition-colors" aria-label="Remove photo">
|
||||||
<X className="h-3.5 w-3.5" />
|
<X className="h-3.5 w-3.5" />
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
) : avatar ? (
|
||||||
|
// Falling back to the kind-0 avatar — no upload needed.
|
||||||
|
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800 flex-shrink-0 ring-2 ring-white/10">
|
||||||
|
<img src={avatar} alt="Profile" className="w-full h-full object-cover opacity-90" />
|
||||||
|
<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>
|
||||||
) : (
|
) : (
|
||||||
<label className="w-28 h-28 rounded-2xl border-2 border-dashed border-white/15 flex flex-col items-center justify-center cursor-pointer hover:border-[#ff0040]/50 hover:bg-white/5 transition-colors">
|
<label className="w-28 h-28 rounded-2xl border-2 border-dashed border-white/15 flex flex-col items-center justify-center cursor-pointer hover:border-[#ff0040]/50 hover:bg-white/5 transition-colors flex-shrink-0">
|
||||||
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||||
{isUploading ? <Loader2 className="h-6 w-6 animate-spin text-gray-500" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-[10px] text-gray-500 uppercase font-bold">Upload</span></>}
|
{isUploading ? <Loader2 className="h-6 w-6 animate-spin text-gray-500" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-[10px] text-gray-500 uppercase font-bold">Upload</span></>}
|
||||||
</label>
|
</label>
|
||||||
)}
|
)}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs text-muted-foreground mb-2">
|
||||||
|
{image
|
||||||
|
? 'A skate-specific shot for your card.'
|
||||||
|
: avatar
|
||||||
|
? 'Using your profile photo. Upload an action shot to override it here.'
|
||||||
|
: 'Add an action shot for your skater card (optional).'}
|
||||||
|
</p>
|
||||||
|
{!image && avatar && (
|
||||||
|
<label className="inline-flex items-center gap-1.5 text-xs font-bold uppercase tracking-wide text-[#ff0040] cursor-pointer hover:underline">
|
||||||
|
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
|
||||||
|
{isUploading ? <Loader2 className="h-3.5 w-3.5 animate-spin" /> : <Upload className="h-3.5 w-3.5" />}
|
||||||
|
Upload action shot
|
||||||
|
</label>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</Card>
|
||||||
|
|
@ -359,7 +431,14 @@ export default function BecomeSkaterPage() {
|
||||||
<Input id="ig" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} />
|
<Input id="ig" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
<div className="space-y-2">
|
<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>
|
<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)} />
|
<Input id="web" placeholder="https://…" value={website} onChange={(e) => setWebsite(e.target.value)} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -14,8 +14,10 @@ import { useUserSkateSpots } from '@/hooks/useSkateSpots';
|
||||||
import { useUserShops } from '@/hooks/useShops';
|
import { useUserShops } from '@/hooks/useShops';
|
||||||
import { useUserCrews } from '@/hooks/useCrews';
|
import { useUserCrews } from '@/hooks/useCrews';
|
||||||
import { useUserPublicStash } from '@/hooks/useStashShowcase';
|
import { useUserPublicStash } from '@/hooks/useStashShowcase';
|
||||||
|
import { useUserSkaterProfile } from '@/hooks/useSkaters';
|
||||||
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
|
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
|
||||||
import NotFound from './NotFound';
|
import NotFound from './NotFound';
|
||||||
import {
|
import {
|
||||||
MapPin,
|
MapPin,
|
||||||
|
|
@ -38,6 +40,10 @@ import {
|
||||||
Bitcoin,
|
Bitcoin,
|
||||||
Award,
|
Award,
|
||||||
MessageSquareLock,
|
MessageSquareLock,
|
||||||
|
PersonStanding,
|
||||||
|
Sparkles,
|
||||||
|
Home,
|
||||||
|
Clock,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { useState } from 'react';
|
import { useState } from 'react';
|
||||||
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
|
||||||
|
|
@ -49,6 +55,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
|
||||||
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
|
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
|
||||||
const { data: publicStash, isLoading: publicStashLoading } = useUserPublicStash(pubkey);
|
const { data: publicStash, isLoading: publicStashLoading } = useUserPublicStash(pubkey);
|
||||||
|
const { data: skater } = useUserSkaterProfile(pubkey);
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
|
|
||||||
const metadata = author.data?.metadata;
|
const metadata = author.data?.metadata;
|
||||||
|
|
@ -75,6 +82,17 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
const npub = nip19.npubEncode(pubkey);
|
const npub = nip19.npubEncode(pubkey);
|
||||||
const isOwnProfile = user?.pubkey === pubkey;
|
const isOwnProfile = user?.pubkey === pubkey;
|
||||||
|
|
||||||
|
// Skate layer (kind 35000). Fields fall back to kind-0 when the skater
|
||||||
|
// profile leaves them empty — kind-0 stays the single source of truth for
|
||||||
|
// shared fields (bio/image/website), 35000 only carries skate-specific data.
|
||||||
|
const stanceMeta = skater?.stance ? SKATER_STANCES[skater.stance] : undefined;
|
||||||
|
const levelMeta = skater?.level ? SKATER_LEVELS[skater.level] : undefined;
|
||||||
|
const skaterWebsite = skater?.website || website;
|
||||||
|
const hasSkateLayer = !!skater && (
|
||||||
|
!!stanceMeta || !!levelMeta || skater.styles.length > 0 ||
|
||||||
|
!!skater.yearsSkating || !!skater.homeSpot
|
||||||
|
);
|
||||||
|
|
||||||
const handleCopyNpub = () => {
|
const handleCopyNpub = () => {
|
||||||
navigator.clipboard.writeText(npub);
|
navigator.clipboard.writeText(npub);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
|
|
@ -228,6 +246,109 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
</Card>
|
</Card>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* ── Skate layer (kind 35000) ── */}
|
||||||
|
{hasSkateLayer ? (
|
||||||
|
<Card className="relative overflow-hidden border-2 border-[#ff0040]/30 bg-gradient-to-br from-[#ff0040]/10 via-black to-[#ccff00]/5">
|
||||||
|
<div className="absolute -top-14 -right-14 w-40 h-40 bg-[#ff0040] opacity-10 rounded-full blur-3xl pointer-events-none" />
|
||||||
|
<CardContent className="relative p-4 sm:p-6">
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-sm font-black uppercase tracking-[0.2em] text-[#ff0040] flex items-center gap-2">
|
||||||
|
<PersonStanding className="h-4 w-4" />
|
||||||
|
Skater
|
||||||
|
</h2>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
{levelMeta && (
|
||||||
|
<span
|
||||||
|
className="text-[10px] font-black uppercase tracking-wide px-2.5 py-1 rounded-full text-black"
|
||||||
|
style={{ backgroundColor: levelMeta.color }}
|
||||||
|
>
|
||||||
|
{levelMeta.emoji} {levelMeta.label}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
{isOwnProfile && (
|
||||||
|
<Link
|
||||||
|
to="/become-skater"
|
||||||
|
className="text-[10px] font-black uppercase tracking-wider text-[#ff0040] hover:underline flex items-center gap-1"
|
||||||
|
>
|
||||||
|
<Pencil className="h-3 w-3" /> Edit
|
||||||
|
</Link>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Stats row */}
|
||||||
|
<div className="flex flex-wrap items-center gap-x-5 gap-y-2 mb-4 text-sm">
|
||||||
|
{stanceMeta && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<span className="text-lg leading-none">{stanceMeta.emoji}</span>
|
||||||
|
<div className="leading-tight">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Stance</p>
|
||||||
|
<p className="text-white font-bold">{stanceMeta.label}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{skater?.yearsSkating && (
|
||||||
|
<div className="flex items-center gap-1.5">
|
||||||
|
<Clock className="h-4 w-4 text-[#ccff00]" />
|
||||||
|
<div className="leading-tight">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Skating</p>
|
||||||
|
<p className="text-white font-bold">{skater.yearsSkating}y</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{skater?.homeSpot && (
|
||||||
|
<div className="flex items-center gap-1.5 min-w-0">
|
||||||
|
<Home className="h-4 w-4 text-[#ff0040] flex-shrink-0" />
|
||||||
|
<div className="leading-tight min-w-0">
|
||||||
|
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Home spot</p>
|
||||||
|
<p className="text-white font-bold truncate">{skater.homeSpot}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Styles */}
|
||||||
|
{skater && skater.styles.length > 0 && (
|
||||||
|
<div className="flex flex-wrap gap-1.5">
|
||||||
|
{skater.styles.map((s) => (
|
||||||
|
<span
|
||||||
|
key={s}
|
||||||
|
className="text-[11px] font-bold px-2.5 py-1 rounded-full bg-white/8 text-gray-200 border border-white/10"
|
||||||
|
>
|
||||||
|
{SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Skate bio — only if it differs from the kind-0 about */}
|
||||||
|
{skater?.bio && skater.bio.trim() !== (about ?? '').trim() && (
|
||||||
|
<p className="text-gray-300 text-sm mt-4 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
|
||||||
|
{skater.bio}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</CardContent>
|
||||||
|
</Card>
|
||||||
|
) : (
|
||||||
|
isOwnProfile && (
|
||||||
|
<Link
|
||||||
|
to="/become-skater"
|
||||||
|
className="group flex items-center gap-3 rounded-xl border-2 border-dashed border-[#ff0040]/30 bg-[#ff0040]/5 p-4 hover:border-[#ff0040]/60 hover:bg-[#ff0040]/10 transition-all"
|
||||||
|
>
|
||||||
|
<div className="flex-shrink-0 w-11 h-11 rounded-xl bg-[#ff0040]/15 flex items-center justify-center group-hover:bg-[#ff0040]/25 transition-colors">
|
||||||
|
<PersonStanding className="h-6 w-6 text-[#ff0040]" />
|
||||||
|
</div>
|
||||||
|
<div className="min-w-0 flex-1">
|
||||||
|
<p className="font-black uppercase text-sm text-white leading-tight">Add your skate details</p>
|
||||||
|
<p className="text-xs text-gray-400 mt-0.5">
|
||||||
|
Set your stance, styles & level to appear in the Skaters directory.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<Sparkles className="h-5 w-5 text-[#ff0040] flex-shrink-0" />
|
||||||
|
</Link>
|
||||||
|
)
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Spots by this user */}
|
{/* Spots by this user */}
|
||||||
<div>
|
<div>
|
||||||
<div className="flex items-center justify-between mb-4">
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
|
@ -400,16 +521,16 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
|
||||||
<CardTitle className="text-lg">Info</CardTitle>
|
<CardTitle className="text-lg">Info</CardTitle>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-3">
|
<CardContent className="space-y-3">
|
||||||
{website && (
|
{skaterWebsite && (
|
||||||
<a
|
<a
|
||||||
href={website.startsWith('http') ? website : `https://${website}`}
|
href={skaterWebsite.startsWith('http') ? skaterWebsite : `https://${skaterWebsite}`}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noopener noreferrer"
|
rel="noopener noreferrer"
|
||||||
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"
|
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"
|
||||||
>
|
>
|
||||||
<Globe className="h-4 w-4 text-blue-400" />
|
<Globe className="h-4 w-4 text-blue-400" />
|
||||||
<span className="text-gray-300 text-sm truncate">
|
<span className="text-gray-300 text-sm truncate">
|
||||||
{website.replace(/^https?:\/\//, '')}
|
{skaterWebsite.replace(/^https?:\/\//, '')}
|
||||||
</span>
|
</span>
|
||||||
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
|
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
|
||||||
</a>
|
</a>
|
||||||
|
|
|
||||||
|
|
@ -2,6 +2,7 @@ import { useSeoMeta } from '@unhead/react';
|
||||||
import { Link, useSearchParams } from 'react-router-dom';
|
import { Link, useSearchParams } from 'react-router-dom';
|
||||||
import { Layout } from '@/components/Layout';
|
import { Layout } from '@/components/Layout';
|
||||||
import { EditProfileForm } from '@/components/EditProfileForm';
|
import { EditProfileForm } from '@/components/EditProfileForm';
|
||||||
|
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
|
||||||
import { RelayListManager } from '@/components/RelayListManager';
|
import { RelayListManager } from '@/components/RelayListManager';
|
||||||
import { FollowListManager } from '@/components/FollowListManager';
|
import { FollowListManager } from '@/components/FollowListManager';
|
||||||
import { WalletModal } from '@/components/WalletModal';
|
import { WalletModal } from '@/components/WalletModal';
|
||||||
|
|
@ -204,6 +205,7 @@ function ProfileScreen({ onBack }: { onBack: () => void }) {
|
||||||
</Button>
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
<SkaterDirectoryCard />
|
||||||
<EditProfileForm />
|
<EditProfileForm />
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue