From 67d8a24d73b47acb87cacfd6ac88905f03fae199 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Wed, 2 Sep 2026 21:11:36 +0700 Subject: [PATCH] Fuse skater profile (35000) with Nostr identity (kind 0) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/SkaterDirectoryCard.tsx | 84 ++++++++++++++++ src/pages/BecomeSkaterPage.tsx | 123 +++++++++++++++++++----- src/pages/NIP19Page.tsx | 127 ++++++++++++++++++++++++- src/pages/SettingsPage.tsx | 2 + 4 files changed, 311 insertions(+), 25 deletions(-) create mode 100644 src/components/SkaterDirectoryCard.tsx diff --git a/src/components/SkaterDirectoryCard.tsx b/src/components/SkaterDirectoryCard.tsx new file mode 100644 index 0000000..1baa178 --- /dev/null +++ b/src/components/SkaterDirectoryCard.tsx @@ -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 ( + +
+ +
+
+

Join the Skater Directory

+

+ Add your stance, styles & level so skaters can find you. +

+
+ + + ); + } + + // Already in the directory — show a compact summary + edit affordance. + const level = skater.level ? SKATER_LEVELS[skater.level] : undefined; + + return ( + +
+ +
+
+

+ + You're in the Skater Directory +

+
+ {level && ( + + {level.emoji} {level.label} + + )} + {skater.styles.slice(0, 3).map((s) => ( + + {SKATER_STYLES[s].emoji} {SKATER_STYLES[s].label} + + ))} + {skater.styles.length > 3 && ( + +{skater.styles.length - 3} + )} +
+
+ + Edit + + + + ); +} diff --git a/src/pages/BecomeSkaterPage.tsx b/src/pages/BecomeSkaterPage.tsx index 237ce8d..ea2d8d4 100644 --- a/src/pages/BecomeSkaterPage.tsx +++ b/src/pages/BecomeSkaterPage.tsx @@ -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(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() { + {/* Linked-to-your-profile notice */} + + +

+ This is your skate layer, built on your Nostr profile. + Your name, photo & bio come from there — edit your full profile to change them everywhere. +

+ + {loadingExisting ? (
Loading your profile… @@ -224,7 +258,14 @@ export default function BecomeSkaterPage() {
- +
+ + {bio.trim() && bio.trim() === kind0About.trim() && ( + + From your profile + + )} +