SkateSpots/src/pages/BecomePhotographerPage.tsx
shakespeare.diy adeab62aa4 Add Photographer card to The Lab (kind 35004) + improve Filmer section
New Photographers category (blue-indigo) — separate from Filmers:
- New addressable kind 35004 + SkatePhotographer type + parseSkatePhotographer.
- Rich photographer taxonomy as queryable `t` tags: services (action,
  sequence, editorial, product, events…), camera bodies, lenses, lighting
  (Profoto/Elinchrom/Godox/triggers…), support/rig, and shooting style,
  plus a freeform "gear" tag and a portfolio link.
- BecomePhotographerPage form with chip-grid selectors for all gear
  categories + "what's in the bag" textarea, wired to /become-photographer.
- The Lab: 4th "Photo" tab (indigo accent) with its own filters, cards,
  empty state; OfferFAB gains "Offer Photography"; useLabProfiles fetches
  35004; CoachDetailPage renders photographers (and editors) properly with
  per-type accent, gear + portfolio display.
- NIP.md: documented the whole Lab (kinds 35001-35004) incl. photographer.

Filmer improvements (now video-focused, photography split out):
- Removed the token "Action Photography" service; added follow/line filming.
- Richer equipment list: cinema camera, HVX, action cam, gimbal, 4K/HFR.
- Filmers gain a freeform "what's in the bag" gear field + a reel/portfolio
  link, both surfaced on the detail page.

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-07-24 11:27:22 +07:00

