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>
287 lines
12 KiB
TypeScript
287 lines
12 KiB
TypeScript
import { useSeoMeta } from '@unhead/react';
|
|
import { useParams, Link, useNavigate } from 'react-router-dom';
|
|
import { Layout } from '@/components/Layout';
|
|
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
|
|
import { Badge } from '@/components/ui/badge';
|
|
import { Button } from '@/components/ui/button';
|
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
|
import { Skeleton } from '@/components/ui/skeleton';
|
|
import { ZapButton } from '@/components/ZapButton';
|
|
import { TrustBadge } from '@/components/TrustBadge';
|
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
|
import { useAuthor } from '@/hooks/useAuthor';
|
|
import { genUserName } from '@/lib/genUserName';
|
|
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS } from '@/lib/types';
|
|
import NotFound from './NotFound';
|
|
import {
|
|
GraduationCap,
|
|
MapPin,
|
|
ArrowLeft,
|
|
MessageCircle,
|
|
Mail,
|
|
Phone,
|
|
Globe,
|
|
Instagram,
|
|
Calendar,
|
|
Video,
|
|
} from 'lucide-react';
|
|
import { nip19 } from 'nostr-tools';
|
|
|
|
function CoachDetailSkeleton() {
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-8 max-w-4xl">
|
|
<Skeleton className="h-6 w-32 mb-6" />
|
|
<div className="grid md:grid-cols-3 gap-6">
|
|
<div className="md:col-span-2 space-y-6">
|
|
<Card>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-start gap-4">
|
|
<Skeleton className="h-20 w-20 rounded-full" />
|
|
<div className="flex-1 space-y-2">
|
|
<Skeleton className="h-8 w-48" />
|
|
<Skeleton className="h-4 w-32" />
|
|
</div>
|
|
</div>
|
|
<Skeleton className="h-24 w-full mt-4" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
<div>
|
|
<Skeleton className="h-64 w-full" />
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|
|
|
|
export default function CoachDetailPage() {
|
|
const { pubkey, identifier } = useParams<{ pubkey: string; identifier: string }>();
|
|
const { data: profile, isLoading, error } = useLabProfile(identifier || '', pubkey);
|
|
const author = useAuthor(profile?.pubkey);
|
|
const navigate = useNavigate();
|
|
|
|
const isCoach = profile?.profileType === 'coach';
|
|
const isFilmer = profile?.profileType === 'filmer';
|
|
|
|
useSeoMeta({
|
|
title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots',
|
|
description: profile?.bio || 'View profile.',
|
|
});
|
|
|
|
const metadata = author.data?.metadata;
|
|
const displayName = metadata?.name ?? genUserName(profile?.pubkey || '');
|
|
|
|
if (isLoading) {
|
|
return <CoachDetailSkeleton />;
|
|
}
|
|
|
|
if (error || !profile) {
|
|
return <NotFound />;
|
|
}
|
|
|
|
const npub = nip19.npubEncode(profile.pubkey);
|
|
|
|
// Get skill labels and descriptions based on profile type
|
|
const getSkillLabel = (skill: string) => {
|
|
if (isCoach) return COACH_SKILL_LABELS[skill] || skill;
|
|
if (isFilmer) return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
|
|
return skill;
|
|
};
|
|
|
|
const getSkillDescription = (skill: string) => {
|
|
if (isCoach) return COACH_SKILL_DESCRIPTIONS[skill];
|
|
if (isFilmer) return FILMING_SERVICE_DESCRIPTIONS[skill] || FILMING_SKILL_DESCRIPTIONS[skill];
|
|
return undefined;
|
|
};
|
|
|
|
return (
|
|
<Layout>
|
|
<div className="container px-4 py-8 max-w-4xl">
|
|
<Link
|
|
to="/lab"
|
|
className="inline-flex items-center text-muted-foreground hover:text-foreground transition-colors mb-6"
|
|
>
|
|
<ArrowLeft className="h-4 w-4 mr-2" />
|
|
Back to The Lab
|
|
</Link>
|
|
|
|
<div className="grid md:grid-cols-3 gap-6">
|
|
{/* Main Content */}
|
|
<div className="md:col-span-2 space-y-6">
|
|
{/* Profile Card */}
|
|
<Card className={`bg-black/90 border-2 ${isCoach ? 'border-[#ff0040]/30' : 'border-orange-500/30'}`}>
|
|
<CardContent className="pt-6">
|
|
<div className="flex items-start gap-4 mb-6">
|
|
<Avatar className="h-20 w-20">
|
|
<AvatarImage src={profile.image || metadata?.picture} alt={profile.title} />
|
|
<AvatarFallback className={`${isCoach ? 'bg-[#ff0040]' : 'bg-orange-500'} text-white text-2xl`}>
|
|
{profile.title.slice(0, 2).toUpperCase()}
|
|
</AvatarFallback>
|
|
</Avatar>
|
|
<div className="flex-1">
|
|
<div className="flex items-center gap-2 mb-2 flex-wrap">
|
|
<h1 className={`text-3xl font-black uppercase ${isCoach ? 'text-[#ff0040]' : 'text-orange-500'}`} style={{
|
|
fontFamily: 'Impact, "Arial Black", sans-serif'
|
|
}}>
|
|
{profile.title}
|
|
</h1>
|
|
{isCoach && <GraduationCap className="h-7 w-7 text-[#ff0040]" />}
|
|
{isFilmer && <Video className="h-7 w-7 text-orange-500" />}
|
|
<TrustBadge pubkey={profile.pubkey} size="md" />
|
|
</div>
|
|
<p className="text-gray-400 flex items-center gap-2">
|
|
<Calendar className="h-4 w-4" />
|
|
Joined {new Date(profile.createdAt * 1000).toLocaleDateString()}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Bio */}
|
|
{profile.bio && (
|
|
<div className="mb-6">
|
|
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2">About</h3>
|
|
<p className="text-gray-300 whitespace-pre-wrap">{profile.bio}</p>
|
|
</div>
|
|
)}
|
|
|
|
{/* Skills */}
|
|
{profile.skills.length > 0 && (
|
|
<div className="mb-6">
|
|
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3">
|
|
{isCoach ? 'Skills' : 'Services & Skills'}
|
|
</h3>
|
|
<div className="space-y-3">
|
|
{profile.skills.map((skill) => (
|
|
<div key={skill} className={`bg-black/50 border rounded-lg p-3 ${isCoach ? 'border-[#ff0040]/30' : 'border-orange-500/30'}`}>
|
|
<div className="flex items-start gap-3">
|
|
<Badge className={`${isCoach ? 'bg-[#ff0040]' : 'bg-orange-500'} text-white font-bold`}>
|
|
{getSkillLabel(skill)}
|
|
</Badge>
|
|
{getSkillDescription(skill) && (
|
|
<p className="text-sm text-gray-400 flex-1">
|
|
{getSkillDescription(skill)}
|
|
</p>
|
|
)}
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Experience & Rate */}
|
|
<div className="grid grid-cols-2 gap-4">
|
|
{profile.experience && (
|
|
<div>
|
|
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">Experience</h4>
|
|
<p className="text-gray-300">{profile.experience} years</p>
|
|
</div>
|
|
)}
|
|
{profile.rate && (
|
|
<div>
|
|
<h4 className="text-sm font-bold text-[#ccff00] uppercase mb-1">Rate</h4>
|
|
<p className="text-gray-300">{profile.rate}</p>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
|
|
{/* Sidebar */}
|
|
<div className="space-y-6">
|
|
{/* Contact Card */}
|
|
<Card className="bg-black/90 border-2 border-[#ccff00]/50">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg text-[#ccff00] uppercase font-black flex items-center gap-2">
|
|
<MessageCircle className="h-5 w-5" />
|
|
Contact
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent className="space-y-3">
|
|
{/* Direct Message */}
|
|
<Button
|
|
variant="outline"
|
|
className="w-full gap-2 border-[#ff0040] hover:bg-[#ff0040]/10 text-white"
|
|
onClick={() => navigate(`/messages/${npub}`)}
|
|
>
|
|
<MessageCircle className="h-4 w-4" />
|
|
Send Message
|
|
</Button>
|
|
|
|
{/* Contact Info from Profile */}
|
|
{profile.phone && (
|
|
<a href={`tel:${profile.phone}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
|
<Phone className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="truncate">{profile.phone}</span>
|
|
</a>
|
|
)}
|
|
|
|
{profile.email && (
|
|
<a href={`mailto:${profile.email}`} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
|
<Mail className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="truncate">{profile.email}</span>
|
|
</a>
|
|
)}
|
|
|
|
{profile.website && (
|
|
<a href={profile.website} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
|
<Globe className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="truncate">{profile.website.replace(/^https?:\/\//, '')}</span>
|
|
</a>
|
|
)}
|
|
|
|
{isCoach && profile.instagram && (
|
|
<a href={`https://instagram.com/${profile.instagram.replace('@', '')}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
|
<Instagram className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="truncate">@{profile.instagram.replace('@', '')}</span>
|
|
</a>
|
|
)}
|
|
|
|
{isFilmer && profile.social && (
|
|
<a href={profile.social.startsWith('http') ? profile.social : `https://${profile.social}`} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">
|
|
<Globe className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="truncate">{profile.social.replace(/^https?:\/\//, '')}</span>
|
|
</a>
|
|
)}
|
|
|
|
{/* Lightning Address from Nostr Profile */}
|
|
{metadata?.lud16 && (
|
|
<div className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded">
|
|
<Mail className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="truncate">{metadata.lud16}</span>
|
|
</div>
|
|
)}
|
|
|
|
{/* Zap Button */}
|
|
<div className="pt-3 border-t border-gray-700">
|
|
<ZapButton
|
|
target={profile.event}
|
|
className="w-full justify-center"
|
|
/>
|
|
</div>
|
|
</CardContent>
|
|
</Card>
|
|
|
|
{/* Location Card */}
|
|
{profile.location && (
|
|
<Card className="bg-black/90 border-2 border-gray-700">
|
|
<CardHeader>
|
|
<CardTitle className="text-lg text-white uppercase font-black flex items-center gap-2">
|
|
<MapPin className={`h-5 w-5 ${isCoach ? 'text-[#ff0040]' : 'text-orange-500'}`} />
|
|
Location
|
|
</CardTitle>
|
|
</CardHeader>
|
|
<CardContent>
|
|
<p className="text-gray-300">{profile.location}</p>
|
|
</CardContent>
|
|
</Card>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Layout>
|
|
);
|
|
}
|