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:
parent
9cefa29640
commit
8ae852f1b8
8 changed files with 210 additions and 29 deletions
28
NIP.md
28
NIP.md
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
50
src/pages/BecomeJudgePage.tsx
Normal file
50
src/pages/BecomeJudgePage.tsx
Normal 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.',
|
||||
},
|
||||
],
|
||||
}}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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]);
|
||||
|
||||
|
|
|
|||
Loading…
Reference in a new issue