feat(lab): add Event Organizer craft profile (kind 35013)

New data-driven Lab role 'Event Organizer / Host' — skaters who put on jams,
comps, meetups, demos and tour stops.
- types.ts: SKATE_ORGANIZER_KIND=35013, ORGANIZER_EVENT/SERVICE/SCALE label
  maps, SkateOrganizer + parseSkateOrganizer (events_hosted extra tag)
- becomeRoles: craft config (teal accent, Tent icon, 3 skill sections)
- useLabProfiles: registered kind/self-tag/parser
- LabPage: Organizers tab, accent, filter drawer section, empty state, Offer
  link, counts
- CoachDetailPage (generic Lab detail): accent/icon/kind/labels/noun
- LabCrewsStrip: union updated
- NIP.md documents kind 35013; smoke covers /lab and /become/organizer

Gate: 101 tests, 0 errors/warnings, boot smoke green.
This commit is contained in:
SkateSpots 2026-09-27 00:02:39 +07:00
parent e1deec21bd
commit b60b13dc18
9 changed files with 212 additions and 16 deletions

View file

@ -44,7 +44,7 @@ session with **no other memory of this project**. So:
· `31873` trusted-trader recommendation
→ `src/hooks/useTradeAttestations.ts` / `useTraderRecommendations.ts`;
reputation UI in `TradeReputationCard` / `TradeRecommendationsCard`
- `35000–35012` role profiles (skater/filmer/coach/…) · `35006` crews · `30003` spot collections
- `35000–35013` role profiles (skater/filmer/coach/…/event-organizer) · `35006` crews · `30003` spot collections
- `24899` spot chat · `2259` spot status · `32671` Sasquatch Pro · `7103` event updates
- **Marketplace**: `GearSwapPage` (grid) + `MarketplaceItemCard` and a listing
detail page at **`/listing/:pubkey/:identifier`**. Trade/escrow actions live in

28
NIP.md
View file

@ -1685,6 +1685,7 @@ They share a common base schema and are distinguished by kind and a self-tag.
| 35010 | Trainer / Physiotherapist | `skatetrainer` |
| 35011 | Musician / Band | `skatemusician` |
| 35012 | Event Judge | `skatejudge` |
| 35013 | Event Organizer / Host | `skateorganizer` |
> Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below).
@ -2108,12 +2109,35 @@ Judge-specific optional tags:
A judging panel can be a `collective` with member `p` tags, and may link to its
skate crew via an `a` tag (`35006:pubkey:d`).
### Event Organizer (kind 35013)
Skaters who put on events — street jams, best-trick comps, Game of S.K.A.T.E.,
meetups, demos, clinics and tour stops. Organizers list the event types they run,
what they take care of (permits, sponsorship, sound, safety…) and the scale they
work at, so skaters and brands can find and book them. Uses the common base
schema.
Categories encoded as `t` tags:
- **Event types**: `street_jam`, `best_trick`, `game_of_skate`, `contest`, `bowl`, `mini_ramp`, `park`, `demo`, `clinic`, `meetup`, `downhill`, `slalom`, `kids`, `charity`, `tour_stop`, `premiere`, `market`
- **What they handle**: `organizing`, `hosting`, `permits`, `sponsorship`, `promotion`, `livestream`, `sound`, `ramps`, `safety`, `vendors`, `media`, `registration`
- **Scale**: `grassroots`, `local`, `regional`, `national`, `international`, `pro`
Organizer-specific optional tags:
| Tag | Description |
|-----|-------------|
| `events_hosted` | Freeform note on notable events / comps / meetups run (≤ base limits). |
An organizing team can be a `collective` with member `p` tags, and may link to its
skate crew via an `a` tag (`35006:pubkey:d`).
### Querying Lab Profiles
```json
{
"kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge"],
"kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012, 35013],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer"],
"limit": 100
}
```

View file

