Add Judges tab to The Lab

- Flesh out SkateJudge as a full craft-based Lab profile (parseCraftBase),
  with JUDGE_FORMAT / SPECIALTY / LEVEL label sets covering Game of S.K.A.T.E.,
  best trick, jams, contests, am series, world tour stops, etc.
- Wire judge into useLabProfiles (union type, self-tag, parse case)
- Add "judging" ServiceType to LabPage: tab, accent, filter sections, empty
  state, counts, profile card icon, and ?craft=judging deep link
- New BecomeJudgePage + /become-judge route + FAB entry
- CoachDetailPage renders judge profiles (accent, icon, labels, noun, title)
- Community Stats "Judges" card now links to /lab?craft=judging
- Document kind 35012 (skatejudge) in NIP.md

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-27 15:30:43 +07:00
parent 9cefa29640
commit 8ae852f1b8
8 changed files with 210 additions and 29 deletions

28
NIP.md
View file

@ -1422,6 +1422,7 @@ They share a common base schema and are distinguished by kind and a self-tag.
| 35009 | Tattoo artist | `skatetattoo` |
| 35010 | Trainer / Physiotherapist | `skatetrainer` |
| 35011 | Musician / Band | `skatemusician` |
| 35012 | Event Judge | `skatejudge` |
> Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below).
@ -1822,12 +1823,35 @@ 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`).
### Event Judge (kind 35012)
Officials who judge skate events — from flat-ground Game of S.K.A.T.E. and best-trick
jams to full contests, am-series qualifiers, and touring stops (e.g. a World
Destruction Tour). A judge lists the formats they run, their judging specialty,
and the level/circuit they have worked so organizers can book credible, fair
officiating. Uses the common base schema.
Categories encoded as `t` tags:
- **Event formats**: `game_of_skate`, `best_trick`, `jam`, `contest`, `street_league`, `bowl`, `mini_ramp`, `park`, `street`, `downhill`, `freestyle`, `world_tour`, `am_series`, `local_jam`, `online`
- **Specialty**: `head_judge`, `scoring`, `mc`, `format_design`, `rules`, `brackets`, `livestream`, `am_scout`
- **Level / circuit**: `local`, `regional`, `national`, `international`, `pro`
Judge-specific optional tags:
| Tag | Description |
|-----|-------------|
| `events_judged` | Freeform note on notable events / comps / tours officiated (≤ base limits). |
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`).
### Querying Lab Profiles
```json
{
"kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician"],
"kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge"],
"limit": 100
}
```

View file

