diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index b96f09e..fc036b1 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -1,6 +1,7 @@ import { lazy, Suspense } from "react"; -import { HashRouter, Route, Routes } from "react-router-dom"; +import { HashRouter, Navigate, Route, Routes } from "react-router-dom"; import { ScrollToTop } from "./components/ScrollToTop"; +import { BECOME_ROLES } from "./lib/becomeRoles"; import { DeepLinkHandler } from "./components/DeepLinkHandler"; import { RouteErrorBoundary } from "./components/RouteErrorBoundary"; @@ -28,18 +29,9 @@ const ShopDetailPage = lazy(() => import("./pages/ShopDetailPage")); const EventDetailPage = lazy(() => import("./pages/EventDetailPage")); const OnboardingPage = lazy(() => import("./pages/OnboardingPage")); const PyramidPage = lazy(() => import("./pages/PyramidPage")); -const BecomeCoachPage = lazy(() => import("./pages/BecomeCoachPage")); -const BecomeFilmerPage = lazy(() => import("./pages/BecomeFilmerPage")); -const BecomeEditorPage = lazy(() => import("./pages/BecomeEditorPage")); -const BecomePhotographerPage = lazy(() => import("./pages/BecomePhotographerPage")); -const BecomeBuilderPage = lazy(() => import("./pages/BecomeBuilderPage")); -const BecomeArtistPage = lazy(() => import("./pages/BecomeArtistPage")); -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 BecomeSkaterPage = lazy(() => import("./pages/BecomeSkaterPage")); +// All "become a " flows now share a single param route backed by a role +// registry (see src/lib/becomeRoles.tsx) instead of 12 separate route lines. +const BecomeRolePage = lazy(() => import("./pages/BecomeRolePage")); const SkatersPage = lazy(() => import("./pages/SkatersPage")); const CrewsPage = lazy(() => import("./pages/CrewsPage")); const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage")); @@ -79,6 +71,10 @@ function PageLoader() { ); } +// Legacy `/become-` URLs are linked from many pages and may be +// bookmarked/shared, so each redirects to the unified `/become/` route. +const BECOME_SLUGS = Object.keys(BECOME_ROLES); + export function AppRouter() { return ( @@ -100,19 +96,16 @@ export function AppRouter() { } /> {/* Legacy route redirect */} } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> - } /> + {/* Unified role route + legacy /become- redirects */} + } /> + {BECOME_SLUGS.map((slug) => ( + } + /> + ))} } /> - } /> } /> } /> } /> diff --git a/src/components/SkaterDirectoryCard.tsx b/src/components/SkaterDirectoryCard.tsx index ab567ed..f8a6603 100644 --- a/src/components/SkaterDirectoryCard.tsx +++ b/src/components/SkaterDirectoryCard.tsx @@ -20,7 +20,7 @@ export function SkaterDirectoryCard() { if (!skater) { return (
@@ -42,7 +42,7 @@ export function SkaterDirectoryCard() { return (
diff --git a/src/lib/becomeRoles.tsx b/src/lib/becomeRoles.tsx new file mode 100644 index 0000000..c3b3b6e --- /dev/null +++ b/src/lib/becomeRoles.tsx @@ -0,0 +1,230 @@ +import { lazy, type ComponentType, type LazyExoticComponent } from 'react'; +import { + Palette, SprayCan, Music, Gavel, Brush, Dumbbell, +} from 'lucide-react'; +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_TATTOO_KIND, TATTOO_STYLE_LABELS, TATTOO_METHOD_LABELS, + SKATE_TRAINER_KIND, TRAINER_FOCUS_LABELS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, +} from '@/lib/types'; + +/** + * Single source of truth for every "become a …" role. + * + * There are two flavours: + * 1. `craft` roles — thin config over the shared . 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- 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; +} + +export type BecomeRole = CraftRole | PageRole; + +export const BECOME_ROLES: Record = { + // ── 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, + rateLabel: 'Rate', + portfolioLabel: 'Portfolio / Shop link', + skillSections: [ + { title: 'What You Make', entries: Object.entries(ARTIST_SERVICE_LABELS), required: true }, + { title: 'Medium', entries: Object.entries(ARTIST_MEDIUM_LABELS) }, + { title: 'Software & Tools', entries: Object.entries(ARTIST_TOOL_LABELS) }, + ], + }, + }, + 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, + rateLabel: 'Rate', + portfolioLabel: 'Portfolio link (IG, site)', + skillSections: [ + { title: 'Style', entries: Object.entries(GRAFFITI_STYLE_LABELS), required: true }, + { title: 'Medium', entries: Object.entries(GRAFFITI_MEDIUM_LABELS) }, + { title: 'Work Context', entries: Object.entries(GRAFFITI_CONTEXT_LABELS) }, + ], + 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, + rateLabel: 'Booking Rate', + portfolioLabel: 'Website / EPK link', + skillSections: [ + { title: 'Genre', entries: Object.entries(MUSICIAN_GENRE_LABELS), required: true }, + { title: 'Instruments & Role', entries: Object.entries(MUSICIAN_INSTRUMENT_LABELS) }, + ], + 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, + 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.' }, + ], + }, + }, + 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, + rateLabel: 'Rate / Hourly', + portfolioLabel: 'Portfolio link (IG, site)', + skillSections: [ + { title: 'Styles', entries: Object.entries(TATTOO_STYLE_LABELS), required: true }, + { title: 'Method', entries: Object.entries(TATTOO_METHOD_LABELS) }, + ], + 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, + rateLabel: 'Rate / Session', + portfolioLabel: 'Profile / booking link', + skillSections: [ + { title: 'Focus', entries: Object.entries(TRAINER_FOCUS_LABELS), required: true }, + { title: 'Injuries You Work With', entries: Object.entries(TRAINER_INJURY_LABELS) }, + { title: 'Credentials & Setting', entries: Object.entries(TRAINER_CREDENTIAL_LABELS) }, + ], + 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?' }, + ], + }, + }, +}; + +export function getBecomeRole(slug: string | undefined): BecomeRole | undefined { + if (!slug) return undefined; + return BECOME_ROLES[slug.toLowerCase()]; +} diff --git a/src/pages/BecomeArtistPage.tsx b/src/pages/BecomeArtistPage.tsx deleted file mode 100644 index 70a2017..0000000 --- a/src/pages/BecomeArtistPage.tsx +++ /dev/null @@ -1,38 +0,0 @@ -import { useSeoMeta } from '@unhead/react'; -import { Palette } from 'lucide-react'; -import { CraftProfileForm } from '@/components/CraftProfileForm'; -import { - SKATE_ARTIST_KIND, ARTIST_SERVICE_LABELS, ARTIST_MEDIUM_LABELS, ARTIST_TOOL_LABELS, -} from '@/lib/types'; - -export default function BecomeArtistPage() { - useSeoMeta({ - title: 'Become a Designer / Artist - SkateSpots', - description: 'List your skate design & art — board graphics, logos, posters, merch and more.', - }); - - return ( - - ); -} diff --git a/src/pages/BecomeGraffitiPage.tsx b/src/pages/BecomeGraffitiPage.tsx deleted file mode 100644 index 7d3db8d..0000000 --- a/src/pages/BecomeGraffitiPage.tsx +++ /dev/null @@ -1,41 +0,0 @@ -import { useSeoMeta } from '@unhead/react'; -import { SprayCan } from 'lucide-react'; -import { CraftProfileForm } from '@/components/CraftProfileForm'; -import { - SKATE_GRAFFITI_KIND, GRAFFITI_STYLE_LABELS, GRAFFITI_MEDIUM_LABELS, GRAFFITI_CONTEXT_LABELS, -} from '@/lib/types'; - -export default function BecomeGraffitiPage() { - useSeoMeta({ - title: 'Offer Graffiti / Murals - SkateSpots', - description: 'List your graffiti & mural work — pieces, murals, skatepark art and more.', - }); - - return ( - - ); -} diff --git a/src/pages/BecomeJudgePage.tsx b/src/pages/BecomeJudgePage.tsx deleted file mode 100644 index 801e7f4..0000000 --- a/src/pages/BecomeJudgePage.tsx +++ /dev/null @@ -1,50 +0,0 @@ -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 ( - - ); -} diff --git a/src/pages/BecomeMusicianPage.tsx b/src/pages/BecomeMusicianPage.tsx deleted file mode 100644 index c792026..0000000 --- a/src/pages/BecomeMusicianPage.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useSeoMeta } from '@unhead/react'; -import { Music } from 'lucide-react'; -import { CraftProfileForm } from '@/components/CraftProfileForm'; -import { SKATE_MUSICIAN_KIND, MUSICIAN_GENRE_LABELS, MUSICIAN_INSTRUMENT_LABELS } from '@/lib/types'; - -export default function BecomeMusicianPage() { - useSeoMeta({ - title: 'Add a Music Profile - SkateSpots', - description: 'Skate and make music? List your band or solo project — genres, instruments and links.', - }); - - return ( - - ); -} diff --git a/src/pages/BecomeRolePage.tsx b/src/pages/BecomeRolePage.tsx new file mode 100644 index 0000000..a1ba8f0 --- /dev/null +++ b/src/pages/BecomeRolePage.tsx @@ -0,0 +1,57 @@ +import { Suspense } from 'react'; +import { useParams } from 'react-router-dom'; +import { useSeoMeta } from '@unhead/react'; +import { CraftProfileForm } from '@/components/CraftProfileForm'; +import { getBecomeRole } from '@/lib/becomeRoles'; +import NotFound from '@/pages/NotFound'; + +/** + * Single entry point for every "become a " flow (/become/:role). + * + * - Craft roles render the shared from their data config. + * - Bespoke roles render their own lazy page component. + * - Unknown roles fall through to the 404 page. + * + * This replaces 12 hardcoded routes + 12 component imports in the router. + */ + +function PageLoader() { + return ( +
+
+
+ ); +} + +export default function BecomeRolePage() { + const { role: slug } = useParams<{ role: string }>(); + const role = getBecomeRole(slug); + + if (!role) { + return ; + } + + if (role.kind === 'craft') { + return ; + } + + const Component = role.component; + return ( + }> + + + ); +} + +function CraftRole({ + role, +}: { + role: Extract, { kind: 'craft' }>; +}) { + useSeoMeta({ + title: role.seoTitle, + description: role.seoDescription, + }); + + return ; +} diff --git a/src/pages/BecomeTattooPage.tsx b/src/pages/BecomeTattooPage.tsx deleted file mode 100644 index c80903b..0000000 --- a/src/pages/BecomeTattooPage.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import { useSeoMeta } from '@unhead/react'; -import { Brush } from 'lucide-react'; -import { CraftProfileForm } from '@/components/CraftProfileForm'; -import { SKATE_TATTOO_KIND, TATTOO_STYLE_LABELS, TATTOO_METHOD_LABELS } from '@/lib/types'; - -export default function BecomeTattooPage() { - useSeoMeta({ - title: 'Become a Tattoo Artist - SkateSpots', - description: 'List your tattoo work for the skate scene — styles, flash, custom and more.', - }); - - return ( - - ); -} diff --git a/src/pages/BecomeTrainerPage.tsx b/src/pages/BecomeTrainerPage.tsx deleted file mode 100644 index d4e7bf3..0000000 --- a/src/pages/BecomeTrainerPage.tsx +++ /dev/null @@ -1,42 +0,0 @@ -import { useSeoMeta } from '@unhead/react'; -import { Dumbbell } from 'lucide-react'; -import { CraftProfileForm } from '@/components/CraftProfileForm'; -import { - SKATE_TRAINER_KIND, TRAINER_FOCUS_LABELS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_LABELS, -} from '@/lib/types'; - -export default function BecomeTrainerPage() { - useSeoMeta({ - title: 'Become a Trainer / Physio - SkateSpots', - description: 'Help skaters get strong and stay healthy — strength, mobility, injury prevention and recovery.', - }); - - return ( - - ); -} diff --git a/src/pages/CrewsPage.tsx b/src/pages/CrewsPage.tsx index a3a07a1..d885837 100644 --- a/src/pages/CrewsPage.tsx +++ b/src/pages/CrewsPage.tsx @@ -7,6 +7,7 @@ import { Button } from '@/components/ui/button'; import { Skeleton } from '@/components/ui/skeleton'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { CrewRepBadge, CrewActivityPulse } from '@/components/CrewBadge'; +import { EmptyState as SharedEmptyState } from '@/components/EmptyState'; import { useCrews } from '@/hooks/useCrews'; import { useCrewsActivity, crewCoordinate } from '@/hooks/useCrewActivity'; import { useGeolocation } from '@/hooks/useGeolocation'; @@ -358,48 +359,43 @@ function EmptyState({ }) { if (query) { return ( -
-
- -
-

No crews match

-

Try a different search.

-
+ ); } // Rich empty state — never feels dead. Prompts to claim the local scene. return ( - -
-
- 🛹 -
-
-

Claim your local scene

-

- No crews planted here yet. Be the first — start your crew, link your home spots, - and put your scene on the map for everyone to find. -

-
-
- - - - {!hasLocation && ( - - )} -
-
+ +
); diff --git a/src/pages/EventsPage.tsx b/src/pages/EventsPage.tsx index 2bbf343..58050ce 100644 --- a/src/pages/EventsPage.tsx +++ b/src/pages/EventsPage.tsx @@ -44,7 +44,7 @@ import { LayoutGrid, CalendarDays, } from 'lucide-react'; -import { cn } from '@/lib/utils'; +import { EmptyState as SharedEmptyState, type EmptyStateAction } from '@/components/EmptyState'; // ─── Accent colours ─────────────────────────────────────────────────────────── @@ -777,33 +777,21 @@ function EmptyState({ onClear?: () => void; hasFilters: boolean; }) { + const actions: EmptyStateAction[] = + hasFilters && onClear + ? [{ label: 'Clear Filters', onClick: onClear, icon: X, variant: 'secondary' }] + : cta && to + ? [{ label: cta, to, icon: Plus, className: ctaClass }] + : []; + return ( -
-
- -
-

{title}

-

{body}

- {hasFilters && onClear ? ( - - ) : cta && to ? ( - - - - ) : null} - - Have questions? Check the FAQ → - -
+ ); } diff --git a/src/pages/Index.tsx b/src/pages/Index.tsx index c75f6cb..178d8e6 100644 --- a/src/pages/Index.tsx +++ b/src/pages/Index.tsx @@ -314,13 +314,13 @@ function TheLabSection() { {t('lab.noCoaches')}

- + - + ) : ( - + - +