diff --git a/AGENTS.md b/AGENTS.md index 1a8dd04..c7b6ba2 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 diff --git a/NIP.md b/NIP.md index 1803249..f539ddb 100644 --- a/NIP.md +++ b/NIP.md @@ -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 } ``` diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 51169c8..99a9ab2 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -130,6 +130,8 @@ const ROUTES = [ '/marketplace', '/listing/abc/def', '/trades', + '/lab', + '/become/organizer', '/leaderboards', '/bounties', '/settings', diff --git a/src/components/LabCrewsStrip.tsx b/src/components/LabCrewsStrip.tsx index d342b64..7ad6c4f 100644 --- a/src/components/LabCrewsStrip.tsx +++ b/src/components/LabCrewsStrip.tsx @@ -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 { diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index 2f93173..c42913a 100644 --- a/src/hooks/useLabProfiles.ts +++ b/src/hooks/useLabProfiles.ts @@ -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; } diff --git a/src/lib/becomeRoles.tsx b/src/lib/becomeRoles.tsx index c3b3b6e..9d6b18c 100644 --- a/src/lib/becomeRoles.tsx +++ b/src/lib/becomeRoles.tsx @@ -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 = { ], }, }, + 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', diff --git a/src/lib/types.ts b/src/lib/types.ts index a550bcf..a675636 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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 = { pro: 'Pro Circuit', }; +// ----- Event Organizers / Hosts (kind 35013) ----- + +// The kinds of events the organizer puts on. +export const ORGANIZER_EVENT_LABELS: Record = { + 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 = { + 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 = { + 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], + }; +} diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 04f6dc1..f7deab0 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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 = { 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> = { 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 = { 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'; diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index f142fa4..d630488 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -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 = { ...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 = { + ...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 = { ...PHOTO_SERVICE_LABELS, @@ -153,7 +164,7 @@ const PHOTO_ALL_LABELS: Record = { 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 = { @@ -168,6 +179,7 @@ const SERVICE_TO_PROFILE_TYPE: Record = 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 = { 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> = { 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>(() => { const empty: Record = { 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]);