- 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>
420 lines
20 KiB
TypeScript
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>
|
|
);
|
|
}
|