SkateSpots/src/pages/NIP19Page.tsx
shakespeare.diy 11c67fcf22 Profile: add Lightning, Silent Payment, Sponsors & Cordn fields
Editable in EditProfileForm and displayed on the profile:
- Bitcoin Lightning Address (lud16) — now editable in-form, shown with a
  labeled Lightning row in the Info card.
- Bitcoin Silent Payment Address (BIP-352, custom `bitcoin_silent_payment`)
  — validated (sp1…), shown as a copyable, truncated row.
- Sponsors & Support (custom `sponsors`) — freeform highlight, rendered in
  a dedicated pink "Sponsors & Support" sidebar card.
- Cordn contact link (custom `cordn`) — private MLS messaging link
  (cordn.net), shown as a "Message privately" row in the Info card.

Crews and the CLINK noffer tip button were already present, so they are
left as-is. Added isValidSilentPayment / isValidLightningAddress
validators and documented the custom kind-0 fields in NIP.md.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-02 20:00:21 +07:00

651 lines
28 KiB
TypeScript

import { nip19 } from 'nostr-tools';
import { useParams, Link } from 'react-router-dom';
import { Layout } from '@/components/Layout';
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 { ClinkPayButton } from '@/components/ClinkPayButton';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
import { useUserShops } from '@/hooks/useShops';
import { useUserCrews } from '@/hooks/useCrews';
import { useUserPublicStash } from '@/hooks/useStashShowcase';
import { SpotCard, SpotCardSkeleton } from '@/components/SpotCard';
import { genUserName } from '@/lib/genUserName';
import NotFound from './NotFound';
import {
MapPin,
Store,
MessageCircle,
Globe,
Zap,
Calendar,
ExternalLink,
ArrowLeft,
Copy,
Check,
Users,
Crown,
Bot,
Pencil,
Package,
Tag,
ArrowRightLeft,
Bitcoin,
Award,
MessageSquareLock,
} from 'lucide-react';
import { useState } from 'react';
import { UserStatusIndicator } from '@/components/UserStatusIndicator';
function ProfilePage({ pubkey }: { pubkey: string }) {
const author = useAuthor(pubkey);
const { user } = useCurrentUser();
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 [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 lud16 = metadata?.lud16;
const isBot = metadata?.bot === true;
// 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 sponsors = typeof raw?.sponsors === 'string' ? raw.sponsors.trim() : undefined;
const cordnUrl = typeof raw?.cordn === 'string' ? raw.cordn : undefined;
const [spCopied, setSpCopied] = useState(false);
const npub = nip19.npubEncode(pubkey);
const isOwnProfile = user?.pubkey === pubkey;
const handleCopyNpub = () => {
navigator.clipboard.writeText(npub);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
};
// Create a mock event for zapping
const profileEvent = author.data?.event;
if (author.isLoading) {
return (
<Layout>
<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>
</Layout>
);
}
return (
<Layout>
<div className="container px-4 py-6 max-w-4xl">
{/* Back Button */}
<Link
to="/bounties"
className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4"
>
<ArrowLeft className="h-4 w-4 mr-2" />
Back to Leaderboard
</Link>
{/* 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 && (
<img src={banner} alt="Banner" className="w-full h-full object-cover" />
)}
<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>
)}
<UserStatusIndicator pubkey={pubkey} size="md" showLabel className="hidden sm:flex" />
</div>
{nip05 && (
<p className="text-sm text-[#ccff00] font-medium flex items-center justify-center sm:justify-start gap-1">
<Check className="h-3 w-3" />
{nip05}
</p>
)}
{/* 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>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{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">Edit Profile</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">Message</span>
</Button>
</Link>
)}
{profileEvent && !isOwnProfile && (
<ZapButton
target={profileEvent}
className="bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
/>
)}
</div>
</div>
{/* Profile Info */}
<div className="grid 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>
)}
{/* Spots by this user */}
<div>
<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]" />
Spots Added
{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>
) : (
<Card className="border-dashed border-2 border-gray-800">
<CardContent className="py-8 text-center">
<MapPin className="h-10 w-10 mx-auto mb-3 text-gray-600" />
<p className="text-gray-500">No spots added yet</p>
</CardContent>
</Card>
)}
</div>
{/* Shops by this user */}
<div>
<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]" />
Shops Added
{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>
) : shops && shops.length > 0 ? (
<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.location && (
<p className="text-sm text-gray-400 truncate">{shop.location}</p>
)}
</div>
</CardContent>
</Card>
</Link>
))}
</div>
) : (
<Card className="border-dashed border-2 border-gray-800">
<CardContent className="py-8 text-center">
<Store className="h-10 w-10 mx-auto mb-3 text-gray-600" />
<p className="text-gray-500">No shops added yet</p>
</CardContent>
</Card>
)}
</div>
{/* Public Stash — gear this skater chose to show publicly */}
{(publicStashLoading || (publicStash && publicStash.length > 0)) && (
<div>
<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]" />
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">
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] ? (
<img
src={g.images[0]}
alt={g.title}
className="w-full h-full object-cover group-hover:scale-105 transition-transform duration-300"
loading="lazy"
/>
) : (
<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" />Trade</>
) : (
<><Tag className="h-2.5 w-2.5" />For Sale</>
)}
</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 */}
<div className="space-y-4 min-w-0">
{/* Quick Info Card */}
<Card className="bg-black/90 border-2 border-gray-800">
<CardHeader className="pb-3">
<CardTitle className="text-lg">Info</CardTitle>
</CardHeader>
<CardContent className="space-y-3">
{website && (
<a
href={website.startsWith('http') ? website : `https://${website}`}
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">
{website.replace(/^https?:\/\//, '')}
</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">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">
Silent Payment {spCopied && <span className="text-green-400">· 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 contact link */}
{cordnUrl && (
<a
href={cordnUrl}
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">Message privately</span>
</div>
<ExternalLink className="h-3 w-3 text-gray-500 ml-auto flex-shrink-0" />
</a>
)}
{/* 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 ? 'Copied!' : `${npub.slice(0, 12)}...${npub.slice(-8)}`}
</span>
</button>
</CardContent>
</Card>
{/* Sponsors Card */}
{sponsors && (
<Card className="bg-gradient-to-br from-pink-500/10 to-transparent border-2 border-pink-500/30">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Award className="h-5 w-5 text-pink-400" />
Sponsors & Support
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-sm text-gray-300 whitespace-pre-wrap break-words [overflow-wrap:anywhere]">
{sponsors}
</p>
</CardContent>
</Card>
)}
{/* Crews Card */}
{(crewsLoading || crews.length > 0) && (
<Card className="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" />
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>
<p className="text-xs text-gray-500 flex items-center gap-1">
{m.role === 'founder' ? (
<><Crown className="h-3 w-3 text-yellow-500" /> Founder</>
) : (
<>Member</>
)}
</p>
</div>
</Link>
))
)}
</CardContent>
</Card>
)}
{/* Stats Card */}
<Card className="bg-gradient-to-br from-[#ff0040]/10 to-[#ccff00]/10 border-2 border-[#ff0040]/30">
<CardContent className="p-4">
<h3 className="text-sm font-bold uppercase text-gray-400 mb-3">Contributions</h3>
<div className="grid grid-cols-4 gap-2">
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-[#ff0040]">{spots?.length || 0}</p>
<p className="text-[10px] text-gray-500 uppercase">Spots</p>
</div>
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-[#ccff00]">{shops?.length || 0}</p>
<p className="text-[10px] text-gray-500 uppercase">Shops</p>
</div>
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-teal-400">{crews.length}</p>
<p className="text-[10px] text-gray-500 uppercase">Crews</p>
</div>
<div className="text-center p-3 rounded-lg bg-black/40">
<p className="text-2xl font-black text-[#ff6600]">{publicStash?.length || 0}</p>
<p className="text-[10px] text-gray-500 uppercase">Gear</p>
</div>
</div>
</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">Support this skater</h3>
<p className="text-xs text-gray-400 mb-3">
Send a tip to thank them for their contributions
</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>
</Layout>
);
}
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':
// AI agent should implement addressable event view here
return <NotFound />;
default:
return <NotFound />;
}
}