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:
parent
b60b13dc18
commit
84d1ba426d
3 changed files with 92 additions and 5 deletions
|
|
@ -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>
|
||||||
|
|
|
||||||
30
src/hooks/useMyLabProfile.ts
Normal file
30
src/hooks/useMyLabProfile.ts
Normal 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;
|
||||||
|
},
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
@ -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 && (
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue