feat(lab): add Clothing Designer craft (kind 35015)

New data-driven Lab craft, mirroring the artist role: three skill
sections (What You Make [required] / How You Make It / Materials) plus
minimum order, turnaround, size range, samples and customisation extras,
inheriting the shared Lab extras (rate, payment, gallery, reel,
languages, availability, booking).

Wired through types.ts (kind, label/description maps, parse, collective-
capable), labelKeys, useLabProfiles (kind list + dispatch), becomeRoles
(role card), craftFields, LabPage (tab, filters, offer button, empty
state, counts, deep links), LabCrewsStrip, NIP.md, and en/es i18n
(labOptions, becomeRolesUi, labUi). /become/clothing added to the smoke
route list. tsc, eslint, check-i18n, vitest 101/101, build and smoke all
pass.
This commit is contained in:
SkateSpots 2026-10-10 19:30:47 +07:00
parent f00031f5fa
commit 0b645d6d67
13 changed files with 387 additions and 13 deletions

2
NIP.md
View file

@ -1843,6 +1843,7 @@ They share a common base schema and are distinguished by kind and a self-tag.
| 35012 | Event Judge | `skatejudge` |
| 35013 | Event Organizer / Host | `skateorganizer` |
| 35014 | Barber / Hairstylist | `skatebarber` |
| 35015 | Clothing Designer | `skateclothing` |
> Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below).
@ -1925,6 +1926,7 @@ for context; they're stored verbatim and rendered by `src/lib/craftFields.ts`
- **Tattoo**: `deposit`, `minimum`
- **Trainer**: `modalities`
- **Barber**: `duration`
- **Clothing**: `minimum`, `turnaround`, `sizing`, `samples`, `customization`
- **Filmer**: `turnaround`, `deliverables`, `drone`
- **Editor**: `turnaround`, `deliverables`
- **Photographer**: `turnaround`, `deliverables`

View file

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

View file

@ -10,7 +10,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' | 'barbering';
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering' | 'clothing';
/** Translate the Lab page's ServiceType into a crew focus scene (or null). */
function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> | null {
@ -24,6 +24,7 @@ function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed
case 'art':
case 'graffiti':
case 'tattoo':
case 'clothing':
return 'art';
case 'music':
return 'music';

View file

@ -2,14 +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,
SKATE_BARBER_KIND, SKATE_DESIGNER_KIND,
parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder,
parseSkateArtist, parseSkateGraffiti, parseSkateTattoo, parseSkateTrainer, parseSkateMusician, parseSkateJudge, parseSkateOrganizer,
parseSkateBarber,
parseSkateBarber, parseSkateDesigner,
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,
type SkateBarber, type SkateDesigner,
} from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
import { useSkateRelays } from '@/hooks/useSkateRelays';
@ -18,12 +18,12 @@ import { verifiedFeaturedCoords } from '@/lib/featuredVerification';
export type LabProfileType =
| 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder'
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer' | 'barber';
| 'artist' | 'graffiti' | 'tattoo' | 'trainer' | 'musician' | 'judge' | 'organizer' | 'barber' | 'designer';
export type LabProfile = (
| SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder
| SkateArtist | SkateGraffiti | SkateTattoo | SkateTrainer | SkateMusician | SkateJudge | SkateOrganizer
| SkateBarber
| SkateBarber | SkateDesigner
) & {
profileType: LabProfileType;
};
@ -31,13 +31,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,
SKATE_BARBER_KIND, SKATE_DESIGNER_KIND,
];
const LAB_SELF_TAGS = [
'skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder',
'skateartist', 'skategraffiti', 'skatetattoo', 'skatetrainer', 'skatemusician', 'skatejudge', 'skateorganizer',
'skatebarber',
'skatebarber', 'skateclothing',
];
/** profileType → addressable kind, for building a coordinate. */
@ -46,7 +46,7 @@ const KIND_BY_TYPE: Record<LabProfileType, number> = {
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,
barber: SKATE_BARBER_KIND, designer: SKATE_DESIGNER_KIND,
};
/** Parse any Lab event into a LabProfile, or null if invalid / unknown kind. */
@ -104,6 +104,10 @@ export function parseLabProfile(event: NostrEvent): LabProfile | null {
const p = parseSkateBarber(event);
return p ? { ...p, profileType: 'barber' } : null;
}
case SKATE_DESIGNER_KIND: {
const p = parseSkateDesigner(event);
return p ? { ...p, profileType: 'designer' } : null;
}
default:
return null;
}

