SkateSpots/src/pages/NIP19Page.tsx
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

378 lines
15 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 { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useUserSkateSpots } from '@/hooks/useSkateSpots';
import { useUserShops } from '@/hooks/useShops';
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,
} 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 [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 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">
<h1 className="text-2xl sm:text-3xl font-black uppercase text-white">
{displayName}
</h1>
<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>
)}
</div>
{/* Actions */}
<div className="flex items-center gap-2">
{!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">
{/* 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">{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>
</div>
{/* Sidebar */}
<div className="space-y-4">
{/* 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" />
<span className="text-gray-300 text-sm truncate">{lud16}</span>
</div>
)}
{/* 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>
{/* 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-2 gap-3">
<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-xs 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-xs text-gray-500 uppercase">Shops</p>
</div>
</div>
</CardContent>
</Card>
{/* Tip Card */}
{!isOwnProfile && profileEvent && (
<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 mapper</h3>
<p className="text-xs text-gray-400 mb-3">
Send a zap to thank them for their contributions
</p>
<ZapButton
target={profileEvent}
className="w-full justify-center bg-[#ccff00] text-black hover:bg-[#ccff00]/90 font-bold"
/>
</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 />;
}
}