- First-visit language auto-detect (Spanish devices land in es; stored choice wins). - Sasquatch AI replies in the app language (skate trick names kept English). - i18n safety net: scripts/check-i18n.mjs wired into npm test — fails on any t() key missing from en/es or any en/es key drift. - ~1,680 new keys (2,297 total) across mapUi / spotUi / marketUi / labUi and ~65 files wired to t(): Map, spots (detail/create/edit/chat), marketplace, trades, offers, escrow, shipping, The Lab, /become/* forms, coaches, skaters. - Policy: only skate TRICK names stay English; user-generated content untouched.
656 lines
30 KiB
TypeScript
656 lines
30 KiB
TypeScript
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 { useTranslation } from '@/contexts/language';
|
|
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 (
|
|
<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() {
|
|
const { t } = useTranslation();
|
|
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<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 [noffer, setNoffer] = useState('');
|
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
|
const [members, setMembers] = useState<string[]>([]);
|
|
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
|
const [titleInput, setTitleInput] = useState('');
|
|
const [prefilled, setPrefilled] = useState(false);
|
|
const [existingGeohash, setExistingGeohash] = useState<string | undefined>(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<string, string>) => 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 t('labUi.photoForm.anonymous');
|
|
};
|
|
|
|
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: t('labUi.become.geolocationUnsupported'), 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: t('labUi.become.locationFound') });
|
|
},
|
|
(error) => {
|
|
setIsGettingLocation(false);
|
|
toast({ title: t('labUi.become.locationError'), 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: t('labUi.become.imageUploaded') });
|
|
}
|
|
} catch (error) {
|
|
toast({ title: t('labUi.become.uploadFailed'), description: error instanceof Error ? error.message : t('common.error'), 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)
|
|
? t('labUi.become.invalidEmail')
|
|
: website.trim() && !sanitizeUrl(normalizeUrlInput(website))
|
|
? t('labUi.become.invalidWebsite')
|
|
: portfolio.trim() && !sanitizeUrl(normalizeUrlInput(portfolio))
|
|
? t('labUi.become.invalidPortfolio')
|
|
: '';
|
|
|
|
const handleSubmit = async (e: React.FormEvent) => {
|
|
e.preventDefault();
|
|
|
|
if (contactError) {
|
|
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
|
|
return;
|
|
}
|
|
|
|
if (selectedServices.length === 0) {
|
|
toast({ title: t('labUi.photoForm.servicesRequired'), description: t('labUi.photoForm.selectService'), variant: 'destructive' });
|
|
return;
|
|
}
|
|
if (selectedCameras.length === 0) {
|
|
toast({ title: t('labUi.photoForm.cameraRequired'), description: t('labUi.photoForm.selectCamera'), 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: existingProfile ? t('labUi.photoForm.updated') : t('labUi.photoForm.created'), description: t('labUi.become.profilePublished') });
|
|
navigate(`/coach/${user?.pubkey}/${identifier}`);
|
|
} catch (error) {
|
|
toast({ title: t('labUi.become.failedToPublish'), description: error instanceof Error ? error.message : t('common.error'), variant: 'destructive' });
|
|
}
|
|
};
|
|
|
|
if (!user) {
|
|
return (
|
|
<>
|
|
<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">{t('labUi.become.loginRequired')}</h2>
|
|
<p className="text-gray-400 mb-6">{t('labUi.become.loginRequiredDesc', { role: t('labUi.role.photographer') })}</p>
|
|
<LoginArea className="justify-center max-w-60 mx-auto" />
|
|
</CardContent>
|
|
</Card>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|
|
|
|
const hasNip05 = metadata?.nip05;
|
|
|
|
return (
|
|
<>
|
|
<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" />
|
|
{t('labUi.become.backToLab')}
|
|
</Link>
|
|
<h1 className="text-3xl font-black uppercase text-indigo-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
|
|
{t('labUi.photoForm.heading')}
|
|
</h1>
|
|
<p className="text-gray-400 mt-1">{t('labUi.photoForm.tagline')}</p>
|
|
|
|
{!hasNip05 && (
|
|
<Nip05Required
|
|
variant="banner"
|
|
color="#eab308"
|
|
role="photographers"
|
|
reason={t('labUi.photoForm.nip05Reason')}
|
|
className="mt-4"
|
|
/>
|
|
)}
|
|
|
|
{/* 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">{t('labUi.become.paymentSetup')}</h3>
|
|
<p className="text-sm text-gray-300 mb-3">
|
|
{t('labUi.photoForm.paymentDesc')}
|
|
</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">{t('labUi.become.walletConnected')}</p>
|
|
</div>
|
|
<p className="text-xs text-gray-400">{webln ? t('labUi.become.usingWebln') : t('labUi.become.usingNwc')}</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">{t('labUi.become.noWallet')}</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" />
|
|
{t('labUi.become.connectWalletNwc')}
|
|
</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" />
|
|
{t('labUi.become.learnHow')}
|
|
</Button>
|
|
</a>
|
|
</div>
|
|
<p className="text-xs text-gray-400 italic">
|
|
{t('labUi.become.tip')}
|
|
</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" /> {t('labUi.photoForm.profileTitle')}
|
|
</CardTitle>
|
|
<CardDescription className="text-gray-400">{t('labUi.photoForm.profileDesc')}</CardDescription>
|
|
</CardHeader>
|
|
<CardContent className="space-y-6">
|
|
<div className="space-y-2">
|
|
<Label>{t('labUi.photoForm.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">{t('labUi.become.fromNostrProfile')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="title">{t('labUi.become.headline')}</Label>
|
|
<Input
|
|
id="title"
|
|
value={titleInput}
|
|
onChange={(e) => setTitleInput(e.target.value)}
|
|
placeholder={getDisplayName()}
|
|
maxLength={100}
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
<p className="text-xs text-gray-500">
|
|
{t('labUi.become.headlineHint', { example: t('labUi.photoForm.headlineExample') })}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label htmlFor="bio">{t('labUi.become.bioWorkLabel')}</Label>
|
|
<Textarea id="bio" placeholder={t('labUi.photoForm.bioPlaceholder')} value={bio} onChange={(e) => setBio(e.target.value)} rows={5} maxLength={3000} className="bg-gray-800 border-gray-700" />
|
|
<p className="text-right text-xs text-gray-500">{bio.length}/3000</p>
|
|
</div>
|
|
|
|
{/* Individual vs Collective */}
|
|
<EntityTypeField
|
|
entityType={entityType}
|
|
onEntityTypeChange={setEntityType}
|
|
members={members}
|
|
onMembersChange={setMembers}
|
|
accentBg="bg-indigo-600"
|
|
accentText="text-indigo-400"
|
|
/>
|
|
|
|
{entityType === 'collective' && (
|
|
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-indigo-600" accentText="text-indigo-400" />
|
|
)}
|
|
|
|
{/* Services */}
|
|
<div className="space-y-3">
|
|
<Label>{t('labUi.photoForm.servicesLabel')}</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" /> {t('labUi.photoForm.cameraLabel')}</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" /> {t('labUi.photoForm.lensesLabel')}</Label>
|
|
<ChipGrid entries={LENS_ENTRIES} selected={selectedLenses} onToggle={toggle(setSelectedLenses)} />
|
|
</div>
|
|
|
|
{/* Lighting */}
|
|
<div className="space-y-3">
|
|
<Label className="flex items-center gap-2">💡 {t('labUi.photoForm.lightingLabel')}</Label>
|
|
<ChipGrid entries={LIGHTING_ENTRIES} selected={selectedLighting} onToggle={toggle(setSelectedLighting)} />
|
|
</div>
|
|
|
|
{/* Support */}
|
|
<div className="space-y-3">
|
|
<Label className="flex items-center gap-2">🎒 {t('labUi.photoForm.supportLabel')}</Label>
|
|
<ChipGrid entries={SUPPORT_ENTRIES} selected={selectedSupport} onToggle={toggle(setSelectedSupport)} />
|
|
</div>
|
|
|
|
{/* Style */}
|
|
<div className="space-y-3">
|
|
<Label className="flex items-center gap-2">🎨 {t('labUi.photoForm.styleLabel')}</Label>
|
|
<ChipGrid entries={STYLE_ENTRIES} selected={selectedStyles} onToggle={toggle(setSelectedStyles)} />
|
|
</div>
|
|
|
|
{/* Freeform gear list */}
|
|
<div className="space-y-2">
|
|
<Label htmlFor="gear">{t('labUi.filmerForm.gearLabel')}</Label>
|
|
<Textarea
|
|
id="gear"
|
|
placeholder={t('labUi.photoForm.gearPlaceholder')}
|
|
value={gear}
|
|
onChange={(e) => setGear(e.target.value)}
|
|
rows={3}
|
|
className="bg-gray-800 border-gray-700"
|
|
/>
|
|
<p className="text-xs text-gray-500">{t('labUi.photoForm.gearHelper')}</p>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="experience">{t('labUi.become.yearsExperience')}</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">{t('labUi.filmerForm.rate')}</Label>
|
|
<Input id="rate" placeholder={t('labUi.photoForm.ratePlaceholder')} 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">{t('labUi.become.cityArea')}</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">{t('labUi.builderForm.contactPortfolio')}</Label>
|
|
<p className="text-sm text-gray-400">{t('labUi.photoForm.contactDesc')}</p>
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
<div className="space-y-2">
|
|
<Label htmlFor="portfolio">{t('labUi.photoForm.portfolioLabel')}</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">{t('labUi.become.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">{t('labUi.become.phoneNumber')}</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">{t('labUi.become.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">{t('labUi.become.otherSocial')}</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>
|
|
{contactError && <p className="text-sm text-red-400">{contactError}</p>}
|
|
<PaymentReadinessNote />
|
|
<NofferField value={noffer} onChange={setNoffer} />
|
|
<div className="p-3 rounded-lg border border-indigo-500/30 bg-indigo-500/5">
|
|
<p className="text-sm text-gray-300">{t('labUi.photoForm.dmNote')}</p>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Spot Picker */}
|
|
<SpotPicker
|
|
selectedSpots={selectedSpots}
|
|
onSpotsChange={setSelectedSpots}
|
|
maxSpots={5}
|
|
label={t('labUi.photoForm.spotsLabel')}
|
|
description={t('labUi.photoForm.spotsDesc')}
|
|
/>
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t('labUi.become.coordinatesOptional')}</Label>
|
|
<div className="grid grid-cols-2 gap-4">
|
|
<Input placeholder={t('labUi.become.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
|
|
<Input placeholder={t('labUi.become.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" />{t('labUi.become.getting')}</> : <><MapPin className="h-4 w-4 mr-2" />{t('labUi.become.useCurrentLocation')}</>}
|
|
</Button>
|
|
</div>
|
|
|
|
<div className="space-y-2">
|
|
<Label>{t('labUi.become.profilePhoto')}</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">{t('labUi.become.upload')}</span></>}
|
|
</label>
|
|
)}
|
|
</div>
|
|
|
|
{/* Optional Pro upgrade — free to list without it */}
|
|
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-indigo-400" entityCoord={user ? `${SKATE_PHOTOGRAPHER_KIND}:${user.pubkey}:photography-profile` : undefined} />
|
|
|
|
<Button
|
|
type="submit"
|
|
className="w-full bg-indigo-600 hover:bg-indigo-700 uppercase font-black"
|
|
disabled={isPublishing || isUploading}
|
|
>
|
|
{isPublishing ? (
|
|
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />{t('labUi.become.publishing')}</>
|
|
) : (
|
|
<><Camera className="h-4 w-4 mr-2" />{existingProfile ? t('labUi.photoForm.update') : isPro ? t('labUi.photoForm.createPro') : t('labUi.photoForm.create')}</>
|
|
)}
|
|
</Button>
|
|
</CardContent>
|
|
</Card>
|
|
</form>
|
|
</div>
|
|
</>
|
|
);
|
|
}
|