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 (
{inner}
);
}
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 */}
{/* 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 && (
{
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 ? : }
{isFollowingProfile ? t('walletUi.profile.unfollow') : t('walletUi.profile.follow')}
)}
{/* Share — available on every profile */}
setShareOpen(true)}
className="gap-2 border-white/15 text-gray-200 hover:bg-white/10"
aria-label={t('walletUi.share.title')}
>
{t('common.share')}
{isOwnProfile && (
{t('settings.editProfile')}
)}
{!isOwnProfile && user && (
{t('walletUi.profile.message')}
)}
{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}
))}
{t('walletUi.profile.startCollab')}
{t('walletUi.profile.viewRiderCard')}
)}
{/* 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 && (
{
void navigator.clipboard.writeText(lud16).then(() => {
setLud16Copied(true);
setTimeout(() => setLud16Copied(false), 2000);
}).catch(() => undefined);
}}
className="flex w-full items-center gap-3 rounded-lg bg-gray-900 p-3 text-left transition-colors hover:bg-gray-800"
>
{t('walletUi.profile.lightning')} {lud16Copied && · {t('common.copied')} }
{lud16}
{lud16Copied ? (
) : (
)}
)}
{/* Bitcoin Silent Payment address — copyable */}
{silentPayment && (
{
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"
>
{t('walletUi.profile.silentPayment')} {spCopied && · {t('common.copied')} }
{silentPayment.slice(0, 14)}…{silentPayment.slice(-6)}
{spCopied ? (
) : (
)}
)}
{/* Cordn coordinator — addable in one tap */}
{cordnPointer ? (
{t('walletUi.profile.coordinator')}
{shortNpub(cordnPointer.pubkey)}
{!isOwnProfile && myCoordinator?.pubkey !== cordnPointer.pubkey && (
{
setCoordinator({
pubkey: cordnPointer.pubkey,
relays: cordnPointer.relays.length ? cordnPointer.relays : [CORDN_RELAY_URL],
});
toast({ title: t('walletUi.profile.coordinatorAdded'), description: shortNpub(cordnPointer.pubkey) });
}}
>
{t('walletUi.profile.add')}
)}
) : cordnUrl ? (
Cordn
{t('walletUi.profile.messagePrivately')}
) : null}
{/* Npub Copy */}
{copied ? (
) : (
)}
{copied ? t('common.copied') : `${npub.slice(0, 12)}...${npub.slice(-8)}`}
{/* 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 ;
}
}