547 lines
25 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

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 { SpotPicker } from '@/components/SpotPicker';
import {
SKATE_PHOTOGRAPHER_KIND,
SKATE_SPOT_KIND,
PHOTO_SERVICE_LABELS,
PHOTO_SERVICE_DESCRIPTIONS,
PHOTO_CAMERA_LABELS,
PHOTO_LENS_LABELS,
PHOTO_LIGHTING_LABELS,
PHOTO_SUPPORT_LABELS,
PHOTO_STYLE_LABELS,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap, Aperture } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(PHOTO_SERVICE_LABELS);
const CAMERA_ENTRIES = Object.entries(PHOTO_CAMERA_LABELS);
const LENS_ENTRIES = Object.entries(PHOTO_LENS_LABELS);
const LIGHTING_ENTRIES = Object.entries(PHOTO_LIGHTING_LABELS);
const SUPPORT_ENTRIES = Object.entries(PHOTO_SUPPORT_LABELS);
const STYLE_ENTRIES = Object.entries(PHOTO_STYLE_LABELS);
// Small reusable multi-select chip grid
function ChipGrid({
entries,
selected,
onToggle,
}: {
entries: [string, string][];
selected: string[];
onToggle: (key: string) => void;
}) {
return (
<div className="flex flex-wrap gap-2">
{entries.map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => onToggle(key)}
className={cn(
'px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95',
selected.includes(key)
? 'bg-indigo-600 text-white border-indigo-600'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
)}
>
{label}
</button>
))}
</div>
);
}
export default function BecomePhotographerPage() {
useSeoMeta({
title: 'Become a Photographer - SkateSpots',
description: 'Create your skate photography profile — cameras, lenses, lighting and services.',
});
const navigate = useNavigate();
const { user } = 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();
const hasWallet = !!webln || !!activeNWC;
const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const [selectedCameras, setSelectedCameras] = useState<string[]>([]);
const [selectedLenses, setSelectedLenses] = useState<string[]>([]);
const [selectedLighting, setSelectedLighting] = useState<string[]>([]);
const [selectedSupport, setSelectedSupport] = useState<string[]>([]);
const [selectedStyles, setSelectedStyles] = useState<string[]>([]);
const [gear, setGear] = useState('');
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 [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 Photographer';
};
const toggle = (setter: React.Dispatch<React.SetStateAction<string[]>>) => (key: string) => {
setter((prev) => (prev.includes(key) ? prev.filter((s) => s !== key) : [...prev, key]));
};
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 you offer.', variant: 'destructive' });
return;
}
if (selectedCameras.length === 0) {
toast({ title: 'Camera required', description: 'Select at least one camera type you shoot on.', variant: 'destructive' });
return;
}
try {
const identifier = 'photography-profile';
const displayName = getDisplayName();
const tags: string[][] = [
['d', identifier],
['title', displayName],
['t', 'skatephotographer'],
['alt', `Skate photographer: ${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]);
}
}
// All selections become queryable `t` tags
for (const s of [
...selectedServices,
...selectedCameras,
...selectedLenses,
...selectedLighting,
...selectedSupport,
...selectedStyles,
]) {
tags.push(['t', s]);
}
if (gear.trim()) tags.push(['gear', gear.trim()]);
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()]);
for (const spotId of selectedSpots) {
const [spotPubkey, spotIdentifier] = spotId.split(':');
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
await createEvent({
kind: SKATE_PHOTOGRAPHER_KIND,
content: bio.trim(),
tags,
});
toast({ title: 'Photography 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">
<Camera 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 photography profile.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const hasNip05 = user?.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 text-indigo-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
Become a Photographer
</h1>
<p className="text-gray-400 mt-1">Shoot the sessions — offer your skate photography</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-yellow-500">⚠️</div>
<div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Required</h3>
<p className="text-sm text-gray-300 mb-2">
Add a NIP-05 identifier to your profile to verify your identity and build trust with clients.
</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
)}
{/* Wallet Requirement */}
<Card className="mt-4 bg-indigo-950/30 border-2 border-indigo-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-indigo-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-indigo-400 mb-2">⚡ Payment Setup Required</h3>
<p className="text-sm text-gray-300 mb-3">
To get paid for shoots, connect a Lightning wallet. Clients 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-indigo-600 hover:bg-indigo-700 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-indigo-500/50 text-indigo-400 hover:bg-indigo-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).
</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Camera className="h-5 w-5 text-indigo-400" /> Photography Profile
</CardTitle>
<CardDescription className="text-gray-400">Tell riders about your eye, your kit and your services</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Your Photographer Identity</Label>
<div className="p-4 rounded-lg border-2 border-indigo-500/30 bg-indigo-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, what you love shooting..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
{/* Services */}
<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={() => toggle(setSelectedServices)(service)}
className={cn(
'p-3 rounded-lg text-left transition-all border text-sm',
selectedServices.includes(service)
? 'bg-indigo-600 text-white border-indigo-600'
: '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')}>
{PHOTO_SERVICE_DESCRIPTIONS[service]}
</div>
</button>
))}
</div>
</div>
{/* Camera */}
<div className="space-y-3">
<Label className="flex items-center gap-2"><Camera className="h-4 w-4 text-indigo-400" /> Camera Bodies * (select at least one)</Label>
<ChipGrid entries={CAMERA_ENTRIES} selected={selectedCameras} onToggle={toggle(setSelectedCameras)} />
</div>
{/* Lenses */}
<div className="space-y-3">
<Label className="flex items-center gap-2"><Aperture className="h-4 w-4 text-indigo-400" /> Lenses</Label>
<ChipGrid entries={LENS_ENTRIES} selected={selectedLenses} onToggle={toggle(setSelectedLenses)} />
</div>
{/* Lighting */}
<div className="space-y-3">
<Label className="flex items-center gap-2">💡 Lighting Gear</Label>
<ChipGrid entries={LIGHTING_ENTRIES} selected={selectedLighting} onToggle={toggle(setSelectedLighting)} />
</div>
{/* Support */}
<div className="space-y-3">
<Label className="flex items-center gap-2">🎒 Support & Rig</Label>
<ChipGrid entries={SUPPORT_ENTRIES} selected={selectedSupport} onToggle={toggle(setSelectedSupport)} />
</div>
{/* Style */}
<div className="space-y-3">
<Label className="flex items-center gap-2">🎨 Shooting Style</Label>
<ChipGrid entries={STYLE_ENTRIES} selected={selectedStyles} onToggle={toggle(setSelectedStyles)} />
</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. Canon R6 II, 8-15mm fisheye, 70-200mm f/2.8, 2× Godox AD200, remote triggers, Manfrotto tripod..."
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 exact bodies, lenses and lights — riders love knowing 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="20000 sats/shoot" 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="Barcelona, ES" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
{/* Contact */}
<div className="space-y-3">
<Label className="text-base font-bold">Contact & Portfolio</Label>
<p className="text-sm text-gray-400">How riders can see your work and 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="portfolio">Portfolio / Instagram</Label>
<Input id="portfolio" type="url" placeholder="https://instagram.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://mywork.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<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="shooter@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="social">Other Social</Label>
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5">
<p className="text-sm text-gray-300">💬 Riders can message you directly through SkateSpots' encrypted messaging</p>
</div>
</div>
{/* Spot Picker */}
<SpotPicker
selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots}
maxSpots={5}
label="Spots Where You Shoot"
description="Select spots where you offer photography"
/>
<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="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>
{/* Platform fee gate — only when a rate is set */}
{rate.trim() && !feePaid && !isPlatformOwner && (
<PlatformFeePayment
type="labProfile"
onSuccess={() => { /* hook auto-updates */ }}
context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
/>
)}
<Button
type="submit"
className="w-full bg-indigo-600 hover:bg-indigo-700 uppercase font-black"
disabled={isPublishing || isUploading || !hasWallet || (!!rate.trim() && !feePaid && !isPlatformOwner)}
>
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
) : !hasWallet ? (
<><Wallet className="h-4 w-4 mr-2" />CONNECT WALLET TO CONTINUE</>
) : rate.trim() && !feePaid && !isPlatformOwner ? (
<><Zap className="h-4 w-4 mr-2" />PAY LISTING FEE ABOVE TO CONTINUE</>
) : (
<><Camera className="h-4 w-4 mr-2" />CREATE PHOTOGRAPHY PROFILE</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</Layout>
);
}