feat(lab): add Barber / Hairstylist profile (kind 35014)

A full Lab role, mirroring how Event Organizer was added:
- kinds: SKATE_BARBER_KIND 35014, self-tag 'skatebarber', BARBER_*_LABELS,
  SkateBarber + parseSkateBarber (studio, books_open).
- craft config at /become/barber (cyan accent, Sparkles icon), Lab tab
  'Barbers', filter sections (services/styles/setting), empty state, FAB item.
- Lab pipeline: useLabProfiles kind/tag/parser, LabPage ServiceType
  'barbering' + ACCENT/maps/counts, CoachDetailPage per-role maps + studio &
  books-open block, LabLinkField label, growth stats + Community Stats card.
- No crew-scene strip for barbers (LabCrewsStrip returns null).
- Documented in NIP.md; smoke test now boots /become/barber.
This commit is contained in:
SkateSpots 2026-09-27 10:15:30 +07:00
parent b70b9c677c
commit a881a87f4d
12 changed files with 242 additions and 15 deletions

View file

@ -44,7 +44,7 @@ session with **no other memory of this project**. So:
· `31873` trusted-trader recommendation · `31873` trusted-trader recommendation
→ `src/hooks/useTradeAttestations.ts` / `useTraderRecommendations.ts`; → `src/hooks/useTradeAttestations.ts` / `useTraderRecommendations.ts`;
reputation UI in `TradeReputationCard` / `TradeRecommendationsCard` reputation UI in `TradeReputationCard` / `TradeRecommendationsCard`
- `35000–35013` role profiles (skater/filmer/coach/…/event-organizer) · `35006` crews · `30003` spot collections - `35000–35014` role profiles (skater/filmer/coach/…/event-organizer/barber) · `35006` crews · `30003` spot collections
- **Lab offer flows** (`/become/*`): the 7 craft roles share - **Lab offer flows** (`/become/*`): the 7 craft roles share
`CraftProfileForm`; coach/filmer/editor/photographer/builder are bespoke but `CraftProfileForm`; coach/filmer/editor/photographer/builder are bespoke but
follow the same conventions. Forms **prefill from the existing profile** to follow the same conventions. Forms **prefill from the existing profile** to

28
NIP.md
View file

