import { dateLocaleCode } from '@/lib/dateFnsI18n'; import { nip19 } from 'nostr-tools'; import { useParams, Link } from 'react-router-dom'; import { useSeoMeta } from '@unhead/react'; import { ImageWithFallback } from '@/components/ImageWithFallback'; import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; import { Button } from '@/components/ui/button'; import { Avatar, AvatarImage, AvatarFallback } from '@/components/ui/avatar'; import { Badge } from '@/components/ui/badge'; import { Skeleton } from '@/components/ui/skeleton'; import { ZapButton } from '@/components/ZapButton'; import { ZapBadgesCard } from '@/components/ZapBadgesCard'; import { TradeReputationCard } from '@/components/TradeReputationCard'; import { TradeRecommendationsCard } from '@/components/TradeRecommendationsCard'; import { ClinkPayButton } from '@/components/ClinkPayButton'; import { useAuthor } from '@/hooks/useAuthor'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useUserSkateSpots } from '@/hooks/useSkateSpots'; import { useUserEvents } from '@/hooks/useSkateEvents'; import { useUserShops } from '@/hooks/useShops'; import { useUserCrews } from '@/hooks/useCrews'; import { useUserPublicStash } from '@/hooks/useStashShowcase'; import { useUserSkaterProfile } from '@/hooks/useSkaters'; import { useCrewNip05 } from '@/hooks/useCrewNip05'; import { useProfileHandles } from '@/hooks/useProfileHandles'; import { Nip05Badges } from '@/components/Nip05Badges'; import { CrewNip05Badge } from '@/components/CrewNip05Badge'; import { useCoordinator } from '@/hooks/useCoordinator'; import { coordinatorAddUrl, parseCoordinatorInput, shortNpub, CORDN_RELAY_URL } from '@/lib/coordinator'; import { toast } from '@/hooks/useToast'; import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard'; import { EventCard } from '@/components/EventCard'; import { genUserName } from '@/lib/genUserName'; import { sanitizeUrl } from '@/lib/sanitizeUrl'; import { cn } from '@/lib/utils'; import { SKATER_STANCES, SKATER_STYLES, SKATER_LEVELS, NEWSLETTER_KIND, isValidNoffer, isValidLightningAddress } from '@/lib/types'; import { NewsletterIssueView } from '@/components/NewsletterIssueView'; import NotFound from './NotFound'; import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, ShieldAlert, Loader2, Instagram, Share2, Users, Crown, Bot, Pencil, Package, Tag, ArrowRightLeft, Bitcoin, Award, MessageSquareLock, PersonStanding, Sparkles, Home, Clock, Server, Plus, UserPlus, UserCheck, Handshake } from 'lucide-react'; import { useState, type ReactNode } from 'react'; import { UserStatusIndicator } from '@/components/UserStatusIndicator'; import { ShareProfileDialog } from '@/components/ShareProfileDialog'; import { ProfileBadges } from '@/components/ProfileBadges'; import { SKATESPOTS_BADGE, isSkateSpotsNip05, parseBadges } from '@/lib/badges'; import { useRiderStats } from '@/hooks/useRiderStats'; import { useFollowers, useFollowing, useFollowActions, useIsFollowing } from '@/hooks/useFollowing'; import { useTranslation } from '@/contexts/language'; /** Format a "member since" year from a kind-0 event timestamp (seconds). */ function profileEventCreatedAt(createdAt: number | undefined): string | undefined { if (!createdAt) return undefined; return new Date(createdAt * 1000).toLocaleDateString(dateLocaleCode(), { year: 'numeric', month: 'short' }); } /** A stat cell for the at-a-glance grid. Clickable (jumps to its section) when * it has a non-zero value; shows an inert, muted state at zero so a brand-new * profile reads as "nothing yet" rather than broken. */ function StatPill({ value, label, color, loading, icon, onClick, }: { value: number; label: string; color: string; loading?: boolean; icon?: ReactNode; onClick?: () => void; }) { const interactive = !!onClick && value > 0; const empty = !loading && value === 0; const inner = ( <> {icon}
{loading ? ( ) : (

{value}

)}

