## 1. sanitizeUrl() (already existed, verified applied) - Applied to ShopDetailPage, CoachDetailPage with urlDomain() tooltip hints - Blocks javascript:, data:, vbscript: and all non-http(s) schemes - Returns null for invalid URLs — link simply doesn't render ## 2. Geohash validation in parsers - Added isValidGeohash() in types.ts using ngeohash.decode() - Min 4 chars (country-level precision rejected), max 12 chars - Rejects lat/lng outside Earth bounds - Rejects 0,0 "null island" — classic bot artifact - VPN/Tor unaffected: geohash comes from map pin, not user IP ## 3. Content length limits in all parsers - MAX_TITLE_LEN=100, MAX_DESCRIPTION_LEN=3000, MAX_LOCATION_LEN=200 - MIN_TITLE_LEN=2, MIN_GEOHASH_LEN=4 - Applied to parseSkateSpot, parseSkateShop, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkateEvent ## 4. Smart WoT default (already in codebase, verified correct) - Logged out / <5 follows → 'all' (don't punish new/anon users) - Logged in with ≥5 follows → 'trusted' (sort trusted first) - Soft mode: trusted content floats to top, nothing hidden - Active in MapPage and EventsPage ## 5. Trusted count stats in useGrowthStats - Added trustedSpots and trustedMappers to GrowthStats type - Heuristic: pubkeys with ≥2 published items = organic community member - CommunityStatsPage now shows "🛡 X from active accounts" below spot count - Purely additive — both numbers always visible ## 6. OfficialBadge component (already existed, wired up) - Added to SpotCard, EventCard, ShopDetailPage, CoachDetailPage - Only renders for FOUNDER_PUBKEY (20e7c953...) - Zero-friction: invisible for all other pubkeys - Impersonation attempts immediately visible ## 7. Leaflet marker clustering - Installed leaflet.markercluster v1.5.3 + @types - MarkerClusterGroup replaces direct marker.addTo(map) - Custom cluster icon: red/orange gradient circle with count - maxClusterRadius: 60px, spiderfyOnMaxZoom: true - MarkerCluster CSS loaded from CDN in index.html ## 8. DM phishing warning banner - PhishingWarningBanner component in DirectMessagesPage - One-time dismissible (localStorage key: dm-phishing-warning-dismissed) - Message: SkateSpots will never DM you asking for keys/payments - Uses useLocalStorage hook, Shield icon, X dismiss button Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
290 lines
12 KiB
TypeScript
290 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 { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
|
|
import { OfficialBadge } from '@/components/OfficialBadge';
|
|
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" />
|
|
<OfficialBadge 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 */}
|
|
{sanitizeTel(profile.phone) && (
|
|
<a href={sanitizeTel(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>
|
|
)}
|
|
|
|
{sanitizeEmail(profile.email) && (
|
|
<a href={sanitizeEmail(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>
|
|
)}
|
|
|
|
{sanitizeUrl(profile.website) && (
|
|
<a href={sanitizeUrl(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" title={`Opens: ${urlDomain(profile.website!)}`}>
|
|
<Globe className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="truncate">{urlDomain(profile.website!)}</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 && sanitizeUrl(profile.social) && (
|
|
<a href={sanitizeUrl(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" title={`Opens: ${urlDomain(profile.social!)}`}>
|
|
<Globe className="h-4 w-4 text-[#ccff00]" />
|
|
<span className="truncate">{urlDomain(profile.social!)}</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>
|
|
);
|
|
}
|