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:
parent
f4318a8b50
commit
0071bcba36
12 changed files with 1754 additions and 140 deletions
102
NIP.md
102
NIP.md
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
489
src/components/CraftProfileForm.tsx
Normal file
489
src/components/CraftProfileForm.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
473
src/lib/types.ts
473
src/lib/types.ts
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
38
src/pages/BecomeArtistPage.tsx
Normal file
38
src/pages/BecomeArtistPage.tsx
Normal 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) },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
41
src/pages/BecomeGraffitiPage.tsx
Normal file
41
src/pages/BecomeGraffitiPage.tsx
Normal 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.' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/pages/BecomeMusicianPage.tsx
Normal file
42
src/pages/BecomeMusicianPage.tsx
Normal 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/…' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
39
src/pages/BecomeTattooPage.tsx
Normal file
39
src/pages/BecomeTattooPage.tsx
Normal 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?' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
42
src/pages/BecomeTrainerPage.tsx
Normal file
42
src/pages/BecomeTrainerPage.tsx
Normal 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?' },
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue