1141 lines
56 KiB
TypeScript
1141 lines
56 KiB
TypeScript
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 />;
|
||
}
|
||
}
|