feat(lab): coach/filmer/editor/photographer/builder are editable + have a title field

Finishes the Lab audit: the 5 bespoke 'become' pages now prefill from your
existing profile (edit, not blind overwrite) and expose a 'Headline' field used
as the profile title. Also:
- submit button shows Update... when editing; success toast says updated
- removed the dead ExistingLabProfileNotice (the warning it gave is now moot)

Every one of the 12 Lab provider flows (5 bespoke + 7 craft) now edits + titles
consistently. Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
SkateSpots 2026-09-27 00:25:10 +07:00
parent 84d1ba426d
commit a4b561fa79
6 changed files with 301 additions and 95 deletions

View file

@ -1,60 +0,0 @@
import { Link } from 'react-router-dom';
import { AlertTriangle, Eye } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLabProfile } from '@/hooks/useLabProfiles';
/**
* Lab provider profiles (coach, filmer, editor, photographer, builder…) are
* addressable events keyed by a fixed `d` identifier per type
* (e.g. "coach-profile"). That means re-publishing REPLACES the existing one.
*
* The "Become a …" forms start blank and don't reconstruct prior state, so a
* returning provider who submits again would silently wipe every field they'd
* set before (bio, rate, spots, contact…). This notice makes that consequence
* explicit: it appears only when the current user already has a profile of this
* type, warns that submitting will overwrite it, and links them to view it.
*/
export function ExistingLabProfileNotice({
identifier,
label,
}: {
/** The fixed `d` identifier for this Lab profile type, e.g. "coach-profile". */
identifier: string;
/** Human label for the profile type, e.g. "coach". */
label: string;
}) {
const { user } = useCurrentUser();
const { data: existing } = useLabProfile(identifier, user?.pubkey);
if (!user || !existing) return null;
return (
<div className="rounded-lg border-2 border-amber-500/40 bg-amber-500/5 p-4">
<div className="flex items-start gap-3">
<AlertTriangle className="mt-0.5 h-5 w-5 shrink-0 text-amber-400" />
<div className="flex-1 space-y-2">
<p className="text-sm font-bold text-amber-300">
You already have a {label} profile
</p>
<p className="text-xs text-gray-400">
Publishing this form <span className="font-semibold text-amber-300">replaces</span> your
current {label} profile — anything you don't re-enter here will be lost. Review your
existing profile first so you don't drop details you already set.
</p>
<Button
asChild
variant="outline"
size="sm"
className="border-amber-500/40 text-amber-300 hover:bg-amber-500/10"
>
<Link to={`/coach/${user.pubkey}/${identifier}`}>
<Eye className="mr-1.5 h-3.5 w-3.5" />
View current {label} profile
</Link>
</Button>
</div>
</div>
</div>
);
}

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
@ -23,7 +23,6 @@ import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { ExistingLabProfileNotice } from '@/components/ExistingLabProfileNotice';
import { CrewLinkField } from '@/components/CrewLinkField';
import {
SKATE_BUILDER_KIND,
@ -39,6 +38,7 @@ import { cn } from '@/lib/utils';
import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Hammer } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
const SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS);
const SKILL_ENTRIES = Object.entries(BUILDER_SKILL_LABELS);
@ -116,6 +116,51 @@ export default function BecomeBuilderPage() {
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);
// Prefill from an existing profile so this is an edit, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(SKATE_BUILDER_KIND, 'builder-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(BUILDER_SERVICE_LABELS);
const skillK = keys(BUILDER_SKILL_LABELS);
const scaleK = keys(BUILDER_SCALE_LABELS);
const sel = tags('t').filter((v) => v !== 'skatebuilder');
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') ?? '');
setGear(tag('gear') ?? '');
setServiceArea(tag('service_area') ?? '');
setInsured(tag('insured') === 'true');
setEntityType((tag('entity') as EntityType) ?? 'collective');
setMembers(tags('p'));
setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v.startsWith('35006:'))?.[1]);
setSelectedServices(sel.filter((v) => svcK.has(v)));
setSelectedSkills(sel.filter((v) => skillK.has(v)));
setSelectedScale(sel.filter((v) => scaleK.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;
@ -181,7 +226,7 @@ export default function BecomeBuilderPage() {
const tags: string[][] = [
['d', identifier],
['title', displayName],
['title', titleInput.trim() || displayName],
['t', 'skatebuilder'],
['alt', `Skate ramp/park builder: ${displayName}`],
];
@ -236,7 +281,7 @@ export default function BecomeBuilderPage() {
tags,
});
toast({ title: 'Builder profile created!', description: 'Your profile has been published.' });
toast({ title: `Builder 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' });
@ -345,8 +390,6 @@ export default function BecomeBuilderPage() {
<CardDescription className="text-gray-400">Tell the scene what you build and how you work</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ExistingLabProfileNotice identifier="builder-profile" label="builder" />
<div className="space-y-2">
<Label>Your Builder Identity</Label>
<div className="p-4 rounded-lg border-2 border-amber-500/30 bg-amber-500/5">
@ -355,6 +398,21 @@ export default function BecomeBuilderPage() {
</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. “Ramp & Park Builder”. Defaults to your name.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Builds</Label>
<Textarea id="bio" placeholder="Your crew, notable builds, how you approach a project..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
@ -535,7 +593,7 @@ export default function BecomeBuilderPage() {
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
) : (
<><HardHat className="h-4 w-4 mr-2" />{isPro ? 'CREATE PRO BUILDER PROFILE' : 'CREATE BUILDER PROFILE'}</>
<><HardHat className="h-4 w-4 mr-2" />{existingProfile ? 'UPDATE BUILDER PROFILE' : isPro ? 'CREATE PRO BUILDER PROFILE' : 'CREATE BUILDER PROFILE'}</>
)}
</Button>
</CardContent>

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
@ -23,12 +23,12 @@ import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { ExistingLabProfileNotice } from '@/components/ExistingLabProfileNotice';
import { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
const SKILL_ENTRIES = Object.entries(COACH_SKILL_LABELS);
@ -71,6 +71,40 @@ export default function BecomeCoachPage() {
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);
// Prefill from an existing profile so this is an edit, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(SKATE_COACH_KIND, 'coach-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);
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') ?? '');
setInstagram(tag('instagram') ?? '');
setNoffer(tag('noffer') ?? '');
setEntityType((tag('entity') as EntityType) ?? 'individual');
setMembers(tags('p'));
setCrewRef(existingProfile.tags.find(([t, v]) => t === 'a' && v.startsWith('35006:'))?.[1]);
setSelectedSkills(tags('t').filter((v) => v !== 'skatecoach'));
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;
@ -138,7 +172,7 @@ export default function BecomeCoachPage() {
const tags: string[][] = [
['d', identifier],
['title', displayName],
['title', titleInput.trim() || displayName],
['t', 'skatecoach'],
['alt', `Skate coach: ${displayName}`],
];
@ -187,7 +221,7 @@ export default function BecomeCoachPage() {
tags,
});
toast({ title: 'Coach profile created!', description: 'Your profile has been published.' });
toast({ title: `Coach 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' });
@ -304,8 +338,6 @@ export default function BecomeCoachPage() {
<CardDescription className="text-gray-400">Tell students about your experience</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ExistingLabProfileNotice identifier="coach-profile" label="coach" />
<div className="space-y-2">
<Label>Your Coach Identity</Label>
<div className="p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
@ -314,6 +346,21 @@ export default function BecomeCoachPage() {
</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 Coach Berlin”. Defaults to your name.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio / Coaching Philosophy</Label>
<Textarea id="bio" placeholder="Your background, teaching style..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
@ -458,7 +505,7 @@ export default function BecomeCoachPage() {
) : (
<>
<GraduationCap className="h-4 w-4 mr-2" />
{isPro ? 'CREATE PRO COACH PROFILE' : 'CREATE COACH PROFILE'}
{existingProfile ? 'UPDATE COACH PROFILE' : isPro ? 'CREATE PRO COACH PROFILE' : 'CREATE COACH PROFILE'}
</>
)}
</Button>

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
@ -29,12 +29,12 @@ import {
type EntityType,
} from '@/lib/types';
import { EntityTypeField } from '@/components/EntityTypeField';
import { ExistingLabProfileNotice } from '@/components/ExistingLabProfileNotice';
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);
@ -80,6 +80,39 @@ export default function BecomeEditorPage() {
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;
@ -143,7 +176,7 @@ export default function BecomeEditorPage() {
const tags: string[][] = [
['d', identifier],
['title', displayName],
['title', titleInput.trim() || displayName],
['t', 'skateeditor'],
['alt', `Skate video editor: ${displayName}`],
];
@ -181,7 +214,7 @@ export default function BecomeEditorPage() {
await createEvent({ kind: SKATE_EDITOR_KIND, content: bio.trim(), tags });
toast({ title: 'Editor profile created!', description: 'Your profile has been published to SkateSpots.' });
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' });
@ -284,8 +317,6 @@ export default function BecomeEditorPage() {
<CardDescription className="text-gray-400">Tell the community about your editing style and skills</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ExistingLabProfileNotice identifier="editor-profile" label="editor" />
{/* Identity */}
<div className="space-y-2">
<Label>Your Identity</Label>
@ -295,6 +326,21 @@ export default function BecomeEditorPage() {
</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>
@ -454,7 +500,7 @@ export default function BecomeEditorPage() {
{isPublishing ? (
<><Loader2 className="h-4 w-4 animate-spin" />Publishing…</>
) : (
<><Scissors className="h-4 w-4" />{isPro ? 'Publish Pro Editor Profile' : 'Publish Editor Profile'}</>
<><Scissors className="h-4 w-4" />{existingProfile ? 'Update Editor Profile' : isPro ? 'Publish Pro Editor Profile' : 'Publish Editor Profile'}</>
)}
</Button>
</div>

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
@ -22,13 +22,13 @@ import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { ExistingLabProfileNotice } from '@/components/ExistingLabProfileNotice';
import { CrewLinkField } from '@/components/CrewLinkField';
import { SKATE_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink } from 'lucide-react';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
const SERVICE_ENTRIES = Object.entries(FILMING_SERVICE_LABELS);
const SKILL_ENTRIES = Object.entries(FILMING_SKILL_LABELS);
@ -75,6 +75,46 @@ export default function BecomeFilmerPage() {
const [gear, setGear] = useState('');
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
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_FILMER_KIND, 'filming-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(FILMING_SERVICE_LABELS));
const skillKeys = new Set(Object.keys(FILMING_SKILL_LABELS));
const sel = tags('t').filter((v) => v !== 'skatefilmer');
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') ?? '');
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) => serviceKeys.has(v)));
setSelectedSkills(sel.filter((v) => skillKeys.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;
@ -151,7 +191,7 @@ export default function BecomeFilmerPage() {
const tags: string[][] = [
['d', identifier],
['title', displayName],
['title', titleInput.trim() || displayName],
['t', 'skatefilmer'],
['alt', `Skate filmer: ${displayName}`],
];
@ -203,7 +243,7 @@ export default function BecomeFilmerPage() {
tags,
});
toast({ title: 'Filming profile created!', description: 'Your profile has been published.' });
toast({ title: `Filming 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' });
@ -320,8 +360,6 @@ export default function BecomeFilmerPage() {
<CardDescription className="text-gray-400">Tell clients about your filming expertise</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ExistingLabProfileNotice identifier="filming-profile" label="filmer" />
<div className="space-y-2">
<Label>Your Filmer Identity</Label>
<div className="p-4 rounded-lg border-2 border-red-500/30 bg-red-500/5">
@ -330,6 +368,21 @@ export default function BecomeFilmerPage() {
</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 Filmer Berlin”. Defaults to your name.
</p>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Work</Label>
<Textarea id="bio" placeholder="Your background, style, equipment..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
@ -516,7 +569,7 @@ export default function BecomeFilmerPage() {
) : (
<>
<Video className="h-4 w-4 mr-2" />
{isPro ? 'CREATE PRO FILMING PROFILE' : 'CREATE FILMING PROFILE'}
{existingProfile ? 'UPDATE FILMING PROFILE' : isPro ? 'CREATE PRO FILMING PROFILE' : 'CREATE FILMING PROFILE'}
</>
)}
</Button>

View file

@ -1,4 +1,4 @@
import { useState } from 'react';
import { useState, useEffect } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
@ -22,7 +22,6 @@ import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { ExistingLabProfileNotice } from '@/components/ExistingLabProfileNotice';
import { CrewLinkField } from '@/components/CrewLinkField';
import type { EntityType } from '@/lib/types';
import {
@ -41,6 +40,7 @@ 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);
@ -123,6 +123,55 @@ export default function BecomePhotographerPage() {
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);
// 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') ?? '');
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;
@ -192,7 +241,7 @@ export default function BecomePhotographerPage() {
const tags: string[][] = [
['d', identifier],
['title', displayName],
['title', titleInput.trim() || displayName],
['t', 'skatephotographer'],
['alt', `Skate photographer: ${displayName}`],
];
@ -253,7 +302,7 @@ export default function BecomePhotographerPage() {
tags,
});
toast({ title: 'Photography profile created!', description: 'Your profile has been published.' });
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' });
@ -362,8 +411,6 @@ export default function BecomePhotographerPage() {
<CardDescription className="text-gray-400">Tell riders about your eye, your kit and your services</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<ExistingLabProfileNotice identifier="photography-profile" label="photographer" />
<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">
@ -372,6 +419,21 @@ export default function BecomePhotographerPage() {
</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 Photography”. Defaults to your name.
</p>
</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" />
@ -557,7 +619,7 @@ export default function BecomePhotographerPage() {
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
) : (
<><Camera className="h-4 w-4 mr-2" />{isPro ? 'CREATE PRO PHOTOGRAPHY PROFILE' : 'CREATE PHOTOGRAPHY PROFILE'}</>
<><Camera className="h-4 w-4 mr-2" />{existingProfile ? 'UPDATE PHOTOGRAPHY PROFILE' : isPro ? 'CREATE PRO PHOTOGRAPHY PROFILE' : 'CREATE PHOTOGRAPHY PROFILE'}</>
)}
</Button>
</CardContent>