SkateSpots/src/pages/CoachDetailPage.tsx
shakespeare.diy f19851da92 security: implement all 8 protection measures
## 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>
2026-06-18 14:03:15 +07:00

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>
);
}