645 lines
31 KiB
TypeScript
645 lines
31 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 { NofferField } from '@/components/NofferField';
|
||
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
|
||
import {
|
||
SKATE_EDITOR_KIND,
|
||
EDITOR_SERVICE_LABELS,
|
||
EDITOR_SERVICE_DESCRIPTIONS,
|
||
EDITOR_SKILL_LABELS,
|
||
EDITOR_SKILL_DESCRIPTIONS,
|
||
isValidNoffer,
|
||
type EntityType,
|
||
} from '@/lib/types';
|
||
import { EDITOR_SERVICE_KEYS, EDITOR_SERVICE_DESC_KEYS, EDITOR_SKILL_KEYS, EDITOR_SKILL_DESC_KEYS } from '@/lib/labelKeys';
|
||
import { EntityTypeField } from '@/components/EntityTypeField';
|
||
import { CrewLinkField } from '@/components/CrewLinkField';
|
||
import { normalizeUrlInput, sanitizeUrl, isValidEmail } from '@/lib/sanitizeUrl';
|
||
import { useTranslation } from '@/contexts/language';
|
||
import { cn } from '@/lib/utils';
|
||
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Eye } from 'lucide-react';
|
||
import { LabProUpgrade } from '@/components/LabProUpgrade';
|
||
import { usePlatformFee } from '@/hooks/usePlatformFee';
|
||
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
|
||
import { LabExtrasFields } from '@/components/LabExtrasFields';
|
||
import { LabProfilePreview } from '@/components/LabProfilePreview';
|
||
import { LabDraftBanner } from '@/components/LabDraftBanner';
|
||
import { LabProfileProgress } from '@/components/LabProfileProgress';
|
||
import { useLabDraft } from '@/hooks/useLabDraft';
|
||
import { labProfilePath } from '@/lib/labRoutes';
|
||
import { pickRateUnits } from '@/lib/labExtras';
|
||
|
||
const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
|
||
const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
|
||
|
||
export default function BecomeEditorPage() {
|
||
const { t } = useTranslation();
|
||
useSeoMeta({
|
||
title: 'Become a Video Editor - SkateSpots',
|
||
description: 'Create your skateboarding video editor profile on SkateSpots.',
|
||
});
|
||
|
||
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;
|
||
|
||
// Optional Pro upgrade — free to list; Pro adds a badge + featured placement
|
||
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 [hardware, setHardware] = useState('');
|
||
const [software, setSoftware] = useState('');
|
||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||
const [titleInput, setTitleInput] = useState('');
|
||
const [prefilled, setPrefilled] = useState(false);
|
||
const [extraTags, setExtraTags] = useState<string[][]>([]);
|
||
const [previewOpen, setPreviewOpen] = useState(false);
|
||
|
||
const [turnaround, setTurnaround] = useState('');
|
||
const [deliverables, setDeliverables] = useState('');
|
||
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_EDITOR_KIND, 'editor-profile');
|
||
const draft = useLabDraft(
|
||
`craft:editor`,
|
||
{ titleInput, bio, location, latitude, longitude, selectedServices, selectedSkills, image, experience, rate, phone, email, website, portfolio, social, noffer, entityType, members, crewRef, hardware, software, turnaround, deliverables, extraTags },
|
||
(d) => {
|
||
setTitleInput(d.titleInput);
|
||
setBio(d.bio);
|
||
setLocation(d.location);
|
||
setLatitude(d.latitude);
|
||
setLongitude(d.longitude);
|
||
setSelectedServices(d.selectedServices);
|
||
setSelectedSkills(d.selectedSkills);
|
||
setImage(d.image);
|
||
setExperience(d.experience);
|
||
setRate(d.rate);
|
||
setPhone(d.phone);
|
||
setEmail(d.email);
|
||
setWebsite(d.website);
|
||
setPortfolio(d.portfolio);
|
||
setSocial(d.social);
|
||
setNoffer(d.noffer);
|
||
setEntityType(d.entityType);
|
||
setMembers(d.members);
|
||
setCrewRef(d.crewRef);
|
||
setHardware(d.hardware);
|
||
setSoftware(d.software);
|
||
setTurnaround(d.turnaround);
|
||
setDeliverables(d.deliverables);
|
||
setExtraTags(d.extraTags);
|
||
},
|
||
);
|
||
|
||
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 serviceKeys = new Set(Object.keys(EDITOR_SERVICE_LABELS));
|
||
const skillKeys = new Set(Object.keys(EDITOR_SKILL_LABELS));
|
||
const sel = tags('t').filter((v) => v !== 'skateeditor');
|
||
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') ?? '');
|
||
setTurnaround(tag('turnaround') ?? '');
|
||
setDeliverables(tag('deliverables') ?? '');
|
||
setPhone(tag('phone') ?? '');
|
||
setEmail(tag('email') ?? '');
|
||
setWebsite(tag('website') ?? '');
|
||
setPortfolio(tag('portfolio') ?? '');
|
||
setSocial(tag('social') ?? '');
|
||
setNoffer(tag('noffer') ?? '');
|
||
setHardware(tag('hardware') ?? '');
|
||
setSoftware(tag('software') ?? '');
|
||
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) => serviceKeys.has(v)));
|
||
setSelectedSkills(sel.filter((v) => skillKeys.has(v)));
|
||
}, [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.editorForm.anonymous');
|
||
};
|
||
|
||
const toggleService = (s: string) =>
|
||
setSelectedServices((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
|
||
|
||
const toggleSkill = (s: string) =>
|
||
setSelectedSkills((prev) => prev.includes(s) ? prev.filter((x) => x !== s) : [...prev, s]);
|
||
|
||
const getCurrentLocation = () => {
|
||
if (!navigator.geolocation) {
|
||
toast({ title: t('labUi.become.geolocationUnsupported'), variant: 'destructive' });
|
||
return;
|
||
}
|
||
setIsGettingLocation(true);
|
||
navigator.geolocation.getCurrentPosition(
|
||
(pos) => {
|
||
setLatitude(pos.coords.latitude.toFixed(6));
|
||
setLongitude(pos.coords.longitude.toFixed(6));
|
||
setIsGettingLocation(false);
|
||
toast({ title: t('labUi.become.locationFound') });
|
||
},
|
||
(err) => {
|
||
setIsGettingLocation(false);
|
||
toast({ title: t('labUi.become.locationError'), description: err.message, variant: 'destructive' });
|
||
}
|
||
);
|
||
};
|
||
|
||
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
|
||
const files = e.target.files;
|
||
if (!files || files.length === 0) return;
|
||
try {
|
||
const tags = await uploadFile(files[0]);
|
||
const url = tags[0]?.[1];
|
||
if (url) { setImage(url); toast({ title: t('labUi.become.imageUploaded') }); }
|
||
} catch (err) {
|
||
toast({ title: t('labUi.become.uploadFailed'), description: err instanceof Error ? err.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) {
|
||
document.getElementById('contact-error')?.scrollIntoView({ behavior: 'smooth', block: 'center' });
|
||
toast({ title: t('labUi.become.checkContact'), description: contactError, variant: 'destructive' });
|
||
return;
|
||
}
|
||
|
||
if (selectedServices.length === 0) {
|
||
document.querySelector('form')?.scrollIntoView({ behavior: 'smooth', block: 'start' });
|
||
toast({ title: t('labUi.editorForm.servicesRequired'), description: t('labUi.editorForm.selectService'), variant: 'destructive' });
|
||
return;
|
||
}
|
||
|
||
try {
|
||
const identifier = 'editor-profile';
|
||
const displayName = getDisplayName();
|
||
|
||
const tags: string[][] = [
|
||
['d', identifier],
|
||
['title', titleInput.trim() || displayName],
|
||
['t', 'skateeditor'],
|
||
['alt', `Skate video editor: ${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)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
|
||
} else if (existingGeohash) {
|
||
// Preserve the existing map pin when editing without re-locating.
|
||
tags.push(['g', existingGeohash]);
|
||
}
|
||
for (const s of selectedServices) tags.push(['t', s]);
|
||
for (const s of selectedSkills) tags.push(['t', s]);
|
||
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]);
|
||
for (const extra of extraTags) tags.push(extra);
|
||
if (turnaround.trim()) tags.push(['turnaround', turnaround.trim()]);
|
||
if (deliverables.trim()) tags.push(['deliverables', deliverables.trim()]);
|
||
if (entityType === 'collective') {
|
||
for (const pk of members) tags.push(['p', pk]);
|
||
if (crewRef) tags.push(['a', crewRef]);
|
||
}
|
||
if (hardware.trim()) tags.push(['hardware', hardware.trim()]);
|
||
if (software.trim()) tags.push(['software', software.trim()]);
|
||
|
||
// Pro / featured listing (optional, paid)
|
||
if (isPro && featuredUntilSeconds) {
|
||
tags.push(['featured', String(featuredUntilSeconds)]);
|
||
}
|
||
|
||
await createEvent({ kind: SKATE_EDITOR_KIND, content: bio.trim(), tags });
|
||
draft.clear();
|
||
|
||
toast({ title: existingProfile ? t('labUi.editorForm.updated') : t('labUi.editorForm.created'), description: t('labUi.editorForm.published') });
|
||
navigate(`${labProfilePath('editor', user?.pubkey ?? '', identifier)}${existingProfile ? '' : '?published=1'}`);
|
||
} catch (err) {
|
||
toast({ title: t('labUi.become.failedToPublish'), description: err instanceof Error ? err.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">
|
||
<Scissors 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.editorForm.loginDesc')}</p>
|
||
<LoginArea className="justify-center max-w-60 mx-auto" />
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
</>
|
||
);
|
||
}
|
||
|
||
const hasNip05 = !!author.data?.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" style={{ color: '#c084fc', fontFamily: 'Impact, "Arial Black", sans-serif', textShadow: '0 0 20px rgba(192,132,252,0.6)' }}>
|
||
{t('labUi.editorForm.heading')}
|
||
</h1>
|
||
<p className="text-gray-400 mt-1">{t('labUi.editorForm.tagline')}</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">
|
||
<span className="text-yellow-500">⚠️</span>
|
||
<div>
|
||
<h3 className="font-bold text-yellow-400 mb-1">{t('labUi.become.nip05Recommended')}</h3>
|
||
<p className="text-sm text-gray-300 mb-2">{t('labUi.become.nip05RecommendedDesc')}</p>
|
||
<Link to="/settings">
|
||
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
|
||
{t('labUi.become.addNip05')}
|
||
</Button>
|
||
</Link>
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
)}
|
||
|
||
<Card className="mt-4 bg-purple-950/30 border-2 border-purple-500/50">
|
||
<CardContent className="p-4">
|
||
<div className="flex items-start gap-3">
|
||
<Wallet className="h-6 w-6 text-purple-400 flex-shrink-0 mt-0.5" />
|
||
<div className="flex-1">
|
||
<h3 className="font-bold text-purple-400 mb-2">{t('labUi.editorForm.walletTitle')}</h3>
|
||
<p className="text-sm text-gray-300 mb-3">{t('labUi.editorForm.walletDesc')}</p>
|
||
{hasWallet ? (
|
||
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
|
||
<div className="flex items-center gap-2">
|
||
<div className="h-2 w-2 rounded-full bg-green-500" />
|
||
<p className="text-sm font-bold text-green-400">{t('labUi.editorForm.walletConnected')}</p>
|
||
</div>
|
||
</div>
|
||
) : (
|
||
<div className="flex flex-col sm:flex-row gap-2">
|
||
<WalletModal>
|
||
<Button className="gap-2 bg-purple-600 hover:bg-purple-700 flex-1">
|
||
<Wallet className="h-4 w-4" />
|
||
{t('labUi.become.connectWallet')}
|
||
</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-purple-500/50 text-purple-400 hover:bg-purple-500/10">
|
||
<ExternalLink className="h-4 w-4" /> {t('labUi.become.learnHow')}
|
||
</Button>
|
||
</a>
|
||
</div>
|
||
)}
|
||
</div>
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
</div>
|
||
|
||
{draft.resumable && <LabDraftBanner onResume={draft.resume} onDiscard={draft.discard} />}
|
||
<LabProfileProgress
|
||
hasPhoto={!!image}
|
||
hasBio={bio.trim().length > 20}
|
||
hasSkills={selectedServices.length + selectedSkills.length > 0}
|
||
hasRate={!!rate.trim()}
|
||
hasContact={!!(email || website || phone || social)}
|
||
hasLocation={!!location.trim()}
|
||
/>
|
||
|
||
<form onSubmit={handleSubmit}>
|
||
<Card className="bg-gray-900 border-gray-700">
|
||
<CardHeader>
|
||
<CardTitle className="flex items-center gap-2">
|
||
<Scissors className="h-5 w-5 text-purple-400" />
|
||
{t('labUi.editorForm.profileTitle')}
|
||
</CardTitle>
|
||
<CardDescription className="text-gray-400">{t('labUi.editorForm.profileDesc')}</CardDescription>
|
||
</CardHeader>
|
||
<CardContent className="space-y-6">
|
||
{/* Identity */}
|
||
<div className="space-y-2">
|
||
<Label>{t('labUi.editorForm.identity')}</Label>
|
||
<div className="p-4 rounded-lg border-2 border-purple-500/30 bg-purple-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.editorForm.headlineExample') })}
|
||
</p>
|
||
</div>
|
||
|
||
{/* Profile image */}
|
||
<div className="space-y-2">
|
||
<Label>{t('labUi.editorForm.thumbnail')}</Label>
|
||
{image ? (
|
||
<div className="relative w-32 h-32">
|
||
<img src={image} alt="profile" className="w-32 h-32 object-cover rounded-lg border-2 border-purple-500/40" />
|
||
<Button type="button" variant="destructive" size="icon" className="min-h-0 min-w-0 absolute -top-2 -right-2 h-6 w-6" onClick={() => setImage(null)}>
|
||
<X className="h-3 w-3" />
|
||
</Button>
|
||
</div>
|
||
) : (
|
||
<label className="flex flex-col items-center justify-center w-full h-32 border-2 border-dashed border-gray-700 rounded-lg cursor-pointer hover:border-purple-500/50 transition-colors bg-gray-800/50">
|
||
<Upload className="h-8 w-8 text-gray-500 mb-2" />
|
||
<span className="text-sm text-gray-500">{isUploading ? t('labUi.become.uploading') : t('labUi.become.uploadImage')}</span>
|
||
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} disabled={isUploading} />
|
||
</label>
|
||
)}
|
||
</div>
|
||
|
||
{/* Bio */}
|
||
<div className="space-y-2">
|
||
<Label htmlFor="bio">{t('labUi.become.bioWorkLabel')}</Label>
|
||
<Textarea id="bio" placeholder={t('labUi.editorForm.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>
|
||
|
||
<EntityTypeField
|
||
entityType={entityType}
|
||
onEntityTypeChange={setEntityType}
|
||
members={members}
|
||
onMembersChange={setMembers}
|
||
accentBg="bg-purple-600 hover:bg-purple-700"
|
||
accentText="text-purple-400"
|
||
/>
|
||
|
||
{entityType === 'collective' && (
|
||
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-purple-600 hover:bg-purple-700" accentText="text-purple-400" />
|
||
)}
|
||
|
||
{/* Edit Types */}
|
||
<div className="space-y-3">
|
||
<Label>{t('labUi.editorForm.editTypes')}</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-purple-600 text-white border-purple-600'
|
||
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
|
||
)}
|
||
>
|
||
<div className="font-bold">{EDITOR_SERVICE_KEYS[service] ? t(EDITOR_SERVICE_KEYS[service]) : label}</div>
|
||
<div className="text-xs opacity-70 mt-1">{EDITOR_SERVICE_DESC_KEYS[service] ? t(EDITOR_SERVICE_DESC_KEYS[service]) : EDITOR_SERVICE_DESCRIPTIONS[service]}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Software / Hardware Skills */}
|
||
<div className="space-y-3">
|
||
<Label>{t('labUi.editorForm.softwareHardware')}</Label>
|
||
<div className="grid grid-cols-2 sm:grid-cols-3 gap-2">
|
||
{SKILL_ENTRIES.map(([skill, label]) => (
|
||
<button
|
||
key={skill}
|
||
type="button"
|
||
onClick={() => toggleSkill(skill)}
|
||
className={cn(
|
||
'p-2.5 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 text-xs uppercase">{EDITOR_SKILL_KEYS[skill] ? t(EDITOR_SKILL_KEYS[skill]) : label}</div>
|
||
<div className="text-[10px] opacity-60 mt-0.5 line-clamp-1">{EDITOR_SKILL_DESC_KEYS[skill] ? t(EDITOR_SKILL_DESC_KEYS[skill]) : EDITOR_SKILL_DESCRIPTIONS[skill]}</div>
|
||
</button>
|
||
))}
|
||
</div>
|
||
</div>
|
||
|
||
{/* Hardware / Software free-text */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="hardware" className="flex items-center gap-2">
|
||
<Monitor className="h-4 w-4 text-gray-400" /> {t('labUi.editorForm.hardware')}
|
||
</Label>
|
||
<Input id="hardware" placeholder={t('labUi.editorForm.hardwarePlaceholder')} value={hardware} onChange={(e) => setHardware(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label htmlFor="software" className="flex items-center gap-2">
|
||
<Scissors className="h-4 w-4 text-gray-400" /> {t('labUi.editorForm.software')}
|
||
</Label>
|
||
<Input id="software" placeholder={t('labUi.editorForm.softwarePlaceholder')} value={software} onChange={(e) => setSoftware(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Experience & Rate */}
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<div className="space-y-2">
|
||
<Label htmlFor="experience">{t('labUi.editorForm.yearsExperience')}</Label>
|
||
<Input id="experience" placeholder={t('labUi.editorForm.yearsPlaceholder')} 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.editorForm.rate')}</Label>
|
||
<Input id="rate" placeholder={t('labUi.editorForm.ratePlaceholder')} value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||
</div>
|
||
</div>
|
||
|
||
{/* Location */}
|
||
<div className="space-y-2">
|
||
<Label>{t('labUi.editorForm.locationOptional')}</Label>
|
||
<Input placeholder={t('labUi.become.cityCountry')} value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||
<div className="grid grid-cols-2 gap-2">
|
||
<Input placeholder={t('labUi.become.latitude')} value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
|
||
<Input placeholder={t('labUi.become.longitude')} value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
|
||
</div>
|
||
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="gap-2 border-gray-700">
|
||
{isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
|
||
{t('labUi.become.useMyLocation')}
|
||
</Button>
|
||
</div>
|
||
|
||
{/* Contact & Portfolio */}
|
||
<div className="space-y-4">
|
||
<Label>{t('labUi.editorForm.contactPortfolio')}</Label>
|
||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
||
<Input placeholder={t('labUi.become.email')} value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||
<Input placeholder={t('labUi.editorForm.phoneTelegram')} value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||
<Input placeholder={t('labUi.editorForm.portfolioPlaceholder')} value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||
<Input placeholder={t('labUi.become.website')} value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||
<Input placeholder={t('labUi.editorForm.socialPlaceholder')} value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
|
||
</div>
|
||
{contactError && <p id="contact-error" className="text-sm text-red-400">{contactError}</p>}
|
||
<PaymentReadinessNote />
|
||
<NofferField value={noffer} onChange={setNoffer} />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
{/* Optional Pro upgrade — free to list without it */}
|
||
<div className="mt-4">
|
||
<Card className="bg-gray-900 border-gray-700">
|
||
<CardContent className="p-4 space-y-4">
|
||
<Label className="text-base font-bold">{t('labUi.craftFields.detailsTitle')}</Label>
|
||
<div className="space-y-2">
|
||
<Label>{t('labUi.craftFields.turnaround')}</Label>
|
||
<Input value={turnaround} onChange={(e) => setTurnaround(e.target.value)} placeholder="e.g. 2–5 days" className="bg-gray-800 border-gray-700" />
|
||
</div>
|
||
<div className="space-y-2">
|
||
<Label>{t('labUi.craftFields.deliverables')}</Label>
|
||
<Input value={deliverables} onChange={(e) => setDeliverables(e.target.value)} placeholder="e.g. 4K master, vertical cut, captions" className="bg-gray-800 border-gray-700" />
|
||
</div>
|
||
</CardContent>
|
||
</Card>
|
||
|
||
<LabExtrasFields
|
||
existingTags={existingProfile?.tags}
|
||
onChange={setExtraTags}
|
||
accentText="text-purple-400"
|
||
accentChip="bg-purple-600 text-white border-purple-600"
|
||
accentChipHover="hover:border-purple-500/60 hover:text-purple-400"
|
||
rateUnits={pickRateUnits('project', 'day', 'hour')}
|
||
/>
|
||
|
||
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText="text-purple-400" entityCoord={user ? `${SKATE_EDITOR_KIND}:${user.pubkey}:editor-profile` : undefined} />
|
||
</div>
|
||
|
||
<div className="mt-6 flex gap-3">
|
||
<Link to="/lab" className="flex-1">
|
||
<Button type="button" variant="outline" className="w-full border-gray-700">{t('common.cancel')}</Button>
|
||
<Button
|
||
type="button"
|
||
variant="outline"
|
||
onClick={() => setPreviewOpen(true)}
|
||
className="gap-2 border-gray-700 font-bold"
|
||
>
|
||
<Eye className="h-4 w-4" /> {t('labUi.preview.button')}
|
||
</Button>
|
||
</Link>
|
||
<Button
|
||
type="submit"
|
||
disabled={isPublishing || selectedServices.length === 0}
|
||
className="flex-1 bg-purple-600 hover:bg-purple-700 font-black uppercase gap-2"
|
||
>
|
||
{isPublishing ? (
|
||
<><Loader2 className="h-4 w-4 animate-spin" />{t('labUi.become.publishing')}</>
|
||
) : (
|
||
<><Scissors className="h-4 w-4" />{existingProfile ? t('labUi.editorForm.update') : isPro ? t('labUi.editorForm.createPro') : t('labUi.editorForm.create')}</>
|
||
)}
|
||
</Button>
|
||
</div>
|
||
</form>
|
||
</div>
|
||
|
||
<LabProfilePreview
|
||
open={previewOpen}
|
||
onOpenChange={setPreviewOpen}
|
||
heading={t('labUi.detail.noun.editor')}
|
||
noun={t('labUi.detail.noun.editor')}
|
||
accentText="text-purple-400"
|
||
accentChip="bg-purple-600 text-white border-purple-600"
|
||
icon={Scissors}
|
||
draft={{
|
||
title: titleInput.trim() || getDisplayName(),
|
||
bio, image, location, experience, rate,
|
||
extraTags,
|
||
contact: { phone, email, website, portfolio, social, noffer },
|
||
}}
|
||
/>
|
||
</>
|
||
);
|
||
}
|