SkateSpots/src/pages/CoachDetailPage.tsx
shakespeare.diy e48a5e6bc5 Add duration tiers + layered NIP-57 zap verification, extend featured/Pro to tours
- Add 4 duration tiers (7d/30d/90d/365d) with per-day price discrimination to usePlatformFee
- PlatformFeePayment: tier selector UI + attempt NIP-57 zap request (a/p tags), graceful fallback to plain LNURL invoice
- Add getZapProviderPubkey + getZapInvoiceFromLightningAddress helpers
- Add useFeaturedVerification hook (verified/claimed/none) querying kind 9735 zap receipts
- ProBadge: optional entityCoord to show green Verified state on detail pages
- Wire entityCoord through all 5 Become* Lab pages + CoachDetailPage
- Add featuredTour fee type; FeaturedUpgrade generalized for events + tours
- CreateTourPage: featured tag on publish + FeaturedUpgrade UI
- TourCard/TourDetailPage: render ProBadge; sort featured tours first
- Document tiers + zap-receipt verification scheme in NIP.md

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 16:18:15 +07:00

420 lines
20 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 { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { LinkedCrewCard } from '@/components/CrossLinkCards';
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, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS } from '@/lib/types';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge';
import NotFound from './NotFound';
import {
GraduationCap,
MapPin,
ArrowLeft,
MessageCircle,
Mail,
Phone,
Globe,
Instagram,
Calendar,
Video,
Camera,
Scissors,
Aperture,
Users,
HardHat,
} 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';
const isEditor = profile?.profileType === 'editor';
const isPhotographer = profile?.profileType === 'photographer';
const isBuilder = profile?.profileType === 'builder';
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);
// Per-type accent (text, border, bg, ring) — keeps the detail page on-brand
const accent = isCoach
? { text: 'text-[#ff0040]', bg: 'bg-[#ff0040]', border: 'border-[#ff0040]/30', hover: 'hover:bg-[#ff0040]/10 border-[#ff0040]' }
: isFilmer
? { text: 'text-orange-500', bg: 'bg-orange-500', border: 'border-orange-500/30', hover: 'hover:bg-orange-500/10 border-orange-500' }
: isPhotographer
? { text: 'text-indigo-400', bg: 'bg-indigo-600', border: 'border-indigo-500/30', hover: 'hover:bg-indigo-500/10 border-indigo-500' }
: isBuilder
? { text: 'text-amber-400', bg: 'bg-amber-500', border: 'border-amber-500/30', hover: 'hover:bg-amber-500/10 border-amber-500' }
: { text: 'text-purple-400', bg: 'bg-purple-600', border: 'border-purple-500/30', hover: 'hover:bg-purple-500/10 border-purple-500' };
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : isBuilder ? HardHat : Scissors;
// Kind for the collective coordinate (for NIP-32 membership attestations)
const profileKind = isCoach ? 35001 : isFilmer ? 35002 : isEditor ? 35003 : isPhotographer ? 35004 : 35005;
const isCollective = profile.entityType === 'collective' && profile.members.length > 0;
// All photographer label maps merged for chip resolution
const photoLabels: Record<string, string> = {
...PHOTO_SERVICE_LABELS, ...PHOTO_CAMERA_LABELS, ...PHOTO_LENS_LABELS,
...PHOTO_LIGHTING_LABELS, ...PHOTO_SUPPORT_LABELS, ...PHOTO_STYLE_LABELS,
};
const builderLabels: Record<string, string> = {
...BUILDER_SERVICE_LABELS, ...BUILDER_SKILL_LABELS, ...BUILDER_SCALE_LABELS,
};
// 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;
if (isPhotographer) return photoLabels[skill] || skill;
if (isBuilder) return builderLabels[skill] || skill;
if (isEditor) return EDITOR_SERVICE_LABELS[skill] || EDITOR_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];
if (isPhotographer) return PHOTO_SERVICE_DESCRIPTIONS[skill];
if (isBuilder) return BUILDER_SERVICE_DESCRIPTIONS[skill];
if (isEditor) return EDITOR_SERVICE_DESCRIPTIONS[skill] || EDITOR_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">
{/* Confirm-membership prompt (only for listed pending members) */}
{isCollective && (
<ConfirmMembershipCard
kind={profileKind}
founderPubkey={profile.pubkey}
identifier={profile.identifier}
collectiveName={profile.title}
claimedMembers={profile.members}
accentBg={`${accent.bg} hover:opacity-90`}
/>
)}
{/* Profile Card */}
<Card className={`bg-black/90 border-2 ${accent.border}`}>
<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={`${accent.bg} 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 ${accent.text}`} style={{
fontFamily: 'Impact, "Arial Black", sans-serif'
}}>
{profile.title}
</h1>
<TypeIcon className={`h-7 w-7 ${accent.text}`} />
{profile.entityType === 'collective' && (
<Badge variant="outline" className={`gap-1 ${accent.text} ${accent.border}`}>
<Users className="h-3 w-3" /> Collective
</Badge>
)}
<TrustBadge pubkey={profile.pubkey} size="md" />
<OfficialBadge pubkey={profile.pubkey} size="md" />
<ProBadge
featuredUntil={profile.featuredUntil}
entityCoord={`${profile.event.kind}:${profile.pubkey}:${profile.identifier}`}
/>
</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' : isPhotographer ? 'Services & Gear' : isBuilder ? 'What We Build' : 'Services & Skills'}
</h3>
<div className="space-y-3">
{profile.skills.map((skill) => (
<div key={skill} className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<div className="flex items-start gap-3">
<Badge className={`${accent.bg} 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>
)}
{/* In the Bag — photographer / filmer gear (freeform) */}
{(isPhotographer || isFilmer || isBuilder) && 'gear' in profile && profile.gear && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
{isBuilder ? <HardHat className={`h-4 w-4 ${accent.text}`} /> : <Aperture className={`h-4 w-4 ${accent.text}`} />}
{isBuilder ? 'How We Work' : 'In the Bag'}
</h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{profile.gear}
</p>
</div>
)}
{/* Builder — service area & insured */}
{isBuilder && 'serviceArea' in profile && (profile.serviceArea || profile.insured) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{profile.serviceArea && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<MapPin className={`h-3 w-3 ${accent.text}`} /> Service Area
</h4>
<p className="text-sm text-gray-300">{profile.serviceArea}</p>
</div>
)}
{profile.insured && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<HardHat className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-gray-200">Licensed / Insured</span>
</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 || isPhotographer || isEditor || isBuilder) && 'social' in profile && 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>
)}
{/* Portfolio / reel (filmers, photographers & editors) */}
{(isPhotographer || isEditor || isFilmer || isBuilder) && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && (
<a href={sanitizeUrl(profile.portfolio)!} 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.portfolio!)}`}>
<Aperture className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">Portfolio · {urlDomain(profile.portfolio!)}</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>
{/* CLINK self-custodial payment (only when a valid noffer is present) */}
{'noffer' in profile && profile.noffer && (
<ClinkPayButton
noffer={profile.noffer}
recipientName={profile.title}
className="w-full justify-center"
/>
)}
</CardContent>
</Card>
{/* Members Card (collectives only) */}
{isCollective && (
<CollectiveMembers
kind={profileKind}
founderPubkey={profile.pubkey}
identifier={profile.identifier}
claimedMembers={profile.members}
accentText={accent.text}
accentBorder={accent.border}
/>
)}
{/* Linked crew (cross-link) */}
<LinkedCrewCard crewRef={profile.crewRef} />
{/* 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 ${accent.text}`} />
Location
</CardTitle>
</CardHeader>
<CardContent>
<p className="text-gray-300">{profile.location}</p>
</CardContent>
</Card>
)}
</div>
</div>
</div>
</Layout>
);
}