From a881a87f4df0a219568a8ee5b9fbfb290c67a1b1 Mon Sep 17 00:00:00 2001 From: SkateSpots Date: Sun, 27 Sep 2026 10:15:30 +0700 Subject: [PATCH] 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. --- AGENTS.md | 2 +- NIP.md | 28 ++++++++++- scripts/smoke-test.mjs | 1 + src/components/LabCrewsStrip.tsx | 3 +- src/components/LabLinkField.tsx | 1 + src/hooks/useGrowthStats.ts | 8 +++- src/hooks/useLabProfiles.ts | 12 ++++- src/lib/becomeRoles.tsx | 32 ++++++++++++- src/lib/types.ts | 80 ++++++++++++++++++++++++++++++++ src/pages/CoachDetailPage.tsx | 31 ++++++++++++- src/pages/CommunityStatsPage.tsx | 10 ++-- src/pages/LabPage.tsx | 49 +++++++++++++++++-- 12 files changed, 242 insertions(+), 15 deletions(-) diff --git a/AGENTS.md b/AGENTS.md index 012c7d4..d455c4f 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–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 `CraftProfileForm`; coach/filmer/editor/photographer/builder are bespoke but follow the same conventions. Forms **prefill from the existing profile** to diff --git a/NIP.md b/NIP.md index f539ddb..8506fb5 100644 --- a/NIP.md +++ b/NIP.md @@ -1686,6 +1686,7 @@ They share a common base schema and are distinguished by kind and a self-tag. | 35011 | Musician / Band | `skatemusician` | | 35012 | Event Judge | `skatejudge` | | 35013 | Event Organizer / Host | `skateorganizer` | +| 35014 | Barber / Hairstylist | `skatebarber` | > 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 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 ```json { - "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"], + "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", "skatebarber"], "limit": 100 } ``` diff --git a/scripts/smoke-test.mjs b/scripts/smoke-test.mjs index 99a9ab2..eeebce0 100755 --- a/scripts/smoke-test.mjs +++ b/scripts/smoke-test.mjs @@ -132,6 +132,7 @@ const ROUTES = [ '/trades', '/lab', '/become/organizer', + '/become/barber', '/leaderboards', '/bounties', '/settings', diff --git a/src/components/LabCrewsStrip.tsx b/src/components/LabCrewsStrip.tsx index c17166a..77d0da0 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' | 'organizing'; + | 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering'; /** Translate the Lab page's ServiceType into a crew focus scene (or null). */ function focusForLabService(serviceType: ServiceType): Exclude | null { @@ -17,6 +17,7 @@ function focusForLabService(serviceType: ServiceType): Exclude = { trainer: 'Trainer', musician: 'Musician', judge: 'Judge', + barber: 'Barber', }; interface LabLinkFieldProps { diff --git a/src/hooks/useGrowthStats.ts b/src/hooks/useGrowthStats.ts index 3693b71..5ea81de 100644 --- a/src/hooks/useGrowthStats.ts +++ b/src/hooks/useGrowthStats.ts @@ -16,6 +16,7 @@ import { SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND, + SKATE_BARBER_KIND, SKATE_TOUR_KIND, SKATE_CREW_KIND, } from '@/lib/types'; @@ -66,6 +67,7 @@ export interface GrowthStats { totalMusicians: number; totalJudges: number; totalOrganizers: number; + totalBarbers: number; totalTours: number; totalCrews: number; /** All unique pubkeys that have published any content */ @@ -152,6 +154,7 @@ export function useGrowthStats() { musicianEvents, judgeEvents, organizerEvents, + barberEvents, ] = await Promise.all([ querySkateContent([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], 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_JUDGE_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) @@ -203,8 +207,9 @@ export function useGrowthStats() { const musicians = dedup(musicianEvents); const judges = dedup(judgeEvents); const organizers = dedup(organizerEvents); + const barbers = dedup(barberEvents); // 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 ─────────────────────────────────────────────── @@ -326,6 +331,7 @@ export function useGrowthStats() { totalMusicians: musicians.length, totalJudges: judges.length, totalOrganizers: organizers.length, + totalBarbers: barbers.length, totalTours: tours.length, totalCrews: crews.length, totalMappers: allPubkeys.size, diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index c42913a..b905062 100644 --- a/src/hooks/useLabProfiles.ts +++ b/src/hooks/useLabProfiles.ts @@ -2,11 +2,14 @@ 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_ORGANIZER_KIND, + SKATE_BARBER_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder, parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge, parseSkateOrganizer, + parseSkateBarber, isFeaturedActive, 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 SkateBarber, } from '@/lib/types'; import type { NostrEvent } from '@nostrify/nostrify'; import { useSkateRelays } from '@/hooks/useSkateRelays'; @@ -14,11 +17,12 @@ import { useAdminModeration, filterAdminHiddenMixed } from '@/hooks/useAdminMode export type LabProfileType = | 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder' - | 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer'; + | 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer' | 'barber'; export type LabProfile = ( | SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder | SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge | SkateOrganizer + | SkateBarber ) & { profileType: LabProfileType; }; @@ -26,11 +30,13 @@ export type LabProfile = ( const LAB_KINDS = [ SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND, SKATE_JUDGE_KIND, SKATE_ORGANIZER_KIND, + SKATE_BARBER_KIND, ]; const LAB_SELF_TAGS = [ 'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder', 'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge', 'skateorganizer', + 'skatebarber', ]; /** 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); return p ? { ...p, profileType: 'organizer' } : null; } + case SKATE_BARBER_KIND: { + const p = parseSkateBarber(event); + return p ? { ...p, profileType: 'barber' } : null; + } default: return null; } diff --git a/src/lib/becomeRoles.tsx b/src/lib/becomeRoles.tsx index 9d6b18c..984d7d8 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, Tent, + Palette, SprayCan, Music, Gavel, Brush, Dumbbell, Tent, Sparkles, } from 'lucide-react'; import type { CraftFormConfig } from '@/components/CraftProfileForm'; import { @@ -11,6 +11,7 @@ import { 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'; /** @@ -251,6 +252,35 @@ export const BECOME_ROLES: Record = { ], }, }, + 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 { diff --git a/src/lib/types.ts b/src/lib/types.ts index e7c0420..261e2a8 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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_JUDGE_KIND = 35012; // Skate judges (event officials) 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 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 @@ -1910,6 +1911,46 @@ export const ORGANIZER_SCALE_LABELS: Record = { pro: 'Pro Circuit', }; +// ----- Barbers / Hairstylists (kind 35014) ----- + +// What the barber offers. +export const BARBER_SERVICE_LABELS: Record = { + haircut: 'Haircut', + fade: 'Fade', + beard: 'Beard Trim', + lineup: 'Line-up / Shape-up', + colour: 'Colour & Dye', + kids: "Kids' Cuts", +}; + +export const BARBER_SERVICE_DESCRIPTIONS: Record = { + 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 = { + 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 = { + shop: 'Shop / Barbershop', + mobile: 'Mobile / House Calls', + event: 'Skate Events & Comps', +}; + // ----- 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], }; } + +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'), + }; +} diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 3f49232..2346872 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, 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 { OfficialBadge } from '@/components/OfficialBadge'; import { ProBadge } from '@/components/ProBadge'; @@ -49,6 +49,7 @@ import { Music, Gavel, Tent, + Sparkles, } from 'lucide-react'; import { nip19 } from 'nostr-tools'; @@ -105,6 +106,7 @@ export default function CoachDetailPage() { const isEditor = profile?.profileType === 'editor'; const isJudge = profile?.profileType === 'judge'; const isOrganizer = profile?.profileType === 'organizer'; + const isBarber = profile?.profileType === 'barber'; useSeoMeta({ 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' }, 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' }, + 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; @@ -160,6 +163,7 @@ export default function CoachDetailPage() { coach: GraduationCap, filmer: Video, photographer: Camera, builder: HardHat, editor: Scissors, artist: Palette, graffiti: SprayCan, tattoo: Brush, trainer: Dumbbell, musician: Music, judge: Gavel, organizer: Tent, + barber: Sparkles, }; 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, graffiti: SKATE_GRAFFITI_KIND, tattoo: SKATE_TATTOO_KIND, trainer: SKATE_TRAINER_KIND, musician: SKATE_MUSICIAN_KIND, judge: SKATE_JUDGE_KIND, organizer: SKATE_ORGANIZER_KIND, + barber: SKATE_BARBER_KIND, }; const profileKind = KINDS[profile.profileType] ?? SKATE_COACH_KIND; const isCollective = profile.entityType === 'collective' && profile.members.length > 0; @@ -187,6 +192,7 @@ export default function CoachDetailPage() { 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 }, + barber: { ...BARBER_SERVICE_LABELS, ...BARBER_STYLE_LABELS, ...BARBER_SETTING_LABELS }, }; const DESCRIPTIONS_BY_TYPE: Record> = { coach: COACH_SKILL_DESCRIPTIONS, @@ -201,6 +207,7 @@ export default function CoachDetailPage() { musician: {}, judge: {}, organizer: {}, + barber: BARBER_SERVICE_DESCRIPTIONS, }; 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', artist: 'artist', graffiti: 'graffiti artist', tattoo: 'tattoo artist', trainer: 'trainer', musician: 'musician', judge: 'event judge', organizer: 'event organizer', + barber: 'barber', }; const typeNoun = TYPE_NOUNS[profile.profileType] ?? 'profile'; @@ -217,6 +225,7 @@ export default function CoachDetailPage() { coach: 'Skills', photographer: 'Services & Gear', builder: 'What We Build', artist: 'What They Make', graffiti: 'Styles & Work', tattoo: 'Styles', trainer: 'Focus & Specialties', musician: 'Genres & Instruments', judge: 'Formats & Specialties', + barber: 'Services & Styles', organizer: 'Event Types & Services', }; const skillsTitle = SKILLS_TITLES[profile.profileType] ?? 'Services & Skills'; @@ -435,6 +444,26 @@ export default function CoachDetailPage() { )} + {/* Barber — shop & books-open */} + {isBarber && (pget('studio') || pbool('booksOpen')) && ( +
+ {pget('studio') && ( +
+

+ Shop / Studio +

+

{pget('studio')}

+
+ )} + {pbool('booksOpen') && ( +
+ + Books Open +
+ )} +
+ )} + {/* Trainer — credentials & remote */} {isTrainer && (pget('credentials') || pbool('remote')) && (
diff --git a/src/pages/CommunityStatsPage.tsx b/src/pages/CommunityStatsPage.tsx index 6c2a714..eb1ca02 100644 --- a/src/pages/CommunityStatsPage.tsx +++ b/src/pages/CommunityStatsPage.tsx @@ -13,7 +13,7 @@ import { cn } from '@/lib/utils'; const ANGOR_PROJECT_URL = 'https://angor.io/project/angor1qtunna00uqyx52rr0nvqa059z2gknhrmn7urd6z'; import { SKATE_RELAY } from '@/lib/types'; 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 ───────────────────────────────────────────────────────────────── @@ -22,12 +22,13 @@ function labCraftTotal(s: { totalCoaches: number; totalFilmers: number; totalEditors: number; totalPhotographers: number; totalBuilders: number; totalArtists: number; totalGraffiti: number; totalTattoo: number; totalTrainers: number; - totalMusicians: number; totalJudges: number; totalOrganizers: number; + totalMusicians: number; totalJudges: number; totalOrganizers: number; totalBarbers: number; }): number { return ( s.totalCoaches + s.totalFilmers + s.totalEditors + s.totalPhotographers + 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() { - + +
diff --git a/src/pages/LabPage.tsx b/src/pages/LabPage.tsx index 55c2961..dd279a6 100644 --- a/src/pages/LabPage.tsx +++ b/src/pages/LabPage.tsx @@ -47,6 +47,9 @@ import { ORGANIZER_EVENT_LABELS, ORGANIZER_SERVICE_LABELS, ORGANIZER_SCALE_LABELS, + BARBER_SERVICE_LABELS, + BARBER_STYLE_LABELS, + BARBER_SETTING_LABELS, } from '@/lib/types'; import { GraduationCap, @@ -75,6 +78,7 @@ import { PersonStanding, ArrowRight, Tent, + Sparkles, } from 'lucide-react'; import { cn } from '@/lib/utils'; import { EmptyState as SharedEmptyState } from '@/components/EmptyState'; @@ -152,6 +156,13 @@ const ORGANIZER_ALL_LABELS: Record = { ...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 = { + ...BARBER_SERVICE_LABELS, ...BARBER_STYLE_LABELS, ...BARBER_SETTING_LABELS, +}; + // All photographer label maps merged — for resolving a chip label regardless of category const PHOTO_ALL_LABELS: Record = { ...PHOTO_SERVICE_LABELS, @@ -164,7 +175,7 @@ const PHOTO_ALL_LABELS: Record = { type ServiceType = | '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. */ const SERVICE_TO_PROFILE_TYPE: Record = { @@ -180,6 +191,7 @@ const SERVICE_TO_PROFILE_TYPE: Record = music: 'musician', judging: 'judge', organizing: 'organizer', + barbering: 'barber', }; // ─── Per-category accent colours ───────────────────────────────────────────── @@ -317,6 +329,17 @@ const ACCENT = { stripe: 'bg-teal-600', 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 ───────────────────────────────────────────────────── @@ -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/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/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 }, ]; @@ -426,6 +450,7 @@ function getSkillLabelForType(skill: string, serviceType: ServiceType): string { 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; + if (serviceType === 'barbering') return BARBER_ALL_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: 'judging', label: 'Judges', icon: Gavel }, { key: 'organizing', label: 'Organizers', icon: Tent }, + { key: 'barbering', label: 'Barbers', icon: Sparkles }, ]; 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: '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: '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', artist: 'art', graffiti: 'graffiti', tattoo: 'tattoo', trainer: 'training', musician: 'music', judge: 'judging', organizer: 'organizing', + barber: 'barbering', }; const serviceType: ServiceType = PROFILE_TO_SERVICE[profile.profileType]; const accent = ACCENT[serviceType]; @@ -905,6 +938,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) { coaching: GraduationCap, filming: Video, editing: Scissors, photography: Camera, building: HardHat, art: Palette, graffiti: SprayCan, tattoo: Brush, training: Dumbbell, music: Music, judging: Gavel, organizing: Tent, + barbering: Sparkles, }; const TypeIcon = TYPE_ICONS[serviceType]; @@ -1136,6 +1170,14 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo to: '/become/organizer', 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]; @@ -1186,7 +1228,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', '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'; })(); @@ -1235,7 +1277,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, organizing: 0, + art: 0, graffiti: 0, tattoo: 0, training: 0, music: 0, judging: 0, organizing: 0, barbering: 0, }; if (!profiles) return empty; const byType = (t: ServiceType) => @@ -1253,6 +1295,7 @@ export default function LabPage() { music: byType('music'), judging: byType('judging'), organizing: byType('organizing'), + barbering: byType('barbering'), }; }, [profiles]);