{label}

); const base = 'group flex items-center gap-3 rounded-2xl border p-3 transition-all'; if (interactive) { return ( ); } return
{inner}
; } function ProfilePage({ pubkey }: { pubkey: string }) { const { t } = useTranslation(); const author = useAuthor(pubkey); const { user } = useCurrentUser(); const { coordinator: myCoordinator, setCoordinator } = useCoordinator(); const { data: spots, isLoading: spotsLoading } = useUserSkateSpots(pubkey); const { data: shops, isLoading: shopsLoading } = useUserShops(pubkey); const { memberships: crews, isLoading: crewsLoading } = useUserCrews(pubkey); const { data: publicStash, isLoading: publicStashLoading } = useUserPublicStash(pubkey); const { data: events = [], isLoading: eventsLoading } = useUserEvents(pubkey); const { data: followers = [], isLoading: followersLoading } = useFollowers(pubkey); const { data: following = [] } = useFollowing(pubkey); const { follow, unfollow, isFollowing: isFollowPending, isUnfollowing } = useFollowActions(); const isFollowingProfile = useIsFollowing(pubkey); const { data: skater } = useUserSkaterProfile(pubkey); const [copied, setCopied] = useState(false); const metadata = author.data?.metadata; const displayName = metadata?.name ?? genUserName(pubkey); const avatar = metadata?.picture; const banner = metadata?.banner; const about = metadata?.about; const website = metadata?.website; const nip05 = metadata?.nip05; const { data: extraHandles = [] } = useProfileHandles(pubkey); const lud16 = metadata?.lud16; const isBot = metadata?.bot === true; // Soft NIP-05 verification — confirms the handle resolves to THIS pubkey. const { data: nip05Result, isLoading: nip05Loading } = useCrewNip05(nip05, pubkey); const nip05Verified = nip05Result?.status === 'verified'; // Custom CLINK offer code stored in the kind 0 content JSON. const raw = metadata as Record | undefined; const noffer = raw?.noffer; const nofferCode = typeof noffer === 'string' ? noffer : undefined; const hasClink = isValidNoffer(nofferCode); const hasLightning = isValidLightningAddress(lud16) || !!metadata?.lud06; // Extra custom profile fields. const silentPayment = typeof raw?.bitcoin_silent_payment === 'string' ? raw.bitcoin_silent_payment : undefined; const userBadges = parseBadges(metadata); const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined; const openToSponsorships = raw?.sponsorships_open === true; const sponsorNote = typeof raw?.sponsorships_note === 'string' ? raw.sponsorships_note : ''; // When the profile's `cordn` value is a coordinator pointer, we can offer a // one-tap "Add" instead of just a link. const cordnPointer = cordnUrl ? parseCoordinatorInput(cordnUrl) : null; const [spCopied, setSpCopied] = useState(false); const [lud16Copied, setLud16Copied] = useState(false); const [shareOpen, setShareOpen] = useState(false); const npub = nip19.npubEncode(pubkey); const isOwnProfile = user?.pubkey === pubkey; useSeoMeta({ title: `${displayName} - SkateSpots`, description: about ? about.slice(0, 160) : `${displayName}'s profile on SkateSpots.`, }); // At-a-glance skater stats for the summary strip. const { data: riderStats } = useRiderStats(pubkey); const memberSince = profileEventCreatedAt(author.data?.event?.created_at); // 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.customLevel || skater.styles.length > 0 || !!skater.yearsSkating || !!skater.homeSpot || !!skater.location || !!skater.instagram ); // Only invite sponsor/collab conversations on profiles that actually show // skate activity — an empty kind-0 shouldn't read as a media kit. const sponsorReady = openToSponsorships || hasSkateLayer || crews.length > 0 || (spots?.length ?? 0) > 0 || events.length > 0; const handleCopyNpub = () => { navigator.clipboard.writeText(npub); setCopied(true); setTimeout(() => setCopied(false), 2000); }; // Smooth-scroll a stat pill to its matching section on the page. const scrollToSection = (id: string) => { const el = document.getElementById(id); if (el) el.scrollIntoView({ behavior: 'smooth', block: 'start' }); }; // Create a mock event for zapping const profileEvent = author.data?.event; if (author.isLoading) { return ( <>
); } return ( <>
{/* Banner */}
{banner && ( )}
{/* Profile Header */}
{displayName.slice(0, 2).toUpperCase()} {/* User Status Indicator */}

{displayName}

{isBot && ( Bot )} {openToSponsorships && ( {t('walletUi.profile.openToSponsorships')} )}
{nip05 && (

{nip05Loading ? ( ) : nip05Verified ? ( ) : ( )} {nip05} {nip05Loading ? ( {t('auth.verifying')} ) : nip05Verified ? ( {t('common.verified')} ) : null}

)} {/* Extra verified handles (separate record) — a skater on several domains. */} {extraHandles.length > 0 && ( )} {/* Crew membership summary — links straight to the crews */} {crews.length > 0 && (

{crews.slice(0, 2).map((m, i) => ( {i > 0 && ', '} {m.crew.name} {m.role === 'founder' && ( )} ))} {crews.length > 2 && ( +{crews.length - 2} more )}

)} {/* SkateSpots member badge — the app logo, linking to the site. Shown for any handle issued by our own domain (name@skate-spots.org). */} {nip05Verified && isSkateSpotsNip05(nip05) && ( )}
{/* Actions */}
{!isOwnProfile && user && ( )} {/* Share — available on every profile */} {isOwnProfile && ( )} {!isOwnProfile && user && ( )} {profileEvent && !isOwnProfile && ( )}
{/* ── At-a-glance stats strip ── */}
} loading={spotsLoading} onClick={() => scrollToSection('section-spots')} /> } loading={shopsLoading} onClick={() => scrollToSection('section-shops')} /> } loading={crewsLoading} onClick={() => scrollToSection('section-crews')} /> } loading={eventsLoading} onClick={() => scrollToSection('section-events')} /> } loading={publicStashLoading} onClick={() => scrollToSection('section-stash')} /> } loading={followersLoading} />

