The coach portfolio/experience/payments treatment is now a shared layer instead of 12 copies: - src/lib/labExtras.ts: parseLabExtras + shared option sets (languages, availability, structured rate, payment methods, booking fee, gallery/reel). - components/LabExtrasFields.tsx: the self-contained form block — prefills from the existing event, owns its state, and reports the managed tags via onChange. - components/LabExtrasSection.tsx: the detail block (all crafts). Wired into CraftProfileForm (the 8 data-driven crafts) and the four bespoke pages (filmer/editor/photographer/builder); CoachDetailPage renders the shared section for every craft. Per-craft buying units (rateUnits) are set per craft in becomeRoles.tsx (e.g. barber 'per cut', musician 'per gig', photographer 'per shoot'). /lab facet filters now read parseLabExtras, so availability/languages filter across crafts. Coach-specific formats/levels/ages/credentials remain coach-only. Money stays custody-free: declared methods + a booking fee paid directly to the provider.
308 lines
15 KiB
TypeScript
308 lines
15 KiB
TypeScript
import { lazy, type ComponentType, type LazyExoticComponent } from 'react';
|
|
import {
|
|
Palette, SprayCan, Music, Gavel, Brush, Dumbbell, Tent, Sparkles,
|
|
} from 'lucide-react';
|
|
import { pickRateUnits } from '@/lib/labExtras';
|
|
import type { CraftFormConfig } from '@/components/CraftProfileForm';
|
|
import {
|
|
SKATE_ARTIST_KIND, ARTIST_SERVICE_LABELS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS,
|
|
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,
|
|
SKATE_BARBER_KIND, BARBER_SERVICE_LABELS, BARBER_STYLE_LABELS, BARBER_SETTING_LABELS,
|
|
} from '@/lib/types';
|
|
import {
|
|
ARTIST_SERVICE_KEYS, ARTIST_MEDIUM_KEYS, ARTIST_TOOL_KEYS,
|
|
GRAFFITI_STYLE_KEYS, GRAFFITI_MEDIUM_KEYS, GRAFFITI_CONTEXT_KEYS,
|
|
MUSICIAN_GENRE_KEYS, MUSICIAN_INSTRUMENT_KEYS,
|
|
JUDGE_FORMAT_KEYS, JUDGE_SPECIALTY_KEYS, JUDGE_LEVEL_KEYS,
|
|
ORGANIZER_EVENT_KEYS, ORGANIZER_SERVICE_KEYS, ORGANIZER_SCALE_KEYS,
|
|
TATTOO_STYLE_KEYS, TATTOO_METHOD_KEYS,
|
|
TRAINER_FOCUS_KEYS, TRAINER_INJURY_KEYS, TRAINER_CREDENTIAL_KEYS,
|
|
BARBER_SERVICE_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
|
|
} from '@/lib/labelKeys';
|
|
|
|
/**
|
|
* Single source of truth for every "become a …" role.
|
|
*
|
|
* There are two flavours:
|
|
* 1. `craft` roles — thin config over the shared <CraftProfileForm>. All the
|
|
* per-role difference lives here in data, not in 7 near-identical files.
|
|
* 2. `page` roles — genuinely bespoke pages (Coach, Filmer, Editor,
|
|
* Photographer, Skater, Builder) that carry extra machinery
|
|
* (SpotPicker, wallet/Pro logic, geohashing, etc.). Forcing these into a
|
|
* config would create a monster; instead they stay as their own lazy
|
|
* components and are simply *routed* through the same /become/:role slug.
|
|
*
|
|
* The router uses this map so the whole feature is one param route + redirects
|
|
* from the legacy /become-<slug> URLs, instead of 12 hardcoded route lines.
|
|
*/
|
|
|
|
interface CraftRole {
|
|
kind: 'craft';
|
|
seoTitle: string;
|
|
seoDescription: string;
|
|
config: CraftFormConfig;
|
|
}
|
|
|
|
interface PageRole {
|
|
kind: 'page';
|
|
/** Lazily-loaded bespoke page component (default export). */
|
|
component: LazyExoticComponent<ComponentType>;
|
|
}
|
|
|
|
export type BecomeRole = CraftRole | PageRole;
|
|
|
|
export const BECOME_ROLES: Record<string, BecomeRole> = {
|
|
// ── Bespoke pages (unchanged — just registered here for routing) ──
|
|
coach: { kind: 'page', component: lazy(() => import('@/pages/BecomeCoachPage')) },
|
|
filmer: { kind: 'page', component: lazy(() => import('@/pages/BecomeFilmerPage')) },
|
|
editor: { kind: 'page', component: lazy(() => import('@/pages/BecomeEditorPage')) },
|
|
photographer: { kind: 'page', component: lazy(() => import('@/pages/BecomePhotographerPage')) },
|
|
builder: { kind: 'page', component: lazy(() => import('@/pages/BecomeBuilderPage')) },
|
|
skater: { kind: 'page', component: lazy(() => import('@/pages/BecomeSkaterPage')) },
|
|
|
|
// ── Craft roles (data-driven over CraftProfileForm) ──
|
|
artist: {
|
|
kind: 'craft',
|
|
seoTitle: 'Become a Designer / Artist - SkateSpots',
|
|
seoDescription: 'List your skate design & art — board graphics, logos, posters, merch and more.',
|
|
config: {
|
|
kind: SKATE_ARTIST_KIND,
|
|
selfTag: 'skateartist',
|
|
identifier: 'artist-profile',
|
|
heading: 'Designer / Artist',
|
|
tagline: 'Board graphics, logos, posters, merch — put your art on the scene',
|
|
noun: 'artist',
|
|
accentText: 'text-fuchsia-400',
|
|
accentBg: 'bg-fuchsia-600',
|
|
accentChip: 'bg-fuchsia-600 text-white border-fuchsia-600',
|
|
accentChipHover: 'hover:border-fuchsia-500/60 hover:text-fuchsia-400',
|
|
icon: Palette,
|
|
rateUnits: pickRateUnits('piece', 'project', 'hour', 'day'),
|
|
rateLabel: 'Rate',
|
|
portfolioLabel: 'Portfolio / Shop link',
|
|
skillSections: [
|
|
{ title: 'What You Make', entries: Object.entries(ARTIST_SERVICE_LABELS), keyMap: ARTIST_SERVICE_KEYS, required: true },
|
|
{ title: 'Medium', entries: Object.entries(ARTIST_MEDIUM_LABELS), keyMap: ARTIST_MEDIUM_KEYS },
|
|
{ title: 'Software & Tools', entries: Object.entries(ARTIST_TOOL_LABELS), keyMap: ARTIST_TOOL_KEYS },
|
|
],
|
|
},
|
|
},
|
|
graffiti: {
|
|
kind: 'craft',
|
|
seoTitle: 'Offer Graffiti / Murals - SkateSpots',
|
|
seoDescription: 'List your graffiti & mural work — pieces, murals, skatepark art and more.',
|
|
config: {
|
|
kind: SKATE_GRAFFITI_KIND,
|
|
selfTag: 'skategraffiti',
|
|
identifier: 'graffiti-profile',
|
|
heading: 'Graffiti / Mural Artist',
|
|
tagline: 'Pieces, murals, skatepark art — paint the scene',
|
|
noun: 'graffiti artist',
|
|
accentText: 'text-lime-400',
|
|
accentBg: 'bg-lime-500',
|
|
accentChip: 'bg-lime-500 text-black border-lime-500',
|
|
accentChipHover: 'hover:border-lime-500/60 hover:text-lime-400',
|
|
icon: SprayCan,
|
|
rateUnits: pickRateUnits('piece', 'project', 'day', 'hour'),
|
|
rateLabel: 'Rate',
|
|
portfolioLabel: 'Portfolio link (IG, site)',
|
|
skillSections: [
|
|
{ title: 'Style', entries: Object.entries(GRAFFITI_STYLE_LABELS), keyMap: GRAFFITI_STYLE_KEYS, required: true },
|
|
{ title: 'Medium', entries: Object.entries(GRAFFITI_MEDIUM_LABELS), keyMap: GRAFFITI_MEDIUM_KEYS },
|
|
{ title: 'Work Context', entries: Object.entries(GRAFFITI_CONTEXT_LABELS), keyMap: GRAFFITI_CONTEXT_KEYS },
|
|
],
|
|
extraFields: [
|
|
{ tag: 'crew', label: 'Graffiti Crew', placeholder: 'Your writing crew (optional)', helper: 'Separate from your skate crew — the writers you paint with.' },
|
|
],
|
|
},
|
|
},
|
|
musician: {
|
|
kind: 'craft',
|
|
seoTitle: 'Add a Music Profile - SkateSpots',
|
|
seoDescription: 'Skate and make music? List your band or solo project — genres, instruments and links.',
|
|
config: {
|
|
kind: SKATE_MUSICIAN_KIND,
|
|
selfTag: 'skatemusician',
|
|
identifier: 'musician-profile',
|
|
heading: 'Musician / Band',
|
|
tagline: 'Skate and make music? Get booked for demos, jams & after-parties',
|
|
noun: 'musician',
|
|
accentText: 'text-sky-400',
|
|
accentBg: 'bg-sky-500',
|
|
accentChip: 'bg-sky-500 text-white border-sky-500',
|
|
accentChipHover: 'hover:border-sky-500/60 hover:text-sky-400',
|
|
icon: Music,
|
|
rateUnits: pickRateUnits('gig', 'event', 'hour', 'day'),
|
|
rateLabel: 'Booking Rate',
|
|
portfolioLabel: 'Website / EPK link',
|
|
skillSections: [
|
|
{ title: 'Genre', entries: Object.entries(MUSICIAN_GENRE_LABELS), keyMap: MUSICIAN_GENRE_KEYS, required: true },
|
|
{ title: 'Instruments & Role', entries: Object.entries(MUSICIAN_INSTRUMENT_LABELS), keyMap: MUSICIAN_INSTRUMENT_KEYS },
|
|
],
|
|
extraFields: [
|
|
{ tag: 'act', label: 'Act Type', placeholder: 'solo / band / dj / producer' },
|
|
{ tag: 'spotify', label: 'Spotify', type: 'url', placeholder: 'https://open.spotify.com/…' },
|
|
{ tag: 'bandcamp', label: 'Bandcamp', type: 'url', placeholder: 'https://…bandcamp.com' },
|
|
{ tag: 'soundcloud', label: 'SoundCloud', type: 'url', placeholder: 'https://soundcloud.com/…' },
|
|
{ tag: 'youtube', label: 'YouTube', type: 'url', placeholder: 'https://youtube.com/…' },
|
|
],
|
|
},
|
|
},
|
|
judge: {
|
|
kind: 'craft',
|
|
seoTitle: 'Judge Skate Events - SkateSpots',
|
|
seoDescription: '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.',
|
|
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,
|
|
rateUnits: pickRateUnits('event', 'day', 'hour'),
|
|
rateLabel: 'Day Rate / Fee',
|
|
portfolioLabel: 'Results archive / profile link',
|
|
skillSections: [
|
|
{ title: 'Event Formats', entries: Object.entries(JUDGE_FORMAT_LABELS), keyMap: JUDGE_FORMAT_KEYS, required: true },
|
|
{ title: 'Specialty', entries: Object.entries(JUDGE_SPECIALTY_LABELS), keyMap: JUDGE_SPECIALTY_KEYS },
|
|
{ title: 'Level / Circuit', entries: Object.entries(JUDGE_LEVEL_LABELS), keyMap: JUDGE_LEVEL_KEYS },
|
|
],
|
|
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.' },
|
|
],
|
|
},
|
|
},
|
|
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,
|
|
rateUnits: pickRateUnits('event', 'day', 'project'),
|
|
rateLabel: 'Event Fee / Budget',
|
|
portfolioLabel: 'Past events / results link',
|
|
skillSections: [
|
|
{ title: 'Event Types', entries: Object.entries(ORGANIZER_EVENT_LABELS), keyMap: ORGANIZER_EVENT_KEYS, required: true },
|
|
{ title: 'What You Handle', entries: Object.entries(ORGANIZER_SERVICE_LABELS), keyMap: ORGANIZER_SERVICE_KEYS },
|
|
{ title: 'Scale', entries: Object.entries(ORGANIZER_SCALE_LABELS), keyMap: ORGANIZER_SCALE_KEYS },
|
|
],
|
|
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',
|
|
seoDescription: 'List your tattoo work for the skate scene — styles, flash, custom and more.',
|
|
config: {
|
|
kind: SKATE_TATTOO_KIND,
|
|
selfTag: 'skatetattoo',
|
|
identifier: 'tattoo-profile',
|
|
heading: 'Tattoo Artist',
|
|
tagline: 'Ink for the scene — flash, custom, fine line & more',
|
|
noun: 'tattoo artist',
|
|
accentText: 'text-rose-400',
|
|
accentBg: 'bg-rose-600',
|
|
accentChip: 'bg-rose-600 text-white border-rose-600',
|
|
accentChipHover: 'hover:border-rose-500/60 hover:text-rose-400',
|
|
icon: Brush,
|
|
rateUnits: pickRateUnits('hour', 'piece', 'session'),
|
|
rateLabel: 'Rate / Hourly',
|
|
portfolioLabel: 'Portfolio link (IG, site)',
|
|
skillSections: [
|
|
{ title: 'Styles', entries: Object.entries(TATTOO_STYLE_LABELS), keyMap: TATTOO_STYLE_KEYS, required: true },
|
|
{ title: 'Method', entries: Object.entries(TATTOO_METHOD_LABELS), keyMap: TATTOO_METHOD_KEYS },
|
|
],
|
|
extraFields: [
|
|
{ tag: 'studio', label: 'Studio / Shop', placeholder: 'Studio name (optional)' },
|
|
{ tag: 'books_open', label: 'Books Open', type: 'toggle', helper: 'Are you currently taking bookings?' },
|
|
],
|
|
},
|
|
},
|
|
trainer: {
|
|
kind: 'craft',
|
|
seoTitle: 'Become a Trainer / Physio - SkateSpots',
|
|
seoDescription: 'Help skaters get strong and stay healthy — strength, mobility, injury prevention and recovery.',
|
|
config: {
|
|
kind: SKATE_TRAINER_KIND,
|
|
selfTag: 'skatetrainer',
|
|
identifier: 'trainer-profile',
|
|
heading: 'Trainer / Physio',
|
|
tagline: 'Strength, mobility, injury prevention & recovery for skaters',
|
|
noun: 'trainer',
|
|
accentText: 'text-emerald-400',
|
|
accentBg: 'bg-emerald-600',
|
|
accentChip: 'bg-emerald-600 text-white border-emerald-600',
|
|
accentChipHover: 'hover:border-emerald-500/60 hover:text-emerald-400',
|
|
icon: Dumbbell,
|
|
rateUnits: pickRateUnits('session', 'month', 'hour'),
|
|
rateLabel: 'Rate / Session',
|
|
portfolioLabel: 'Profile / booking link',
|
|
skillSections: [
|
|
{ title: 'Focus', entries: Object.entries(TRAINER_FOCUS_LABELS), keyMap: TRAINER_FOCUS_KEYS, required: true },
|
|
{ title: 'Injuries You Work With', entries: Object.entries(TRAINER_INJURY_LABELS), keyMap: TRAINER_INJURY_KEYS },
|
|
{ title: 'Credentials & Setting', entries: Object.entries(TRAINER_CREDENTIAL_LABELS), keyMap: TRAINER_CREDENTIAL_KEYS },
|
|
],
|
|
extraFields: [
|
|
{ tag: 'credentials', label: 'Certifications', placeholder: 'e.g. BSc Physiotherapy, CSCS…', helper: 'List real qualifications — skaters trust verifiable credentials.' },
|
|
{ tag: 'remote', label: 'Remote / Online', type: 'toggle', helper: 'Do you offer online coaching or consultations?' },
|
|
],
|
|
},
|
|
},
|
|
barber: {
|
|
kind: 'craft',
|
|
seoTitle: 'Become a Barber / Hairstylist - SkateSpots',
|
|
seoDescription: 'List your barbershop or chair for the skate scene — cuts, fades, beard trims, colour and more. Get booked by skaters.',
|
|
config: {
|
|
kind: SKATE_BARBER_KIND,
|
|
selfTag: 'skatebarber',
|
|
identifier: 'barber-profile',
|
|
heading: 'Barber / Hairstylist',
|
|
tagline: 'Cuts, fades & beard trims for the scene — shop chair or mobile',
|
|
noun: 'barber',
|
|
accentText: 'text-cyan-400',
|
|
accentBg: 'bg-cyan-600',
|
|
accentChip: 'bg-cyan-600 text-white border-cyan-600',
|
|
accentChipHover: 'hover:border-cyan-500/60 hover:text-cyan-400',
|
|
icon: Sparkles,
|
|
rateUnits: pickRateUnits('cut', 'hour', 'session'),
|
|
rateLabel: 'Price / Cut',
|
|
portfolioLabel: 'Cuts gallery / IG link',
|
|
skillSections: [
|
|
{ title: 'What You Offer', entries: Object.entries(BARBER_SERVICE_LABELS), keyMap: BARBER_SERVICE_KEYS, required: true },
|
|
{ title: 'Signature Styles', entries: Object.entries(BARBER_STYLE_LABELS), keyMap: BARBER_STYLE_KEYS },
|
|
{ title: 'Setting', entries: Object.entries(BARBER_SETTING_LABELS), keyMap: BARBER_SETTING_KEYS },
|
|
],
|
|
extraFields: [
|
|
{ tag: 'studio', label: 'Shop / Studio', placeholder: 'Shop or studio name (optional)' },
|
|
{ tag: 'books_open', label: 'Books Open', type: 'toggle', helper: 'Are you currently taking bookings?' },
|
|
],
|
|
},
|
|
},
|
|
};
|
|
|
|
export function getBecomeRole(slug: string | undefined): BecomeRole | undefined {
|
|
if (!slug) return undefined;
|
|
return BECOME_ROLES[slug.toLowerCase()];
|
|
}
|