SkateSpots/src/pages/BecomeFilmerPage.tsx
shakespeare.diy 5d96f8dfa8 Add tour countdown badges + fix/standardize NIP-05 provider prompts
Countdown badges (upcoming tours):
- New getCountdown() in TourCard: "In 3 days" / "Tomorrow" / "In 2 weeks" /
  "Happening now" (pulsing) based on start/end dates, category-tinted
- Shown on both compact and full TourCard, and in the TourDetailPage schedule card

NIP-05 provider fix (removes obsolete nip05.social):
- New single source of truth src/lib/nip05Providers.ts with working, curated
  providers: nostrcheck.me + nostr.directory (free), Nostr Plebs + nostr.band (paid)
- New reusable Nip05Required component (full-page "gate" + inline "banner"
  variants) listing those providers and deep-linking to /settings?section=nip05
- Wired into CreateTourPage (gate), CreateListingPage (banner) and all four
  Become* pages (Coach/Filmer/Photographer/Builder) for a consistent prompt
- SettingsPage NIP-05 screen now reads from the shared provider list
- Updated internal planning docs to match

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-09 22:31:44 +07:00

529 lines
24 KiB
TypeScript

import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { Nip05Required } from '@/components/Nip05Required';
import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { ExistingLabProfileNotice } from '@/components/ExistingLabProfileNotice';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
const SKILL_ENTRIES = Object.entries(FILMING_SKILL_LABELS);
export default function BecomeFilmerPage() {
useSeoMeta({
title: 'Become a Filmer - SkateSpots',
description: 'Create your skateboarding filming profile.',
});
const navigate = useNavigate();
const { user, metadata } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
// Check if user has any wallet connected
const hasWallet = !!webln || !!activeNWC;
// Platform listing fee — required when a rate is set
const { isActive: isPro, featuredUntilSeconds } = usePlatformFee('labProfile');
const [goPro, setGoPro] = useState(false);
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [image, setImage] = useState<string | null>(null);
const [experience, setExperience] = useState('');
const [rate, setRate] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
const [gear, setGear] = useState('');
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const getDisplayName = () => {
if (author.data?.metadata?.name) return author.data.metadata.name;
if (user?.pubkey) {
const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
return 'Anonymous Filmer';
};
const toggleService = (service: string) => {
setSelectedServices((prev) => prev.includes(service) ? prev.filter((s) => s !== service) : [...prev, service]);
};
const toggleSkill = (skill: string) => {
setSelectedSkills((prev) => prev.includes(skill) ? prev.filter((s) => s !== skill) : [...prev, skill]);
};
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
},
(error) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
}
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const file = files[0];
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setImage(url);
toast({ title: 'Image uploaded' });
}
} catch (error) {
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
}
e.target.value = '';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedServices.length === 0) {
toast({ title: 'Services required', description: 'Select at least one service.', variant: 'destructive' });
return;
}
if (selectedSkills.length === 0) {
toast({ title: 'Skills required', description: 'Select at least one filming skill.', variant: 'destructive' });
return;
}
try {
const identifier = 'filming-profile';
const displayName = getDisplayName();
const tags: string[][] = [
['d', identifier],
['title', displayName],
['t', 'skatefilmer'],
['alt', `Skate filmer: ${displayName}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
if (latitude && longitude) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) {
const geohash = ngeohash.encode(lat, lng, 7);
tags.push(['g', geohash]);
}
}
for (const service of selectedServices) tags.push(['t', service]);
for (const skill of selectedSkills) tags.push(['t', skill]);
if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.trim()]);
if (rate.trim()) tags.push(['rate', rate.trim()]);
if (phone.trim()) tags.push(['phone', phone.trim()]);
if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', website.trim()]);
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
tags.push(['entity', entityType]);
if (entityType === 'collective') {
for (const pk of members) tags.push(['p', pk]);
if (crewRef) tags.push(['a', crewRef]);
}
if (gear.trim()) tags.push(['gear', gear.trim()]);
// Add spot references
for (const spotId of selectedSpots) {
const [spotPubkey, spotIdentifier] = spotId.split(':');
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
// Pro / featured listing (optional, paid)
if (isPro && featuredUntilSeconds) {
tags.push(['featured', String(featuredUntilSeconds)]);
}
await createEvent({
kind: SKATE_FILMER_KIND,
content: bio.trim(),
tags,
});
toast({ title: 'Filming profile created!', description: 'Your profile has been published.' });
navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (error) {
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
}
};
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<Video className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-gray-400 mb-6">You need to log in to create a filming profile.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const hasNip05 = metadata?.nip05;
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab
</Link>
<h1 className="text-3xl font-black uppercase neon-lime" style={{
fontFamily: 'Impact, "Arial Black", sans-serif'
}}>Become a Filmer</h1>
<p className="text-gray-400 mt-1">Offer your skate video filming — clips, lines, full parts &amp; contest coverage</p>
{!hasNip05 && (
<Nip05Required
variant="banner"
color="#eab308"
role="filmers"
reason="Add a NIP-05 identifier to verify your identity and build trust with clients — it shows them you're a legitimate filmer."
className="mt-4"
/>
)}
{/* Wallet Requirement */}
<Card className="mt-4 bg-orange-950/30 border-2 border-orange-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-orange-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-orange-400 mb-2">⚡ Payment Setup Required</h3>
<p className="text-sm text-gray-300 mb-3">
To receive payments from clients for filming services, you need to connect a Lightning wallet. Clients will zap you directly for your work.
</p>
{hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
<div className="flex items-center gap-2 mb-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
<p className="text-sm font-bold text-green-400">Wallet Connected - Ready to Receive Payments!</p>
</div>
<p className="text-xs text-gray-400">
{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}
</p>
</div>
) : (
<div className="space-y-3">
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500" />
<p className="text-sm font-bold text-red-400">No Wallet Connected</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<WalletModal>
<Button className="gap-2 bg-orange-500 hover:bg-orange-600 flex-1">
<Wallet className="h-4 w-4" />
Connect Wallet with NWC
</Button>
</WalletModal>
<a
href="https://planb.academy/en/tutorials/wallet"
target="_blank"
rel="noopener noreferrer"
className="flex-1"
>
<Button variant="outline" className="gap-2 w-full border-orange-500/50 text-orange-400 hover:bg-orange-500/10">
<ExternalLink className="h-4 w-4" />
Learn How
</Button>
</a>
</div>
<p className="text-xs text-gray-400 italic">
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos). Takes 30 seconds to set up.
</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle>Filming Profile</CardTitle>
<CardDescription className="text-gray-400">Tell clients about your filming expertise</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ExistingLabProfileNotice identifier="filming-profile" label="filmer" />
<div className="space-y-2">
<Label>Your Filmer Identity</Label>
<div className="p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
<p className="font-black text-lg">{getDisplayName()}</p>
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label>
<Textarea id="bio" placeholder="Your background, style, equipment..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
<EntityTypeField
entityType={entityType}
onEntityTypeChange={setEntityType}
members={members}
onMembersChange={setMembers}
accentBg="bg-blue-600 hover:bg-blue-700"
accentText="text-blue-400"
/>
{entityType === 'collective' && (
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-blue-600 hover:bg-blue-700" accentText="text-blue-400" />
)}
<div className="space-y-3">
<Label>Services Offered * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SERVICE_ENTRIES.map(([service, label]) => (
<button
key={service}
type="button"
onClick={() => toggleService(service)}
className={cn(
'p-3 rounded-lg text-left transition-all border text-sm',
selectedServices.includes(service)
? 'bg-red-500 text-white border-red-500'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className={cn('text-xs mt-1 line-clamp-1', selectedServices.includes(service) ? 'text-white/80' : 'text-gray-400')}>
{FILMING_SERVICE_DESCRIPTIONS[service]}
</div>
</button>
))}
</div>
</div>
<div className="space-y-3">
<Label>Cameras & Equipment * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SKILL_ENTRIES.map(([skill, label]) => (
<button
key={skill}
type="button"
onClick={() => toggleSkill(skill)}
className={cn(
'p-3 rounded-lg text-left transition-all border text-sm',
selectedSkills.includes(skill)
? 'bg-purple-500 text-white border-purple-500'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className={cn('text-xs mt-1 line-clamp-1', selectedSkills.includes(skill) ? 'text-white/80' : 'text-gray-400')}>
{FILMING_SKILL_DESCRIPTIONS[skill]}
</div>
</button>
))}
</div>
</div>
{/* Freeform gear list */}
<div className="space-y-2">
<Label htmlFor="gear">What's in the Bag (optional)</Label>
<Textarea
id="gear"
placeholder="e.g. Sony VX1000 + MK1 fisheye, Panasonic HVX200, DJI RS3 gimbal, GoPro Hero 12, Sennheiser mic..."
value={gear}
onChange={(e) => setGear(e.target.value)}
rows={3}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-500">List your exact cameras, lenses and rigs — skaters care about the setup.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="experience">Years Experience</Label>
<Input id="experience" type="number" placeholder="5" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="rate">Rate</Label>
<Input id="rate" placeholder="10000 sats/video" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="location">City / Area</Label>
<Input id="location" placeholder="Los Angeles, CA" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-3">
<Label className="text-base font-bold">Contact Information</Label>
<p className="text-sm text-gray-400">How clients can reach you (all optional, but recommended)</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input id="phone" type="tel" placeholder="+1 234 567 8900" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="filmer@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="portfolio">Reel / Portfolio</Label>
<Input id="portfolio" type="url" placeholder="https://vimeo.com/you" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input id="website" type="url" placeholder="https://mysite.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="social">Social Media</Label>
<Input id="social" placeholder="@username" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-blue-500/30 bg-blue-500/5">
<p className="text-sm text-gray-300">
💬 Clients can message you directly through SkateSpots' encrypted messaging
</p>
</div>
</div>
{/* Spot Picker */}
<SpotPicker
selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots}
maxSpots={5}
label="Spots Where You Film"
description="Select spots where you offer filming services"
/>
<div className="space-y-2">
<Label>Coordinates (optional)</Label>
<div className="grid grid-cols-2 gap-4">
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
</Button>
</div>
<div className="space-y-2">
<Label>Profile Photo</Label>
{image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
<img src={image} alt="Profile" className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" />
</button>
</div>
) : (
<label className="block w-32 h-32 rounded-full border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
</label>
)}
</div>
{/* Optional Pro upgrade — free to list without it */}
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-red-400" entityCoord={user ? `${SKATE_FILMER_KIND}:${user.pubkey}:filming-profile` : undefined} />
<Button
type="submit"
className="w-full bg-red-500 hover:bg-red-600 uppercase font-black"
disabled={isPublishing || isUploading}
>
{isPublishing ? (
<>
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
Publishing...
</>
) : (
<>
<Video className="h-4 w-4 mr-2" />
{isPro ? 'CREATE PRO FILMING PROFILE' : 'CREATE FILMING PROFILE'}
</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</Layout>
);
}