feat(lab): craft profiles are now editable + add a title/headline field

Audit of the Lab 'offer a service' flows found two cross-cutting gaps:
1. Every Lab card showed the name TWICE (the card renders a 'title' line, but
   all forms wrote title = the kind-0 display name). The field is meant to be a
   self-described headline.
2. Only the Skater flow could be edited; the 7 craft roles started blank and
   silently overwrote the previous profile on submit.

Fixes (shared CraftProfileForm -> 7 roles: artist, graffiti, tattoo, trainer,
musician, judge, organizer):
- New useMyLabProfileEvent(kind, identifier) hook; the form prefills from your
  existing profile and becomes an EDIT (button: Update Profile; toast: updated).
- New 'Name / headline' field -> the  tag (defaults to your name).
- Lab ProfileCard only shows the title line when it differs from the name.

Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
SkateSpots 2026-09-27 00:17:05 +07:00
parent b60b13dc18
commit 84d1ba426d
3 changed files with 92 additions and 5 deletions

View file

@ -1,4 +1,4 @@
import { useState } from 'react'; import { useState, useEffect } from 'react';
import { useNavigate, Link } from 'react-router-dom'; import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash'; import ngeohash from 'ngeohash';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
@ -21,6 +21,7 @@ import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField'; import { CrewLinkField } from '@/components/CrewLinkField';
import { LabProUpgrade } from '@/components/LabProUpgrade'; import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee'; import { usePlatformFee } from '@/hooks/usePlatformFee';
import { useMyLabProfileEvent } from '@/hooks/useMyLabProfile';
import { isValidNoffer, type EntityType } from '@/lib/types'; import { isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react'; import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react';
@ -117,6 +118,38 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const [crewRef, setCrewRef] = useState<string | undefined>(undefined); const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
const [extras, setExtras] = useState<Record<string, string>>({}); const [extras, setExtras] = useState<Record<string, string>>({});
const [isGettingLocation, setIsGettingLocation] = useState(false); const [isGettingLocation, setIsGettingLocation] = useState(false);
const [titleInput, setTitleInput] = useState('');
const [prefilled, setPrefilled] = useState(false);
// Load + prefill an existing profile so this is an EDIT, not a blind overwrite.
const { data: existingProfile } = useMyLabProfileEvent(kind, identifier);
useEffect(() => {
if (prefilled || !existingProfile) return;
setPrefilled(true);
const tag = (n: string) => existingProfile.tags.find(([t]) => t === n)?.[1];
setTitleInput(tag('title') ?? '');
setBio(existingProfile.content ?? '');
setLocation(tag('location') ?? '');
setSelected(existingProfile.tags.filter(([t, v]) => t === 't' && v !== selfTag).map(([, v]) => v));
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') ?? '');
setEntityType((tag('entity') as EntityType) ?? 'individual');
setMembers(existingProfile.tags.filter(([t]) => t === 'p').map(([, v]) => v));
setCrewRef(existingProfile.tags.find(([t]) => t === 'a')?.[1]);
const ex: Record<string, string> = {};
for (const f of extraFields) {
const v = tag(f.tag);
if (v !== undefined) ex[f.tag] = v;
}
setExtras(ex);
}, [existingProfile, prefilled, selfTag, extraFields]);
const getDisplayName = () => { const getDisplayName = () => {
if (author.data?.metadata?.name) return author.data.metadata.name; if (author.data?.metadata?.name) return author.data.metadata.name;
@ -184,7 +217,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const displayName = getDisplayName(); const displayName = getDisplayName();
const tags: string[][] = [ const tags: string[][] = [
['d', identifier], ['d', identifier],
['title', displayName], ['title', titleInput.trim() || displayName],
['t', selfTag], ['t', selfTag],
['alt', `Skate ${noun}: ${displayName}`], ['alt', `Skate ${noun}: ${displayName}`],
]; ];
@ -228,7 +261,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]); if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]);
await createEvent({ kind, content: bio.trim(), tags }); await createEvent({ kind, content: bio.trim(), tags });
toast({ title: `${heading.replace(/^Become an?/, '').trim()} profile created!`, description: 'Your profile has been published.' }); toast({ title: `${heading.replace(/^Become an?/, '').trim()} profile ${existingProfile ? 'updated' : 'created'}!`, description: 'Your profile has been published.' });
navigate(`/coach/${user?.pubkey}/${identifier}`); navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (error) { } catch (error) {
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' }); toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
@ -298,6 +331,12 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
)} )}
</div> </div>
{existingProfile && (
<p className="mb-3 text-xs text-gray-500">
Editing your existing {noun} profile — publishing replaces it.
</p>
)}
<form onSubmit={handleSubmit} className="space-y-6"> <form onSubmit={handleSubmit} className="space-y-6">
{/* Image + Bio */} {/* Image + Bio */}
<Card className="bg-black/60 border-white/10"> <Card className="bg-black/60 border-white/10">
@ -325,6 +364,21 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
</div> </div>
</div> </div>
<div>
<Label htmlFor="title" className="text-xs font-bold uppercase tracking-widest text-gray-400">Name / headline</Label>
<Input
id="title"
value={titleInput}
onChange={(e) => setTitleInput(e.target.value)}
placeholder={getDisplayName()}
maxLength={120}
className="mt-1.5 bg-white/5 border-white/10"
/>
<p className="mt-1 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> <div>
<Label htmlFor="bio" className="text-xs font-bold uppercase tracking-widest text-gray-400">About</Label> <Label htmlFor="bio" className="text-xs font-bold uppercase tracking-widest text-gray-400">About</Label>
<Textarea <Textarea
@ -485,7 +539,7 @@ export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
disabled={isPending} disabled={isPending}
className={cn('w-full h-12 rounded-xl font-black text-base uppercase tracking-wide', accentChip)} className={cn('w-full h-12 rounded-xl font-black text-base uppercase tracking-wide', accentChip)}
> >
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Publish Profile'} {isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : existingProfile ? 'Update Profile' : 'Publish Profile'}
</Button> </Button>
</form> </form>
</div> </div>

View file

@ -0,0 +1,30 @@
import { useQuery } from '@tanstack/react-query';
import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import { useCurrentUser } from '@/hooks/useCurrentUser';
/**
* The current user's existing Lab profile **event** for a given kind + fixed
* `d` identifier, so a "Become …" form can prefill and edit what was already
* published (instead of always starting blank and silently overwriting).
*/
export function useMyLabProfileEvent(kind: number, identifier: string) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const { user } = useCurrentUser();
return useQuery<NostrEvent | null>({
queryKey: ['my-lab-profile', kind, identifier, user?.pubkey, getQueryKeySuffix()],
enabled: !!user?.pubkey,
staleTime: 30_000,
refetchOnMount: 'always',
queryFn: async () => {
if (!user?.pubkey) return null;
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [kind], authors: [user.pubkey], '#d': [identifier], limit: 1 }],
{ signal },
);
return events[0] ?? null;
},
});
}

View file

@ -968,7 +968,10 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
</div> </div>
{/* Title + location */} {/* Title + location */}
<p className="text-xs text-gray-400 truncate mb-1.5 leading-snug">{profile.title}</p> {/* Title / headline — only when it adds something beyond the name */}
{profile.title && profile.title !== displayName && (
<p className="text-xs text-gray-400 truncate mb-1.5 leading-snug">{profile.title}</p>
)}
<div className="flex items-center gap-3 text-[11px] text-gray-500 mb-2"> <div className="flex items-center gap-3 text-[11px] text-gray-500 mb-2">
{profile.location && ( {profile.location && (