SkateSpots/src/pages/NIP19Page.tsx

1141 lines
56 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 } from '@/lib/types';
import { NewsletterIssueView } from '@/components/NewsletterIssueView';
import NotFound from './NotFound';
import { MapPin, Store, MessageCircle, Globe, Zap, ExternalLink, Copy, Check, ShieldCheck, ShieldAlert, Loader2, Info, 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,
hint,
}: {
value: number;
label: string;
color: string;
loading?: boolean;
icon?: ReactNode;
onClick?: () => void;
hint?: string;
}) {
const interactive = !!onClick && value > 0;
const empty = !loading && value === 0;
const inner = (
<>
<span
className="flex-shrink-0 h-9 w-9 rounded-xl flex items-center justify-center transition-colors"
style={{
backgroundColor: empty ? 'rgba(255,255,255,0.05)' : `${color}22`,
color: empty ? '#6b7280' : color,
}}
>
{icon}
</span>
<div className="leading-none text-left min-w-0">
{loading ? (
<Skeleton className="h-6 w-8" />
) : (
<p className="text-xl sm:text-2xl font-black" style={{ color: empty ? '#6b7280' : color }}>
{value}
</p>
)}
<p className={`text-[10px] uppercase tracking-wide font-bold mt-0.5 transition-colors ${empty ? 'text-gray-600' : 'text-gray-400 group-hover:text-gray-200'}`}>
{label}
{hint && (
<Info className="ml-1 inline h-2.5 w-2.5 align-[-1px] text-gray-600" aria-label={hint} />
)}
</p>
</div>
</>
);
const base = 'group flex items-center gap-3 rounded-2xl border p-3 transition-all';
if (interactive) {
return (
<button
type="button"
onClick={onClick}
title={hint}
className={`${base} border-white/10 bg-white/[0.04] hover:bg-white/10 hover:border-white/25 hover:-translate-y-0.5 cursor-pointer`}
>
{inner}
</button>
);
}
return <div className={`${base} border-white/5 bg-white/[0.02]`} title={hint}>{inner}</div>;
}
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<string, unknown> | undefined;
const noffer = raw?.noffer;
const nofferCode = typeof noffer === 'string' ? noffer : undefined;
// 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 [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 (
<>
<div className="container px-4 py-8 max-w-4xl">
<Skeleton className="h-48 w-full rounded-xl mb-4" />
<div className="flex items-end gap-4 -mt-16 px-4 mb-6">
<Skeleton className="h-32 w-32 rounded-full border-4 border-black" />
<div className="flex-1 space-y-2 pb-2">
<Skeleton className="h-8 w-48" />
<Skeleton className="h-4 w-32" />
</div>
</div>
<div className="space-y-4">
<Skeleton className="h-20 w-full" />
<div className="grid grid-cols-2 gap-4">
<Skeleton className="h-24" />
<Skeleton className="h-24" />
</div>
</div>
</div>
</>
);
}
return (
<>
<div className="container px-4 py-6 max-w-4xl">
{/* Banner */}
<div className="relative h-32 sm:h-48 rounded-xl overflow-hidden bg-gradient-to-br from-[#ff0040]/30 to-[#ccff00]/20 mb-4">
{banner && (
<ImageWithFallback src={banner} alt={t('legacyMiscUi.bannerAlt')} className="w-full h-full object-cover" fallback={null} />
)}
<div className="absolute inset-0 bg-gradient-to-t from-black/60 to-transparent" />
</div>
{/* Profile Header */}
<div className="flex flex-col sm:flex-row items-center sm:items-end gap-4 -mt-16 sm:-mt-20 px-4 mb-6 relative z-10">
<div className="relative">
<Avatar className="h-28 w-28 sm:h-32 sm:w-32 border-4 border-black shadow-xl">
<AvatarImage src={avatar} alt={displayName} />
<AvatarFallback className="bg-[#ff0040]/20 text-[#ff0040] text-2xl sm:text-3xl font-black">
{displayName.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
{/* User Status Indicator */}
<div className="absolute -bottom-1 -right-1">
<UserStatusIndicator pubkey={pubkey} size="lg" showLabel />
</div>
</div>
<div className="flex-1 text-center sm:text-left pb-2">
<div className="flex items-center justify-center sm:justify-start gap-2 mb-1 flex-wrap">
<h1 className="text-2xl sm:text-3xl font-black uppercase text-white">
{displayName}
</h1>
{isBot && (
<Badge className="bg-gray-700/60 text-gray-300 border-gray-600 gap-1">
<Bot className="h-3 w-3" /> Bot
</Badge>
)}
{openToSponsorships && (
<Badge className="gap-1 border-pink-500/40 bg-pink-500/15 text-pink-300">
<Handshake className="h-3 w-3" /> {t('walletUi.profile.openToSponsorships')}
</Badge>
)}
</div>
{nip05 && (
<p
className={cn(
'text-sm font-medium flex items-center justify-center sm:justify-start gap-1',
nip05Verified ? 'text-[#ccff00]' : 'text-gray-400',
)}
title={nip05Verified ? t('walletUi.profile.nip05Verified') : t('walletUi.profile.nip05NotVerified')}
>
{nip05Loading ? (
<Loader2 className="h-3.5 w-3.5 animate-spin" />
) : nip05Verified ? (
<ShieldCheck className="h-3.5 w-3.5" />
) : (
<ShieldAlert className="h-3.5 w-3.5" />
)}
{nip05}
{nip05Loading ? (
<span className="text-[10px] font-bold uppercase tracking-wide text-gray-500">
{t('auth.verifying')}
</span>
) : nip05Verified ? (
<span className="text-[10px] font-black uppercase tracking-wide bg-[#ccff00]/15 text-[#ccff00] px-1.5 py-0.5 rounded-full ml-0.5">
{t('common.verified')}
</span>
) : null}
</p>
)}
{/* Extra verified handles (separate record) — a skater on several domains. */}
{extraHandles.length > 0 && (
<Nip05Badges handles={extraHandles} pubkey={pubkey} className="mt-1.5 justify-center sm:justify-start" />
)}
{/* Crew membership summary — links straight to the crews */}
{crews.length > 0 && (
<p className="mt-1.5 text-sm text-gray-400 flex items-center justify-center sm:justify-start gap-1.5 flex-wrap">
<Users className="h-3.5 w-3.5 text-teal-400 flex-shrink-0" />
<span>
{crews.slice(0, 2).map((m, i) => (
<span key={`${m.crew.pubkey}:${m.crew.identifier}`}>
{i > 0 && ', '}
<Link
to={`/crew/${m.crew.pubkey}/${m.crew.identifier}`}
className="font-bold text-teal-300 hover:text-teal-200"
>
{m.crew.name}
</Link>
{m.role === 'founder' && (
<Crown className="inline h-3 w-3 ml-0.5 -mt-0.5 text-yellow-500" />
)}
</span>
))}
{crews.length > 2 && (
<span className="text-gray-500"> +{crews.length - 2} more</span>
)}
</span>
</p>
)}
{/* 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) && (
<ProfileBadges
badges={[SKATESPOTS_BADGE]}
size="sm"
className="mt-1.5 justify-center sm:justify-start"
/>
)}
</div>
{/* Actions */}
<div className="flex flex-wrap items-center gap-2">
{!isOwnProfile && user && (
<Button
variant={isFollowingProfile ? 'outline' : 'default'}
onClick={() => {
if (isFollowingProfile) void unfollow(pubkey);
else void follow(pubkey);
}}
disabled={isFollowPending || isUnfollowing}
className={cn(
'gap-2 font-bold',
isFollowingProfile
? 'border-teal-500/50 text-teal-300 hover:bg-teal-500/10'
: 'bg-[#ccff00] text-black hover:bg-[#ccff00]/90',
)}
aria-label={isFollowingProfile ? t('walletUi.profile.unfollow') : t('walletUi.profile.follow')}
>
{isFollowingProfile ? <UserCheck className="h-4 w-4" /> : <UserPlus className="h-4 w-4" />}
<span className="hidden sm:inline">
{isFollowingProfile ? t('walletUi.profile.unfollow') : t('walletUi.profile.follow')}
</span>
</Button>
)}
{/* Share — available on every profile */}
<Button
variant="outline"
onClick={() => setShareOpen(true)}
className="gap-2 border-white/15 text-gray-200 hover:bg-white/10"
aria-label={t('walletUi.share.title')}
>
<Share2 className="h-4 w-4" />
<span className="hidden sm:inline">{t('common.share')}</span>
</Button>
{isOwnProfile && (
<Link to="/settings?section=profile">
<Button variant="outline" className="gap-2 border-[#ccff00]/50 text-[#ccff00] hover:bg-[#ccff00]/10">
<Pencil className="h-4 w-4" />
<span className="hidden sm:inline">{t('settings.editProfile')}</span>
</Button>
</Link>
)}
{!isOwnProfile && user && (
<Link to={`/messages/${npub}`}>
<Button variant="outline" className="gap-2 border-[#ff0040]/50 text-[#ff0040] hover:bg-[#ff0040]/10">
<MessageCircle className="h-4 w-4" />
<span className="hidden sm:inline">{t('walletUi.profile.message')}</span>
</Button>
</Link>
)}
{profileEvent && !isOwnProfile && (
<ZapButton
target={profileEvent}
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
/>
)}
</div>
</div>
{/* ── At-a-glance stats strip ── */}
<div className="grid grid-cols-3 sm:grid-cols-6 gap-2 sm:gap-3 mb-3">
<StatPill value={spots?.length ?? 0} label={t('walletUi.stats.spots')} color="#ff0040" icon={<MapPin className="h-4 w-4" />} loading={spotsLoading} onClick={() => scrollToSection('section-spots')} />
<StatPill value={shops?.length ?? 0} label={t('walletUi.stats.shops')} color="#ccff00" icon={<Store className="h-4 w-4" />} loading={shopsLoading} onClick={() => scrollToSection('section-shops')} />
<StatPill value={crews.length} label={t('walletUi.profile.crews')} color="#2dd4bf" icon={<Users className="h-4 w-4" />} loading={crewsLoading} onClick={() => scrollToSection('section-crews')} />
<StatPill value={events.length} label={t('nav.events')} color="#ccff00" icon={<Clock className="h-4 w-4" />} loading={eventsLoading} onClick={() => scrollToSection('section-events')} />
<StatPill value={publicStash?.length ?? 0} label={t('walletUi.profile.gear')} color="#ff6600" icon={<Package className="h-4 w-4" />} loading={publicStashLoading} onClick={() => scrollToSection('section-stash')} />
<StatPill value={followers.length} label={t('walletUi.profile.followers')} color="#ec4899" icon={<Users className="h-4 w-4" />} loading={followersLoading} hint={t('walletUi.profile.followersHint')} />
</div>
{/* Secondary meta line: zaps received + member since */}
{(memberSince || (riderStats?.zapsReceived ?? 0) > 0) && (
<div className="flex flex-wrap items-center gap-x-4 gap-y-1.5 mb-6 px-1 text-xs text-gray-500">
{(riderStats?.zapsReceived ?? 0) > 0 && (
<span className="flex items-center gap-1.5">
<Zap className="h-3.5 w-3.5 text-yellow-400" />
<span className="font-black text-white">{riderStats!.zapsReceived}</span> {t('walletUi.profile.zapsReceived')}
</span>
)}
{memberSince && (
<span className="flex items-center gap-1.5">
<Clock className="h-3.5 w-3.5" />
{t('walletUi.profile.memberSince')} <span className="font-bold text-gray-300">{memberSince}</span>
</span>
)}
{following.length > 0 && (
<span className="flex items-center gap-1.5">
<UserPlus className="h-3.5 w-3.5 text-gray-400" />
<span className="font-black text-white">{following.length}</span> {t('walletUi.profile.following')}
</span>
)}
</div>
)}
{/* ── Badges — reppin' sponsors & backers; each links where the skater chose ── */}
{userBadges.length > 0 && (
<div className="relative overflow-hidden rounded-2xl border-2 border-pink-500/30 bg-gradient-to-r from-pink-500/15 via-black to-fuchsia-500/5 p-4 sm:p-5 mb-6">
<div className="absolute -top-10 -right-10 w-40 h-40 bg-pink-500 opacity-10 rounded-full blur-3xl pointer-events-none" />
<div className="relative flex flex-col sm:flex-row sm:items-center gap-3 sm:gap-4">
<div className="flex items-center gap-2.5 flex-shrink-0">
<div className="h-9 w-9 rounded-xl bg-pink-500/20 flex items-center justify-center">
<Award className="h-5 w-5 text-pink-400" />
</div>
<div className="leading-tight">
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-pink-400">{t('walletUi.profile.badges')}</p>
<p className="text-xs text-gray-400">{t('walletUi.profile.sponsorsSupport')}</p>
</div>
</div>
<ProfileBadges badges={userBadges} className="min-w-0" />
</div>
</div>
)}
{/* Profile Info */}
<div className="grid grid-cols-1 lg:grid-cols-3 gap-6">
{/* Main Content */}
<div className="lg:col-span-2 space-y-6 min-w-0">
{/* About */}
{about && (
<Card className="bg-black/90 border-2 border-gray-800">
<CardContent className="p-4 sm:p-6">
<p className="text-gray-300 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">{about}</p>
</CardContent>
</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" />
{t('walletUi.profile.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} {t(levelMeta.labelKey)}
</span>
) : skater?.customLevel ? (
<span className="text-[10px] font-black uppercase tracking-wide px-2.5 py-1 rounded-full bg-[#ccff00] text-black">
✏️ {skater.customLevel}
</span>
) : null}
{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" /> {t('common.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">{t('walletUi.profile.stance')}</p>
<p className="text-white font-bold">{t(stanceMeta.labelKey)}</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">{t('walletUi.profile.skating')}</p>
<p className="text-white font-bold">{skater.yearsSkating}y</p>
</div>
</div>
)}
{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 = (
<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">{t('walletUi.profile.homeSpot')}</p>
<p className={cn('text-white font-bold truncate', spotHref && 'group-hover/home:text-[#ccff00] group-hover/home:underline')}>
{skater.homeSpot}
</p>
</div>
</div>
);
return spotHref ? (
<Link to={spotHref} className="group/home min-w-0">{inner}</Link>
) : inner;
})()}
{skater?.location && (
<div className="flex items-center gap-1.5 min-w-0">
<MapPin className="h-4 w-4 text-[#ccff00] flex-shrink-0" />
<div className="leading-tight min-w-0">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">{t('walletUi.profile.basedIn')}</p>
<p className="text-white font-bold truncate">{skater.location}</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} {t(SKATER_STYLES[s].labelKey)}
</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">{t('walletUi.profile.addSkateDetails')}</p>
<p className="text-xs text-gray-400 mt-0.5">
{t('walletUi.profile.addSkateDetailsDesc')}
</p>
</div>
<Sparkles className="h-5 w-5 text-[#ff0040] flex-shrink-0" />
</Link>
)
)}
{/* Spots by this user — hidden entirely for others when empty */}
{(spotsLoading || (spots && spots.length > 0) || isOwnProfile) && (
<div id="section-spots" className="scroll-mt-24">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<MapPin className="h-5 w-5 text-[#ff0040]" />
{t('walletUi.profile.spotsAdded')}
{spots && spots.length > 0 && (
<Badge className="bg-[#ff0040]/20 text-[#ff0040] border-[#ff0040]/50">
{spots.length}
</Badge>
)}
</h2>
</div>
{spotsLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from({ length: 2 }).map((_, i) => (
<SpotCardSkeleton key={i} />
))}
</div>
) : spots && spots.length > 0 ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{spots.slice(0, 4).map((spot) => (
<SpotCard key={spot.id} spot={spot} />
))}
</div>
) : (
// Only shown on your own profile — an actionable nudge, not a dead end.
<Link
to="/create-spot"
className="group flex items-center gap-3 rounded-xl border-2 border-dashed border-gray-800 hover:border-[#ff0040]/50 hover:bg-[#ff0040]/5 transition-all p-6"
>
<MapPin className="h-8 w-8 text-gray-600 group-hover:text-[#ff0040] transition-colors" />
<div>
<p className="text-gray-300 font-bold">{t('walletUi.profile.noSpots')}</p>
<p className="text-sm text-gray-500">{t('walletUi.profile.noSpotsDesc')}</p>
</div>
</Link>
)}
</div>
)}
{/* Shops by this user — only render when there's something, or it's you */}
{(shopsLoading || (shops && shops.length > 0)) && (
<div id="section-shops" className="scroll-mt-24">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<Store className="h-5 w-5 text-[#ccff00]" />
{t('walletUi.profile.shopsAdded')}
{shops && shops.length > 0 && (
<Badge className="bg-[#ccff00]/20 text-[#ccff00] border-[#ccff00]/50">
{shops.length}
</Badge>
)}
</h2>
</div>
{shopsLoading ? (
<div className="grid grid-cols-1 gap-3">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-20" />
))}
</div>
) : (
<div className="space-y-3">
{shops!.slice(0, 4).map((shop) => (
<Link key={shop.id} to={`/shop/${shop.pubkey}/${shop.identifier}`}>
<Card className="bg-black/90 border-2 border-[#ccff00]/30 hover:border-[#ccff00]/60 transition-all">
<CardContent className="p-4 flex items-center gap-4">
<div className="w-12 h-12 rounded-lg bg-[#ccff00]/20 flex items-center justify-center flex-shrink-0">
<Store className="h-6 w-6 text-[#ccff00]" />
</div>
<div className="flex-1 min-w-0">
<h3 className="font-bold text-white truncate">{shop.name}</h3>
{shop.nip05s.length > 0 && (
<Nip05Badges handles={shop.nip05s} pubkey={shop.pubkey} className="mt-0.5" />
)}
{shop.location && (
<p className="text-sm text-gray-400 truncate">{shop.location}</p>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
)}
</div>
)}
{/* Events hosted by this skater — sponsor/community signal */}
{(eventsLoading || events.length > 0) && (
<div id="section-events" className="scroll-mt-24">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<Clock className="h-5 w-5 text-[#ccff00]" />
{t('walletUi.profile.eventsHosted')}
{events.length > 0 && (
<Badge className="bg-[#ccff00]/20 text-[#ccff00] border-[#ccff00]/50">
{events.length}
</Badge>
)}
</h2>
</div>
{eventsLoading ? (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{Array.from({ length: 2 }).map((_, i) => (
<Skeleton key={i} className="h-48 rounded-xl" />
))}
</div>
) : (
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
{events.slice(0, 4).map((event) => (
<EventCard key={event.id} event={event} compact />
))}
</div>
)}
</div>
)}
{/* Public Stash — gear this skater chose to show publicly */}
{(publicStashLoading || (publicStash && publicStash.length > 0)) && (
<div id="section-stash" className="scroll-mt-24">
<div className="flex items-center justify-between mb-4">
<h2 className="text-xl font-black uppercase flex items-center gap-2">
<Package className="h-5 w-5 text-[#ff6600]" />
{t('walletUi.profile.stash')}
{publicStash && publicStash.length > 0 && (
<Badge className="bg-[#ff6600]/20 text-[#ff6600] border-[#ff6600]/50">
{publicStash.length}
</Badge>
)}
</h2>
{isOwnProfile && (
<Link to="/stash" className="text-xs font-bold uppercase tracking-wider text-[#ff6600] hover:underline">
{t('walletUi.page.manage')}
</Link>
)}
</div>
{publicStashLoading ? (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{Array.from({ length: 3 }).map((_, i) => (
<Skeleton key={i} className="h-40 rounded-xl" />
))}
</div>
) : (
<div className="grid grid-cols-2 sm:grid-cols-3 gap-3">
{publicStash!.map((g) => (
<div
key={g.id}
className="group rounded-xl overflow-hidden border-2 border-gray-800 bg-black/60 hover:border-[#ff6600]/50 transition-all"
>
<div className="relative aspect-square bg-gradient-to-br from-[#ff6600]/10 to-[#ff0040]/5 flex items-center justify-center overflow-hidden">
{g.images[0] ? (
<ImageWithFallback
src={g.images[0]}
alt={g.title}
fallback={null}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
/>
) : (
<Package className="h-10 w-10 text-[#ff6600]/40" />
)}
{g.forSale && (
<span className="absolute top-2 left-2 flex items-center gap-1 text-[10px] font-black uppercase tracking-wide px-2 py-0.5 rounded-full bg-[#ccff00] text-black">
{g.willingToTrade && !g.priceSats ? (
<><ArrowRightLeft className="h-2.5 w-2.5" />{t('marketUi.seller.trade')}</>
) : (
<><Tag className="h-2.5 w-2.5" />{t('marketUi.gearSwap.forSale')}</>
)}
</span>
)}
</div>
<div className="p-2.5">
<p className="font-bold text-white text-sm truncate">{g.title}</p>
<div className="flex items-center justify-between mt-0.5">
{g.brand ? (
<p className="text-xs text-gray-500 truncate">{g.brand}</p>
) : (
<p className="text-xs text-gray-600 capitalize truncate">{g.category}</p>
)}
{g.forSale && g.priceSats ? (
<span className="flex items-center gap-0.5 text-xs font-bold text-[#ccff00] flex-shrink-0">
<Zap className="h-3 w-3" />
{g.priceSats > 999 ? `${Math.floor(g.priceSats / 1000)}k` : g.priceSats}
</span>
) : null}
</div>
</div>
</div>
))}
</div>
)}
</div>
)}
</div>
{/* Sidebar */}
{/* On mobile the identity/contact cards come first — the info card was
previously buried below every content section on phones. */}
<div className="space-y-4 min-w-0 order-first lg:order-none">
{/* Zap achievements (only renders when this profile has sent zaps) */}
<ZapBadgesCard pubkey={pubkey} />
{/* Marketplace trade reputation (only renders with trades) */}
<TradeReputationCard pubkey={pubkey} />
<TradeRecommendationsCard pubkey={pubkey} />
{/* Sponsor / collab card — neutral, no assumed opt-in */}
{!isOwnProfile && sponsorReady && (
<Card className="relative overflow-hidden border-2 border-pink-500/30 bg-gradient-to-br from-pink-500/10 via-black to-transparent">
<div className="absolute -top-12 -right-12 h-36 w-36 rounded-full bg-pink-500 opacity-10 blur-3xl pointer-events-none" />
<CardContent className="relative p-4">
<div className="flex items-center gap-2.5">
<div className="flex h-9 w-9 items-center justify-center rounded-xl bg-pink-500/15 text-pink-300">
<Handshake className="h-4 w-4" />
</div>
<div className="min-w-0">
<p className="text-[10px] font-black uppercase tracking-[0.15em] text-pink-300">
{openToSponsorships ? t('walletUi.profile.openToSponsorships') : t('walletUi.profile.forSponsors')}
</p>
<p className="text-xs text-gray-400">
{openToSponsorships && sponsorNote ? sponsorNote : t('walletUi.profile.sponsorPitch')}
</p>
</div>
</div>
<div className="mt-3 grid grid-cols-2 gap-2">
{[
{ 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) => (
<div key={stat.label} className="rounded-lg border border-white/10 bg-black/30 px-2.5 py-2">
<p className="text-lg font-black text-white leading-none">{stat.value}</p>
<p className="mt-0.5 text-[10px] uppercase tracking-wide text-gray-500">{stat.label}</p>
</div>
))}
</div>
<div className="mt-3 grid grid-cols-1 gap-2">
<Button asChild className="w-full justify-center bg-pink-500 font-bold text-black hover:bg-pink-400">
<Link to={`/messages/${npub}?message=${encodeURIComponent(t('walletUi.profile.collabMessage', { name: displayName }))}`}>
<MessageCircle className="mr-2 h-4 w-4" />
{t('walletUi.profile.startCollab')}
</Link>
</Button>
<Button asChild variant="outline" className="w-full justify-center border-white/15">
<Link to={`/card/${npub}`}>
<Award className="mr-2 h-4 w-4" />
{t('walletUi.profile.viewRiderCard')}
</Link>
</Button>
</div>
</CardContent>
</Card>
)}
{/* Quick Info Card */}
<Card className="bg-black/90 border-2 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-lg">{t('walletUi.profile.info')}</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{skaterWebsite && (
<a
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">
{skaterWebsite.replace(/^https?:\/\//, '')}
</span>
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
</a>
)}
{skater?.instagram && (() => {
const handle = skater.instagram.trim().replace(/^@+/, '').replace(/^https?:\/\/(www\.)?instagram\.com\//i, '').replace(/\/+$/, '');
if (!handle) return null;
return (
<a
href={`https://instagram.com/${handle}`}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"
>
<Instagram className="h-4 w-4 text-pink-400 flex-shrink-0" />
<span className="text-gray-300 text-sm truncate">@{handle}</span>
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
</a>
);
})()}
{lud16 && (
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900">
<Zap className="h-4 w-4 text-yellow-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">{t('walletUi.profile.lightning')}</p>
<span className="text-gray-300 text-sm truncate block">{lud16}</span>
</div>
</div>
)}
{/* Bitcoin Silent Payment address — copyable */}
{silentPayment && (
<button
onClick={() => {
navigator.clipboard.writeText(silentPayment);
setSpCopied(true);
setTimeout(() => setSpCopied(false), 2000);
}}
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors w-full text-left"
>
<Bitcoin className="h-4 w-4 text-orange-400 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">
{t('walletUi.profile.silentPayment')} {spCopied && <span className="text-green-400">· {t('common.copied')}</span>}
</p>
<span className="text-gray-300 text-sm truncate block font-mono">
{silentPayment.slice(0, 14)}…{silentPayment.slice(-6)}
</span>
</div>
{spCopied ? (
<Check className="h-3.5 w-3.5 text-green-400 flex-shrink-0" />
) : (
<Copy className="h-3.5 w-3.5 text-gray-500 flex-shrink-0" />
)}
</button>
)}
{/* Cordn coordinator — addable in one tap */}
{cordnPointer ? (
<div className="flex items-center gap-3 p-3 rounded-lg bg-gray-900">
<Server className="h-4 w-4 text-teal-400 flex-shrink-0" />
<div className="min-w-0 flex-1">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">{t('walletUi.profile.coordinator')}</p>
<span className="text-gray-300 text-sm truncate block font-mono">
{shortNpub(cordnPointer.pubkey)}
</span>
</div>
{!isOwnProfile && myCoordinator?.pubkey !== cordnPointer.pubkey && (
<Button
size="sm"
variant="outline"
className="flex-shrink-0 border-teal-500/40 text-teal-600 hover:bg-teal-500/10 dark:text-teal-400"
onClick={() => {
setCoordinator({
pubkey: cordnPointer.pubkey,
relays: cordnPointer.relays.length ? cordnPointer.relays : [CORDN_RELAY_URL],
});
toast({ title: t('walletUi.profile.coordinatorAdded'), description: shortNpub(cordnPointer.pubkey) });
}}
>
<Plus className="mr-1 h-3.5 w-3.5" /> {t('walletUi.profile.add')}
</Button>
)}
<a
href={coordinatorAddUrl(cordnPointer)}
target="_blank"
rel="noopener noreferrer"
className="flex-shrink-0 text-gray-500 transition-colors hover:text-teal-300"
title={t('legacyMiscUi.openInCordn')}
>
<ExternalLink className="h-3.5 w-3.5" />
</a>
</div>
) : cordnUrl ? (
<a
href={sanitizeUrl(cordnUrl) ?? undefined}
target="_blank"
rel="noopener noreferrer"
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"
>
<MessageSquareLock className="h-4 w-4 text-teal-400 flex-shrink-0" />
<div className="min-w-0">
<p className="text-[10px] uppercase tracking-wide text-gray-500 font-bold">Cordn</p>
<span className="text-gray-300 text-sm truncate block">{t('walletUi.profile.messagePrivately')}</span>
</div>
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
</a>
) : null}
{/* Npub Copy */}
<button
onClick={handleCopyNpub}
className="flex items-center gap-3 p-3 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors w-full text-left"
>
{copied ? (
<Check className="h-4 w-4 text-green-400" />
) : (
<Copy className="h-4 w-4 text-gray-400" />
)}
<span className="text-gray-400 text-sm truncate flex-1">
{copied ? t('common.copied') : `${npub.slice(0, 12)}...${npub.slice(-8)}`}
</span>
</button>
</CardContent>
</Card>
{/* Crews Card */}
{(crewsLoading || crews.length > 0) && (
<Card id="section-crews" className="scroll-mt-24 bg-black/90 border-2 border-teal-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Users className="h-5 w-5 text-teal-400" />
{t('walletUi.profile.crews')}
{crews.length > 0 && (
<Badge className="bg-teal-500/20 text-teal-300 border-teal-500/40">
{crews.length}
</Badge>
)}
</CardTitle>
</CardHeader>
<CardContent className="space-y-2">
{crewsLoading ? (
<>
<Skeleton className="h-12 w-full" />
<Skeleton className="h-12 w-full" />
</>
) : (
crews.map((m) => (
<Link
key={`${m.crew.pubkey}:${m.crew.identifier}`}
to={`/crew/${m.crew.pubkey}/${m.crew.identifier}`}
className="flex items-center gap-3 p-2.5 rounded-lg bg-gray-900 hover:bg-gray-800 transition-colors"
>
<Avatar className="h-9 w-9 rounded-lg border border-teal-500/30">
<AvatarImage src={m.crew.image} alt={m.crew.name} className="object-cover" />
<AvatarFallback className="rounded-lg bg-teal-900/40 text-teal-300 text-xs font-black">
{m.crew.name.slice(0, 2).toUpperCase()}
</AvatarFallback>
</Avatar>
<div className="flex-1 min-w-0">
<p className="font-bold text-white text-sm truncate">{m.crew.name}</p>
{m.crew.nip05s && m.crew.nip05s.length > 0 && (
<CrewNip05Badge nip05s={m.crew.nip05s} founderPubkey={m.crew.pubkey} className="mt-0.5" />
)}
<p className="text-xs text-gray-500 flex items-center gap-1">
{m.role === 'founder' ? (
<><Crown className="h-3 w-3 text-yellow-500" /> {t('walletUi.profile.founder')}</>
) : (
<>{t('walletUi.profile.member')}</>
)}
</p>
</div>
</Link>
))
)}
</CardContent>
</Card>
)}
{/* Tip Card */}
{!isOwnProfile && (profileEvent || nofferCode) && (
<Card className="bg-gradient-to-br from-[#ccff00]/10 to-transparent border-2 border-[#ccff00]/30">
<CardContent className="p-4 text-center">
<Zap className="h-8 w-8 mx-auto mb-2 text-[#ccff00]" />
<h3 className="font-bold text-white mb-1">{t('walletUi.profile.supportSkater')}</h3>
<p className="text-xs text-gray-400 mb-3">
{t('walletUi.profile.supportSkaterDesc')}
</p>
<div className="space-y-2">
{profileEvent && (
<ZapButton
target={profileEvent}
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
/>
)}
{nofferCode && (
<ClinkPayButton
noffer={nofferCode}
recipientName={displayName}
className="w-full"
/>
)}
</div>
</CardContent>
</Card>
)}
</div>
</div>
</div>
<ShareProfileDialog
open={shareOpen}
onOpenChange={setShareOpen}
npub={npub}
displayName={displayName}
/>
</>
);
}
export function NIP19Page() {
const { nip19: identifier } = useParams<{ nip19: string }>();
if (!identifier) {
return <NotFound />;
}
let decoded;
try {
decoded = nip19.decode(identifier);
} catch {
return <NotFound />;
}
const { type, data } = decoded;
switch (type) {
case 'npub':
return <ProfilePage pubkey={data as string} />;
case 'nprofile':
return <ProfilePage pubkey={(data as { pubkey: string }).pubkey} />;
case 'note':
// AI agent should implement note view here
return <NotFound />;
case 'nevent':
// AI agent should implement event view here
return <NotFound />;
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 <NewsletterIssueView pubkey={addr.pubkey} identifier={addr.identifier} />;
}
// Other addressable kinds aren't rendered here yet.
return <NotFound />;
}
default:
return <NotFound />;
}
}