SkateSpots/src/pages/BecomeEditorPage.tsx

511 lines
24 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.

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 { 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 { 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 { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
const SERVICE_ENTRIES = Object.entries(EDITOR_SERVICE_LABELS);
const SKILL_ENTRIES = Object.entries(EDITOR_SKILL_LABELS);
export default function BecomeEditorPage() {
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);
// Prefill from an existing profile so this is an edit, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(SKATE_EDITOR_KIND, 'editor-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 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') ?? '');
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') ?? '');
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 'Anonymous Editor';
};
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: 'Geolocation not supported', 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: 'Location found' });
},
(err) => {
setIsGettingLocation(false);
toast({ title: 'Location error', 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: 'Image uploaded' }); }
} catch (err) {
toast({ title: 'Upload failed', description: err instanceof Error ? err.message : 'Error', 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 edit type.', 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)]);
}
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', 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 (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 });
toast({ title: `Editor profile ${existingProfile ? 'updated' : 'created'}!`, description: 'Your profile has been published to SkateSpots.' });
navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (err) {
toast({ title: 'Failed to publish', description: err instanceof Error ? err.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">
<Scissors 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">Log in to create your video editor profile.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const hasNip05 = !!author.data?.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" style={{ color: '#c084fc', fontFamily: 'Impact, "Arial Black", sans-serif', textShadow: '0 0 20px rgba(192,132,252,0.6)' }}>
Become a Video Editor
</h1>
<p className="text-gray-400 mt-1">Offer your video editing services to the skate community</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">NIP-05 Verification Recommended</h3>
<p className="text-sm text-gray-300 mb-2">Verify your identity to 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>
)}
<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">⚡ Lightning Wallet for Payments</h3>
<p className="text-sm text-gray-300 mb-3">Receive Bitcoin ⚡ payments directly from clients for your editing 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">
<div className="h-2 w-2 rounded-full bg-green-500" />
<p className="text-sm font-bold text-green-400">Wallet Connected ✓</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" />
Connect Wallet
</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" /> Learn How
</Button>
</a>
</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">
<Scissors className="h-5 w-5 text-purple-400" />
Editor Profile
</CardTitle>
<CardDescription className="text-gray-400">Tell the community about your editing style and skills</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
{/* Identity */}
<div className="space-y-2">
<Label>Your 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">From your Nostr profile</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="title">Headline</Label>
<Input
id="title"
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
placeholder={getDisplayName()}
maxLength={120}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-500">
Shown as the title on your Lab card — e.g. “Skate Video Editor”. Defaults to your name.
</p>
</div>
{/* Profile image */}
<div className="space-y-2">
<Label>Profile / Showreel 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="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 ? 'Uploading…' : 'Upload image'}</span>
<input type="file" className="hidden" accept="image/*" onChange={handleImageUpload} disabled={isUploading} />
</label>
)}
</div>
{/* Bio */}
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label>
<Textarea id="bio" placeholder="Your editing style, background, notable projects…" 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-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>Edit Types 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-purple-600 text-white border-purple-600'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className="text-xs opacity-70 mt-1">{EDITOR_SERVICE_DESCRIPTIONS[service]}</div>
</button>
))}
</div>
</div>
{/* Software / Hardware Skills */}
<div className="space-y-3">
<Label>Software & Hardware Skills</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">{label}</div>
<div className="text-[10px] opacity-60 mt-0.5 line-clamp-1">{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" /> Hardware
</Label>
<Input id="hardware" placeholder="e.g. MacBook Pro M3, Sony FX3" 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" /> Software
</Label>
<Input id="software" placeholder="e.g. DaVinci Resolve, Premiere" 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">Years of Experience</Label>
<Input id="experience" placeholder="e.g. 5 years" 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 / Pricing</Label>
<Input id="rate" placeholder="e.g. 50k sats/min, DM for quote" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
{/* Location */}
<div className="space-y-2">
<Label>Location (optional)</Label>
<Input placeholder="City, Country" 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="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700 text-sm" />
<Input placeholder="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" />}
Use My Location
</Button>
</div>
{/* Contact & Portfolio */}
<div className="space-y-4">
<Label>Contact & Portfolio</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<Input placeholder="Email" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Phone / Telegram" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Portfolio (Vimeo, YouTube…)" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Website" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Instagram / Social handle" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
</div>
</CardContent>
</Card>
{/* Optional Pro upgrade — free to list without it */}
<div className="mt-4">
<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">Cancel</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" />Publishing…</>
) : (
<><Scissors className="h-4 w-4" />{existingProfile ? 'Update Editor Profile' : isPro ? 'Publish Pro Editor Profile' : 'Publish Editor Profile'}</>
)}
</Button>
</div>
</form>
</div>
</Layout>
);
}