@ -130,6 +130,8 @@ const ROUTES = [
'/marketplace',
'/listing/abc/def',
'/trades',
'/lab',
'/become/organizer',
'/leaderboards',
'/bounties',
'/settings',

View file

@ -8,7 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
/** The Lab page's own ServiceType union (kept in sync with LabPage). */
type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging';
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing';
/** Translate the Lab page's ServiceType into a crew focus scene. */
function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> {

View file

@ -1,12 +1,12 @@
import { useQuery } from '@tanstack/react-query';
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, SKATE_JUDGE_KIND,
SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND,
parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder,
parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge,
parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge, parseSkateOrganizer,
isFeaturedActive,
type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder,
type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician, type SkateJudge,
type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician, type SkateJudge, type SkateOrganizer,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays';
@ -14,23 +14,23 @@ import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminMode
export type LabProfileType =
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge';
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer';
export type LabProfile = (
| SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder
| SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge
| SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge | SkateOrganizer
) & {
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, SKATE_JUDGE_KIND,
SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND,
];
const LAB_SELF_TAGS = [
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge',
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge', 'skateorganizer',
];
/** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */
@ -80,6 +80,10 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null {
const p = parseSkateJudge(event);
return p ? { ...p, profileType: 'judge' } : null;
}
case SKATE_ORGANIZER_KIND: {
const p = parseSkateOrganizer(event);
return p ? { ...p, profileType: 'organizer' } : null;
}
default:
return null;
}

View file

@ -1,6 +1,6 @@
import { lazy, type ComponentType, type LazyExoticComponent } from 'react';
import {
Palette, SprayCan, Music, Gavel, Brush, Dumbbell,
Palette, SprayCan, Music, Gavel, Brush, Dumbbell, Tent,
} from 'lucide-react';
import type { CraftFormConfig } from '@/components/CraftProfileForm';
import {
@ -8,6 +8,7 @@ import {
SKATE_GRAFFITI_KIND, GRAFFITI_STYLE_LABELS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS,
SKATE_MUSICIAN_KIND, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS,
SKATE_JUDGE_KIND, JUDGE_FORMAT_LABELS, JUDGE_SPECIALTY_LABELS, JUDGE_LEVEL_LABELS,
SKATE_ORGANIZER_KIND, ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS,
SKATE_TATTOO_KIND, TATTOO_STYLE_LABELS, TATTOO_METHOD_LABELS,
SKATE_TRAINER_KIND, TRAINER_FOCUS_LABELS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS,
} from '@/lib/types';
@ -165,6 +166,34 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
],
},
},
organizer: {
kind: 'craft',
seoTitle: 'Organize Skate Events - SkateSpots',
seoDescription: 'List yourself as a skate event organizer — street jams, best trick, contests, meetups, demos and tour stops. Get discovered and booked to run the session.',
config: {
kind: SKATE_ORGANIZER_KIND,
selfTag: 'skateorganizer',
identifier: 'organizer-profile',
heading: 'Event Organizer',
tagline: 'Jams, contests, meetups & demos — put on the session and bring the scene together',
noun: 'organizer',
accentText: 'text-teal-400',
accentBg: 'bg-teal-600',
accentChip: 'bg-teal-600 text-white border-teal-600',
accentChipHover: 'hover:border-teal-500/60 hover:text-teal-400',
icon: Tent,
rateLabel: 'Event Fee / Budget',
portfolioLabel: 'Past events / results link',
skillSections: [
{ title: 'Event Types', entries: Object.entries(ORGANIZER_EVENT_LABELS), required: true },
{ title: 'What You Handle', entries: Object.entries(ORGANIZER_SERVICE_LABELS) },
{ title: 'Scale', entries: Object.entries(ORGANIZER_SCALE_LABELS) },
],
extraFields: [
{ tag: 'events_hosted', label: 'Events Organized', placeholder: 'e.g. 25 jams, 3 city comps', helper: 'Notable events, comps or meetups you have run.' },
],
},
},
tattoo: {
kind: 'craft',
seoTitle: 'Become a Tattoo Artist - SkateSpots',

View file

@ -62,6 +62,7 @@ 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 SKATE_JUDGE_KIND = 35012; // Skate judges (event officials)
export const SKATE_ORGANIZER_KIND = 35013; // Event organizers / hosts (jams, comps, meetups)
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
@ -1857,6 +1858,55 @@ export const JUDGE_LEVEL_LABELS: Record<string, string> = {
pro: 'Pro Circuit',
};
// ----- Event Organizers / Hosts (kind 35013) -----
// The kinds of events the organizer puts on.
export const ORGANIZER_EVENT_LABELS: Record<string, string> = {
street_jam: 'Street Jam',
best_trick: 'Best Trick',
game_of_skate: 'Game of S.K.A.T.E.',
contest: 'Contest / Comp',
bowl: 'Bowl / Vert',
mini_ramp: 'Mini Ramp',
park: 'Park Session',
demo: 'Pro Demo',
clinic: 'Clinic / Workshop',
meetup: 'Meetup / Cruise',
downhill: 'Downhill / Race',
slalom: 'Slalom',
kids: 'Kids / Youth',
charity: 'Charity / Fundraiser',
tour_stop: 'Tour Stop',
premiere: 'Video Premiere',
market: 'Market / Art Show',
};
// What the organizer actually takes care of.
export const ORGANIZER_SERVICE_LABELS: Record<string, string> = {
organizing: 'Full Organizing',
hosting: 'Hosting / MC',
permits: 'Permits & Council',
sponsorship: 'Sponsorship / Brand Deals',
promotion: 'Promotion & Marketing',
livestream: 'Livestream / Coverage',
sound: 'Sound & Music',
ramps: 'Ramps & Obstacles',
safety: 'Safety & First Aid',
vendors: 'Food & Vendors',
media: 'Photo / Video Coverage',
registration: 'Sign-ups & Brackets',
};
// How big the events they run are.
export const ORGANIZER_SCALE_LABELS: Record<string, string> = {
grassroots: 'Grassroots',
local: 'Local',
regional: 'Regional',
national: 'National',
international: 'International',
pro: 'Pro Circuit',
};
// ----- Crews (community identity, map-anchored) -----
/**
@ -3614,3 +3664,40 @@ export function parseSkateJudge(event: NostrEvent): SkateJudge | null {
eventsJudged: event.tags.find(([name]) => name === 'events_judged')?.[1],
};
}
export interface SkateOrganizer {
id: string;
pubkey: string;
identifier: string;
featuredUntil?: number;
title: string; // organizer / crew name
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // event types + services + scale tags
experience?: string; // years organizing
rate?: string; // fee / budget for putting on an event
phone?: string;
email?: string;
website?: string;
portfolio?: string; // past events gallery / results link
social?: string;
eventsHosted?: string; // freeform count / notable events run
noffer?: string;
entityType: EntityType;
members: string[];
crewRef?: string;
createdAt: number;
event: NostrEvent;
}
export function parseSkateOrganizer(event: NostrEvent): SkateOrganizer | null {
if (event.kind !== SKATE_ORGANIZER_KIND) return null;
const base = parseCraftBase(event, 'skateorganizer');
if (!base) return null;
return {
...base,
eventsHosted: event.tags.find(([name]) => name === 'events_hosted')?.[1],
};
}

View file

@ -21,7 +21,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { isAdminPubkey } from '@/lib/admins';
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, JUDGE_FORMAT_LABELS, JUDGE_SPECIALTY_LABELS, JUDGE_LEVEL_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, SKATE_JUDGE_KIND } 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, JUDGE_FORMAT_LABELS, JUDGE_SPECIALTY_LABELS, JUDGE_LEVEL_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, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND, ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS } from '@/lib/types';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge';
@ -48,6 +48,7 @@ import {
Dumbbell,
Music,
Gavel,
Tent,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
@ -136,6 +137,7 @@ export default function CoachDetailPage() {
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' },
judge: { text: 'text-yellow-400', bg: 'bg-yellow-500', border: 'border-yellow-500/30', hover: 'hover:bg-yellow-500/10 border-yellow-500' },
organizer: { text: 'text-teal-400', bg: 'bg-teal-600', border: 'border-teal-500/30', hover: 'hover:bg-teal-500/10 border-teal-500' },
};
const accent = ACCENTS[profile.profileType] ?? ACCENTS.coach;
@ -154,6 +156,7 @@ export default function CoachDetailPage() {
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, judge: Gavel,
organizer: Tent,
};
const TypeIcon = TYPE_ICONS[profile.profileType] ?? GraduationCap;
@ -162,6 +165,7 @@ export default function CoachDetailPage() {
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, judge: SKATE_JUDGE_KIND,
organizer: SKATE_ORGANIZER_KIND,
};
const profileKind = KINDS[profile.profileType] ?? SKATE_COACH_KIND;
const isCollective = profile.entityType === 'collective' && profile.members.length > 0;
@ -179,6 +183,7 @@ export default function CoachDetailPage() {
trainer: { ...TRAINER_FOCUS_LABELS, ...TRAINER_INJURY_LABELS, ...TRAINER_CREDENTIAL_LABELS },
musician: { ...MUSICIAN_GENRE_LABELS, ...MUSICIAN_INSTRUMENT_LABELS },
judge: { ...JUDGE_FORMAT_LABELS, ...JUDGE_SPECIALTY_LABELS, ...JUDGE_LEVEL_LABELS },
organizer: { ...ORGANIZER_EVENT_LABELS, ...ORGANIZER_SERVICE_LABELS, ...ORGANIZER_SCALE_LABELS },
};
const DESCRIPTIONS_BY_TYPE: Record<string, Record<string, string>> = {
coach: COACH_SKILL_DESCRIPTIONS,
@ -192,6 +197,7 @@ export default function CoachDetailPage() {
trainer: TRAINER_FOCUS_DESCRIPTIONS,
musician: {},
judge: {},
organizer: {},
};
const getSkillLabel = (skill: string) => LABELS_BY_TYPE[profile.profileType]?.[skill] || skill;
@ -200,6 +206,7 @@ export default function CoachDetailPage() {
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', judge: 'event judge',
organizer: 'event organizer',
};
const typeNoun = TYPE_NOUNS[profile.profileType] ?? 'profile';

View file

@ -44,6 +44,9 @@ import {
JUDGE_FORMAT_LABELS,
JUDGE_SPECIALTY_LABELS,
JUDGE_LEVEL_LABELS,
ORGANIZER_EVENT_LABELS,
ORGANIZER_SERVICE_LABELS,
ORGANIZER_SCALE_LABELS,
} from '@/lib/types';
import {
GraduationCap,
@ -71,6 +74,7 @@ import {
Shield,
PersonStanding,
ArrowRight,
Tent,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
@ -141,6 +145,13 @@ const JUDGE_ALL_LABELS: Record<string, string> = {
...JUDGE_FORMAT_LABELS, ...JUDGE_SPECIALTY_LABELS, ...JUDGE_LEVEL_LABELS,
};
const ORGANIZER_EVENT_ENTRIES = Object.entries(ORGANIZER_EVENT_LABELS);
const ORGANIZER_SERVICE_ENTRIES = Object.entries(ORGANIZER_SERVICE_LABELS);
const ORGANIZER_SCALE_ENTRIES = Object.entries(ORGANIZER_SCALE_LABELS);
const ORGANIZER_ALL_LABELS: Record<string, string> = {
...ORGANIZER_EVENT_LABELS, ...ORGANIZER_SERVICE_LABELS, ...ORGANIZER_SCALE_LABELS,
};
// All photographer label maps merged — for resolving a chip label regardless of category
const PHOTO_ALL_LABELS: Record<string, string> = {
...PHOTO_SERVICE_LABELS,
@ -153,7 +164,7 @@ const PHOTO_ALL_LABELS: Record<string, string> = {
type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging';
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing';
/** Map a ServiceType to the LabProfile.profileType it filters. */
const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> = {
@ -168,6 +179,7 @@ const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> =
training: 'trainer',
music: 'musician',
judging: 'judge',
organizing: 'organizer',
};
// ─── Per-category accent colours ─────────────────────────────────────────────
@ -294,6 +306,17 @@ const ACCENT = {
stripe: 'bg-yellow-500',
badgeSkill: 'bg-yellow-900/40 border-yellow-700/50 text-yellow-300',
},
organizing: {
active: 'bg-teal-600 hover:bg-teal-700 text-white border-teal-600',
chip: 'bg-teal-600 text-white border-teal-600',
chipHover: 'hover:border-teal-500/60 hover:text-teal-400',
border: 'border-teal-500/40',
glow: 'shadow-teal-500/20',
icon: 'text-teal-400',
badge: 'bg-teal-500/15 text-teal-400 border-teal-500/30',
stripe: 'bg-teal-600',
badgeSkill: 'bg-teal-900/40 border-teal-700/50 text-teal-300',
},
};
// ─── FAB — offer services ─────────────────────────────────────────────────────
@ -324,6 +347,7 @@ function OfferFAB() {
{ 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: '/become/judge', label: 'Judge Events', color: 'bg-yellow-500 hover:bg-yellow-600 text-black', icon: Gavel },
{ to: '/become/organizer', label: 'Organize Events', color: 'bg-teal-600 hover:bg-teal-700', icon: Tent },
{ to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
];
@ -401,6 +425,7 @@ function getSkillLabelForType(skill: string, serviceType: ServiceType): string {
if (serviceType === 'training') return TRAINER_ALL_LABELS[skill] || skill;
if (serviceType === 'music') return MUSICIAN_ALL_LABELS[skill] || skill;
if (serviceType === 'judging') return JUDGE_ALL_LABELS[skill] || skill;
if (serviceType === 'organizing') return ORGANIZER_ALL_LABELS[skill] || skill;
return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
}
@ -434,6 +459,7 @@ function LabToolbar({
{ key: 'training', label: 'Trainers', icon: Dumbbell },
{ key: 'music', label: 'Music', icon: Music },
{ key: 'judging', label: 'Judges', icon: Gavel },
{ key: 'organizing', label: 'Organizers', icon: Tent },
];
const accent = ACCENT[serviceType];
@ -689,6 +715,12 @@ function FilterDrawer({
{ title: 'Specialty', entries: JUDGE_SPECIALTY_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' },
{ title: 'Level / Circuit', entries: JUDGE_LEVEL_ENTRIES, activeColor: 'bg-yellow-400 text-black border-yellow-400' },
]
: serviceType === 'organizing'
? [
{ title: 'Event Types', entries: ORGANIZER_EVENT_ENTRIES, activeColor: accent.chip },
{ title: 'What You Handle', entries: ORGANIZER_SERVICE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' },
{ title: 'Scale', entries: ORGANIZER_SCALE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-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' },
@ -864,6 +896,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
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', judge: 'judging',
organizer: 'organizing',
};
const serviceType: ServiceType = PROFILE_TO_SERVICE[profile.profileType];
const accent = ACCENT[serviceType];
@ -871,6 +904,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
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, judging: Gavel,
organizing: Tent,
};
const TypeIcon = TYPE_ICONS[serviceType];
@ -1091,6 +1125,14 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
to: '/become/judge',
color: 'bg-yellow-500 hover:bg-yellow-600 text-black',
},
organizing: {
icon: Tent,
title: 'No organizers yet',
body: 'Put on jams, comps, meetups or demos? List yourself so skaters can find and book you.',
cta: 'Organize Events',
to: '/become/organizer',
color: 'bg-teal-600 hover:bg-teal-700',
},
};
const c = configs[serviceType];
@ -1141,7 +1183,7 @@ export default function LabPage() {
const [searchParams] = useSearchParams();
const initialCraft = (() => {
const c = searchParams.get('craft');
const valid: ServiceType[] = ['coaching', 'filming', 'editing', 'photography', 'building', 'art', 'graffiti', 'tattoo', 'training', 'music', 'judging'];
const valid: ServiceType[] = ['coaching', 'filming', 'editing', 'photography', 'building', 'art', 'graffiti', 'tattoo', 'training', 'music', 'judging', 'organizing'];
return c && (valid as string[]).includes(c) ? (c as ServiceType) : 'coaching';
})();
@ -1190,7 +1232,7 @@ export default function LabPage() {
const counts = useMemo<Record<ServiceType, number>>(() => {
const empty: Record<ServiceType, number> = {
coaching: 0, filming: 0, editing: 0, photography: 0, building: 0,
art: 0, graffiti: 0, tattoo: 0, training: 0, music: 0, judging: 0,
art: 0, graffiti: 0, tattoo: 0, training: 0, music: 0, judging: 0, organizing: 0,
};
if (!profiles) return empty;
const byType = (t: ServiceType) =>
@ -1207,6 +1249,7 @@ export default function LabPage() {
training: byType('training'),
music: byType('music'),
judging: byType('judging'),
organizing: byType('organizing'),
};
}, [profiles]);