Lab: add Designer/Artist, Graffiti, Tattoo, Trainer/Physio & Musician crafts (kinds 35007–35011)

- New addressable kinds 35007–35011 with types, label maps and parsers
- Shared parseCraftBase helper + parseLabProfile dispatcher in useLabProfiles
- Reusable, config-driven CraftProfileForm (polished shared shell, per-craft
  skill sections + extra fields, entity/collective, CLINK, Pro upgrade)
- Five thin Become* pages wired into AppRouter and the Lab "Offer" menu
- LabPage: extended ServiceType, accents, scrollable tab strip, filter
  sections, counts, empty states and ProfileCard to all 10 crafts
- CoachDetailPage renders all craft types (map-driven accents/icons/labels)
  plus craft-specific fields (studio, books-open, credentials, remote,
  graffiti crew, music links)
- NIP.md documents all five new craft schemas

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-10 20:28:59 +07:00
parent f4318a8b50
commit 0071bcba36
12 changed files with 1754 additions and 140 deletions

102
NIP.md
View file

@ -1417,6 +1417,13 @@ They share a common base schema and are distinguished by kind and a self-tag.
| 35003 | Video Editor | `skateeditor` |
| 35004 | Photographer | `skatephotographer` |
| 35005 | Builder (ramp/park/DIY) | `skatebuilder` |
| 35007 | Designer / Artist | `skateartist` |
| 35008 | Graffiti / Mural artist | `skategraffiti` |
| 35009 | Tattoo artist | `skatetattoo` |
| 35010 | Trainer / Physiotherapist | `skatetrainer` |
| 35011 | Musician / Band | `skatemusician` |
> Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below).
### Common Base Schema
@ -1677,12 +1684,103 @@ on the map.
}
```
### Designer / Artist (kind 35007)
Skate designers and artists — board graphics, logos, posters, merch, zines and
illustration. Uses the common base schema with `t`-tag categories.
Categories encoded as `t` tags:
- **What they make**: `boardart`, `logos`, `posters`, `merch`, `zines`, `illustration`, `stickers`, `webdesign`, `typography`, `animation`
- **Medium**: `digital`, `handdrawn`, `screenprint`, `paint`, `collage`, `photo`, `3d`
- **Software / tools**: `illustrator`, `photoshop`, `procreate`, `affinity`, `figma`, `blender`, `aftereffects`, `clipstudio`
Optional tags: `portfolio`, `social` (shared base).
### Graffiti / Mural Artist (kind 35008)
Graffiti writers and muralists. Uses the common base schema.
Categories encoded as `t` tags:
- **Style**: `throwups`, `pieces`, `murals`, `characters`, `lettering`, `stencil`, `handstyles`, `3dstyle`, `abstract`
- **Medium**: `spray`, `rollers`, `markers`, `brush`, `digital`
- **Work context**: `legalwalls`, `commissions`, `liveart`, `skateparks`, `interiors`, `workshops`
Graffiti-specific optional tags:
| Tag | Description |
|-----|-------------|
| `crew` | Freeform **graffiti** crew name (distinct from a SkateSpots skate crew) |
| `a` | Walls/spots painted — references to kind `30818` skate spots |
### Tattoo Artist (kind 35009)
Skate-culture tattoo artists. Uses the common base schema.
Categories encoded as `t` tags:
- **Styles**: `traditional`, `neotraditional`, `fineline`, `blackwork`, `realism`, `lettering`, `illustrative`, `skateflash`, `ignorant`, `japanese`
- **Method**: `machine`, `handpoke`, `flash`, `custom`, `coverup`
Tattoo-specific optional tags:
| Tag | Description |
|-----|-------------|
| `studio` | Studio / shop name |
| `books_open` | `"true"` if currently taking bookings |
### Trainer / Physiotherapist (kind 35010)
Physical trainers, physiotherapists and sports therapists who work with skaters.
Uses the common base schema.
Categories encoded as `t` tags:
- **Focus**: `strength`, `mobility`, `injuryprevention`, `rehab`, `recovery`, `balance`, `cardio`, `nutrition`
- **Injuries treated**: `ankles`, `knees`, `wrists`, `hips`, `back`, `shoulders`, `heelbruise`, `concussion`
- **Credentials / setting**: `physio`, `osteo`, `certtrainer`, `sportstherapist`, `massage`, `inperson`, `remote`
Trainer-specific optional tags:
| Tag | Description |
|-----|-------------|
| `credentials` | Freeform certifications (e.g. "BSc Physiotherapy, CSCS") |
| `remote` | `"true"` if remote / online coaching is offered |
### Musician / Band (kind 35011)
Skaters who make music — solo artists, bands, DJs and producers. Skate scenes and
local music are tightly linked (demos, jams, after-parties), so musicians are a
first-class Lab craft. A skate **crew** may also carry `band`/`band-url` tags
(see Crews) for a lighter-weight association; a full musician profile is the
first-class, bookable identity. Uses the common base schema.
Categories encoded as `t` tags:
- **Genre**: `punk`, `hardcore`, `metal`, `rock`, `indie`, `hiphop`, `electronic`, `lofi`, `reggae`, `ska`, `jazz`, `funk`, `flamenco`, `folk`, `experimental`, `grunge`
- **Instruments / role**: `vocals`, `guitar`, `bass`, `drums`, `keys`, `dj`, `producer`, `sax`, `strings`, `other`
Musician-specific optional tags:
| Tag | Description |
|-----|-------------|
| `act` | Act type: `solo`, `band`, `dj`, `producer`, `collective` |
| `spotify` | Spotify link (`http(s)` only) |
| `bandcamp` | Bandcamp link (`http(s)` only) |
| `soundcloud` | SoundCloud link (`http(s)` only) |
| `youtube` | YouTube link (`http(s)` only) |
A band can be a `collective` with member `p` tags (bilateral NIP-32 attestation,
same as other collectives), and may link to its skate crew via an `a` tag
(`35006:pubkey:d`).
### Querying Lab Profiles
```json
{
"kinds": [35001, 35002, 35003, 35004, 35005],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder"],
"kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician"],
"limit": 100
}
```

View file

@ -31,6 +31,11 @@ const BecomeFilmerPage = lazy(() => import("./pages/BecomeFilmerPage"));
const BecomeEditorPage = lazy(() => import("./pages/BecomeEditorPage"));
const BecomePhotographerPage = lazy(() => import("./pages/BecomePhotographerPage"));
const BecomeBuilderPage = lazy(() => import("./pages/BecomeBuilderPage"));
const BecomeArtistPage = lazy(() => import("./pages/BecomeArtistPage"));
const BecomeGraffitiPage = lazy(() => import("./pages/BecomeGraffitiPage"));
const BecomeTattooPage = lazy(() => import("./pages/BecomeTattooPage"));
const BecomeTrainerPage = lazy(() => import("./pages/BecomeTrainerPage"));
const BecomeMusicianPage = lazy(() => import("./pages/BecomeMusicianPage"));
const CrewsPage = lazy(() => import("./pages/CrewsPage"));
const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage"));
const EditCrewPage = lazy(() => import("./pages/EditCrewPage"));
@ -90,6 +95,11 @@ export function AppRouter() {
<Route path="/become-editor" element={<BecomeEditorPage />} />
<Route path="/become-photographer" element={<BecomePhotographerPage />} />
<Route path="/become-builder" element={<BecomeBuilderPage />} />
<Route path="/become-artist" element={<BecomeArtistPage />} />
<Route path="/become-graffiti" element={<BecomeGraffitiPage />} />
<Route path="/become-tattoo" element={<BecomeTattooPage />} />
<Route path="/become-trainer" element={<BecomeTrainerPage />} />
<Route path="/become-musician" element={<BecomeMusicianPage />} />
<Route path="/crews" element={<CrewsPage />} />
<Route path="/create-crew" element={<CreateCrewPage />} />
<Route path="/edit-crew/:pubkey/:identifier" element={<EditCrewPage />} />

View file

@ -0,0 +1,489 @@
import { useState } from '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 } 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 { Switch } from '@/components/ui/switch';
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 { LoginArea } from '@/components/auth/LoginArea';
import { NofferField } from '@/components/NofferField';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react';
/** A group of selectable tag chips (e.g. "Genre", "Instruments"). */
export interface SkillSection {
title: string;
/** [tagValue, label] pairs. */
entries: [string, string][];
/** If true, at least one must be selected before publishing. */
required?: boolean;
}
/** A freeform extra field specific to a craft (e.g. tattoo studio, music links). */
export interface ExtraField {
/** Nostr tag name to write, e.g. "studio", "spotify". */
tag: string;
label: string;
placeholder?: string;
/** "text" (default) | "url" | "toggle" */
type?: 'text' | 'url' | 'toggle';
helper?: string;
}
export interface CraftFormConfig {
kind: number;
selfTag: string;
/** Fixed `d` identifier for this craft's single profile per user. */
identifier: string;
/** Page heading, e.g. "Become a Tattoo Artist". */
heading: string;
/** Sub-line under the heading. */
tagline: string;
/** Human noun for toasts / alt text, e.g. "tattoo artist". */
noun: string;
/** Tailwind text color for the accent, e.g. "text-rose-400". */
accentText: string;
/** Tailwind bg color for the accent, e.g. "bg-rose-600". */
accentBg: string;
/** Active-chip classes, e.g. "bg-rose-600 text-white border-rose-600". */
accentChip: string;
/** Hover classes for inactive chips. */
accentChipHover: string;
/** Icon component for the login-gate + header. */
icon: React.ComponentType<{ className?: string }>;
/** Tag chip sections. */
skillSections: SkillSection[];
/** Craft-specific freeform fields. */
extraFields?: ExtraField[];
/** Whether this craft can be a collective (default true). */
allowCollective?: boolean;
/** Whether to show the rate field (default true). */
showRate?: boolean;
/** Label for the rate field. */
rateLabel?: string;
/** Label for the portfolio field. */
portfolioLabel?: string;
}
export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const {
kind, selfTag, identifier, heading, tagline, noun,
accentText, accentBg, accentChip, accentChipHover, icon: Icon,
skillSections, extraFields = [], allowCollective = true,
showRate = true, rateLabel = 'Rate', portfolioLabel = 'Portfolio Link',
} = config;
const navigate = useNavigate();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
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 [selected, setSelected] = 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 [extras, setExtras] = useState<Record<string, string>>({});
const [isGettingLocation, setIsGettingLocation] = useState(false);
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 ${noun}`;
};
const toggle = (key: string) =>
setSelected((prev) => (prev.includes(key) ? prev.filter((s) => s !== key) : [...prev, key]));
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
},
(error) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: error.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 (error) {
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
}
e.target.value = '';
};
const requiredSections = skillSections.filter((s) => s.required);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Enforce required skill sections
for (const section of requiredSections) {
const keys = section.entries.map(([k]) => k);
if (!selected.some((s) => keys.includes(s))) {
toast({ title: `${section.title} required`, description: `Select at least one option under "${section.title}".`, variant: 'destructive' });
return;
}
}
try {
const displayName = getDisplayName();
const tags: string[][] = [
['d', identifier],
['title', displayName],
['t', selfTag],
['alt', `Skate ${noun}: ${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 selected) tags.push(['t', s]);
if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.trim()]);
if (showRate && 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()]);
// Craft-specific extras
for (const f of extraFields) {
const v = (extras[f.tag] ?? '').trim();
if (f.type === 'toggle') {
if (extras[f.tag] === 'true') tags.push([f.tag, 'true']);
} else if (v) {
if (f.type === 'url' && !/^https?:\/\//i.test(v)) continue;
tags.push([f.tag, v]);
}
}
tags.push(['entity', entityType]);
if (allowCollective && entityType === 'collective') {
for (const pk of members) tags.push(['p', pk]);
if (crewRef) tags.push(['a', crewRef]);
}
if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]);
await createEvent({ kind, content: bio.trim(), tags });
toast({ title: `${heading.replace(/^Become an?/, '').trim()} profile 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' });
}
};
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">
<Icon className={cn('h-12 w-12 mx-auto mb-4', accentText)} />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-gray-400 mb-6">You need to log in to create a {noun} profile.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const hasNip05 = user?.metadata?.nip05;
const entityCoord = `${kind}:${user.pubkey}:${identifier}`;
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
{/* Header */}
<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>
<div className="flex items-center gap-3">
<div className={cn('w-11 h-11 rounded-xl flex items-center justify-center', accentChip)}>
<Icon className="h-6 w-6" />
</div>
<div>
<h1 className={cn('text-2xl sm:text-3xl font-black uppercase leading-none', accentText)} style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
{heading}
</h1>
<p className="text-gray-400 mt-1 text-sm">{tagline}</p>
</div>
</div>
{!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">
<div className="text-yellow-500">⚠️</div>
<div>
<h3 className="font-bold text-yellow-400 mb-1">Add NIP-05 to build trust</h3>
<p className="text-sm text-gray-300 mb-2">
A NIP-05 identifier helps skaters verify you're the real deal. Highly recommended before listing.
</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>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Image + Bio */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<div className="flex items-center gap-4">
<div className={cn('relative w-20 h-20 rounded-2xl border-2 flex items-center justify-center overflow-hidden flex-shrink-0', image ? 'border-white/20' : 'border-dashed border-white/20')}>
{image ? (
<>
<img src={image} alt="Profile" className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-0.5 right-0.5 bg-black/70 rounded-full p-0.5">
<X className="h-3 w-3 text-white" />
</button>
</>
) : (
<Icon className="h-7 w-7 text-gray-600" />
)}
</div>
<div className="flex-1">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">Profile image</Label>
<label className={cn('mt-1.5 flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 cursor-pointer text-sm w-fit transition-colors', isUploading && 'opacity-50 pointer-events-none')}>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{isUploading ? 'Uploading…' : 'Upload'}
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
</label>
</div>
</div>
<div>
<Label htmlFor="bio" className="text-xs font-bold uppercase tracking-widest text-gray-400">About</Label>
<Textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={`Tell skaters about your ${noun} work…`}
className="mt-1.5 bg-white/5 border-white/10 min-h-[100px] resize-none"
maxLength={3000}
/>
</div>
</CardContent>
</Card>
{/* Skill sections */}
{skillSections.map((section) => (
<Card key={section.title} className="bg-black/60 border-white/10">
<CardContent className="p-5">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400 flex items-center gap-2">
{section.title}
{section.required && <span className={cn('text-[10px]', accentText)}>• required</span>}
</Label>
<div className="mt-3 flex flex-wrap gap-2">
{section.entries.map(([key, label]) => {
const active = selected.includes(key);
return (
<button
key={key}
type="button"
onClick={() => toggle(key)}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
active ? accentChip : cn('bg-white/5 border-white/10 text-gray-400', accentChipHover)
)}
>
{label}
</button>
);
})}
</div>
</CardContent>
</Card>
))}
{/* Craft-specific extras */}
{extraFields.length > 0 && (
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
{extraFields.map((f) => (
f.type === 'toggle' ? (
<div key={f.tag} className="flex items-center justify-between gap-3">
<div>
<Label className="text-sm font-bold text-gray-200">{f.label}</Label>
{f.helper && <p className="text-xs text-gray-500">{f.helper}</p>}
</div>
<Switch
checked={extras[f.tag] === 'true'}
onCheckedChange={(v) => setExtras((prev) => ({ ...prev, [f.tag]: v ? 'true' : 'false' }))}
/>
</div>
) : (
<div key={f.tag}>
<Label htmlFor={f.tag} className="text-xs font-bold uppercase tracking-widest text-gray-400">{f.label}</Label>
<Input
id={f.tag}
value={extras[f.tag] ?? ''}
onChange={(e) => setExtras((prev) => ({ ...prev, [f.tag]: e.target.value }))}
placeholder={f.placeholder}
inputMode={f.type === 'url' ? 'url' : undefined}
className="mt-1.5 bg-white/5 border-white/10"
/>
{f.helper && <p className="text-xs text-gray-500 mt-1">{f.helper}</p>}
</div>
)
))}
</CardContent>
</Card>
)}
{/* Location + experience + rate */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<div>
<Label htmlFor="location" className="text-xs font-bold uppercase tracking-widest text-gray-400">Location</Label>
<div className="mt-1.5 flex gap-2">
<Input id="location" value={location} onChange={(e) => setLocation(e.target.value)} placeholder="City, Country" className="bg-white/5 border-white/10" />
<Button type="button" variant="outline" onClick={getCurrentLocation} disabled={isGettingLocation} className="border-white/10 flex-shrink-0">
{isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
</Button>
</div>
{latitude && longitude && <p className="text-xs text-green-400 mt-1">📍 Pinned to map ({latitude}, {longitude})</p>}
</div>
<div className={cn('grid gap-4', showRate ? 'grid-cols-2' : 'grid-cols-1')}>
<div>
<Label htmlFor="exp" className="text-xs font-bold uppercase tracking-widest text-gray-400">Experience (years)</Label>
<Input id="exp" value={experience} onChange={(e) => setExperience(e.target.value)} placeholder="e.g. 5" inputMode="numeric" className="mt-1.5 bg-white/5 border-white/10" />
</div>
{showRate && (
<div>
<Label htmlFor="rate" className="text-xs font-bold uppercase tracking-widest text-gray-400">{rateLabel}</Label>
<Input id="rate" value={rate} onChange={(e) => setRate(e.target.value)} placeholder="e.g. 20k sats / by project" className="mt-1.5 bg-white/5 border-white/10" />
</div>
)}
</div>
</CardContent>
</Card>
{/* Contact + links */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">Contact & Links</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Phone (optional)" className="bg-white/5 border-white/10" />
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email (optional)" type="email" className="bg-white/5 border-white/10" />
<Input value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="Website" inputMode="url" className="bg-white/5 border-white/10" />
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder="Social (Instagram, etc.)" className="bg-white/5 border-white/10" />
<Input value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className="bg-white/5 border-white/10 sm:col-span-2" />
</div>
</CardContent>
</Card>
{/* CLINK tip jar */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5">
<NofferField value={noffer} onChange={setNoffer} />
</CardContent>
</Card>
{/* Entity type + crew link (collective-capable crafts) */}
{allowCollective && (
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<EntityTypeField
entityType={entityType}
onEntityTypeChange={setEntityType}
members={members}
onMembersChange={setMembers}
accentBg={accentBg}
accentText={accentText}
/>
{entityType === 'collective' && (
<CrewLinkField value={crewRef} onChange={setCrewRef} accentText={accentText} accentBg={accentBg} />
)}
</CardContent>
</Card>
)}
{/* Pro upgrade (optional) */}
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText={accentText} entityCoord={entityCoord} />
{/* Submit */}
<Button
type="submit"
disabled={isPending}
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'}
</Button>
</form>
</div>
</Layout>
);
}

View file

@ -1,11 +1,85 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder, isFeaturedActive, type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder } from '@/lib/types';
import {
SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND,
SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND,
parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder,
parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician,
isFeaturedActive,
type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder,
type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays';
export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder) & {
profileType: 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder';
export type LabProfileType =
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician';
export type LabProfile = (
| SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder
| SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician
) & {
profileType: LabProfileType;
};
const LAB_KINDS = [
SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND,
SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND,
];
const LAB_SELF_TAGS = [
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician',
];
/** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */
export function parseLabProfile(event: NostrEvent): LabProfile | null {
switch (event.kind) {
case SKATE_COACH_KIND: {
const p = parseSkateCoach(event);
return p ? { ...p, profileType: 'coach' } : null;
}
case SKATE_FILMER_KIND: {
const p = parseSkateFilmer(event);
return p ? { ...p, profileType: 'filmer' } : null;
}
case SKATE_EDITOR_KIND: {
const p = parseSkateEditor(event);
return p ? { ...p, profileType: 'editor' } : null;
}
case SKATE_PHOTOGRAPHER_KIND: {
const p = parseSkatePhotographer(event);
return p ? { ...p, profileType: 'photographer' } : null;
}
case SKATE_BUILDER_KIND: {
const p = parseSkateBuilder(event);
return p ? { ...p, profileType: 'builder' } : null;
}
case SKATE_ARTIST_KIND: {
const p = parseSkateArtist(event);
return p ? { ...p, profileType: 'artist' } : null;
}
case SKATE_GRAFFITI_KIND: {
const p = parseSkateGraffiti(event);
return p ? { ...p, profileType: 'graffiti' } : null;
}
case SKATE_TATTOO_KIND: {
const p = parseSkateTattoo(event);
return p ? { ...p, profileType: 'tattoo' } : null;
}
case SKATE_TRAINER_KIND: {
const p = parseSkateTrainer(event);
return p ? { ...p, profileType: 'trainer' } : null;
}
case SKATE_MUSICIAN_KIND: {
const p = parseSkateMusician(event);
return p ? { ...p, profileType: 'musician' } : null;
}
default:
return null;
}
}
export function useLabProfiles(limit = 100) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
@ -14,53 +88,23 @@ export function useLabProfiles(limit = 100) {
queryFn: async () => {
const signal = AbortSignal.timeout(15000);
console.log('[useLabProfiles] Querying for coaches and filmers...');
const events = await querySkateContent(
[
{
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND],
'#t': ['skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder'],
kinds: LAB_KINDS,
'#t': LAB_SELF_TAGS,
limit: limit * 2,
},
],
{ signal }
);
console.log(`[useLabProfiles] Received ${events.length} events`);
const profiles: LabProfile[] = [];
for (const event of events) {
if (event.kind === SKATE_COACH_KIND) {
const coach = parseSkateCoach(event);
if (coach) {
profiles.push({ ...coach, profileType: 'coach' });
}
} else if (event.kind === SKATE_FILMER_KIND) {
const filmer = parseSkateFilmer(event);
if (filmer) {
profiles.push({ ...filmer, profileType: 'filmer' });
}
} else if (event.kind === SKATE_EDITOR_KIND) {
const editor = parseSkateEditor(event);
if (editor) {
profiles.push({ ...editor, profileType: 'editor' });
}
} else if (event.kind === SKATE_PHOTOGRAPHER_KIND) {
const photographer = parseSkatePhotographer(event);
if (photographer) {
profiles.push({ ...photographer, profileType: 'photographer' });
}
} else if (event.kind === SKATE_BUILDER_KIND) {
const builder = parseSkateBuilder(event);
if (builder) {
profiles.push({ ...builder, profileType: 'builder' });
}
}
const parsed = parseLabProfile(event);
if (parsed) profiles.push(parsed);
}
console.log(`[useLabProfiles] Parsed profiles: ${profiles.length} (coaches + filmers + editors + photographers)`);
// Featured (Pro) profiles first, then newest.
return profiles.sort((a, b) => {
const aPro = isFeaturedActive(a.featuredUntil) ? 1 : 0;
@ -88,44 +132,18 @@ export function useLabProfile(identifier: string, pubkey?: string) {
authors?: string[];
limit: number;
} = {
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND],
kinds: LAB_KINDS,
'#d': [identifier],
limit: 1,
};
if (pubkey) filter.authors = [pubkey];
console.log(`[useLabProfile] Querying for profile: ${identifier}`);
const events = await querySkateContent([filter], { signal });
console.log(`[useLabProfile] Received ${events.length} events`);
if (events.length === 0) return null;
if (events.length === 0) {
console.warn(`[useLabProfile] No events found for identifier: ${identifier}`);
return null;
}
const event = events[0];
if (event.kind === SKATE_COACH_KIND) {
const coach = parseSkateCoach(event);
return coach ? { ...coach, profileType: 'coach' as const } : null;
} else if (event.kind === SKATE_FILMER_KIND) {
const filmer = parseSkateFilmer(event);
return filmer ? { ...filmer, profileType: 'filmer' as const } : null;
} else if (event.kind === SKATE_EDITOR_KIND) {
const editor = parseSkateEditor(event);
return editor ? { ...editor, profileType: 'editor' as const } : null;
} else if (event.kind === SKATE_PHOTOGRAPHER_KIND) {
const photographer = parseSkatePhotographer(event);
return photographer ? { ...photographer, profileType: 'photographer' as const } : null;
} else if (event.kind === SKATE_BUILDER_KIND) {
const builder = parseSkateBuilder(event);
return builder ? { ...builder, profileType: 'builder' as const } : null;
}
return null;
return parseLabProfile(events[0]);
},
enabled: !!identifier,
staleTime: 30000,

View file

@ -51,6 +51,11 @@ export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles
export const SKATE_PHOTOGRAPHER_KIND = 35004; // Skate photographer profiles
export const SKATE_BUILDER_KIND = 35005; // Skate ramp / park builder profiles
export const SKATE_CREW_KIND = 35006; // Skate crews (community identity, map-anchored)
export const SKATE_ARTIST_KIND = 35007; // Designer / artist profiles (board graphics, logos, posters, merch)
export const SKATE_GRAFFITI_KIND = 35008; // Graffiti / mural artist profiles
export const SKATE_TATTOO_KIND = 35009; // Tattoo artist profiles
export const SKATE_TRAINER_KIND = 35010; // Physical trainer / physiotherapist profiles
export const SKATE_MUSICIAN_KIND = 35011; // Musician / band profiles (skaters who make music)
export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings
export const SPOT_COLLECTION_KIND = 30003; // NIP-51 Bookmark sets for spot collections
export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
@ -929,6 +934,235 @@ export const BUILDER_SCALE_LABELS: Record<string, string> = {
event: 'Event / Contest Course',
};
// ----- Designer / Artist (board graphics, logos, posters, merch) -----
// What they make
export const ARTIST_SERVICE_LABELS: Record<string, string> = {
boardart: 'Board Graphics',
logos: 'Logos & Branding',
posters: 'Posters & Flyers',
merch: 'Merch & Apparel',
zines: 'Zines & Print',
illustration: 'Illustration',
stickers: 'Stickers & Decals',
webdesign: 'Web / Digital',
typography: 'Typography / Lettering',
animation: 'Animation / Motion',
};
export const ARTIST_SERVICE_DESCRIPTIONS: Record<string, string> = {
boardart: 'Deck graphics and griptape art',
logos: 'Brand identity, logos and wordmarks',
posters: 'Event posters, flyers and gig art',
merch: 'T-shirts, hoodies and apparel graphics',
zines: 'Zines, editorial and print layout',
illustration: 'Custom illustration and character work',
stickers: 'Sticker packs, decals and slaps',
webdesign: 'Websites, digital and UI',
typography: 'Hand lettering and custom type',
animation: 'Motion graphics and animation',
};
// Medium / how they work
export const ARTIST_MEDIUM_LABELS: Record<string, string> = {
digital: 'Digital / Vector',
handdrawn: 'Hand-Drawn',
screenprint: 'Screen Print',
paint: 'Paint / Acrylic',
collage: 'Collage / Mixed',
photo: 'Photo / Photoshop',
'3d': '3D / Render',
};
// Software / tools they use
export const ARTIST_TOOL_LABELS: Record<string, string> = {
illustrator: 'Adobe Illustrator',
photoshop: 'Adobe Photoshop',
procreate: 'Procreate',
affinity: 'Affinity',
figma: 'Figma',
blender: 'Blender',
aftereffects: 'After Effects',
clipstudio: 'Clip Studio',
};
// ----- Graffiti / Mural artist -----
// Styles
export const GRAFFITI_STYLE_LABELS: Record<string, string> = {
throwups: 'Throw-ups',
pieces: 'Pieces / Burners',
murals: 'Murals',
characters: 'Characters',
lettering: 'Lettering / Wildstyle',
stencil: 'Stencil',
handstyles: 'Handstyles / Tags',
'3dstyle': '3D Style',
abstract: 'Abstract',
};
export const GRAFFITI_STYLE_DESCRIPTIONS: Record<string, string> = {
throwups: 'Quick two-color fills',
pieces: 'Full-color pieces and burners',
murals: 'Large-scale murals and productions',
characters: 'Character and figure work',
lettering: 'Wildstyle and letter structure',
stencil: 'Stencil and street art',
handstyles: 'Tags and handstyles',
'3dstyle': '3D and anamorphic lettering',
abstract: 'Abstract and freestyle',
};
// Medium / surface
export const GRAFFITI_MEDIUM_LABELS: Record<string, string> = {
spray: 'Spray Cans',
rollers: 'Rollers / Emulsion',
markers: 'Markers / Inks',
brush: 'Brush / Paint',
digital: 'Digital / Procreate',
};
// Work context
export const GRAFFITI_CONTEXT_LABELS: Record<string, string> = {
legalwalls: 'Legal Walls',
commissions: 'Commissions',
liveart: 'Live Painting',
skateparks: 'Skatepark Murals',
interiors: 'Interiors / Shops',
workshops: 'Workshops',
};
// ----- Tattoo artist -----
// Styles
export const TATTOO_STYLE_LABELS: Record<string, string> = {
traditional: 'Traditional / Old School',
neotraditional: 'Neo-Traditional',
fineline: 'Fine Line',
blackwork: 'Blackwork',
realism: 'Realism',
lettering: 'Lettering / Script',
illustrative: 'Illustrative',
skateflash: 'Skate Flash',
ignorant: 'Ignorant / Handpoke Style',
japanese: 'Japanese / Irezumi',
};
export const TATTOO_STYLE_DESCRIPTIONS: Record<string, string> = {
traditional: 'Bold lines, classic Americana',
neotraditional: 'Traditional with modern detail',
fineline: 'Delicate single-needle work',
blackwork: 'Solid black and pattern',
realism: 'Photo-realistic and portraits',
lettering: 'Script, lettering and quotes',
illustrative: 'Illustrative and graphic',
skateflash: 'Skate-culture flash designs',
ignorant: 'Ignorant / naive handpoke style',
japanese: 'Japanese traditional / irezumi',
};
// Method
export const TATTOO_METHOD_LABELS: Record<string, string> = {
machine: 'Machine',
handpoke: 'Hand-Poke / Stick & Poke',
flash: 'Flash Available',
custom: 'Custom Designs',
coverup: 'Cover-ups',
};
// ----- Physical Trainer / Physiotherapist -----
// Focus areas
export const TRAINER_FOCUS_LABELS: Record<string, string> = {
strength: 'Strength & Conditioning',
mobility: 'Mobility & Flexibility',
injuryprevention: 'Injury Prevention',
rehab: 'Rehab / Physio',
recovery: 'Recovery & Bodywork',
balance: 'Balance & Stability',
cardio: 'Cardio / Endurance',
nutrition: 'Nutrition Guidance',
};
export const TRAINER_FOCUS_DESCRIPTIONS: Record<string, string> = {
strength: 'Building strength and power for skating',
mobility: 'Range of motion and flexibility',
injuryprevention: 'Prehab and reducing injury risk',
rehab: 'Post-injury physiotherapy and rehab',
recovery: 'Massage, bodywork and recovery',
balance: 'Proprioception, balance and stability',
cardio: 'Conditioning and endurance',
nutrition: 'Fuelling and nutrition advice',
};
// Common skate injuries they work with
export const TRAINER_INJURY_LABELS: Record<string, string> = {
ankles: 'Ankles',
knees: 'Knees',
wrists: 'Wrists',
hips: 'Hips',
back: 'Back / Spine',
shoulders: 'Shoulders',
heelbruise: 'Heel Bruises',
concussion: 'Concussion Protocol',
};
// Credentials / setting
export const TRAINER_CREDENTIAL_LABELS: Record<string, string> = {
physio: 'Licensed Physiotherapist',
osteo: 'Osteopath',
certtrainer: 'Certified Trainer',
sportstherapist: 'Sports Therapist',
massage: 'Massage Therapist',
inperson: 'In-Person',
remote: 'Remote / Online',
};
// ----- Musician / Band (skaters who make music) -----
// Genres
export const MUSICIAN_GENRE_LABELS: Record<string, string> = {
punk: 'Punk',
hardcore: 'Hardcore',
metal: 'Metal',
rock: 'Rock',
indie: 'Indie / Alt',
hiphop: 'Hip-Hop / Rap',
electronic: 'Electronic',
lofi: 'Lo-Fi / Beats',
reggae: 'Reggae / Dub',
ska: 'Ska',
jazz: 'Jazz',
funk: 'Funk / Soul',
flamenco: 'Flamenco',
folk: 'Folk / Acoustic',
experimental: 'Experimental',
grunge: 'Grunge',
};
// Instruments / roles
export const MUSICIAN_INSTRUMENT_LABELS: Record<string, string> = {
vocals: 'Vocals',
guitar: 'Guitar',
bass: 'Bass',
drums: 'Drums',
keys: 'Keys / Synth',
dj: 'DJ / Turntables',
producer: 'Producer / Beats',
sax: 'Sax / Brass',
strings: 'Strings',
other: 'Other',
};
// Act type
export const MUSICIAN_ACT_LABELS: Record<string, string> = {
solo: 'Solo Artist',
band: 'Band',
dj: 'DJ',
producer: 'Producer',
collective: 'Collective',
};
// ----- Crews (community identity, map-anchored) -----
/**
@ -1116,6 +1350,146 @@ export interface SkateCoach {
event: NostrEvent;
}
export interface SkateArtist {
id: string;
pubkey: string;
identifier: string;
featuredUntil?: number;
title: string;
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // services + medium + tools tags
experience?: string;
rate?: string;
phone?: string;
email?: string;
website?: string;
portfolio?: string; // portfolio / shop link
social?: string;
noffer?: string;
entityType: EntityType;
members: string[];
crewRef?: string;
createdAt: number;
event: NostrEvent;
}
export interface SkateGraffiti {
id: string;
pubkey: string;
identifier: string;
featuredUntil?: number;
title: string;
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // style + medium + context tags
experience?: string;
rate?: string;
phone?: string;
email?: string;
website?: string;
portfolio?: string;
social?: string;
crew?: string; // freeform graffiti crew name (distinct from skate crew)
noffer?: string;
entityType: EntityType;
members: string[];
spotRefs?: string[]; // walls / spots painted (kind 30818 refs)
crewRef?: string;
createdAt: number;
event: NostrEvent;
}
export interface SkateTattoo {
id: string;
pubkey: string;
identifier: string;
featuredUntil?: number;
title: string;
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // style + method tags
experience?: string;
rate?: string;
phone?: string;
email?: string;
website?: string;
portfolio?: string;
social?: string;
studio?: string; // studio / shop name
booksOpen?: boolean; // currently taking bookings
noffer?: string;
entityType: EntityType;
members: string[];
crewRef?: string;
createdAt: number;
event: NostrEvent;
}
export interface SkateTrainer {
id: string;
pubkey: string;
identifier: string;
featuredUntil?: number;
title: string;
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // focus + injury + credential tags
experience?: string;
rate?: string;
phone?: string;
email?: string;
website?: string;
portfolio?: string; // profile / booking link
social?: string;
credentials?: string; // freeform certifications
remote?: boolean; // offers remote/online coaching
noffer?: string;
entityType: EntityType;
members: string[];
crewRef?: string;
createdAt: number;
event: NostrEvent;
}
export interface SkateMusician {
id: string;
pubkey: string;
identifier: string;
featuredUntil?: number;
title: string; // artist / band name
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // genre + instrument tags
actType?: string; // solo / band / dj / producer / collective
experience?: string;
rate?: string; // e.g. booking rate for gigs
phone?: string;
email?: string;
website?: string;
spotify?: string; // Spotify link
bandcamp?: string; // Bandcamp link
soundcloud?: string; // SoundCloud link
youtube?: string; // YouTube link
social?: string;
noffer?: string;
entityType: EntityType;
members: string[]; // band members (collective)
crewRef?: string; // linked skate crew coordinate
createdAt: number;
event: NostrEvent;
}
export interface SkateFilmer {
id: string;
pubkey: string;
@ -1553,6 +1927,105 @@ export function parseSkateBuilder(event: NostrEvent): SkateBuilder | null {
};
}
/**
* Shared parser for the "simple craft" Lab profiles that all follow the common
* base schema (title + bio + t-tag skills + contact/links), distinguished only
* by kind and self-tag. Returns the common fields; callers layer craft-specific
* extras on top.
*/
function parseCraftBase(event: NostrEvent, selfTag: string) {
const d = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !title) return null;
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
return {
id: event.id,
pubkey: event.pubkey,
identifier: d,
title: title.slice(0, MAX_TITLE_LEN),
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
location: clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN),
geohash: event.tags.find(([name]) => name === 'g')?.[1],
image: event.tags.find(([name]) => name === 'image')?.[1],
skills: event.tags.filter(([name, value]) => name === 't' && value !== selfTag).map(([, value]) => value),
experience: event.tags.find(([name]) => name === 'experience')?.[1],
rate: event.tags.find(([name]) => name === 'rate')?.[1],
phone: event.tags.find(([name]) => name === 'phone')?.[1],
email: event.tags.find(([name]) => name === 'email')?.[1],
website: event.tags.find(([name]) => name === 'website')?.[1],
portfolio: event.tags.find(([name]) => name === 'portfolio')?.[1],
social: event.tags.find(([name]) => name === 'social')?.[1],
noffer: isValidNoffer(nofferTag) ? nofferTag : undefined,
entityType: parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]),
members: parseMemberPubkeys(event),
crewRef: parseCrewRef(event),
featuredUntil: parseFeaturedUntil(event),
createdAt: event.created_at,
event,
};
}
export function parseSkateArtist(event: NostrEvent): SkateArtist | null {
if (event.kind !== SKATE_ARTIST_KIND) return null;
const base = parseCraftBase(event, 'skateartist');
if (!base) return null;
return base;
}
export function parseSkateGraffiti(event: NostrEvent): SkateGraffiti | null {
if (event.kind !== SKATE_GRAFFITI_KIND) return null;
const base = parseCraftBase(event, 'skategraffiti');
if (!base) return null;
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
.map(([, value]) => value);
return {
...base,
crew: clampStr(event.tags.find(([name]) => name === 'crew')?.[1], 100),
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
};
}
export function parseSkateTattoo(event: NostrEvent): SkateTattoo | null {
if (event.kind !== SKATE_TATTOO_KIND) return null;
const base = parseCraftBase(event, 'skatetattoo');
if (!base) return null;
return {
...base,
studio: clampStr(event.tags.find(([name]) => name === 'studio')?.[1], MAX_LOCATION_LEN),
booksOpen: event.tags.some(([name, value]) => name === 'books_open' && value === 'true'),
};
}
export function parseSkateTrainer(event: NostrEvent): SkateTrainer | null {
if (event.kind !== SKATE_TRAINER_KIND) return null;
const base = parseCraftBase(event, 'skatetrainer');
if (!base) return null;
return {
...base,
credentials: clampStr(event.tags.find(([name]) => name === 'credentials')?.[1], MAX_LOCATION_LEN),
remote: event.tags.some(([name, value]) => name === 'remote' && value === 'true'),
};
}
export function parseSkateMusician(event: NostrEvent): SkateMusician | null {
if (event.kind !== SKATE_MUSICIAN_KIND) return null;
const base = parseCraftBase(event, 'skatemusician');
if (!base) return null;
const httpOnly = (v?: string) => (v && /^https?:\/\//i.test(v.trim()) ? v.trim() : undefined);
return {
...base,
actType: event.tags.find(([name]) => name === 'act')?.[1],
spotify: httpOnly(event.tags.find(([name]) => name === 'spotify')?.[1]),
bandcamp: httpOnly(event.tags.find(([name]) => name === 'bandcamp')?.[1]),
soundcloud: httpOnly(event.tags.find(([name]) => name === 'soundcloud')?.[1]),
youtube: httpOnly(event.tags.find(([name]) => name === 'youtube')?.[1]),
};
}
export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
if (event.kind !== SKATE_CREW_KIND) return null;

View file

@ -0,0 +1,38 @@
import { useSeoMeta } from '@unhead/react';
import { Palette } from 'lucide-react';
import { CraftProfileForm } from '@/components/CraftProfileForm';
import {
SKATE_ARTIST_KIND, ARTIST_SERVICE_LABELS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS,
} from '@/lib/types';
export default function BecomeArtistPage() {
useSeoMeta({
title: 'Become a Designer / Artist - SkateSpots',
description: 'List your skate design & art — board graphics, logos, posters, merch and more.',
});
return (
<CraftProfileForm
config={{
kind: SKATE_ARTIST_KIND,
selfTag: 'skateartist',
identifier: 'artist-profile',
heading: 'Designer / Artist',
tagline: 'Board graphics, logos, posters, merch — put your art on the scene',
noun: 'artist',
accentText: 'text-fuchsia-400',
accentBg: 'bg-fuchsia-600',
accentChip: 'bg-fuchsia-600 text-white border-fuchsia-600',
accentChipHover: 'hover:border-fuchsia-500/60 hover:text-fuchsia-400',
icon: Palette,
rateLabel: 'Rate',
portfolioLabel: 'Portfolio / Shop link',
skillSections: [
{ title: 'What You Make', entries: Object.entries(ARTIST_SERVICE_LABELS), required: true },
{ title: 'Medium', entries: Object.entries(ARTIST_MEDIUM_LABELS) },
{ title: 'Software & Tools', entries: Object.entries(ARTIST_TOOL_LABELS) },
],
}}
/>
);
}

View file

@ -0,0 +1,41 @@
import { useSeoMeta } from '@unhead/react';
import { SprayCan } from 'lucide-react';
import { CraftProfileForm } from '@/components/CraftProfileForm';
import {
SKATE_GRAFFITI_KIND, GRAFFITI_STYLE_LABELS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS,
} from '@/lib/types';
export default function BecomeGraffitiPage() {
useSeoMeta({
title: 'Offer Graffiti / Murals - SkateSpots',
description: 'List your graffiti & mural work — pieces, murals, skatepark art and more.',
});
return (
<CraftProfileForm
config={{
kind: SKATE_GRAFFITI_KIND,
selfTag: 'skategraffiti',
identifier: 'graffiti-profile',
heading: 'Graffiti / Mural Artist',
tagline: 'Pieces, murals, skatepark art — paint the scene',
noun: 'graffiti artist',
accentText: 'text-lime-400',
accentBg: 'bg-lime-500',
accentChip: 'bg-lime-500 text-black border-lime-500',
accentChipHover: 'hover:border-lime-500/60 hover:text-lime-400',
icon: SprayCan,
rateLabel: 'Rate',
portfolioLabel: 'Portfolio link (IG, site)',
skillSections: [
{ title: 'Style', entries: Object.entries(GRAFFITI_STYLE_LABELS), required: true },
{ title: 'Medium', entries: Object.entries(GRAFFITI_MEDIUM_LABELS) },
{ title: 'Work Context', entries: Object.entries(GRAFFITI_CONTEXT_LABELS) },
],
extraFields: [
{ tag: 'crew', label: 'Graffiti Crew', placeholder: 'Your writing crew (optional)', helper: 'Separate from your skate crew — the writers you paint with.' },
],
}}
/>
);
}

View file

@ -0,0 +1,42 @@
import { useSeoMeta } from '@unhead/react';
import { Music } from 'lucide-react';
import { CraftProfileForm } from '@/components/CraftProfileForm';
import { SKATE_MUSICIAN_KIND, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS } from '@/lib/types';
export default function BecomeMusicianPage() {
useSeoMeta({
title: 'Add a Music Profile - SkateSpots',
description: 'Skate and make music? List your band or solo project — genres, instruments and links.',
});
return (
<CraftProfileForm
config={{
kind: SKATE_MUSICIAN_KIND,
selfTag: 'skatemusician',
identifier: 'musician-profile',
heading: 'Musician / Band',
tagline: 'Skate and make music? Get booked for demos, jams & after-parties',
noun: 'musician',
accentText: 'text-sky-400',
accentBg: 'bg-sky-500',
accentChip: 'bg-sky-500 text-white border-sky-500',
accentChipHover: 'hover:border-sky-500/60 hover:text-sky-400',
icon: Music,
rateLabel: 'Booking Rate',
portfolioLabel: 'Website / EPK link',
skillSections: [
{ title: 'Genre', entries: Object.entries(MUSICIAN_GENRE_LABELS), required: true },
{ title: 'Instruments & Role', entries: Object.entries(MUSICIAN_INSTRUMENT_LABELS) },
],
extraFields: [
{ tag: 'act', label: 'Act Type', placeholder: 'solo / band / dj / producer' },
{ tag: 'spotify', label: 'Spotify', type: 'url', placeholder: 'https://open.spotify.com/…' },
{ tag: 'bandcamp', label: 'Bandcamp', type: 'url', placeholder: 'https://…bandcamp.com' },
{ tag: 'soundcloud', label: 'SoundCloud', type: 'url', placeholder: 'https://soundcloud.com/…' },
{ tag: 'youtube', label: 'YouTube', type: 'url', placeholder: 'https://youtube.com/…' },
],
}}
/>
);
}

View file

@ -0,0 +1,39 @@
import { useSeoMeta } from '@unhead/react';
import { Brush } from 'lucide-react';
import { CraftProfileForm } from '@/components/CraftProfileForm';
import { SKATE_TATTOO_KIND, TATTOO_STYLE_LABELS, TATTOO_METHOD_LABELS } from '@/lib/types';
export default function BecomeTattooPage() {
useSeoMeta({
title: 'Become a Tattoo Artist - SkateSpots',
description: 'List your tattoo work for the skate scene — styles, flash, custom and more.',
});
return (
<CraftProfileForm
config={{
kind: SKATE_TATTOO_KIND,
selfTag: 'skatetattoo',
identifier: 'tattoo-profile',
heading: 'Tattoo Artist',
tagline: 'Ink for the scene — flash, custom, fine line & more',
noun: 'tattoo artist',
accentText: 'text-rose-400',
accentBg: 'bg-rose-600',
accentChip: 'bg-rose-600 text-white border-rose-600',
accentChipHover: 'hover:border-rose-500/60 hover:text-rose-400',
icon: Brush,
rateLabel: 'Rate / Hourly',
portfolioLabel: 'Portfolio link (IG, site)',
skillSections: [
{ title: 'Styles', entries: Object.entries(TATTOO_STYLE_LABELS), required: true },
{ title: 'Method', entries: Object.entries(TATTOO_METHOD_LABELS) },
],
extraFields: [
{ tag: 'studio', label: 'Studio / Shop', placeholder: 'Studio name (optional)' },
{ tag: 'books_open', label: 'Books Open', type: 'toggle', helper: 'Are you currently taking bookings?' },
],
}}
/>
);
}

View file

@ -0,0 +1,42 @@
import { useSeoMeta } from '@unhead/react';
import { Dumbbell } from 'lucide-react';
import { CraftProfileForm } from '@/components/CraftProfileForm';
import {
SKATE_TRAINER_KIND, TRAINER_FOCUS_LABELS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS,
} from '@/lib/types';
export default function BecomeTrainerPage() {
useSeoMeta({
title: 'Become a Trainer / Physio - SkateSpots',
description: 'Help skaters get strong and stay healthy — strength, mobility, injury prevention and recovery.',
});
return (
<CraftProfileForm
config={{
kind: SKATE_TRAINER_KIND,
selfTag: 'skatetrainer',
identifier: 'trainer-profile',
heading: 'Trainer / Physio',
tagline: 'Strength, mobility, injury prevention & recovery for skaters',
noun: 'trainer',
accentText: 'text-emerald-400',
accentBg: 'bg-emerald-600',
accentChip: 'bg-emerald-600 text-white border-emerald-600',
accentChipHover: 'hover:border-emerald-500/60 hover:text-emerald-400',
icon: Dumbbell,
rateLabel: 'Rate / Session',
portfolioLabel: 'Profile / booking link',
skillSections: [
{ title: 'Focus', entries: Object.entries(TRAINER_FOCUS_LABELS), required: true },
{ title: 'Injuries You Work With', entries: Object.entries(TRAINER_INJURY_LABELS) },
{ title: 'Credentials & Setting', entries: Object.entries(TRAINER_CREDENTIAL_LABELS) },
],
extraFields: [
{ tag: 'credentials', label: 'Certifications', placeholder: 'e.g. BSc Physiotherapy, CSCS…', helper: 'List real qualifications — skaters trust verifiable credentials.' },
{ tag: 'remote', label: 'Remote / Online', type: 'toggle', helper: 'Do you offer online coaching or consultations?' },
],
}}
/>
);
}

View file

@ -17,7 +17,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { genUserName } from '@/lib/genUserName';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS } from '@/lib/types';
import { COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, EDITOR_SERVICE_LABELS, EDITOR_SERVICE_DESCRIPTIONS, EDITOR_SKILL_LABELS, EDITOR_SKILL_DESCRIPTIONS, PHOTO_SERVICE_LABELS, PHOTO_SERVICE_DESCRIPTIONS, PHOTO_CAMERA_LABELS, PHOTO_LENS_LABELS, PHOTO_LIGHTING_LABELS, PHOTO_SUPPORT_LABELS, PHOTO_STYLE_LABELS, BUILDER_SERVICE_LABELS, BUILDER_SERVICE_DESCRIPTIONS, BUILDER_SKILL_LABELS, BUILDER_SCALE_LABELS, ARTIST_SERVICE_LABELS, ARTIST_SERVICE_DESCRIPTIONS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, GRAFFITI_STYLE_LABELS, GRAFFITI_STYLE_DESCRIPTIONS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, TATTOO_STYLE_LABELS, TATTOO_STYLE_DESCRIPTIONS, TATTOO_METHOD_LABELS, TRAINER_FOCUS_LABELS, TRAINER_FOCUS_DESCRIPTIONS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS, SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge';
@ -38,6 +38,11 @@ import {
Aperture,
Users,
HardHat,
Palette,
SprayCan,
Brush,
Dumbbell,
Music,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
@ -82,6 +87,11 @@ export default function CoachDetailPage() {
const isEditor = profile?.profileType === 'editor';
const isPhotographer = profile?.profileType === 'photographer';
const isBuilder = profile?.profileType === 'builder';
const isArtist = profile?.profileType === 'artist';
const isGraffiti = profile?.profileType === 'graffiti';
const isTattoo = profile?.profileType === 'tattoo';
const isTrainer = profile?.profileType === 'trainer';
const isMusician = profile?.profileType === 'musician';
useSeoMeta({
title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots',
@ -102,49 +112,76 @@ export default function CoachDetailPage() {
const npub = nip19.npubEncode(profile.pubkey);
// Per-type accent (text, border, bg, ring) — keeps the detail page on-brand
const accent = isCoach
? { text: 'text-[#ff0040]', bg: 'bg-[#ff0040]', border: 'border-[#ff0040]/30', hover: 'hover:bg-[#ff0040]/10 border-[#ff0040]' }
: isFilmer
? { text: 'text-orange-500', bg: 'bg-orange-500', border: 'border-orange-500/30', hover: 'hover:bg-orange-500/10 border-orange-500' }
: isPhotographer
? { text: 'text-indigo-400', bg: 'bg-indigo-600', border: 'border-indigo-500/30', hover: 'hover:bg-indigo-500/10 border-indigo-500' }
: isBuilder
? { text: 'text-amber-400', bg: 'bg-amber-500', border: 'border-amber-500/30', hover: 'hover:bg-amber-500/10 border-amber-500' }
: { text: 'text-purple-400', bg: 'bg-purple-600', border: 'border-purple-500/30', hover: 'hover:bg-purple-500/10 border-purple-500' };
const ACCENTS: Record<string, { text: string; bg: string; border: string; hover: string }> = {
coach: { text: 'text-[#ff0040]', bg: 'bg-[#ff0040]', border: 'border-[#ff0040]/30', hover: 'hover:bg-[#ff0040]/10 border-[#ff0040]' },
filmer: { text: 'text-orange-500', bg: 'bg-orange-500', border: 'border-orange-500/30', hover: 'hover:bg-orange-500/10 border-orange-500' },
photographer: { text: 'text-indigo-400', bg: 'bg-indigo-600', border: 'border-indigo-500/30', hover: 'hover:bg-indigo-500/10 border-indigo-500' },
builder: { text: 'text-amber-400', bg: 'bg-amber-500', border: 'border-amber-500/30', hover: 'hover:bg-amber-500/10 border-amber-500' },
editor: { text: 'text-purple-400', bg: 'bg-purple-600', border: 'border-purple-500/30', hover: 'hover:bg-purple-500/10 border-purple-500' },
artist: { text: 'text-fuchsia-400', bg: 'bg-fuchsia-600', border: 'border-fuchsia-500/30', hover: 'hover:bg-fuchsia-500/10 border-fuchsia-500' },
graffiti: { text: 'text-lime-400', bg: 'bg-lime-500', border: 'border-lime-500/30', hover: 'hover:bg-lime-500/10 border-lime-500' },
tattoo: { text: 'text-rose-400', bg: 'bg-rose-600', border: 'border-rose-500/30', hover: 'hover:bg-rose-500/10 border-rose-500' },
trainer: { text: 'text-emerald-400', bg: 'bg-emerald-600', border: 'border-emerald-500/30', hover: 'hover:bg-emerald-500/10 border-emerald-500' },
musician: { text: 'text-sky-400', bg: 'bg-sky-500', border: 'border-sky-500/30', hover: 'hover:bg-sky-500/10 border-sky-500' },
};
const accent = ACCENTS[profile.profileType] ?? ACCENTS.coach;
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : isBuilder ? HardHat : Scissors;
const TYPE_ICONS: Record<string, typeof GraduationCap> = {
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music,
};
const TypeIcon = TYPE_ICONS[profile.profileType] ?? GraduationCap;
// Kind for the collective coordinate (for NIP-32 membership attestations)
const profileKind = isCoach ? 35001 : isFilmer ? 35002 : isEditor ? 35003 : isPhotographer ? 35004 : 35005;
const KINDS: Record<string, number> = {
coach: SKATE_COACH_KIND, filmer: SKATE_FILMER_KIND, editor: SKATE_EDITOR_KIND,
photographer: SKATE_PHOTOGRAPHER_KIND, builder: SKATE_BUILDER_KIND, artist: SKATE_ARTIST_KIND,
graffiti: SKATE_GRAFFITI_KIND, tattoo: SKATE_TATTOO_KIND, trainer: SKATE_TRAINER_KIND, musician: SKATE_MUSICIAN_KIND,
};
const profileKind = KINDS[profile.profileType] ?? SKATE_COACH_KIND;
const isCollective = profile.entityType === 'collective' && profile.members.length > 0;
// All photographer label maps merged for chip resolution
const photoLabels: Record<string, string> = {
...PHOTO_SERVICE_LABELS, ...PHOTO_CAMERA_LABELS, ...PHOTO_LENS_LABELS,
...PHOTO_LIGHTING_LABELS, ...PHOTO_SUPPORT_LABELS, ...PHOTO_STYLE_LABELS,
// Merged label maps per craft for chip resolution
const LABELS_BY_TYPE: Record<string, Record<string, string>> = {
coach: COACH_SKILL_LABELS,
filmer: { ...FILMING_SERVICE_LABELS, ...FILMING_SKILL_LABELS },
editor: { ...EDITOR_SERVICE_LABELS, ...EDITOR_SKILL_LABELS },
photographer: { ...PHOTO_SERVICE_LABELS, ...PHOTO_CAMERA_LABELS, ...PHOTO_LENS_LABELS, ...PHOTO_LIGHTING_LABELS, ...PHOTO_SUPPORT_LABELS, ...PHOTO_STYLE_LABELS },
builder: { ...BUILDER_SERVICE_LABELS, ...BUILDER_SKILL_LABELS, ...BUILDER_SCALE_LABELS },
artist: { ...ARTIST_SERVICE_LABELS, ...ARTIST_MEDIUM_LABELS, ...ARTIST_TOOL_LABELS },
graffiti: { ...GRAFFITI_STYLE_LABELS, ...GRAFFITI_MEDIUM_LABELS, ...GRAFFITI_CONTEXT_LABELS },
tattoo: { ...TATTOO_STYLE_LABELS, ...TATTOO_METHOD_LABELS },
trainer: { ...TRAINER_FOCUS_LABELS, ...TRAINER_INJURY_LABELS, ...TRAINER_CREDENTIAL_LABELS },
musician: { ...MUSICIAN_GENRE_LABELS, ...MUSICIAN_INSTRUMENT_LABELS },
};
const builderLabels: Record<string, string> = {
...BUILDER_SERVICE_LABELS, ...BUILDER_SKILL_LABELS, ...BUILDER_SCALE_LABELS,
const DESCRIPTIONS_BY_TYPE: Record<string, Record<string, string>> = {
coach: COACH_SKILL_DESCRIPTIONS,
filmer: { ...FILMING_SERVICE_DESCRIPTIONS, ...FILMING_SKILL_DESCRIPTIONS },
editor: { ...EDITOR_SERVICE_DESCRIPTIONS, ...EDITOR_SKILL_DESCRIPTIONS },
photographer: PHOTO_SERVICE_DESCRIPTIONS,
builder: BUILDER_SERVICE_DESCRIPTIONS,
artist: ARTIST_SERVICE_DESCRIPTIONS,
graffiti: GRAFFITI_STYLE_DESCRIPTIONS,
tattoo: TATTOO_STYLE_DESCRIPTIONS,
trainer: TRAINER_FOCUS_DESCRIPTIONS,
musician: {},
};
// Get skill labels and descriptions based on profile type
const getSkillLabel = (skill: string) => {
if (isCoach) return COACH_SKILL_LABELS[skill] || skill;
if (isFilmer) return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (isPhotographer) return photoLabels[skill] || skill;
if (isBuilder) return builderLabels[skill] || skill;
if (isEditor) return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
return skill;
};
const getSkillLabel = (skill: string) => LABELS_BY_TYPE[profile.profileType]?.[skill] || skill;
const getSkillDescription = (skill: string): string | undefined => DESCRIPTIONS_BY_TYPE[profile.profileType]?.[skill];
const getSkillDescription = (skill: string) => {
if (isCoach) return COACH_SKILL_DESCRIPTIONS[skill];
if (isFilmer) return FILMING_SERVICE_DESCRIPTIONS[skill] || FILMING_SKILL_DESCRIPTIONS[skill];
if (isPhotographer) return PHOTO_SERVICE_DESCRIPTIONS[skill];
if (isBuilder) return BUILDER_SERVICE_DESCRIPTIONS[skill];
if (isEditor) return EDITOR_SERVICE_DESCRIPTIONS[skill] || EDITOR_SKILL_DESCRIPTIONS[skill];
return undefined;
const TYPE_NOUNS: Record<string, string> = {
coach: 'coach', filmer: 'filmer', editor: 'video editor', photographer: 'photographer', builder: 'builder',
artist: 'artist', graffiti: 'graffiti artist', tattoo: 'tattoo artist', trainer: 'trainer', musician: 'musician',
};
const typeNoun = TYPE_NOUNS[profile.profileType] ?? 'profile';
const SKILLS_TITLES: Record<string, string> = {
coach: 'Skills', photographer: 'Services & Gear', builder: 'What We Build',
artist: 'What They Make', graffiti: 'Styles & Work', tattoo: 'Styles',
trainer: 'Focus & Specialties', musician: 'Genres & Instruments',
};
const skillsTitle = SKILLS_TITLES[profile.profileType] ?? 'Services & Skills';
return (
<Layout>
@ -221,7 +258,7 @@ export default function CoachDetailPage() {
{profile.skills.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3">
{isCoach ? 'Skills' : isPhotographer ? 'Services & Gear' : isBuilder ? 'What We Build' : 'Services & Skills'}
{skillsTitle}
</h3>
<div className="space-y-3">
{profile.skills.map((skill) => (
@ -275,6 +312,89 @@ export default function CoachDetailPage() {
</div>
)}
{/* Tattoo — studio & books-open */}
{isTattoo && (('studio' in profile && profile.studio) || ('booksOpen' in profile && profile.booksOpen)) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{'studio' in profile && profile.studio && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<MapPin className={`h-3 w-3 ${accent.text}`} /> Studio
</h4>
<p className="text-sm text-gray-300">{profile.studio}</p>
</div>
)}
{'booksOpen' in profile && profile.booksOpen && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<Brush className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-emerald-400">Books Open</span>
</div>
)}
</div>
)}
{/* Trainer — credentials & remote */}
{isTrainer && (('credentials' in profile && profile.credentials) || ('remote' in profile && profile.remote)) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{'credentials' in profile && profile.credentials && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<Dumbbell className={`h-3 w-3 ${accent.text}`} /> Credentials
</h4>
<p className="text-sm text-gray-300">{profile.credentials}</p>
</div>
)}
{'remote' in profile && profile.remote && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<Globe className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-gray-200">Remote / Online available</span>
</div>
)}
</div>
)}
{/* Graffiti — crew name */}
{isGraffiti && 'crew' in profile && profile.crew && (
<div className="mb-6">
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<SprayCan className={`h-3 w-3 ${accent.text}`} /> Graffiti Crew
</h4>
<p className="text-sm text-gray-300">{profile.crew}</p>
</div>
)}
{/* Musician — act type & listen links */}
{isMusician && (
<div className="mb-6 space-y-3">
{'actType' in profile && profile.actType && (
<div>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1">Act</h4>
<p className="text-sm text-gray-300 capitalize">{profile.actType}</p>
</div>
)}
{(('spotify' in profile && profile.spotify) || ('bandcamp' in profile && profile.bandcamp) || ('soundcloud' in profile && profile.soundcloud) || ('youtube' in profile && profile.youtube)) && (
<div>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-1">
<Music className={`h-3 w-3 ${accent.text}`} /> Listen
</h4>
<div className="flex flex-wrap gap-2">
{'spotify' in profile && sanitizeUrl(profile.spotify) && (
<a href={sanitizeUrl(profile.spotify)!} target="_blank" rel="noopener noreferrer" className={`px-3 py-1.5 rounded-full text-xs font-bold border ${accent.border} text-gray-200 hover:bg-white/5`}>Spotify</a>
)}
{'bandcamp' in profile && sanitizeUrl(profile.bandcamp) && (
<a href={sanitizeUrl(profile.bandcamp)!} target="_blank" rel="noopener noreferrer" className={`px-3 py-1.5 rounded-full text-xs font-bold border ${accent.border} text-gray-200 hover:bg-white/5`}>Bandcamp</a>
)}
{'soundcloud' in profile && sanitizeUrl(profile.soundcloud) && (
<a href={sanitizeUrl(profile.soundcloud)!} target="_blank" rel="noopener noreferrer" className={`px-3 py-1.5 rounded-full text-xs font-bold border ${accent.border} text-gray-200 hover:bg-white/5`}>SoundCloud</a>
)}
{'youtube' in profile && sanitizeUrl(profile.youtube) && (
<a href={sanitizeUrl(profile.youtube)!} target="_blank" rel="noopener noreferrer" className={`px-3 py-1.5 rounded-full text-xs font-bold border ${accent.border} text-gray-200 hover:bg-white/5`}>YouTube</a>
)}
</div>
</div>
)}
</div>
)}
{/* Experience & Rate */}
<div className="grid grid-cols-2 gap-4">
{profile.experience && (
@ -344,15 +464,15 @@ export default function CoachDetailPage() {
</a>
)}
{(isFilmer || isPhotographer || isEditor || isBuilder) && 'social' in profile && sanitizeUrl(profile.social) && (
{!isCoach && 'social' in profile && sanitizeUrl(profile.social) && (
<a href={sanitizeUrl(profile.social)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(profile.social!)}`}>
<Globe className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">{urlDomain(profile.social!)}</span>
</a>
)}
{/* Portfolio / reel (filmers, photographers & editors) */}
{(isPhotographer || isEditor || isFilmer || isBuilder) && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && (
{/* Portfolio / reel (all crafts except coach) */}
{!isCoach && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && (
<a href={sanitizeUrl(profile.portfolio)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(profile.portfolio!)}`}>
<Aperture className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">Portfolio · {urlDomain(profile.portfolio!)}</span>
@ -396,11 +516,11 @@ export default function CoachDetailPage() {
</CardHeader>
<CardContent className="space-y-3">
<p className="text-sm text-gray-400">
Remove your {isCoach ? 'coach' : isFilmer ? 'filmer' : isEditor ? 'video editor' : isPhotographer ? 'photographer' : 'builder'} profile from The Lab.
Remove your {typeNoun} profile from The Lab.
</p>
<DeleteEntityDialog
event={profile.event}
entityLabel={`${isCoach ? 'coach' : isFilmer ? 'filmer' : isEditor ? 'editor' : isPhotographer ? 'photographer' : 'builder'} profile`}
entityLabel={`${typeNoun} profile`}
entityName={profile.title}
redirectTo="/lab"
triggerText="Delete Profile"

View file

@ -27,6 +27,19 @@ import {
BUILDER_SERVICE_LABELS,
BUILDER_SKILL_LABELS,
BUILDER_SCALE_LABELS,
ARTIST_SERVICE_LABELS,
ARTIST_MEDIUM_LABELS,
ARTIST_TOOL_LABELS,
GRAFFITI_STYLE_LABELS,
GRAFFITI_MEDIUM_LABELS,
GRAFFITI_CONTEXT_LABELS,
TATTOO_STYLE_LABELS,
TATTOO_METHOD_LABELS,
TRAINER_FOCUS_LABELS,
TRAINER_INJURY_LABELS,
TRAINER_CREDENTIAL_LABELS,
MUSICIAN_GENRE_LABELS,
MUSICIAN_INSTRUMENT_LABELS,
} from '@/lib/types';
import {
GraduationCap,
@ -38,6 +51,11 @@ import {
Scissors,
Camera,
HardHat,
Palette,
SprayCan,
Brush,
Dumbbell,
Music,
CreditCard,
X,
ChevronRight,
@ -75,6 +93,39 @@ const BUILDER_ALL_LABELS: Record<string, string> = {
...BUILDER_SCALE_LABELS,
};
const ARTIST_SERVICE_ENTRIES = Object.entries(ARTIST_SERVICE_LABELS);
const ARTIST_MEDIUM_ENTRIES = Object.entries(ARTIST_MEDIUM_LABELS);
const ARTIST_TOOL_ENTRIES = Object.entries(ARTIST_TOOL_LABELS);
const ARTIST_ALL_LABELS: Record<string, string> = {
...ARTIST_SERVICE_LABELS, ...ARTIST_MEDIUM_LABELS, ...ARTIST_TOOL_LABELS,
};
const GRAFFITI_STYLE_ENTRIES = Object.entries(GRAFFITI_STYLE_LABELS);
const GRAFFITI_MEDIUM_ENTRIES = Object.entries(GRAFFITI_MEDIUM_LABELS);
const GRAFFITI_CONTEXT_ENTRIES = Object.entries(GRAFFITI_CONTEXT_LABELS);
const GRAFFITI_ALL_LABELS: Record<string, string> = {
...GRAFFITI_STYLE_LABELS, ...GRAFFITI_MEDIUM_LABELS, ...GRAFFITI_CONTEXT_LABELS,
};
const TATTOO_STYLE_ENTRIES = Object.entries(TATTOO_STYLE_LABELS);
const TATTOO_METHOD_ENTRIES = Object.entries(TATTOO_METHOD_LABELS);
const TATTOO_ALL_LABELS: Record<string, string> = {
...TATTOO_STYLE_LABELS, ...TATTOO_METHOD_LABELS,
};
const TRAINER_FOCUS_ENTRIES = Object.entries(TRAINER_FOCUS_LABELS);
const TRAINER_INJURY_ENTRIES = Object.entries(TRAINER_INJURY_LABELS);
const TRAINER_CREDENTIAL_ENTRIES = Object.entries(TRAINER_CREDENTIAL_LABELS);
const TRAINER_ALL_LABELS: Record<string, string> = {
...TRAINER_FOCUS_LABELS, ...TRAINER_INJURY_LABELS, ...TRAINER_CREDENTIAL_LABELS,
};
const MUSICIAN_GENRE_ENTRIES = Object.entries(MUSICIAN_GENRE_LABELS);
const MUSICIAN_INSTRUMENT_ENTRIES = Object.entries(MUSICIAN_INSTRUMENT_LABELS);
const MUSICIAN_ALL_LABELS: Record<string, string> = {
...MUSICIAN_GENRE_LABELS, ...MUSICIAN_INSTRUMENT_LABELS,
};
// All photographer label maps merged — for resolving a chip label regardless of category
const PHOTO_ALL_LABELS: Record<string, string> = {
...PHOTO_SERVICE_LABELS,
@ -85,7 +136,23 @@ const PHOTO_ALL_LABELS: Record<string, string> = {
...PHOTO_STYLE_LABELS,
};
type ServiceType = 'coaching' | 'filming' | 'editing' | 'photography' | 'building';
type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music';
/** Map a ServiceType to the LabProfile.profileType it filters. */
const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> = {
coaching: 'coach',
filming: 'filmer',
editing: 'editor',
photography: 'photographer',
building: 'builder',
art: 'artist',
graffiti: 'graffiti',
tattoo: 'tattoo',
training: 'trainer',
music: 'musician',
};
// ─── Per-category accent colours ─────────────────────────────────────────────
@ -145,6 +212,61 @@ const ACCENT = {
stripe: 'bg-amber-500',
badgeSkill: 'bg-amber-900/40 border-amber-700/50 text-amber-300',
},
art: {
active: 'bg-fuchsia-600 hover:bg-fuchsia-700 text-white border-fuchsia-600',
chip: 'bg-fuchsia-600 text-white border-fuchsia-600',
chipHover: 'hover:border-fuchsia-500/60 hover:text-fuchsia-400',
border: 'border-fuchsia-500/40',
glow: 'shadow-fuchsia-500/20',
icon: 'text-fuchsia-400',
badge: 'bg-fuchsia-500/15 text-fuchsia-400 border-fuchsia-500/30',
stripe: 'bg-fuchsia-600',
badgeSkill: 'bg-fuchsia-900/40 border-fuchsia-700/50 text-fuchsia-300',
},
graffiti: {
active: 'bg-lime-500 hover:bg-lime-600 text-black border-lime-500',
chip: 'bg-lime-500 text-black border-lime-500',
chipHover: 'hover:border-lime-500/60 hover:text-lime-400',
border: 'border-lime-500/40',
glow: 'shadow-lime-500/20',
icon: 'text-lime-400',
badge: 'bg-lime-500/15 text-lime-400 border-lime-500/30',
stripe: 'bg-lime-500',
badgeSkill: 'bg-lime-900/40 border-lime-700/50 text-lime-300',
},
tattoo: {
active: 'bg-rose-600 hover:bg-rose-700 text-white border-rose-600',
chip: 'bg-rose-600 text-white border-rose-600',
chipHover: 'hover:border-rose-500/60 hover:text-rose-400',
border: 'border-rose-500/40',
glow: 'shadow-rose-500/20',
icon: 'text-rose-400',
badge: 'bg-rose-500/15 text-rose-400 border-rose-500/30',
stripe: 'bg-rose-600',
badgeSkill: 'bg-rose-900/40 border-rose-700/50 text-rose-300',
},
training: {
active: 'bg-emerald-600 hover:bg-emerald-700 text-white border-emerald-600',
chip: 'bg-emerald-600 text-white border-emerald-600',
chipHover: 'hover:border-emerald-500/60 hover:text-emerald-400',
border: 'border-emerald-500/40',
glow: 'shadow-emerald-500/20',
icon: 'text-emerald-400',
badge: 'bg-emerald-500/15 text-emerald-400 border-emerald-500/30',
stripe: 'bg-emerald-600',
badgeSkill: 'bg-emerald-900/40 border-emerald-700/50 text-emerald-300',
},
music: {
active: 'bg-sky-500 hover:bg-sky-600 text-white border-sky-500',
chip: 'bg-sky-500 text-white border-sky-500',
chipHover: 'hover:border-sky-500/60 hover:text-sky-400',
border: 'border-sky-500/40',
glow: 'shadow-sky-500/20',
icon: 'text-sky-400',
badge: 'bg-sky-500/15 text-sky-400 border-sky-500/30',
stripe: 'bg-sky-500',
badgeSkill: 'bg-sky-900/40 border-sky-700/50 text-sky-300',
},
};
// ─── FAB — offer services ─────────────────────────────────────────────────────
@ -169,6 +291,11 @@ function OfferFAB() {
{ to: '/become-photographer', label: 'Offer Photography', color: 'bg-indigo-600 hover:bg-indigo-700', icon: Camera },
{ to: '/become-editor', label: 'Offer Editing', color: 'bg-purple-600 hover:bg-purple-700', icon: Scissors },
{ to: '/become-builder', label: 'Offer Building', color: 'bg-amber-500 hover:bg-amber-600 text-black', icon: HardHat },
{ to: '/become-artist', label: 'Offer Design / Art', color: 'bg-fuchsia-600 hover:bg-fuchsia-700', icon: Palette },
{ to: '/become-graffiti', label: 'Offer Graffiti', color: 'bg-lime-500 hover:bg-lime-600 text-black', icon: SprayCan },
{ to: '/become-tattoo', label: 'Offer Tattoo', color: 'bg-rose-600 hover:bg-rose-700', icon: Brush },
{ to: '/become-trainer', label: 'Offer Training / Physio', color: 'bg-emerald-600 hover:bg-emerald-700', icon: Dumbbell },
{ to: '/become-musician', label: 'Add Music Profile', color: 'bg-sky-500 hover:bg-sky-600', icon: Music },
{ to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
];
@ -241,6 +368,11 @@ function getSkillLabelForType(skill: string, serviceType: ServiceType): string {
if (serviceType === 'filming') return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (serviceType === 'photography') return PHOTO_ALL_LABELS[skill] || skill;
if (serviceType === 'building') return BUILDER_ALL_LABELS[skill] || skill;
if (serviceType === 'art') return ARTIST_ALL_LABELS[skill] || skill;
if (serviceType === 'graffiti') return GRAFFITI_ALL_LABELS[skill] || skill;
if (serviceType === 'tattoo') return TATTOO_ALL_LABELS[skill] || skill;
if (serviceType === 'training') return TRAINER_ALL_LABELS[skill] || skill;
if (serviceType === 'music') return MUSICIAN_ALL_LABELS[skill] || skill;
return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
}
@ -268,6 +400,11 @@ function LabToolbar({
{ key: 'photography', label: 'Photo', icon: Camera },
{ key: 'editing', label: 'Editors', icon: Scissors },
{ key: 'building', label: 'Builders', icon: HardHat },
{ key: 'art', label: 'Artists', icon: Palette },
{ key: 'graffiti', label: 'Graffiti', icon: SprayCan },
{ key: 'tattoo', label: 'Tattoo', icon: Brush },
{ key: 'training', label: 'Trainers', icon: Dumbbell },
{ key: 'music', label: 'Music', icon: Music },
];
const accent = ACCENT[serviceType];
@ -282,10 +419,11 @@ function LabToolbar({
return (
<div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-3 space-y-2.5">
{/* Tab row — fixed 5-across grid so every category is visible without
horizontal scrolling. Icon-over-label stack keeps each tab compact
but tappable on small phones; a count badge floats in the corner. */}
<div className="grid grid-cols-5 gap-1.5 sm:gap-2">
{/* Tab row — horizontally scrollable pill strip. With 10 crafts a fixed
grid would be too cramped, so tabs scroll sideways on small screens and
wrap comfortably on wide ones. Icon-over-label stack keeps each tab
compact but tappable; a count badge floats in the corner. */}
<div className="flex gap-1.5 sm:gap-2 overflow-x-auto scrollbar-none -mx-1 px-1 md:flex-wrap md:overflow-visible snap-x">
{tabs.map(({ key, label, icon: Icon }) => {
const isActive = serviceType === key;
const a = ACCENT[key];
@ -294,7 +432,7 @@ function LabToolbar({
key={key}
onClick={() => onServiceChange(key)}
className={cn(
'relative flex flex-col items-center justify-center gap-1 px-1 py-2 rounded-xl font-black text-[10px] sm:text-xs uppercase tracking-tight sm:tracking-wide transition-all duration-150 active:scale-95',
'relative flex flex-col items-center justify-center gap-1 px-2.5 py-2 rounded-xl font-black text-[10px] sm:text-xs uppercase tracking-tight sm:tracking-wide transition-all duration-150 active:scale-95 flex-shrink-0 snap-start min-w-[60px]',
isActive
? a.active
: 'text-gray-500 bg-white/5 hover:bg-white/10 hover:text-white border border-transparent'
@ -488,6 +626,34 @@ function FilterDrawer({
{ title: 'Materials & Trade', entries: BUILDER_SKILL_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
{ title: 'Project Scale', entries: BUILDER_SCALE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
]
: serviceType === 'art'
? [
{ title: 'What They Make', entries: ARTIST_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Medium', entries: ARTIST_MEDIUM_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' },
{ title: 'Software & Tools', entries: ARTIST_TOOL_ENTRIES, activeColor: 'bg-fuchsia-400 text-white border-fuchsia-400' },
]
: serviceType === 'graffiti'
? [
{ title: 'Style', entries: GRAFFITI_STYLE_ENTRIES, activeColor: accent.chip },
{ title: 'Medium', entries: GRAFFITI_MEDIUM_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' },
{ title: 'Work Context', entries: GRAFFITI_CONTEXT_ENTRIES, activeColor: 'bg-lime-400 text-black border-lime-400' },
]
: serviceType === 'tattoo'
? [
{ title: 'Style', entries: TATTOO_STYLE_ENTRIES, activeColor: accent.chip },
{ title: 'Method', entries: TATTOO_METHOD_ENTRIES, activeColor: 'bg-rose-400 text-white border-rose-400' },
]
: serviceType === 'training'
? [
{ title: 'Focus', entries: TRAINER_FOCUS_ENTRIES, activeColor: accent.chip },
{ title: 'Injuries Treated', entries: TRAINER_INJURY_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' },
{ title: 'Credentials & Setting', entries: TRAINER_CREDENTIAL_ENTRIES, activeColor: 'bg-emerald-400 text-black border-emerald-400' },
]
: serviceType === 'music'
? [
{ title: 'Genre', entries: MUSICIAN_GENRE_ENTRIES, activeColor: accent.chip },
{ title: 'Instruments & Role', entries: MUSICIAN_INSTRUMENT_ENTRIES, activeColor: 'bg-sky-400 text-white border-sky-400' },
]
: [
{ title: 'Edit Type', entries: EDITOR_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Software & Tools', entries: EDITOR_SKILL_ENTRIES, activeColor: 'bg-purple-400 text-white border-purple-400' },
@ -660,25 +826,20 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
const avatar = profile.image || metadata?.picture;
const nip05 = metadata?.nip05;
const isCoach = profile.profileType === 'coach';
const isFilmer = profile.profileType === 'filmer';
const isEditor = profile.profileType === 'editor';
const isPhotographer = profile.profileType === 'photographer';
const isBuilder = profile.profileType === 'builder';
const serviceType: ServiceType = isCoach ? 'coaching' : isFilmer ? 'filming' : isPhotographer ? 'photography' : isBuilder ? 'building' : 'editing';
const PROFILE_TO_SERVICE: Record<LabProfile['profileType'], ServiceType> = {
coach: 'coaching', filmer: 'filming', editor: 'editing', photographer: 'photography', builder: 'building',
artist: 'art', graffiti: 'graffiti', tattoo: 'tattoo', trainer: 'training', musician: 'music',
};
const serviceType: ServiceType = PROFILE_TO_SERVICE[profile.profileType];
const accent = ACCENT[serviceType];
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : isBuilder ? HardHat : Scissors;
const getSkillLabel = (skill: string) => {
if (isCoach) return COACH_SKILL_LABELS[skill] || skill;
if (isFilmer) return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (isPhotographer) return PHOTO_ALL_LABELS[skill] || skill;
if (isBuilder) return BUILDER_ALL_LABELS[skill] || skill;
if (isEditor) return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
return skill;
const TYPE_ICONS: Record<ServiceType, React.ComponentType<{ className?: string }>> = {
coaching: GraduationCap, filming: Video, editing: Scissors, photography: Camera, building: HardHat,
art: Palette, graffiti: SprayCan, tattoo: Brush, training: Dumbbell, music: Music,
};
const TypeIcon = TYPE_ICONS[serviceType];
const getSkillLabel = (skill: string) => getSkillLabelForType(skill, serviceType);
return (
<Link to={`/coach/${profile.pubkey}/${profile.identifier}`}>
@ -847,6 +1008,46 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
to: '/become-builder',
color: 'bg-amber-500 hover:bg-amber-600 text-black',
},
art: {
icon: Palette,
title: 'No artists yet',
body: 'Be the first to offer board graphics, logos, posters and merch for the scene.',
cta: 'Offer Design / Art',
to: '/become-artist',
color: 'bg-fuchsia-600 hover:bg-fuchsia-700',
},
graffiti: {
icon: SprayCan,
title: 'No graffiti artists yet',
body: 'Be the first to offer pieces, murals and skatepark art in your area.',
cta: 'Offer Graffiti',
to: '/become-graffiti',
color: 'bg-lime-500 hover:bg-lime-600 text-black',
},
tattoo: {
icon: Brush,
title: 'No tattoo artists yet',
body: 'Be the first skate-culture tattoo artist to list — flash, custom, fine line and more.',
cta: 'Offer Tattoo',
to: '/become-tattoo',
color: 'bg-rose-600 hover:bg-rose-700',
},
training: {
icon: Dumbbell,
title: 'No trainers yet',
body: 'Be the first trainer or physio to help skaters get strong and stay healthy.',
cta: 'Offer Training / Physio',
to: '/become-trainer',
color: 'bg-emerald-600 hover:bg-emerald-700',
},
music: {
icon: Music,
title: 'No musicians yet',
body: 'Skate and make music? Add your band or solo project — genres, instruments and links.',
cta: 'Add Music Profile',
to: '/become-musician',
color: 'bg-sky-500 hover:bg-sky-600',
},
};
const c = configs[serviceType];
@ -935,12 +1136,19 @@ export default function LabPage() {
// Counts per tab (unfiltered by skills/search/location, just by type)
const counts = useMemo<Record<ServiceType, number>>(() => {
if (!profiles) return { coaching: 0, filming: 0, editing: 0, photography: 0, building: 0 };
const byType = (t: ServiceType) =>
profiles.filter((p) => p.profileType === SERVICE_TO_PROFILE_TYPE[t]).length;
return {
coaching: profiles.filter((p) => p.profileType === 'coach').length,
filming: profiles.filter((p) => p.profileType === 'filmer').length,
photography: profiles.filter((p) => p.profileType === 'photographer').length,
editing: profiles.filter((p) => p.profileType === 'editor').length,
building: profiles.filter((p) => p.profileType === 'builder').length,
coaching: byType('coaching'),
filming: byType('filming'),
photography: byType('photography'),
editing: byType('editing'),
building: byType('building'),
art: byType('art'),
graffiti: byType('graffiti'),
tattoo: byType('tattoo'),
training: byType('training'),
music: byType('music'),
};
}, [profiles]);
@ -948,11 +1156,7 @@ export default function LabPage() {
if (!profiles) return [];
let filtered = profiles.filter((profile) => {
if (serviceType === 'coaching' && profile.profileType !== 'coach') return false;
if (serviceType === 'filming' && profile.profileType !== 'filmer') return false;
if (serviceType === 'photography' && profile.profileType !== 'photographer') return false;
if (serviceType === 'editing' && profile.profileType !== 'editor') return false;
if (serviceType === 'building' && profile.profileType !== 'builder') return false;
if (profile.profileType !== SERVICE_TO_PROFILE_TYPE[serviceType]) return false;
// Text search: title, bio, and genUserName fallback
if (searchQuery) {