View file

@ -554,6 +554,60 @@ const labOptions = {
event: 'Skate Events & Comps',
},
},
clothing: {
service: {
tees: 'T-Shirts',
hoodies: 'Hoodies & Sweats',
pants: 'Pants & Shorts',
jackets: 'Jackets & Outerwear',
kits: 'Team Kits & Jerseys',
caps: 'Caps & Beanies',
socks: 'Socks',
accessories: 'Bags & Accessories',
footwear: 'Footwear & Shoes',
patches: 'Patches & Appliqué',
griptape: 'Griptape & Deck Apparel',
onepiece: 'One-of-One / Upcycled',
},
serviceDesc: {
tees: 'Custom tees and tops',
hoodies: 'Hoodies, crews and sweatpants',
pants: 'Work pants, cargos and shorts',
jackets: 'Coaches, shells and work jackets',
kits: 'Team kits, jerseys and uniforms',
caps: 'Caps, beanies and headwear',
socks: 'Crew socks and knitwear',
accessories: 'Bags, belts and small goods',
footwear: 'Custom shoes and footwear',
patches: 'Embroidered patches and appliqué',
griptape: 'Griptape art and deck apparel',
onepiece: 'Single pieces and upcycled garments',
},
method: {
screenprint: 'Screen Print',
dtg: 'DTG / Direct-to-Garment',
embroidery: 'Embroidery',
cutsew: 'Cut & Sew',
sublimation: 'Sublimation',
heattransfer: 'Heat Transfer / Vinyl',
garmentdye: 'Garment Dye',
knit: 'Knitwear',
patchwork: 'Patchwork',
allover: 'All-over Print',
},
material: {
cotton: 'Cotton',
heavyweight: 'Heavyweight',
organic: 'Organic / Sustainable',
fleece: 'Fleece',
denim: 'Denim',
canvas: 'Canvas',
nylon: 'Nylon / Technical',
wool: 'Merino / Wool',
leather: 'Leather',
recycled: 'Recycled / Deadstock',
},
},
tour: {
skill: {
beginner: { label: 'Beginner', description: 'New to skating or basic tricks' },
@ -1110,6 +1164,60 @@ const labOptions = {
event: 'Eventos y competiciones de skate',
},
},
clothing: {
service: {
tees: 'Camisetas',
hoodies: 'Sudaderas y jerséis',
pants: 'Pantalones y shorts',
jackets: 'Chaquetas y abrigos',
kits: 'Equipaciones y camisetas',
caps: 'Gorras y gorros',
socks: 'Calcetines',
accessories: 'Bolsos y accesorios',
footwear: 'Calzado y zapatillas',
patches: 'Parches y apliques',
griptape: 'Lija y ropa de tabla',
onepiece: 'Piezas únicas / upcycled',
},
serviceDesc: {
tees: 'Camisetas y tops personalizados',
hoodies: 'Sudaderas, jerséis y pantalones de chándal',
pants: 'Pantalones de trabajo, cargo y shorts',
jackets: 'Chaquetas de entrenador, cortavientos y de trabajo',
kits: 'Equipaciones, camisetas y uniformes de equipo',
caps: 'Gorras, gorros y prendas para la cabeza',
socks: 'Calcetines y punto',
accessories: 'Bolsos, cinturones y pequeños complementos',
footwear: 'Zapatos y calzado personalizado',
patches: 'Parches bordados y apliques',
griptape: 'Arte en lija y ropa de tabla',
onepiece: 'Piezas únicas y prendas upcycled',
},
method: {
screenprint: 'Serigrafía',
dtg: 'DTG / impresión directa',
embroidery: 'Bordado',
cutsew: 'Corte y confección',
sublimation: 'Sublimación',
heattransfer: 'Transfer / vinilo',
garmentdye: 'Tinte de prenda',
knit: 'Punto',
patchwork: 'Patchwork',
allover: 'Estampado integral',
},
material: {
cotton: 'Algodón',
heavyweight: 'Peso pesado',
organic: 'Orgánico / sostenible',
fleece: 'Polar',
denim: 'Denim',
canvas: 'Lona',
nylon: 'Nailon / técnico',
wool: 'Merino / lana',
leather: 'Cuero',
recycled: 'Reciclado / deadstock',
},
},
tour: {
skill: {
beginner: { label: 'Principiante', description: 'Nuevo en el skate o trucos básicos' },

View file

@ -2113,6 +2113,7 @@ labUi: {
offer: 'Offer',
offerBuilding: 'Offer Building',
offerCoaching: 'Offer Coaching',
offerClothing: 'Offer Clothing',
offerCuts: 'Offer Cuts',
offerDesignArt: 'Offer Design / Art',
offerEditing: 'Offer Editing',
@ -2183,6 +2184,7 @@ labUi: {
tabJudges: 'Judges',
tabOrganizers: 'Organizers',
tabBarbers: 'Barbers',
tabClothing: 'Clothing',
filterSkillsToLearn: 'Skills to Learn',
filterContentType: 'Content Type',
filterEquipmentStyle: 'Equipment & Style',
@ -2252,6 +2254,8 @@ labUi: {
organizersBody: 'Put on jams, comps, meetups or demos? List yourself so skaters can find and book you.',
barbersTitle: 'No barbers yet',
barbersBody: 'Cut hair for skaters? List your chair or mobile service so the scene can get a fresh trim.',
clothingTitle: 'No clothing designers yet',
clothingBody: 'Be the first to offer custom clothes, team kits and apparel for the scene.',
},
become: {
addNip05: 'Add NIP-05 in Settings',
@ -2895,6 +2899,9 @@ labUi: {
drone: 'Drone',
leadTime: 'Lead Time',
warranty: 'Warranty',
sizing: 'Size Range',
samples: 'Samples / Prototypes',
customization: 'Customisation',
},
craftForm: {
profileImage: 'Profile image',

View file

@ -2113,6 +2113,7 @@ labUi: {
offer: 'Ofrecer',
offerBuilding: 'Ofrecer construcción',
offerCoaching: 'Ofrecer clases',
offerClothing: 'Ofrecer ropa',
offerCuts: 'Ofrecer cortes',
offerDesignArt: 'Ofrecer diseño / arte',
offerEditing: 'Ofrecer edición',
@ -2183,6 +2184,7 @@ labUi: {
tabJudges: 'Jueces',
tabOrganizers: 'Organizadores',
tabBarbers: 'Barberos',
tabClothing: 'Ropa',
filterSkillsToLearn: 'Trucos que enseñar',
filterContentType: 'Tipo de contenido',
filterEquipmentStyle: 'Equipo y estilo',
@ -2252,6 +2254,8 @@ labUi: {
organizersBody: '¿Organizas quedadas, competiciones, encuentros o demos? Apúntate para que los skaters puedan encontrarte y contratarte.',
barbersTitle: 'Aún no hay barberos',
barbersBody: '¿Cortas el pelo a skaters? Añade tu silla o servicio móvil para que la escena se dé un retoque.',
clothingTitle: 'Aún no hay diseñadores de ropa',
clothingBody: 'Sé el primero en ofrecer ropa personalizada, equipaciones y prendas para la escena.',
},
become: {
addNip05: 'Añade NIP-05 en Ajustes',
@ -2895,6 +2899,9 @@ labUi: {
drone: 'Dron',
leadTime: 'Plazo de entrega',
warranty: 'Garantía',
sizing: 'Tallas',
samples: 'Muestras / prototipos',
customization: 'Personalización',
},
craftForm: {
profileImage: 'Imagen de perfil',

View file

@ -149,6 +149,23 @@ const becomeRolesUi = {
booksOpenHelper: 'Are you currently taking bookings?',
durationPlaceholder: 'e.g. 30–45 min',
},
clothing: {
seoTitle: 'Become a Clothing Designer - SkateSpots',
seoDescription: 'Custom clothes, team kits & apparel for the scene.',
heading: 'Clothing Designer',
tagline: 'Custom clothes, team kits & apparel for the scene',
noun: 'clothing designer',
rateLabel: 'Rate',
portfolioLabel: 'Shop / portfolio link',
sectionWhatYouMake: 'What You Make',
sectionHowYouMake: 'How You Make It',
sectionMaterials: 'Materials',
minimumPlaceholder: 'e.g. 10 pieces',
turnaroundPlaceholder: 'e.g. 2-3 weeks',
sizingPlaceholder: 'e.g. XS-3XL / made to measure',
samplesHelper: 'Do you offer samples or prototypes before a full run?',
customizationPlaceholder: 'e.g. names, numbers, graphics',
},
},
es: {
artist: {
@ -290,6 +307,23 @@ const becomeRolesUi = {
booksOpenHelper: '¿Estás aceptando reservas ahora mismo?',
durationPlaceholder: 'p. ej. 30–45 min',
},
clothing: {
seoTitle: 'Conviértete en diseñador de ropa - SkateSpots',
seoDescription: 'Ropa a medida, equipaciones y prendas para la escena.',
heading: 'Diseñador de ropa',
tagline: 'Ropa a medida, equipaciones y prendas para la escena',
noun: 'diseñador de ropa',
rateLabel: 'Tarifa',
portfolioLabel: 'Enlace de tienda / portafolio',
sectionWhatYouMake: 'Qué haces',
sectionHowYouMake: 'Cómo lo haces',
sectionMaterials: 'Materiales',
minimumPlaceholder: 'p. ej. 10 prendas',
turnaroundPlaceholder: 'p. ej. 2-3 semanas',
sizingPlaceholder: 'p. ej. XS-3XL / a medida',
samplesHelper: '¿Ofreces muestras o prototipos antes de una producción completa?',
customizationPlaceholder: 'p. ej. nombres, números, gráficos',
},
},
};

View file

@ -1,6 +1,6 @@
import { lazy, type ComponentType, type LazyExoticComponent } from 'react';
import {
Palette, SprayCan, Music, Gavel, Brush, Dumbbell, Tent, Sparkles,
Palette, SprayCan, Music, Gavel, Brush, Dumbbell, Tent, Sparkles, Shirt,
} from 'lucide-react';
import { pickRateUnits } from '@/lib/labExtras';
import type { CraftFormConfig } from '@/components/CraftProfileForm';
@ -13,6 +13,7 @@ import {
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,
SKATE_DESIGNER_KIND, DESIGNER_SERVICE_LABELS, DESIGNER_METHOD_LABELS, DESIGNER_MATERIAL_LABELS,
} from '@/lib/types';
import {
ARTIST_SERVICE_KEYS, ARTIST_MEDIUM_KEYS, ARTIST_TOOL_KEYS,
@ -23,6 +24,7 @@ import {
TATTOO_STYLE_KEYS, TATTOO_METHOD_KEYS,
TRAINER_FOCUS_KEYS, TRAINER_INJURY_KEYS, TRAINER_CREDENTIAL_KEYS,
BARBER_SERVICE_KEYS, BARBER_STYLE_KEYS, BARBER_SETTING_KEYS,
DESIGNER_SERVICE_KEYS, DESIGNER_METHOD_KEYS, DESIGNER_MATERIAL_KEYS,
} from '@/lib/labelKeys';
/**
@ -375,6 +377,46 @@ export const BECOME_ROLES: Record<string, BecomeRole> = {
],
},
},
clothing: {
kind: 'craft',
seoTitle: 'Become a Clothing Designer - SkateSpots',
seoDescription: 'Custom clothes, team kits & apparel for the scene.',
seoTitleKey: 'becomeRolesUi.clothing.seoTitle',
seoDescriptionKey: 'becomeRolesUi.clothing.seoDescription',
config: {
kind: SKATE_DESIGNER_KIND,
selfTag: 'skateclothing',
identifier: 'clothing-profile',
heading: 'Clothing Designer',
headingKey: 'becomeRolesUi.clothing.heading',
tagline: 'Custom clothes, team kits & apparel for the scene',
taglineKey: 'becomeRolesUi.clothing.tagline',
noun: 'clothing designer',
nounKey: 'becomeRolesUi.clothing.noun',
accentText: 'text-violet-400',
accentBg: 'bg-violet-600',
accentChip: 'bg-violet-600 text-white border-violet-600',
accentChipHover: 'hover:border-violet-500/60 hover:text-violet-400',
icon: Shirt,
rateUnits: pickRateUnits('piece', 'project', 'hour', 'day'),
extraFields: [
{ tag: 'minimum', label: 'Minimum', labelKey: 'labUi.craftFields.minimum', placeholder: 'e.g. 10 pieces', placeholderKey: 'becomeRolesUi.clothing.minimumPlaceholder' },
{ tag: 'turnaround', label: 'Turnaround', labelKey: 'labUi.craftFields.turnaround', placeholder: 'e.g. 2-3 weeks', placeholderKey: 'becomeRolesUi.clothing.turnaroundPlaceholder' },
{ tag: 'sizing', label: 'Size Range', labelKey: 'labUi.craftFields.sizing', placeholder: 'e.g. XS-3XL / made to measure', placeholderKey: 'becomeRolesUi.clothing.sizingPlaceholder' },
{ tag: 'samples', label: 'Samples / Prototypes', labelKey: 'labUi.craftFields.samples', type: 'toggle', helper: 'Do you offer samples or prototypes before a full run?', helperKey: 'becomeRolesUi.clothing.samplesHelper' },
{ tag: 'customization', label: 'Customisation', labelKey: 'labUi.craftFields.customization', placeholder: 'e.g. names, numbers, graphics', placeholderKey: 'becomeRolesUi.clothing.customizationPlaceholder' },
],
rateLabel: 'Rate',
rateLabelKey: 'becomeRolesUi.clothing.rateLabel',
portfolioLabel: 'Shop / portfolio link',
portfolioLabelKey: 'becomeRolesUi.clothing.portfolioLabel',
skillSections: [
{ title: 'What You Make', titleKey: 'becomeRolesUi.clothing.sectionWhatYouMake', entries: Object.entries(DESIGNER_SERVICE_LABELS), keyMap: DESIGNER_SERVICE_KEYS, required: true },
{ title: 'How You Make It', titleKey: 'becomeRolesUi.clothing.sectionHowYouMake', entries: Object.entries(DESIGNER_METHOD_LABELS), keyMap: DESIGNER_METHOD_KEYS },
{ title: 'Materials', titleKey: 'becomeRolesUi.clothing.sectionMaterials', entries: Object.entries(DESIGNER_MATERIAL_LABELS), keyMap: DESIGNER_MATERIAL_KEYS },
],
},
},
};
export function getBecomeRole(slug: string | undefined): BecomeRole | undefined {

View file

@ -44,6 +44,13 @@ export const CRAFT_DETAIL_FIELDS: Partial<Record<LabProfileType, CraftDetailFiel
barber: [
{ tag: 'duration', labelKey: 'labUi.craftFields.duration' },
],
designer: [
{ tag: 'minimum', labelKey: 'labUi.craftFields.minimum' },
{ tag: 'turnaround', labelKey: 'labUi.craftFields.turnaround' },
{ tag: 'sizing', labelKey: 'labUi.craftFields.sizing' },
{ tag: 'samples', labelKey: 'labUi.craftFields.samples' },
{ tag: 'customization', labelKey: 'labUi.craftFields.customization' },
],
filmer: [
{ tag: 'turnaround', labelKey: 'labUi.craftFields.turnaround' },
{ tag: 'deliverables', labelKey: 'labUi.craftFields.deliverables' },

View file

@ -627,6 +627,47 @@ export const ARTIST_TOOL_KEYS: Record<string, string> = {
clipstudio: 'labOptions.artist.tool.clipstudio',
};
export const DESIGNER_SERVICE_KEYS: Record<string, string> = {
tees: 'labOptions.clothing.service.tees',
hoodies: 'labOptions.clothing.service.hoodies',
pants: 'labOptions.clothing.service.pants',
jackets: 'labOptions.clothing.service.jackets',
kits: 'labOptions.clothing.service.kits',
caps: 'labOptions.clothing.service.caps',
socks: 'labOptions.clothing.service.socks',
accessories: 'labOptions.clothing.service.accessories',
footwear: 'labOptions.clothing.service.footwear',
patches: 'labOptions.clothing.service.patches',
griptape: 'labOptions.clothing.service.griptape',
onepiece: 'labOptions.clothing.service.onepiece',
};
export const DESIGNER_METHOD_KEYS: Record<string, string> = {
screenprint: 'labOptions.clothing.method.screenprint',
dtg: 'labOptions.clothing.method.dtg',
embroidery: 'labOptions.clothing.method.embroidery',
cutsew: 'labOptions.clothing.method.cutsew',
sublimation: 'labOptions.clothing.method.sublimation',
heattransfer: 'labOptions.clothing.method.heattransfer',
garmentdye: 'labOptions.clothing.method.garmentdye',
knit: 'labOptions.clothing.method.knit',
patchwork: 'labOptions.clothing.method.patchwork',
allover: 'labOptions.clothing.method.allover',
};
export const DESIGNER_MATERIAL_KEYS: Record<string, string> = {
cotton: 'labOptions.clothing.material.cotton',
heavyweight: 'labOptions.clothing.material.heavyweight',
organic: 'labOptions.clothing.material.organic',
fleece: 'labOptions.clothing.material.fleece',
denim: 'labOptions.clothing.material.denim',
canvas: 'labOptions.clothing.material.canvas',
nylon: 'labOptions.clothing.material.nylon',
wool: 'labOptions.clothing.material.wool',
leather: 'labOptions.clothing.material.leather',
recycled: 'labOptions.clothing.material.recycled',
};
export const GRAFFITI_STYLE_KEYS: Record<string, string> = {
throwups: 'labOptions.graffiti.style.throwups',
pieces: 'labOptions.graffiti.style.pieces',

View file

@ -70,6 +70,7 @@ export const SKATE_MUSICIAN_KIND = 35011; // Musician / band profiles (skaters w
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 SKATE_DESIGNER_KIND = 35015; // Clothing designers (custom clothes, team kits, apparel)
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
@ -488,6 +489,7 @@ const COLLECTIVE_CAPABLE_KINDS = new Set<number>([
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_DESIGNER_KIND,
]);
export function parseCollectiveJoinRequest(event: NostrEvent): CollectiveJoinRequest | null {
@ -1699,6 +1701,67 @@ export const ARTIST_TOOL_LABELS: Record<string, string> = {
clipstudio: 'Clip Studio',
};
// ----- Clothing designer (custom clothes, team kits, apparel) -----
// What they make
export const DESIGNER_SERVICE_LABELS: Record<string, string> = {
tees: 'T-Shirts',
hoodies: 'Hoodies & Sweats',
pants: 'Pants & Shorts',
jackets: 'Jackets & Outerwear',
kits: 'Team Kits & Jerseys',
caps: 'Caps & Beanies',
socks: 'Socks',
accessories: 'Bags & Accessories',
footwear: 'Footwear & Shoes',
patches: 'Patches & Appliqué',
griptape: 'Griptape & Deck Apparel',
onepiece: 'One-of-One / Upcycled',
};
export const DESIGNER_SERVICE_DESCRIPTIONS: Record<string, string> = {
tees: 'Custom tees and tops',
hoodies: 'Hoodies, crews and sweatpants',
pants: 'Work pants, cargos and shorts',
jackets: 'Coaches, shells and work jackets',
kits: 'Team kits, jerseys and uniforms',
caps: 'Caps, beanies and headwear',
socks: 'Crew socks and knitwear',
accessories: 'Bags, belts and small goods',
footwear: 'Custom shoes and footwear',
patches: 'Embroidered patches and appliqué',
griptape: 'Griptape art and deck apparel',
onepiece: 'Single pieces and upcycled garments',
};
// How they make it
export const DESIGNER_METHOD_LABELS: Record<string, string> = {
screenprint: 'Screen Print',
dtg: 'DTG / Direct-to-Garment',
embroidery: 'Embroidery',
cutsew: 'Cut & Sew',
sublimation: 'Sublimation',
heattransfer: 'Heat Transfer / Vinyl',
garmentdye: 'Garment Dye',
knit: 'Knitwear',
patchwork: 'Patchwork',
allover: 'All-over Print',
};
// Materials
export const DESIGNER_MATERIAL_LABELS: Record<string, string> = {
cotton: 'Cotton',
heavyweight: 'Heavyweight',
organic: 'Organic / Sustainable',
fleece: 'Fleece',
denim: 'Denim',
canvas: 'Canvas',
nylon: 'Nylon / Technical',
wool: 'Merino / Wool',
leather: 'Leather',
recycled: 'Recycled / Deadstock',
};
// ----- Graffiti / Mural artist -----
// Styles
@ -2415,6 +2478,8 @@ export interface SkateArtist {
event: NostrEvent;
}
export type SkateDesigner = SkateArtist;
export interface SkateGraffiti {
id: string;
pubkey: string;
@ -3065,6 +3130,13 @@ export function parseSkateArtist(event: NostrEvent): SkateArtist | null {
return base;
}
export function parseSkateDesigner(event: NostrEvent): SkateDesigner | null {
if (event.kind !== SKATE_DESIGNER_KIND) return null;
const base = parseCraftBase(event, 'skateclothing');
if (!base) return null;
return base;
}
export function parseSkateGraffiti(event: NostrEvent): SkateGraffiti | null {
if (event.kind !== SKATE_GRAFFITI_KIND) return null;
const base = parseCraftBase(event, 'skategraffiti');

View file

@ -52,6 +52,9 @@ import {
BARBER_SERVICE_LABELS,
BARBER_STYLE_LABELS,
BARBER_SETTING_LABELS,
DESIGNER_SERVICE_LABELS,
DESIGNER_METHOD_LABELS,
DESIGNER_MATERIAL_LABELS,
} from '@/lib/types';
import {
COACH_SKILL_KEYS,
@ -90,6 +93,9 @@ import {
BARBER_SERVICE_KEYS,
BARBER_STYLE_KEYS,
BARBER_SETTING_KEYS,
DESIGNER_SERVICE_KEYS,
DESIGNER_METHOD_KEYS,
DESIGNER_MATERIAL_KEYS,
} from '@/lib/labelKeys';
import {
GraduationCap,
@ -120,6 +126,7 @@ import {
Tent,
Sparkles,
Pencil,
Shirt,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import { EmptyState as SharedEmptyState } from '@/components/EmptyState';
@ -204,6 +211,13 @@ const BARBER_ALL_LABELS: Record<string, string> = {
...BARBER_SERVICE_LABELS, ...BARBER_STYLE_LABELS, ...BARBER_SETTING_LABELS,
};
const DESIGNER_SERVICE_ENTRIES = Object.entries(DESIGNER_SERVICE_LABELS);
const DESIGNER_METHOD_ENTRIES = Object.entries(DESIGNER_METHOD_LABELS);
const DESIGNER_MATERIAL_ENTRIES = Object.entries(DESIGNER_MATERIAL_LABELS);
const DESIGNER_ALL_LABELS: Record<string, string> = {
...DESIGNER_SERVICE_LABELS, ...DESIGNER_METHOD_LABELS, ...DESIGNER_MATERIAL_LABELS,
};
// All photographer label maps merged — for resolving a chip label regardless of category
const PHOTO_ALL_LABELS: Record<string, string> = {
...PHOTO_SERVICE_LABELS,
@ -216,7 +230,8 @@ const PHOTO_ALL_LABELS: Record<string, string> = {
type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering';
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music' | 'judging' | 'organizing' | 'barbering'
| 'clothing';
/** Map a ServiceType to the LabProfile.profileType it filters. */
const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> = {
@ -233,6 +248,7 @@ const SERVICE_TO_PROFILE_TYPE: Record<ServiceType, LabProfile['profileType']> =
judging: 'judge',
organizing: 'organizer',
barbering: 'barber',
clothing: 'designer',
};
/** Lab option translation keys per service type (merged across each craft's maps). */
@ -250,6 +266,7 @@ const KEYS_BY_TYPE: Record<ServiceType, Record<string, string>> = {
judging: { ...JUDGE_FORMAT_KEYS, ...JUDGE_SPECIALTY_KEYS, ...JUDGE_LEVEL_KEYS },
organizing: { ...ORGANIZER_EVENT_KEYS, ...ORGANIZER_SERVICE_KEYS, ...ORGANIZER_SCALE_KEYS },
barbering: { ...BARBER_SERVICE_KEYS, ...BARBER_STYLE_KEYS, ...BARBER_SETTING_KEYS },
clothing: { ...DESIGNER_SERVICE_KEYS, ...DESIGNER_METHOD_KEYS, ...DESIGNER_MATERIAL_KEYS },
};
// ─── Per-category accent colours ─────────────────────────────────────────────
@ -398,6 +415,17 @@ const ACCENT = {
stripe: 'bg-cyan-600',
badgeSkill: 'bg-cyan-900/40 border-cyan-700/50 text-cyan-300',
},
clothing: {
active: 'bg-violet-600 hover:bg-violet-700 text-white border-violet-600',
chip: 'bg-violet-600 text-white border-violet-600',
chipHover: 'hover:border-violet-500/60 hover:text-violet-400',
border: 'border-violet-500/40',
glow: 'shadow-violet-500/20',
icon: 'text-violet-400',
badge: 'bg-violet-500/15 text-violet-400 border-violet-500/30',
stripe: 'bg-violet-600',
badgeSkill: 'bg-violet-900/40 border-violet-700/50 text-violet-300',
},
};
// ─── FAB — offer services ─────────────────────────────────────────────────────
@ -433,6 +461,7 @@ function OfferFAB() {
{ to: '/become/judge', label: t('labUi.judgeEvents'), color: 'bg-yellow-500 hover:bg-yellow-600 text-black', icon: Gavel },
{ to: '/become/organizer', label: t('labUi.organizeEvents'), color: 'bg-teal-600 hover:bg-teal-700', icon: Tent },
{ to: '/become/barber', label: t('labUi.offerCuts'), color: 'bg-cyan-600 hover:bg-cyan-700', icon: Sparkles },
{ to: '/become/clothing', label: t('labUi.offerClothing'), color: 'bg-violet-600 hover:bg-violet-700', icon: Shirt },
];
return (
@ -530,6 +559,7 @@ function getSkillLabelForType(skill: string, serviceType: ServiceType, t: (key:
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;
if (serviceType === 'clothing') return DESIGNER_ALL_LABELS[skill] || skill;
return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
}
@ -568,6 +598,7 @@ function LabToolbar({
{ key: 'judging', label: t('labUi.tabJudges'), icon: Gavel },
{ key: 'organizing', label: t('labUi.tabOrganizers'), icon: Tent },
{ key: 'barbering', label: t('labUi.tabBarbers'), icon: Sparkles },
{ key: 'clothing', label: t('labUi.tabClothing'), icon: Shirt },
];
const accent = ACCENT[serviceType];
@ -857,6 +888,12 @@ function FilterDrawer({
{ title: t('labUi.filterSignatureStyles'), entries: BARBER_STYLE_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' },
{ title: t('labUi.filterSetting'), entries: BARBER_SETTING_ENTRIES, activeColor: 'bg-cyan-400 text-white border-cyan-400' },
]
: serviceType === 'clothing'
? [
{ title: t('labUi.filterWhatTheyMake'), entries: DESIGNER_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: t('labUi.filterMethod'), entries: DESIGNER_METHOD_ENTRIES, activeColor: 'bg-violet-400 text-white border-violet-400' },
{ title: t('labUi.filterMaterialsTrade'), entries: DESIGNER_MATERIAL_ENTRIES, activeColor: 'bg-violet-400 text-white border-violet-400' },
]
: [
{ title: t('labUi.filterEditType'), entries: EDITOR_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: t('labUi.filterSoftwareTools'), entries: EDITOR_SKILL_ENTRIES, activeColor: 'bg-purple-400 text-white border-purple-400' },
@ -1067,6 +1104,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
artist: 'art', graffiti: 'graffiti', tattoo: 'tattoo', trainer: 'training', musician: 'music', judge: 'judging',
organizer: 'organizing',
barber: 'barbering',
designer: 'clothing',
};
const serviceType: ServiceType = PROFILE_TO_SERVICE[profile.profileType];
const accent = ACCENT[serviceType];
@ -1076,6 +1114,7 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
art: Palette, graffiti: SprayCan, tattoo: Brush, training: Dumbbell, music: Music, judging: Gavel,
organizing: Tent,
barbering: Sparkles,
clothing: Shirt,
};
const TypeIcon = TYPE_ICONS[serviceType];
@ -1316,6 +1355,14 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
to: '/become/barber',
color: 'bg-cyan-600 hover:bg-cyan-700',
},
clothing: {
icon: Shirt,
title: t('labUi.empty.clothingTitle'),
body: t('labUi.empty.clothingBody'),
cta: t('labUi.offerClothing'),
to: '/become/clothing',
color: 'bg-violet-600 hover:bg-violet-700',
},
};
const c = configs[serviceType];
@ -1370,7 +1417,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', 'barbering'];
const valid: ServiceType[] = ['coaching', 'filming', 'editing', 'photography', 'building', 'art', 'graffiti', 'tattoo', 'training', 'music', 'judging', 'organizing', 'barbering', 'clothing'];
return c && (valid as string[]).includes(c) ? (c as ServiceType) : 'coaching';
})();
@ -1463,7 +1510,7 @@ export default function LabPage() {
const counts = useMemo<Record<ServiceType, number>>(() => {
const empty: Record<ServiceType, number> = {
coaching: 0, filming: 0, editing: 0, photography: 0, building: 0,
art: 0, graffiti: 0, tattoo: 0, training: 0, music: 0, judging: 0, organizing: 0, barbering: 0,
art: 0, graffiti: 0, tattoo: 0, training: 0, music: 0, judging: 0, organizing: 0, barbering: 0, clothing: 0,
};
if (!profiles) return empty;
const byType = (t: ServiceType) =>
@ -1482,6 +1529,7 @@ export default function LabPage() {
judging: byType('judging'),
organizing: byType('organizing'),
barbering: byType('barbering'),
clothing: byType('clothing'),
};
}, [profiles]);