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:
shakespeare.diy 2026-09-02 21:11:36 +07:00
parent b42c234709
commit 67d8a24d73
4 changed files with 311 additions and 25 deletions

View 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>
);
}

View file

@ -28,7 +28,7 @@ import {
} from '@/lib/types';
import { genUserName } from '@/lib/genUserName';
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() {
useSeoMeta({
@ -47,6 +47,9 @@ export default function BecomeSkaterPage() {
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 [bio, setBio] = useState('');
const [location, setLocation] = useState('');
@ -59,11 +62,16 @@ export default function BecomeSkaterPage() {
const [homeSpot, setHomeSpot] = useState('');
const [instagram, setInstagram] = 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 [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(() => {
if (prefilled) return;
if (existing) {
setBio(existing.bio || '');
setLocation(existing.location || '');
@ -73,10 +81,16 @@ export default function BecomeSkaterPage() {
setYears(existing.yearsSkating || '');
setHomeSpot(existing.homeSpot || '');
setInstagram(existing.instagram || '');
setWebsite(existing.website || '');
setWebsite(existing.website || kind0Website);
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) =>
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 (homeSpot.trim()) tags.push(['home_spot', homeSpot.trim()]);
if (instagram.trim()) tags.push(['instagram', instagram.trim()]);
if (website.trim()) tags.push(['website', website.trim()]);
if (image) tags.push(['image', image]);
// 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.
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({
title: existing ? 'Profile updated!' : "You're on the map! 🛹",
@ -214,6 +236,18 @@ export default function BecomeSkaterPage() {
</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 ? (
<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…
@ -224,7 +258,14 @@ export default function BecomeSkaterPage() {
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-5">
<div className="space-y-2">
<Label htmlFor="bio" className="text-sm font-bold uppercase tracking-wide text-gray-400">Your skate story</Label>
<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…"
@ -233,24 +274,55 @@ export default function BecomeSkaterPage() {
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>
{/* Photo */}
<div className="space-y-2">
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Action shot (optional)</Label>
{image ? (
<div className="relative w-28 h-28 rounded-2xl overflow-hidden bg-gray-800">
<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">
<X className="h-3.5 w-3.5" />
</button>
<Label className="text-sm font-bold uppercase tracking-wide text-gray-400">Action shot</Label>
<div className="flex items-center gap-4">
{image ? (
<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" />
<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" />
</button>
</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 flex-shrink-0">
<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></>}
</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>
) : (
<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">
<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></>}
</label>
)}
</div>
</div>
</CardContent>
</Card>
@ -359,7 +431,14 @@ export default function BecomeSkaterPage() {
<Input id="ig" placeholder="@username" value={instagram} onChange={(e) => setInstagram(e.target.value)} />
</div>
<div className="space-y-2">
<Label htmlFor="web" className="text-sm font-bold uppercase tracking-wide text-gray-400">Website</Label>
<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>

View file

@ -14,8 +14,10 @@ import { useUserSkateSpots } from '@/hooks/useSkateSpots';
import { useUserShops } from '@/hooks/useShops';
import { useUserCrews } from '@/hooks/useCrews';
import { useUserPublicStash } from '@/hooks/useStashShowcase';
import { useUserSkaterProfile } from '@/hooks/useSkaters';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { genUserName } from '@/lib/genUserName';
import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS } from '@/lib/types';
import NotFound from './NotFound';
import {
MapPin,
@ -38,6 +40,10 @@ import {
Bitcoin,
Award,
MessageSquareLock,
PersonStanding,
Sparkles,
Home,
Clock,
} from 'lucide-react';
import { useState } from 'react';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
@ -49,6 +55,7 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey);
const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey);
const { data: publicStash, isLoading: publicStashLoading } = useUserPublicStash(pubkey);
const { data: skater } = useUserSkaterProfile(pubkey);
const [copied, setCopied] = useState(false);
const metadata = author.data?.metadata;
@ -75,6 +82,17 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
const npub = nip19.npubEncode(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 = () => {
navigator.clipboard.writeText(npub);
setCopied(true);
@ -228,6 +246,109 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
</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 */}
<div>
<div className="flex items-center justify-between mb-4">
@ -400,16 +521,16 @@ function ProfilePage({ pubkey }: { pubkey: string }) {
<CardTitle className="text-lg">Info</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{website && (
{skaterWebsite && (
<a
href={website.startsWith('http') ? website : `https://${website}`}
href={skaterWebsite.startsWith('http') ? skaterWebsite : `https://${skaterWebsite}`}
target="_blank"
rel="noopener noreferrer"
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" />
<span className="text-gray-300 text-sm truncate">
{website.replace(/^https?:\/\//, '')}
{skaterWebsite.replace(/^https?:\/\//, '')}
</span>
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
</a>

View file

@ -2,6 +2,7 @@ import { useSeoMeta } from '@unhead/react';
import { Link, useSearchParams } from 'react-router-dom';
import { Layout } from '@/components/Layout';
import { EditProfileForm } from '@/components/EditProfileForm';
import { SkaterDirectoryCard } from '@/components/SkaterDirectoryCard';
import { RelayListManager } from '@/components/RelayListManager';
import { FollowListManager } from '@/components/FollowListManager';
import { WalletModal } from '@/components/WalletModal';
@ -204,6 +205,7 @@ function ProfileScreen({ onBack }: { onBack: () => void }) {
</Button>
</div>
)}
<SkaterDirectoryCard />
<EditProfileForm />
</div>
)}