@ -36,6 +36,7 @@ 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 BecomeJudgePage = lazy(() => import("./pages/BecomeJudgePage"));
const CrewsPage = lazy(() => import("./pages/CrewsPage"));
const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage"));
const EditCrewPage = lazy(() => import("./pages/EditCrewPage"));
@ -101,6 +102,7 @@ export function AppRouter() {
<Route path="/become-tattoo" element={<BecomeTattooPage />} />
<Route path="/become-trainer" element={<BecomeTrainerPage />} />
<Route path="/become-musician" element={<BecomeMusicianPage />} />
<Route path="/become-judge" element={<BecomeJudgePage />} />
<Route path="/crews" element={<CrewsPage />} />
<Route path="/create-crew" element={<CreateCrewPage />} />
<Route path="/edit-crew/:pubkey/:identifier" element={<EditCrewPage />} />

View file

@ -6,7 +6,7 @@ import {
parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge,
isFeaturedActive,
type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder,
type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician,
type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician, type SkateJudge,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays';
@ -18,7 +18,7 @@ export type LabProfileType =
export type LabProfile = (
| SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder
| SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician
| SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge
) & {
profileType: LabProfileType;
};
@ -26,7 +26,6 @@ export type LabProfile = (
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,
'judge'
];
const LAB_SELF_TAGS = [
@ -77,6 +76,10 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null {
const p = parseSkateMusician(event);
return p ? { ...p, profileType: 'musician' } : null;
}
case SKATE_JUDGE_KIND: {
const p = parseSkateJudge(event);
return p ? { ...p, profileType: 'judge' } : null;
}
default:
return null;
}

View file

@ -1214,6 +1214,49 @@ export const MUSICIAN_ACT_LABELS: Record<string, string> = {
collective: 'Collective',
};
// ----- Judges (skate event officials) -----
// Event formats a judge is experienced with. Covers everything from a casket
// of flat-ground S.K.A.T. to the biggest touring contest circuits.
export const JUDGE_FORMAT_LABELS: Record<string, string> = {
game_of_skate: 'Game of S.K.A.T.E.',
best_trick: 'Best Trick',
jam: 'Jam Format',
contest: 'Contest / Comp',
street_league: 'Street League Style',
bowl: 'Bowl / Vert',
mini_ramp: 'Mini Ramp',
park: 'Park',
street: 'Street',
downhill: 'Downhill / Longboard',
freestyle: 'Freestyle',
world_tour: 'World Destruction Tour',
am_series: 'Am Series / Qualifiers',
local_jam: 'Local Jam / Grassroots',
online: 'Online / IG Contest',
};
// What the judge specializes in evaluating.
export const JUDGE_SPECIALTY_LABELS: Record<string, string> = {
head_judge: 'Head Judge',
scoring: 'Scoring / Tabulation',
mc: 'MC / Announcer',
format_design: 'Format Design',
rules: 'Rules / Officiating',
brackets: 'Brackets / Heats',
livestream: 'Livestream Judging',
am_scout: 'Am Scouting',
};
// Level / circuit the judge has worked.
export const JUDGE_LEVEL_LABELS: Record<string, string> = {
local: 'Local',
regional: 'Regional',
national: 'National',
international: 'International',
pro: 'Pro Circuit',
};
// ----- Crews (community identity, map-anchored) -----
/**
@ -2804,25 +2847,35 @@ export interface SkateJudge {
id: string;
pubkey: string;
identifier: string;
expertise: string;
experience: string;
events_judged: string;
featuredUntil?: number;
title: string; // judge name / handle
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // format + specialty + level tags
experience?: string; // years judging
rate?: string; // booking rate for events
phone?: string;
email?: string;
website?: string;
portfolio?: string; // results archive / profile link
social?: string;
eventsJudged?: string; // freeform count / notable events judged
noffer?: string;
entityType: EntityType;
members: string[];
crewRef?: string;
createdAt: number;
event: NostrEvent;
}
export function parseSkateJudge(event: NostrEvent): SkateJudge | null {
if (event.kind !== SKATE_JUDGE_KIND) return null;
const judge = {
id: event.id,
pubkey: event.pubkey,
identifier: event.tags.find(([name]) => name === 'd')?.[1] || '',
expertise: event.tags.find(([name]) => name === 'expertise')?.[1] || '',
experience: event.tags.find(([name]) => name === 'experience')?.[1] || '',
events_judged: event.tags.find(([name]) => name === 'events_judged')?.[1] || '0',
const base = parseCraftBase(event, 'skatejudge');
if (!base) return null;
return {
...base,
eventsJudged: event.tags.find(([name]) => name === 'events_judged')?.[1],
};
return judge;
}

View file

@ -0,0 +1,50 @@
import { useSeoMeta } from '@unhead/react';
import { Gavel } from 'lucide-react';
import { CraftProfileForm } from '@/components/CraftProfileForm';
import {
SKATE_JUDGE_KIND,
JUDGE_FORMAT_LABELS,
JUDGE_SPECIALTY_LABELS,
JUDGE_LEVEL_LABELS,
} from '@/lib/types';
export default function BecomeJudgePage() {
useSeoMeta({
title: 'Judge Skate Events - SkateSpots',
description:
'List yourself as a skate event judge — Game of S.K.A.T.E., best trick jams, contests, am series and world tour stops. Get booked to run fair, credible events.',
});
return (
<CraftProfileForm
config={{
kind: SKATE_JUDGE_KIND,
selfTag: 'skatejudge',
identifier: 'judge-profile',
heading: 'Skate Event Judge',
tagline: 'From flat-ground Game of S.K.A.T.E. to the World Destruction Tour — run it fair, run it credible',
noun: 'judge',
accentText: 'text-yellow-400',
accentBg: 'bg-yellow-500',
accentChip: 'bg-yellow-500 text-black border-yellow-500',
accentChipHover: 'hover:border-yellow-500/60 hover:text-yellow-400',
icon: Gavel,
rateLabel: 'Day Rate / Fee',
portfolioLabel: 'Results archive / profile link',
skillSections: [
{ title: 'Event Formats', entries: Object.entries(JUDGE_FORMAT_LABELS), required: true },
{ title: 'Specialty', entries: Object.entries(JUDGE_SPECIALTY_LABELS) },
{ title: 'Level / Circuit', entries: Object.entries(JUDGE_LEVEL_LABELS) },
],
extraFields: [
{
tag: 'events_judged',
label: 'Events Judged',
placeholder: 'e.g. 40+ contests, 3 World Tour stops',
helper: 'Notable events, comps or tours you have officiated.',
},
],
}}
/>
);
}

View file

@ -20,7 +20,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { isAdminPubkey } from '@/lib/admins';
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, 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 { 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 { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge';
@ -46,6 +46,7 @@ import {
Brush,
Dumbbell,
Music,
Gavel,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
@ -132,12 +133,13 @@ export default function CoachDetailPage() {
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' },
judge: { text: 'text-yellow-400', bg: 'bg-yellow-500', border: 'border-yellow-500/30', hover: 'hover:bg-yellow-500/10 border-yellow-500' },
};
const accent = ACCENTS[profile.profileType] ?? ACCENTS.coach;
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,
artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel,
};
const TypeIcon = TYPE_ICONS[profile.profileType] ?? GraduationCap;
@ -145,7 +147,7 @@ export default function CoachDetailPage() {
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,
graffiti: SKATE_GRAFFITI_KIND, tattoo: SKATE_TATTOO_KIND, trainer: SKATE_TRAINER_KIND, musician: SKATE_MUSICIAN_KIND, judge: SKATE_JUDGE_KIND,
};
const profileKind = KINDS[profile.profileType] ?? SKATE_COACH_KIND;
const isCollective = profile.entityType === 'collective' && profile.members.length > 0;
@ -162,6 +164,7 @@ export default function CoachDetailPage() {
tattoo: { ...TATTOO_STYLE_LABELS, ...TATTOO_METHOD_LABELS },
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 },
};
const DESCRIPTIONS_BY_TYPE: Record<string, Record<string, string>> = {
coach: COACH_SKILL_DESCRIPTIONS,
@ -174,6 +177,7 @@ export default function CoachDetailPage() {
tattoo: TATTOO_STYLE_DESCRIPTIONS,
trainer: TRAINER_FOCUS_DESCRIPTIONS,
musician: {},
judge: {},
};
const getSkillLabel = (skill: string) => LABELS_BY_TYPE[profile.profileType]?.[skill] || skill;
@ -181,14 +185,14 @@ 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',
artist: 'artist', graffiti: 'graffiti artist', tattoo: 'tattoo artist', trainer: 'trainer', musician: 'musician', judge: 'event judge',
};
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',
trainer: 'Focus & Specialties', musician: 'Genres & Instruments', judge: 'Formats & Specialties',
};
const skillsTitle = SKILLS_TITLES[profile.profileType] ?? 'Services & Skills';

View file

@ -280,7 +280,7 @@ export default function CommunityStatsPage() {
<StatCard icon={PenTool} value={stats?.totalTattoo ?? 0} label="Tattoo" color="#f87171" loading={isLoading} to="/lab?craft=tattoo" />
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" />
<StatCard icon={Music} value={stats?.totalMusicians ?? 0} label="Musicians" color="#818cf8" loading={isLoading} to="/lab?craft=music" />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} />
<StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
</div>
</section>

View file

@ -40,6 +40,9 @@ import {
TRAINER_CREDENTIAL_LABELS,
MUSICIAN_GENRE_LABELS,
MUSICIAN_INSTRUMENT_LABELS,
JUDGE_FORMAT_LABELS,
JUDGE_SPECIALTY_LABELS,
JUDGE_LEVEL_LABELS,
} from '@/lib/types';
import {
GraduationCap,
@ -56,6 +59,7 @@ import {
Brush,
Dumbbell,
Music,
Gavel,
CreditCard,
X,
ChevronRight,
@ -126,6 +130,13 @@ const MUSICIAN_ALL_LABELS: Record<string, string> = {
...MUSICIAN_GENRE_LABELS, ...MUSICIAN_INSTRUMENT_LABELS,
};
const JUDGE_FORMAT_ENTRIES = Object.entries(JUDGE_FORMAT_LABELS);
const JUDGE_SPECIALTY_ENTRIES = Object.entries(JUDGE_SPECIALTY_LABELS);
const JUDGE_LEVEL_ENTRIES = Object.entries(JUDGE_LEVEL_LABELS);
const JUDGE_ALL_LABELS: Record<string, string> = {
...JUDGE_FORMAT_LABELS, ...JUDGE_SPECIALTY_LABELS, ...JUDGE_LEVEL_LABELS,
};
// All photographer label maps merged — for resolving a chip label regardless of category
const PHOTO_ALL_LABELS: Record<string, string> = {
...PHOTO_SERVICE_LABELS,
@ -138,7 +149,7 @@ const PHOTO_ALL_LABELS: Record<string, string> = {
type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music';
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging';
/** Map a ServiceType to the LabProfile.profileType it filters. */
const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> = {
@ -152,6 +163,7 @@ const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> =
tattoo: 'tattoo',
training: 'trainer',
music: 'musician',
judging: 'judge',
};
// ─── Per-category accent colours ─────────────────────────────────────────────
@ -267,6 +279,17 @@ const ACCENT = {
stripe: 'bg-sky-500',
badgeSkill: 'bg-sky-900/40 border-sky-700/50 text-sky-300',
},
judging: {
active: 'bg-yellow-500 hover:bg-yellow-600 text-black border-yellow-500',
chip: 'bg-yellow-500 text-black border-yellow-500',
chipHover: 'hover:border-yellow-500/60 hover:text-yellow-400',
border: 'border-yellow-500/40',
glow: 'shadow-yellow-500/20',
icon: 'text-yellow-400',
badge: 'bg-yellow-500/15 text-yellow-400 border-yellow-500/30',
stripe: 'bg-yellow-500',
badgeSkill: 'bg-yellow-900/40 border-yellow-700/50 text-yellow-300',
},
};
// ─── FAB — offer services ─────────────────────────────────────────────────────
@ -296,6 +319,7 @@ function OfferFAB() {
{ 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: '/become-judge', label: 'Judge Events', color: 'bg-yellow-500 hover:bg-yellow-600 text-black', icon: Gavel },
{ to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
];
@ -373,6 +397,7 @@ function getSkillLabelForType(skill: string, serviceType: ServiceType): string {
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;
if (serviceType === 'judging') return JUDGE_ALL_LABELS[skill] || skill;
return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
}
@ -405,6 +430,7 @@ function LabToolbar({
{ key: 'tattoo', label: 'Tattoo', icon: Brush },
{ key: 'training', label: 'Trainers', icon: Dumbbell },
{ key: 'music', label: 'Music', icon: Music },
{ key: 'judging', label: 'Judges', icon: Gavel },
];
const accent = ACCENT[serviceType];
@ -655,6 +681,12 @@ function FilterDrawer({
{ 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' },
]
: serviceType === 'judging'
? [
{ title: 'Event Formats', entries: JUDGE_FORMAT_ENTRIES, activeColor: accent.chip },
{ 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' },
]
: [
{ 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' },
@ -829,14 +861,14 @@ 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',
artist: 'art', graffiti: 'graffiti', tattoo: 'tattoo', trainer: 'training', musician: 'music', judge: 'judging',
};
const serviceType: ServiceType = PROFILE_TO_SERVICE[profile.profileType];
const accent = ACCENT[serviceType];
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,
art: Palette, graffiti: SprayCan, tattoo: Brush, training: Dumbbell, music: Music, judging: Gavel,
};
const TypeIcon = TYPE_ICONS[serviceType];
@ -1049,6 +1081,14 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
to: '/become-musician',
color: 'bg-sky-500 hover:bg-sky-600',
},
judging: {
icon: Gavel,
title: 'No judges yet',
body: 'Run a fair contest? List yourself to judge Game of S.K.A.T.E., best trick jams, contests and touring stops.',
cta: 'Judge Events',
to: '/become-judge',
color: 'bg-yellow-500 hover:bg-yellow-600 text-black',
},
};
const c = configs[serviceType];
@ -1098,7 +1138,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'];
const valid: ServiceType[] = ['coaching', 'filming', 'editing', 'photography', 'building', 'art', 'graffiti', 'tattoo', 'training', 'music', 'judging'];
return c && (valid as string[]).includes(c) ? (c as ServiceType) : 'coaching';
})();
@ -1145,7 +1185,11 @@ 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 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,
};
if (!profiles) return empty;
const byType = (t: ServiceType) =>
profiles.filter((p) => p.profileType === SERVICE_TO_PROFILE_TYPE[t]).length;
return {
@ -1159,6 +1203,7 @@ export default function LabPage() {
tattoo: byType('tattoo'),
training: byType('training'),
music: byType('music'),
judging: byType('judging'),
};
}, [profiles]);