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>
651 lines
28 KiB
TypeScript
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 />;
|
|
}
|
|
}
|