{t('walletUi.profile.followersHint')}

{/* Secondary meta line: zaps received + member since */} {(memberSince || (riderStats?.zapsReceived ?? 0) > 0) && (
{(riderStats?.zapsReceived ?? 0) > 0 && ( {riderStats!.zapsReceived} {t('walletUi.profile.zapsReceived')} )} {memberSince && ( {t('walletUi.profile.memberSince')} {memberSince} )} {following.length > 0 && ( {following.length} {t('walletUi.profile.following')} )}
)} {/* ── Badges — reppin' sponsors & backers; each links where the skater chose ── */} {userBadges.length > 0 && (

{t('walletUi.profile.badges')}

{t('walletUi.profile.sponsorsSupport')}

)} {/* Profile Info */}
{/* Main Content */}
{/* About */} {about && (

{about}

)} {/* ── Skate layer (kind 35000) ── */} {hasSkateLayer ? (

{t('walletUi.profile.skater')}

{levelMeta ? ( {levelMeta.emoji} {t(levelMeta.labelKey)} ) : skater?.customLevel ? ( ✏️ {skater.customLevel} ) : null} {isOwnProfile && ( {t('common.edit')} )}
{/* Stats row */}
{stanceMeta && (
{stanceMeta.emoji}

{t('walletUi.profile.stance')}

{t(stanceMeta.labelKey)}

)} {skater?.yearsSkating && (

{t('walletUi.profile.skating')}

{skater.yearsSkating}y

)} {skater?.homeSpot && (() => { // Link to the mapped spot when the skater linked one. const parts = skater.homeSpotRef?.split(':'); const spotHref = parts && parts.length >= 3 ? `/spot/${parts[1]}/${parts.slice(2).join(':')}` : undefined; const inner = (

{t('walletUi.profile.homeSpot')}

{skater.homeSpot}

); return spotHref ? ( {inner} ) : inner; })()} {skater?.location && (

{t('walletUi.profile.basedIn')}

{skater.location}

)}
{/* Styles */} {skater && skater.styles.length > 0 && (
{skater.styles.map((s) => ( {SKATER_STYLES[s].emoji} {t(SKATER_STYLES[s].labelKey)} ))}
)} {/* Skate bio — only if it differs from the kind-0 about */} {skater?.bio && skater.bio.trim() !== (about ?? '').trim() && (

{skater.bio}

)}
) : ( isOwnProfile && (

{t('walletUi.profile.addSkateDetails')}

{t('walletUi.profile.addSkateDetailsDesc')}

) )} {/* Spots by this user — hidden entirely for others when empty */} {(spotsLoading || (spots && spots.length > 0) || isOwnProfile) && (

{t('walletUi.profile.spotsAdded')} {spots && spots.length > 0 && ( {spots.length} )}

{spotsLoading ? (
{Array.from({ length: 2 }).map((_, i) => ( ))}
) : spots && spots.length > 0 ? (
{spots.slice(0, 4).map((spot) => ( ))}
) : ( // Only shown on your own profile — an actionable nudge, not a dead end.

{t('walletUi.profile.noSpots')}

{t('walletUi.profile.noSpotsDesc')}

)}
)} {/* Shops by this user — only render when there's something, or it's you */} {(shopsLoading || (shops && shops.length > 0)) && (

{t('walletUi.profile.shopsAdded')} {shops && shops.length > 0 && ( {shops.length} )}

{shopsLoading ? (
{Array.from({ length: 2 }).map((_, i) => ( ))}
) : (
{shops!.slice(0, 4).map((shop) => (

{shop.name}

{shop.nip05s.length > 0 && ( )} {shop.location && (

{shop.location}

)}
))}
)}
)} {/* Events hosted by this skater — sponsor/community signal */} {(eventsLoading || events.length > 0) && (

{t('walletUi.profile.eventsHosted')} {events.length > 0 && ( {events.length} )}

{eventsLoading ? (
{Array.from({ length: 2 }).map((_, i) => ( ))}
) : (
{events.slice(0, 4).map((event) => ( ))}
)}
)} {/* Public Stash — gear this skater chose to show publicly */} {(publicStashLoading || (publicStash && publicStash.length > 0)) && (

{t('walletUi.profile.stash')} {publicStash && publicStash.length > 0 && ( {publicStash.length} )}

{isOwnProfile && ( {t('walletUi.page.manage')} )}
{publicStashLoading ? (
{Array.from({ length: 3 }).map((_, i) => ( ))}
) : (
{publicStash!.map((g) => (
{g.images[0] ? ( ) : ( )} {g.forSale && ( {g.willingToTrade && !g.priceSats ? ( <>{t('marketUi.seller.trade')} ) : ( <>{t('marketUi.gearSwap.forSale')} )} )}

{g.title}

{g.brand ? (

{g.brand}

) : (

{g.category}

)} {g.forSale && g.priceSats ? ( {g.priceSats > 999 ? `${Math.floor(g.priceSats / 1000)}k` : g.priceSats} ) : null}
))}
)}
)}
{/* Sidebar */} {/* On mobile the identity/contact cards come first — the info card was previously buried below every content section on phones. */}
{/* Zap achievements (only renders when this profile has sent zaps) */} {/* Marketplace trade reputation (only renders with trades) */} {/* Sponsor / collab card — neutral, no assumed opt-in */} {!isOwnProfile && sponsorReady && (

{openToSponsorships ? t('walletUi.profile.openToSponsorships') : t('walletUi.profile.forSponsors')}

{openToSponsorships && sponsorNote ? sponsorNote : t('walletUi.profile.sponsorPitch')}

{[ { label: t('walletUi.profile.followers'), value: followersLoading ? '—' : followers.length }, { label: t('nav.events'), value: eventsLoading ? '—' : events.length }, { label: t('walletUi.stats.spots'), value: spotsLoading ? '—' : (spots?.length ?? 0) }, { label: t('walletUi.profile.zapsReceived'), value: riderStats ? riderStats.zapsReceived : '—' }, ].map((stat) => (

{stat.value}

{stat.label}

))}
)} {/* Quick Info Card */} {t('walletUi.profile.info')} {skaterWebsite && ( {skaterWebsite.replace(/^https?:\/\//, '')} )} {skater?.instagram && (() => { const handle = skater.instagram.trim().replace(/^@+/, '').replace(/^https?:\/\/(www\.)?instagram\.com\//i, '').replace(/\/+$/, ''); if (!handle) return null; return ( @{handle} ); })()} {hasLightning && lud16 && ( )} {/* Bitcoin Silent Payment address — copyable */} {silentPayment && ( )} {/* Cordn coordinator — addable in one tap */} {cordnPointer ? (

{t('walletUi.profile.coordinator')}

{shortNpub(cordnPointer.pubkey)}
{!isOwnProfile && myCoordinator?.pubkey !== cordnPointer.pubkey && ( )}
) : cordnUrl ? (

Cordn

{t('walletUi.profile.messagePrivately')}
) : null} {/* Npub Copy */}
{/* Crews Card */} {(crewsLoading || crews.length > 0) && ( {t('walletUi.profile.crews')} {crews.length > 0 && ( {crews.length} )} {crewsLoading ? ( <> ) : ( crews.map((m) => ( {m.crew.name.slice(0, 2).toUpperCase()}

{m.crew.name}

{m.crew.nip05s && m.crew.nip05s.length > 0 && ( )}

{m.role === 'founder' ? ( <> {t('walletUi.profile.founder')} ) : ( <>{t('walletUi.profile.member')} )}

)) )}
)} {/* Tip Card */} {!isOwnProfile && (hasLightning || hasClink) && (

{t('walletUi.profile.supportSkater')}

{t('walletUi.profile.supportSkaterDesc')}

{profileEvent && hasLightning && ( )} {hasClink && nofferCode && ( )}
)}
); } export function NIP19Page() { const { nip19: identifier } = useParams<{ nip19: string }>(); if (!identifier) { return ; } let decoded; try { decoded = nip19.decode(identifier); } catch { return ; } const { type, data } = decoded; switch (type) { case 'npub': return ; case 'nprofile': return ; case 'note': // AI agent should implement note view here return ; case 'nevent': // AI agent should implement event view here return ; case 'naddr': { const addr = data as { kind: number; pubkey: string; identifier: string }; // Newsletter issues (NIP-23 long-form used by SkateSpots) get a reader. if (addr.kind === NEWSLETTER_KIND) { return ; } // Other addressable kinds aren't rendered here yet. return ; } default: return ; } }