@ -1686,6 +1686,7 @@ They share a common base schema and are distinguished by kind and a self-tag.
| 35011 | Musician / Band | `skatemusician` | | 35011 | Musician / Band | `skatemusician` |
| 35012 | Event Judge | `skatejudge` | | 35012 | Event Judge | `skatejudge` |
| 35013 | Event Organizer / Host | `skateorganizer` | | 35013 | Event Organizer / Host | `skateorganizer` |
| 35014 | Barber / Hairstylist | `skatebarber` |
> Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below). > Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below).
@ -2132,12 +2133,35 @@ Organizer-specific optional tags:
An organizing team can be a `collective` with member `p` tags, and may link to its 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`). skate crew via an `a` tag (`35006:pubkey:d`).
### Barber / Hairstylist (kind 35014)
Barbers and hairstylists who cut for the skate scene — shop chairs, mobile /
house calls, and cuts at events or comps. They list the services they offer,
their signature styles, and where they work, so skaters can find a fresh trim.
Uses the common base schema.
Categories encoded as `t` tags:
- **Services**: `haircut`, `fade`, `beard`, `lineup`, `colour`, `kids`
- **Signature styles**: `skin_fade`, `taper`, `buzz`, `mohawk`, `mullet`, `curly`, `long`, `classic`
- **Setting**: `shop`, `mobile`, `event`
Barber-specific optional tags:
| Tag | Description |
|-----|-------------|
| `studio` | Shop / studio name (≤ `MAX_LOCATION_LEN`). |
| `books_open` | `"true"` when currently taking bookings. |
A shop or collective 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 ### Querying Lab Profiles
```json ```json
{ {
"kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012, 35013], "kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011, 35012, 35013, 35014],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer"], "#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician", "skatejudge", "skateorganizer", "skatebarber"],
"limit": 100 "limit": 100
} }
``` ```

View file

@ -132,6 +132,7 @@ const ROUTES = [
'/trades', '/trades',
'/lab', '/lab',
'/become/organizer', '/become/organizer',
'/become/barber',
'/leaderboards', '/leaderboards',
'/bounties', '/bounties',
'/settings', '/settings',

View file

@ -8,7 +8,7 @@ import { Skeleton } from '@/components/ui/skeleton';
/** The Lab page's own ServiceType union (kept in sync with LabPage). */ /** The Lab page's own ServiceType union (kept in sync with LabPage). */
type ServiceType = type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building' | 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing'; | 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering';
/** Translate the Lab page's ServiceType into a crew focus scene (or null). */ /** Translate the Lab page's ServiceType into a crew focus scene (or null). */
function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> | null { function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> | null {
@ -17,6 +17,7 @@ function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed
// to fall through to "Photo & Film" and imply a wrong association. // to fall through to "Photo & Film" and imply a wrong association.
case 'judging': case 'judging':
case 'organizing': case 'organizing':
case 'barbering':
return null; return null;
case 'art': case 'art':
case 'graffiti': case 'graffiti':

View file

@ -17,6 +17,7 @@ const TYPE_LABEL: Record<string, string> = {
trainer: 'Trainer', trainer: 'Trainer',
musician: 'Musician', musician: 'Musician',
judge: 'Judge', judge: 'Judge',
barber: 'Barber',
}; };
interface LabLinkFieldProps { interface LabLinkFieldProps {

View file

@ -16,6 +16,7 @@ import {
SKATE_MUSICIAN_KIND, SKATE_MUSICIAN_KIND,
SKATE_JUDGE_KIND, SKATE_JUDGE_KIND,
SKATE_ORGANIZER_KIND, SKATE_ORGANIZER_KIND,
SKATE_BARBER_KIND,
SKATE_TOUR_KIND, SKATE_TOUR_KIND,
SKATE_CREW_KIND, SKATE_CREW_KIND,
} from '@/lib/types'; } from '@/lib/types';
@ -66,6 +67,7 @@ export interface GrowthStats {
totalMusicians: number; totalMusicians: number;
totalJudges: number; totalJudges: number;
totalOrganizers: number; totalOrganizers: number;
totalBarbers: number;
totalTours: number; totalTours: number;
totalCrews: number; totalCrews: number;
/** All unique pubkeys that have published any content */ /** All unique pubkeys that have published any content */
@ -152,6 +154,7 @@ export function useGrowthStats() {
musicianEvents, musicianEvents,
judgeEvents, judgeEvents,
organizerEvents, organizerEvents,
barberEvents,
] = await Promise.all([ ] = await Promise.all([
querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
@ -172,6 +175,7 @@ export function useGrowthStats() {
querySkateContent([{ kinds: [SKATE_MUSICIAN_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_MUSICIAN_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_JUDGE_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_JUDGE_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_ORGANIZER_KIND], limit: 500 }], { signal }).catch(() => []), querySkateContent([{ kinds: [SKATE_ORGANIZER_KIND], limit: 500 }], { signal }).catch(() => []),
querySkateContent([{ kinds: [SKATE_BARBER_KIND], limit: 500 }], { signal }).catch(() => []),
]); ]);
// Deduplicate addressable events (keep newest per pubkey+kind+d) // Deduplicate addressable events (keep newest per pubkey+kind+d)
@ -203,8 +207,9 @@ export function useGrowthStats() {
const musicians = dedup(musicianEvents); const musicians = dedup(musicianEvents);
const judges = dedup(judgeEvents); const judges = dedup(judgeEvents);
const organizers = dedup(organizerEvents); const organizers = dedup(organizerEvents);
const barbers = dedup(barberEvents);
// Aggregate of the "other" crafts for active-mapper / total rollups. // Aggregate of the "other" crafts for active-mapper / total rollups.
const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges, ...organizers]; const otherLab = [...artists, ...graffiti, ...tattoo, ...trainers, ...musicians, ...judges, ...organizers, ...barbers];
// ── Monthly bucketing ─────────────────────────────────────────────── // ── Monthly bucketing ───────────────────────────────────────────────
@ -326,6 +331,7 @@ export function useGrowthStats() {
totalMusicians: musicians.length, totalMusicians: musicians.length,
totalJudges: judges.length, totalJudges: judges.length,
totalOrganizers: organizers.length, totalOrganizers: organizers.length,
totalBarbers: barbers.length,
totalTours: tours.length, totalTours: tours.length,
totalCrews: crews.length, totalCrews: crews.length,
totalMappers: allPubkeys.size, totalMappers: allPubkeys.size,

View file

@ -2,11 +2,14 @@ import { useQuery } from '@tanstack/react-query';
import { import {
SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, 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, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND,
SKATE_BARBER_KIND,
parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder,
parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge, parseSkateOrganizer, parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge, parseSkateOrganizer,
parseSkateBarber,
isFeaturedActive, isFeaturedActive,
type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder, type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder,
type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician, type SkateJudge, type SkateOrganizer, type SkateArtist, type SkateGraffiti, type SkateTattoo, type SkateTrainer, type SkateMusician, type SkateJudge, type SkateOrganizer,
type SkateBarber,
} from '@/lib/types'; } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify'; import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays'; import { useSkateRelays } from '@/hooks/useSkateRelays';
@ -14,11 +17,12 @@ import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminMode
export type LabProfileType = export type LabProfileType =
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder' | 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer'; | 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer' | 'barber';
export type LabProfile = ( export type LabProfile = (
| SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder | SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder
| SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge | SkateOrganizer | SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge | SkateOrganizer
| SkateBarber
) & { ) & {
profileType: LabProfileType; profileType: LabProfileType;
}; };
@ -26,11 +30,13 @@ export type LabProfile = (
const LAB_KINDS = [ const LAB_KINDS = [
SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, 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, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND,
SKATE_BARBER_KIND,
]; ];
const LAB_SELF_TAGS = [ const LAB_SELF_TAGS = [
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder', 'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge', 'skateorganizer', 'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge', 'skateorganizer',
'skatebarber',
]; ];
/** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */ /** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */
@ -84,6 +90,10 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null {
const p = parseSkateOrganizer(event); const p = parseSkateOrganizer(event);
return p ? { ...p, profileType: 'organizer' } : null; return p ? { ...p, profileType: 'organizer' } : null;
} }
case SKATE_BARBER_KIND: {
const p = parseSkateBarber(event);
return p ? { ...p, profileType: 'barber' } : null;
}
default: default:
return null; return null;
} }

View file

@ -1,6 +1,6 @@
import { lazy, type ComponentType, type LazyExoticComponent } from 'react'; import { lazy, type ComponentType, type LazyExoticComponent } from 'react';
import { import {
Palette, SprayCan, Music, Gavel, Brush, Dumbbell, Tent, Palette, SprayCan, Music, Gavel, Brush, Dumbbell, Tent, Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import type { CraftFormConfig } from '@/components/CraftProfileForm'; import type { CraftFormConfig } from '@/components/CraftProfileForm';
import { import {
@ -11,6 +11,7 @@ import {
SKATE_ORGANIZER_KIND, ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS, SKATE_ORGANIZER_KIND, ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS,
SKATE_TATTOO_KIND, TATTOO_STYLE_LABELS, TATTOO_METHOD_LABELS, SKATE_TATTOO_KIND, TATTOO_STYLE_LABELS, TATTOO_METHOD_LABELS,
SKATE_TRAINER_KIND, TRAINER_FOCUS_LABELS, TRAINER_INJURY_LABELS, TRAINER_CREDENTIAL_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'; } from '@/lib/types';
/** /**
@ -251,6 +252,35 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
], ],
}, },
}, },
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,
rateLabel: 'Price / Cut',
portfolioLabel: 'Cuts gallery / IG link',
skillSections: [
{ title: 'What You Offer', entries: Object.entries(BARBER_SERVICE_LABELS), required: true },
{ title: 'Signature Styles', entries: Object.entries(BARBER_STYLE_LABELS) },
{ title: 'Setting', entries: Object.entries(BARBER_SETTING_LABELS) },
],
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 { export function getBecomeRole(slug: string | undefined): BecomeRole | undefined {

View file

@ -66,6 +66,7 @@ export const SKATE_TRAINER_KIND = 35010; // Physical trainer / physiotherapist p
export const SKATE_MUSICIAN_KIND = 35011; // Musician / band profiles (skaters who make music) 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_JUDGE_KIND = 35012; // Skate judges (event officials)
export const SKATE_ORGANIZER_KIND = 35013; // Event organizers / hosts (jams, comps, meetups) export const SKATE_ORGANIZER_KIND = 35013; // Event organizers / hosts (jams, comps, meetups)
export const SKATE_BARBER_KIND = 35014; // Barbers / hairstylists (skater-friendly cuts)
export const MARKETPLACE_ITEM_KIND = 30402; // NIP-99 Classified Listings 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 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 export const SKATE_TOUR_KIND = 30823; // Local skate tours - guides offer to show hidden spots
@ -1910,6 +1911,46 @@ export const ORGANIZER_SCALE_LABELS: Record<string, string> = {
pro: 'Pro Circuit', pro: 'Pro Circuit',
}; };
// ----- Barbers / Hairstylists (kind 35014) -----
// What the barber offers.
export const BARBER_SERVICE_LABELS: Record<string, string> = {
haircut: 'Haircut',
fade: 'Fade',
beard: 'Beard Trim',
lineup: 'Line-up / Shape-up',
colour: 'Colour & Dye',
kids: "Kids' Cuts",
};
export const BARBER_SERVICE_DESCRIPTIONS: Record<string, string> = {
haircut: 'A classic cut — clippers and scissors.',
fade: 'A graduated fade — low, mid or high.',
beard: 'Beard trim, shape and tidy-up.',
lineup: 'Clean edges around the hairline and neck.',
colour: 'Colour, bleach, dye and toning.',
kids: 'Patient cuts for younger skaters.',
};
// Signature styles.
export const BARBER_STYLE_LABELS: Record<string, string> = {
skin_fade: 'Skin Fade',
taper: 'Taper',
buzz: 'Buzz Cut',
mohawk: 'Mohawk',
mullet: 'Mullet',
curly: 'Curls / Afro',
long: 'Long Hair / Layers',
classic: 'Classic Scissor Cut',
};
// Where they cut hair.
export const BARBER_SETTING_LABELS: Record<string, string> = {
shop: 'Shop / Barbershop',
mobile: 'Mobile / House Calls',
event: 'Skate Events & Comps',
};
// ----- Crews (community identity, map-anchored) ----- // ----- Crews (community identity, map-anchored) -----
/** /**
@ -3704,3 +3745,42 @@ export function parseSkateOrganizer(event: NostrEvent): SkateOrganizer | null {
eventsHosted: event.tags.find(([name]) => name === 'events_hosted')?.[1], eventsHosted: event.tags.find(([name]) => name === 'events_hosted')?.[1],
}; };
} }
export interface SkateBarber {
id: string;
pubkey: string;
identifier: string;
featuredUntil?: number;
title: string; // barber / shop name
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // services + styles + settings tags
experience?: string; // years cutting
rate?: string; // price / cut
phone?: string;
email?: string;
website?: string;
portfolio?: string; // Instagram / gallery of cuts
social?: string;
studio?: string; // shop / studio name
booksOpen?: boolean; // currently taking bookings
noffer?: string;
entityType: EntityType;
members: string[];
crewRef?: string;
createdAt: number;
event: NostrEvent;
}
export function parseSkateBarber(event: NostrEvent): SkateBarber | null {
if (event.kind !== SKATE_BARBER_KIND) return null;
const base = parseCraftBase(event, 'skatebarber');
if (!base) return null;
return {
...base,
studio: clampStr(event.tags.find(([name]) => name === 'studio')?.[1], MAX_LOCATION_LEN),
booksOpen: event.tags.some(([name, value]) => name === 'books_open' && value === 'true'),
};
}

View file

@ -21,7 +21,7 @@ import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser'; import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog'; import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
import { isAdminPubkey } from '@/lib/admins'; 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, SKATE_ORGANIZER_KIND, ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS } 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, BARBER_SERVICE_LABELS, BARBER_SERVICE_DESCRIPTIONS, BARBER_STYLE_LABELS, BARBER_SETTING_LABELS, SKATE_BARBER_KIND } from '@/lib/types';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl'; import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge'; import { OfficialBadge } from '@/components/OfficialBadge';
import { ProBadge } from '@/components/ProBadge'; import { ProBadge } from '@/components/ProBadge';
@ -49,6 +49,7 @@ import {
Music, Music,
Gavel, Gavel,
Tent, Tent,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { nip19 } from 'nostr-tools'; import { nip19 } from 'nostr-tools';
@ -105,6 +106,7 @@ export default function CoachDetailPage() {
const isEditor = profile?.profileType === 'editor'; const isEditor = profile?.profileType === 'editor';
const isJudge = profile?.profileType === 'judge'; const isJudge = profile?.profileType === 'judge';
const isOrganizer = profile?.profileType === 'organizer'; const isOrganizer = profile?.profileType === 'organizer';
const isBarber = profile?.profileType === 'barber';
useSeoMeta({ useSeoMeta({
title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots', title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots',
@ -141,6 +143,7 @@ export default function CoachDetailPage() {
musician: { text: 'text-sky-400', bg: 'bg-sky-500', border: 'border-sky-500/30', hover: 'hover:bg-sky-500/10 border-sky-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' }, 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' }, organizer: { text: 'text-teal-400', bg: 'bg-teal-600', border: 'border-teal-500/30', hover: 'hover:bg-teal-500/10 border-teal-500' },
barber: { text: 'text-cyan-400', bg: 'bg-cyan-600', border: 'border-cyan-500/30', hover: 'hover:bg-cyan-500/10 border-cyan-500' },
}; };
const accent = ACCENTS[profile.profileType] ?? ACCENTS.coach; const accent = ACCENTS[profile.profileType] ?? ACCENTS.coach;
@ -160,6 +163,7 @@ export default function CoachDetailPage() {
coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors, coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors,
artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel, artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel,
organizer: Tent, organizer: Tent,
barber: Sparkles,
}; };
const TypeIcon = TYPE_ICONS[profile.profileType] ?? GraduationCap; const TypeIcon = TYPE_ICONS[profile.profileType] ?? GraduationCap;
@ -169,6 +173,7 @@ export default function CoachDetailPage() {
photographer: SKATE_PHOTOGRAPHER_KIND, builder: SKATE_BUILDER_KIND, artist: SKATE_ARTIST_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, graffiti: SKATE_GRAFFITI_KIND, tattoo: SKATE_TATTOO_KIND, trainer: SKATE_TRAINER_KIND, musician: SKATE_MUSICIAN_KIND, judge: SKATE_JUDGE_KIND,
organizer: SKATE_ORGANIZER_KIND, organizer: SKATE_ORGANIZER_KIND,
barber: SKATE_BARBER_KIND,
}; };
const profileKind = KINDS[profile.profileType] ?? SKATE_COACH_KIND; const profileKind = KINDS[profile.profileType] ?? SKATE_COACH_KIND;
const isCollective = profile.entityType === 'collective' && profile.members.length > 0; const isCollective = profile.entityType === 'collective' && profile.members.length > 0;
@ -187,6 +192,7 @@ export default function CoachDetailPage() {
musician: { ...MUSICIAN_GENRE_LABELS, ...MUSICIAN_INSTRUMENT_LABELS }, musician: { ...MUSICIAN_GENRE_LABELS, ...MUSICIAN_INSTRUMENT_LABELS },
judge: { ...JUDGE_FORMAT_LABELS, ...JUDGE_SPECIALTY_LABELS, ...JUDGE_LEVEL_LABELS }, judge: { ...JUDGE_FORMAT_LABELS, ...JUDGE_SPECIALTY_LABELS, ...JUDGE_LEVEL_LABELS },
organizer: { ...ORGANIZER_EVENT_LABELS, ...ORGANIZER_SERVICE_LABELS, ...ORGANIZER_SCALE_LABELS }, organizer: { ...ORGANIZER_EVENT_LABELS, ...ORGANIZER_SERVICE_LABELS, ...ORGANIZER_SCALE_LABELS },
barber: { ...BARBER_SERVICE_LABELS, ...BARBER_STYLE_LABELS, ...BARBER_SETTING_LABELS },
}; };
const DESCRIPTIONS_BY_TYPE: Record<string, Record<string, string>> = { const DESCRIPTIONS_BY_TYPE: Record<string, Record<string, string>> = {
coach: COACH_SKILL_DESCRIPTIONS, coach: COACH_SKILL_DESCRIPTIONS,
@ -201,6 +207,7 @@ export default function CoachDetailPage() {
musician: {}, musician: {},
judge: {}, judge: {},
organizer: {}, organizer: {},
barber: BARBER_SERVICE_DESCRIPTIONS,
}; };
const getSkillLabel = (skill: string) => LABELS_BY_TYPE[profile.profileType]?.[skill] || skill; const getSkillLabel = (skill: string) => LABELS_BY_TYPE[profile.profileType]?.[skill] || skill;
@ -210,6 +217,7 @@ export default function CoachDetailPage() {
coach: 'coach', filmer: 'filmer', editor: 'video editor', photographer: 'photographer', builder: 'builder', 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', artist: 'artist', graffiti: 'graffiti artist', tattoo: 'tattoo artist', trainer: 'trainer', musician: 'musician', judge: 'event judge',
organizer: 'event organizer', organizer: 'event organizer',
barber: 'barber',
}; };
const typeNoun = TYPE_NOUNS[profile.profileType] ?? 'profile'; const typeNoun = TYPE_NOUNS[profile.profileType] ?? 'profile';
@ -217,6 +225,7 @@ export default function CoachDetailPage() {
coach: 'Skills', photographer: 'Services & Gear', builder: 'What We Build', coach: 'Skills', photographer: 'Services & Gear', builder: 'What We Build',
artist: 'What They Make', graffiti: 'Styles & Work', tattoo: 'Styles', artist: 'What They Make', graffiti: 'Styles & Work', tattoo: 'Styles',
trainer: 'Focus & Specialties', musician: 'Genres & Instruments', judge: 'Formats & Specialties', trainer: 'Focus & Specialties', musician: 'Genres & Instruments', judge: 'Formats & Specialties',
barber: 'Services & Styles', organizer: 'Event Types & Services',
}; };
const skillsTitle = SKILLS_TITLES[profile.profileType] ?? 'Services & Skills'; const skillsTitle = SKILLS_TITLES[profile.profileType] ?? 'Services & Skills';
@ -435,6 +444,26 @@ export default function CoachDetailPage() {
</div> </div>
)} )}
{/* Barber — shop & books-open */}
{isBarber && (pget('studio') || pbool('booksOpen')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{pget('studio') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border}`}>
<h4 className="text-xs font-bold text-[#ccff00] uppercase mb-1 flex items-center gap-1">
<MapPin className={`h-3 w-3 ${accent.text}`} /> Shop / Studio
</h4>
<p className="text-sm text-gray-300">{pget('studio')}</p>
</div>
)}
{pbool('booksOpen') && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<Sparkles className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-emerald-400">Books Open</span>
</div>
)}
</div>
)}
{/* Trainer — credentials & remote */} {/* Trainer — credentials & remote */}
{isTrainer && (pget('credentials') || pbool('remote')) && ( {isTrainer && (pget('credentials') || pbool('remote')) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3"> <div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">

View file

@ -13,7 +13,7 @@ import { cn } from '@/lib/utils';
const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z'; const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z';
import { SKATE_RELAY } from '@/lib/types'; import { SKATE_RELAY } from '@/lib/types';
import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts'; import { AreaChart, Area, BarChart, Bar, XAxis, YAxis, CartesianGrid, Tooltip, ResponsiveContainer, PieChart, Pie, Cell, Legend } from 'recharts';
import { MapPin, Store, Calendar, GraduationCap, Video, Scissors, Route, Users, Users2, Camera, Hammer, TrendingUp, Radio, Zap, ExternalLink, Trophy, FlaskConical, Palette, SprayCan, PenTool, Dumbbell, Music, Gavel, Tent } from 'lucide-react'; import { MapPin, Store, Calendar, GraduationCap, Video, Scissors, Route, Users, Users2, Camera, Hammer, TrendingUp, Radio, Zap, ExternalLink, Trophy, FlaskConical, Palette, SprayCan, PenTool, Dumbbell, Music, Gavel, Tent, Sparkles } from 'lucide-react';
// ─── Helpers ───────────────────────────────────────────────────────────────── // ─── Helpers ─────────────────────────────────────────────────────────────────
@ -22,12 +22,13 @@ function labCraftTotal(s: {
totalCoaches: number; totalFilmers: number; totalEditors: number; totalCoaches: number; totalFilmers: number; totalEditors: number;
totalPhotographers: number; totalBuilders: number; totalArtists: number; totalPhotographers: number; totalBuilders: number; totalArtists: number;
totalGraffiti: number; totalTattoo: number; totalTrainers: number; totalGraffiti: number; totalTattoo: number; totalTrainers: number;
totalMusicians: number; totalJudges: number; totalOrganizers: number; totalMusicians: number; totalJudges: number; totalOrganizers: number; totalBarbers: number;
}): number { }): number {
return ( return (
s.totalCoaches + s.totalFilmers + s.totalEditors + s.totalPhotographers + s.totalCoaches + s.totalFilmers + s.totalEditors + s.totalPhotographers +
s.totalBuilders + s.totalArtists + s.totalGraffiti + s.totalTattoo + s.totalBuilders + s.totalArtists + s.totalGraffiti + s.totalTattoo +
s.totalTrainers + s.totalMusicians + s.totalJudges + s.totalOrganizers s.totalTrainers + s.totalMusicians + s.totalJudges + s.totalOrganizers +
s.totalBarbers
); );
} }
@ -261,7 +262,8 @@ export default function CommunityStatsPage() {
<StatCard icon={Dumbbell} value={stats?.totalTrainers ?? 0} label="Trainers" color="#34d399" loading={isLoading} to="/lab?craft=training" /> <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={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} to="/lab?craft=judging" /> <StatCard icon={Gavel} value={stats?.totalJudges ?? 0} label="Judges" color="#fbbf24" loading={isLoading} to="/lab?craft=judging" />
<StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#22d3ee" loading={isLoading} to="/lab?craft=organizing" /> <StatCard icon={Tent} value={stats?.totalOrganizers ?? 0} label="Organizers" color="#2dd4bf" loading={isLoading} to="/lab?craft=organizing" />
<StatCard icon={Sparkles} value={stats?.totalBarbers ?? 0} label="Barbers" color="#22d3ee" loading={isLoading} to="/lab?craft=barbering" />
</div> </div>
</section> </section>

View file

@ -47,6 +47,9 @@ import {
ORGANIZER_EVENT_LABELS, ORGANIZER_EVENT_LABELS,
ORGANIZER_SERVICE_LABELS, ORGANIZER_SERVICE_LABELS,
ORGANIZER_SCALE_LABELS, ORGANIZER_SCALE_LABELS,
BARBER_SERVICE_LABELS,
BARBER_STYLE_LABELS,
BARBER_SETTING_LABELS,
} from '@/lib/types'; } from '@/lib/types';
import { import {
GraduationCap, GraduationCap,
@ -75,6 +78,7 @@ import {
PersonStanding, PersonStanding,
ArrowRight, ArrowRight,
Tent, Tent,
Sparkles,
} from 'lucide-react'; } from 'lucide-react';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { EmptyState as SharedEmptyState } from '@/components/EmptyState'; import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
@ -152,6 +156,13 @@ const ORGANIZER_ALL_LABELS: Record<string, string> = {
...ORGANIZER_EVENT_LABELS, ...ORGANIZER_SERVICE_LABELS, ...ORGANIZER_SCALE_LABELS, ...ORGANIZER_EVENT_LABELS, ...ORGANIZER_SERVICE_LABELS, ...ORGANIZER_SCALE_LABELS,
}; };
const BARBER_SERVICE_ENTRIES = Object.entries(BARBER_SERVICE_LABELS);
const BARBER_STYLE_ENTRIES = Object.entries(BARBER_STYLE_LABELS);
const BARBER_SETTING_ENTRIES = Object.entries(BARBER_SETTING_LABELS);
const BARBER_ALL_LABELS: Record<string, string> = {
...BARBER_SERVICE_LABELS, ...BARBER_STYLE_LABELS, ...BARBER_SETTING_LABELS,
};
// All photographer label maps merged — for resolving a chip label regardless of category // All photographer label maps merged — for resolving a chip label regardless of category
const PHOTO_ALL_LABELS: Record<string, string> = { const PHOTO_ALL_LABELS: Record<string, string> = {
...PHOTO_SERVICE_LABELS, ...PHOTO_SERVICE_LABELS,
@ -164,7 +175,7 @@ const PHOTO_ALL_LABELS: Record<string, string> = {
type ServiceType = type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building' | 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing'; | 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering';
/** Map a ServiceType to the LabProfile.profileType it filters. */ /** Map a ServiceType to the LabProfile.profileType it filters. */
const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> = { const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> = {
@ -180,6 +191,7 @@ const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> =
music: 'musician', music: 'musician',
judging: 'judge', judging: 'judge',
organizing: 'organizer', organizing: 'organizer',
barbering: 'barber',
}; };
// ─── Per-category accent colours ───────────────────────────────────────────── // ─── Per-category accent colours ─────────────────────────────────────────────
@ -317,6 +329,17 @@ const ACCENT = {
stripe: 'bg-teal-600', stripe: 'bg-teal-600',
badgeSkill: 'bg-teal-900/40 border-teal-700/50 text-teal-300', badgeSkill: 'bg-teal-900/40 border-teal-700/50 text-teal-300',
}, },
barbering: {
active: 'bg-cyan-600 hover:bg-cyan-700 text-white border-cyan-600',
chip: 'bg-cyan-600 text-white border-cyan-600',
chipHover: 'hover:border-cyan-500/60 hover:text-cyan-400',
border: 'border-cyan-500/40',
glow: 'shadow-cyan-500/20',
icon: 'text-cyan-400',
badge: 'bg-cyan-500/15 text-cyan-400 border-cyan-500/30',
stripe: 'bg-cyan-600',
badgeSkill: 'bg-cyan-900/40 border-cyan-700/50 text-cyan-300',
},
}; };
// ─── FAB — offer services ───────────────────────────────────────────────────── // ─── FAB — offer services ─────────────────────────────────────────────────────
@ -348,6 +371,7 @@ function OfferFAB() {
{ to: '/become/musician', label: 'Add Music Profile', color: 'bg-sky-500 hover:bg-sky-600', icon: Music }, { 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/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: '/become/organizer', label: 'Organize Events', color: 'bg-teal-600 hover:bg-teal-700', icon: Tent },
{ to: '/become/barber', label: 'Offer Cuts', color: 'bg-cyan-600 hover:bg-cyan-700', icon: Sparkles },
{ to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard }, { to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
]; ];
@ -426,6 +450,7 @@ function getSkillLabelForType(skill: string, serviceType: ServiceType): string {
if (serviceType === 'music') return MUSICIAN_ALL_LABELS[skill] || skill; if (serviceType === 'music') return MUSICIAN_ALL_LABELS[skill] || skill;
if (serviceType === 'judging') return JUDGE_ALL_LABELS[skill] || skill; if (serviceType === 'judging') return JUDGE_ALL_LABELS[skill] || skill;
if (serviceType === 'organizing') return ORGANIZER_ALL_LABELS[skill] || skill; if (serviceType === 'organizing') return ORGANIZER_ALL_LABELS[skill] || skill;
if (serviceType === 'barbering') return BARBER_ALL_LABELS[skill] || skill;
return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill; return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
} }
@ -460,6 +485,7 @@ function LabToolbar({
{ key: 'music', label: 'Music', icon: Music }, { key: 'music', label: 'Music', icon: Music },
{ key: 'judging', label: 'Judges', icon: Gavel }, { key: 'judging', label: 'Judges', icon: Gavel },
{ key: 'organizing', label: 'Organizers', icon: Tent }, { key: 'organizing', label: 'Organizers', icon: Tent },
{ key: 'barbering', label: 'Barbers', icon: Sparkles },
]; ];
const accent = ACCENT[serviceType]; const accent = ACCENT[serviceType];
@ -721,6 +747,12 @@ function FilterDrawer({
{ title: 'What You Handle', entries: ORGANIZER_SERVICE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' }, { 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: 'Scale', entries: ORGANIZER_SCALE_ENTRIES, activeColor: 'bg-teal-400 text-black border-teal-400' },
] ]
: serviceType === 'barbering'
? [
{ title: 'What They Offer', entries: BARBER_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Signature Styles', entries: BARBER_STYLE_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' },
{ title: 'Setting', entries: BARBER_SETTING_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' },
]
: [ : [
{ title: 'Edit Type', entries: EDITOR_SERVICE_ENTRIES, activeColor: accent.chip }, { 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' }, { title: 'Software & Tools', entries: EDITOR_SKILL_ENTRIES, activeColor: 'bg-purple-400 text-white border-purple-400' },
@ -897,6 +929,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
coach: 'coaching', filmer: 'filming', editor: 'editing', photographer: 'photography', builder: 'building', coach: 'coaching', filmer: 'filming', editor: 'editing', photographer: 'photography', builder: 'building',
artist: 'art', graffiti: 'graffiti', tattoo: 'tattoo', trainer: 'training', musician: 'music', judge: 'judging', artist: 'art', graffiti: 'graffiti', tattoo: 'tattoo', trainer: 'training', musician: 'music', judge: 'judging',
organizer: 'organizing', organizer: 'organizing',
barber: 'barbering',
}; };
const serviceType: ServiceType = PROFILE_TO_SERVICE[profile.profileType]; const serviceType: ServiceType = PROFILE_TO_SERVICE[profile.profileType];
const accent = ACCENT[serviceType]; const accent = ACCENT[serviceType];
@ -905,6 +938,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
coaching: GraduationCap, filming: Video, editing: Scissors, photography: Camera, building: HardHat, coaching: GraduationCap, filming: Video, editing: Scissors, photography: Camera, building: HardHat,
art: Palette, graffiti: SprayCan, tattoo: Brush, training: Dumbbell, music: Music, judging: Gavel, art: Palette, graffiti: SprayCan, tattoo: Brush, training: Dumbbell, music: Music, judging: Gavel,
organizing: Tent, organizing: Tent,
barbering: Sparkles,
}; };
const TypeIcon = TYPE_ICONS[serviceType]; const TypeIcon = TYPE_ICONS[serviceType];
@ -1136,6 +1170,14 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
to: '/become/organizer', to: '/become/organizer',
color: 'bg-teal-600 hover:bg-teal-700', color: 'bg-teal-600 hover:bg-teal-700',
}, },
barbering: {
icon: Sparkles,
title: 'No barbers yet',
body: 'Cut hair for skaters? List your chair or mobile service so the scene can get a fresh trim.',
cta: 'Offer Cuts',
to: '/become/barber',
color: 'bg-cyan-600 hover:bg-cyan-700',
},
}; };
const c = configs[serviceType]; const c = configs[serviceType];
@ -1186,7 +1228,7 @@ export default function LabPage() {
const [searchParams] = useSearchParams(); const [searchParams] = useSearchParams();
const initialCraft = (() => { const initialCraft = (() => {
const c = searchParams.get('craft'); const c = searchParams.get('craft');
const valid: ServiceType[] = ['coaching', 'filming', 'editing', 'photography', 'building', 'art', 'graffiti', 'tattoo', 'training', 'music', 'judging', 'organizing']; const valid: ServiceType[] = ['coaching', 'filming', 'editing', 'photography', 'building', 'art', 'graffiti', 'tattoo', 'training', 'music', 'judging', 'organizing', 'barbering'];
return c && (valid as string[]).includes(c) ? (c as ServiceType) : 'coaching'; return c && (valid as string[]).includes(c) ? (c as ServiceType) : 'coaching';
})(); })();
@ -1235,7 +1277,7 @@ export default function LabPage() {
const counts = useMemo<Record<ServiceType, number>>(() => { const counts = useMemo<Record<ServiceType, number>>(() => {
const empty: Record<ServiceType, number> = { const empty: Record<ServiceType, number> = {
coaching: 0, filming: 0, editing: 0, photography: 0, building: 0, coaching: 0, filming: 0, editing: 0, photography: 0, building: 0,
art: 0, graffiti: 0, tattoo: 0, training: 0, music: 0, judging: 0, organizing: 0, art: 0, graffiti: 0, tattoo: 0, training: 0, music: 0, judging: 0, organizing: 0, barbering: 0,
}; };
if (!profiles) return empty; if (!profiles) return empty;
const byType = (t: ServiceType) => const byType = (t: ServiceType) =>
@ -1253,6 +1295,7 @@ export default function LabPage() {
music: byType('music'), music: byType('music'),
judging: byType('judging'), judging: byType('judging'),
organizing: byType('organizing'), organizing: byType('organizing'),
barbering: byType('barbering'),
}; };
}, [profiles]); }, [profiles]);