import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { nip19 } from 'nostr-tools';
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 { CrewLinkField } from '@/components/CrewLinkField';
import type { EntityType } from '@/lib/types';
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,
isValidNoffer,
} from '@/lib/types';
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
import { cn } from '@/lib/utils';
import { Camera, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Aperture } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
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 (
{entries.map(([key, label]) => (
))}
);
}
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, 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();
const hasWallet = !!webln || !!activeNWC;
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([]);
const [selectedCameras, setSelectedCameras] = useState([]);
const [selectedLenses, setSelectedLenses] = useState([]);
const [selectedLighting, setSelectedLighting] = useState([]);
const [selectedSupport, setSelectedSupport] = useState([]);
const [selectedStyles, setSelectedStyles] = useState([]);
const [gear, setGear] = useState('');
const [image, setImage] = useState(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('individual');
const [members, setMembers] = useState([]);
const [crewRef, setCrewRef] = useState(undefined);
const [selectedSpots, setSelectedSpots] = useState([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
const [existingGeohash, setExistingGeohash] = useState(undefined);
// Prefill from an existing profile so this is an edit, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(SKATE_PHOTOGRAPHER_KIND, 'photography-profile');
useEffect(() => {
if (prefilled || !existingProfile) return;
setPrefilled(true);
const tag = (n: string) => existingProfile.tags.find(([t]) => t === n)?.[1];
const tags = (n: string) => existingProfile.tags.filter(([t]) => t === n).map(([, v]) => v);
const keys = (m: Record) => new Set(Object.keys(m));
const svcK = keys(PHOTO_SERVICE_LABELS);
const camK = keys(PHOTO_CAMERA_LABELS);
const lensK = keys(PHOTO_LENS_LABELS);
const lightK = keys(PHOTO_LIGHTING_LABELS);
const supK = keys(PHOTO_SUPPORT_LABELS);
const styleK = keys(PHOTO_STYLE_LABELS);
const sel = tags('t').filter((v) => v !== 'skatephotographer');
setTitleInput(tag('title') ?? '');
setExistingGeohash(tag('g'));
setGoPro(existingProfile.tags.some(([t]) => t === 'featured'));
setBio(existingProfile.content ?? '');
setLocation(tag('location') ?? '');
setImage(tag('image') ?? null);
setExperience(tag('experience') ?? '');
setRate(tag('rate') ?? '');
setPhone(tag('phone') ?? '');
setEmail(tag('email') ?? '');
setWebsite(tag('website') ?? '');
setPortfolio(tag('portfolio') ?? '');
setSocial(tag('social') ?? '');
setNoffer(tag('noffer') ?? '');
setGear(tag('gear') ?? '');
setEntityType((tag('entity') as EntityType) ?? 'individual');
setMembers(tags('p'));
setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v.startsWith('35006:'))?.[1]);
setSelectedServices(sel.filter((v) => svcK.has(v)));
setSelectedCameras(sel.filter((v) => camK.has(v)));
setSelectedLenses(sel.filter((v) => lensK.has(v)));
setSelectedLighting(sel.filter((v) => lightK.has(v)));
setSelectedSupport(sel.filter((v) => supK.has(v)));
setSelectedStyles(sel.filter((v) => styleK.has(v)));
setSelectedSpots(
existingProfile.tags
.filter(([t, v]) => t === 'a' && v.startsWith(`${SKATE_SPOT_KIND}:`))
.map(([, v]) => {
const [, pk, ...rest] = v.split(':');
return `${pk}:${rest.join(':')}`;
}),
);
}, [existingProfile, prefilled]);
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>) => (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) => {
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 = '';
};
// Optional, but if given it must be usable — a bad email/URL would otherwise
// be stored and then silently hidden.
const contactError = email.trim() && !isValidEmail(email)
? 'Enter a valid email address.'
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
? 'Enter a valid website URL.'
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
? 'Enter a valid portfolio URL.'
: '';
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (contactError) {
toast({ title: 'Check your contact details', description: contactError, variant: 'destructive' });
return;
}
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', titleInput.trim() || 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]);
}
} else if (existingGeohash) {
// Preserve the existing map pin when editing without re-locating.
tags.push(['g', existingGeohash]);
}
// 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', normalizeUrlInput(website)]);
if (portfolio.trim()) tags.push(['portfolio', normalizeUrlInput(portfolio)]);
if (social.trim()) tags.push(['social', normalizeUrlInput(social)]);
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]);
}
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_PHOTOGRAPHER_KIND,
content: bio.trim(),
tags,
});
toast({ title: `Photography profile ${existingProfile ? 'updated' : '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 (
<>
Login Required
You need to log in to create a photography profile.
>
);
}
const hasNip05 = metadata?.nip05;
return (
<>
Back to The Lab
Become a Photographer
Shoot the sessions — offer your skate photography
{!hasNip05 && (
)}
{/* Wallet Requirement */}
⚡ Payment Setup Required
To get paid for shoots, connect a Lightning wallet. Clients zap you directly for your work.
{hasWallet ? (
Wallet Connected — Ready to Receive Payments!
{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}
) : (
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos).
)}
>
);
}