Add Builder category to The Lab (kind 35005), collective-aware + spot-linked

Step 2 of the collective plan:
- types.ts: SKATE_BUILDER_KIND 35005, BUILDER_SERVICE/SKILL/SCALE label maps,
  SkateBuilder interface + parseSkateBuilder (entity/members/noffer/spotRefs +
  service_area & insured fields)
- useLabProfiles/useLabProfile: query + parse builders (profileType 'builder')
- BecomeBuilderPage (/become-builder): amber accent, build-type/materials/scale
  chip selectors, "how we work" gear, service area, licensed/insured switch,
  entity toggle (defaults to collective), noffer, and "spots you've built" picker
- LabPage: 'building' ServiceType — amber accent, Builders tab (5 tabs now,
  horizontally scrollable on mobile), OfferFAB item, filter sections, card
  type-icon/labels, empty state, counts & filtering
- CoachDetailPage: builder accent/icon/kind, "What We Build" + "How We Work"
  sections, service-area & insured cards, portfolio/social handling
- NIP.md: document Builder kind 35005 with tag taxonomy + collective example

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-07-24 13:33:52 +07:00
parent 9cb8d1181d
commit 657c36d264
7 changed files with 857 additions and 23 deletions

61
NIP.md
View file

@ -1416,6 +1416,7 @@ They share a common base schema and are distinguished by kind and a self-tag.
| 35002 | Filmer (video) | `skatefilmer` |
| 35003 | Video Editor | `skateeditor` |
| 35004 | Photographer | `skatephotographer` |
| 35005 | Builder (ramp/park/DIY) | `skatebuilder` |
### Common Base Schema
@ -1561,12 +1562,68 @@ Photographer-specific optional tags:
}
```
### Builder (kind 35005)
Skate ramp / park / DIY builders. Most builders are crews or companies, so this
category defaults to `entity: collective`. Build types, materials and scale are
stored as queryable `t` tags, plus builder-specific optional tags.
Categories encoded as `t` tags:
- **What they build**: `diy`, `concrete`, `wood`, `bowls`, `transitions`, `rails`, `boxes`, `minis`, `pumptrack`, `foam`, `repairs`, `design`
- **Materials / trade**: `concretework`, `welding`, `carpentry`, `formwork`, `masonry`, `coping`, `surfacing`, `earthworks`
- **Project scale**: `backyard`, `diycommunity`, `publicpark`, `indoor`, `event`
Builder-specific optional tags:
| Tag | Description |
|-----|-------------|
| `gear` | Freeform "how we work / equipment" |
| `service_area` | How far they travel (freeform) |
| `insured` | `"true"` if licensed/insured (relevant for public park work) |
| `portfolio` | Link to past builds (Instagram, site) |
| `social` | Additional social handle or link |
The `a` (spot reference) tags are especially meaningful for builders — they link
to the actual spots/parks (kind 30818) the builder has made, surfacing their work
on the map.
#### Example: Builder (collective)
```json
{
"kind": 35005,
"content": "Concrete crew out of Malmö. 12 years pouring parks and DIY across the EU.",
"tags": [
["d", "builder-profile"],
["title", "Betong Bros"],
["t", "skatebuilder"],
["t", "concrete"], ["t", "bowls"], ["t", "transitions"], ["t", "repairs"],
["t", "concretework"], ["t", "formwork"], ["t", "coping"],
["t", "publicpark"], ["t", "diycommunity"],
["location", "Malmö, SE"],
["g", "u3bu5"],
["experience", "12"],
["rate", "per m² / by project"],
["service_area", "Scandinavia + will travel EU"],
["insured", "true"],
["gear", "Own mixer + shotcrete rig, mobile welding, handle drainage & permits"],
["portfolio", "https://instagram.com/betongbros"],
["entity", "collective"],
["p", "<member1-pubkey>"],
["p", "<member2-pubkey>"],
["a", "30818:<pubkey>:<d-tag>"],
["alt", "Skate ramp/park builder: Betong Bros"]
]
}
```
### Querying Lab Profiles
```json
{
"kinds": [35001, 35002, 35003, 35004],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer"],
"kinds": [35001, 35002, 35003, 35004, 35005],
"#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder"],
"limit": 100
}
```

View file

@ -30,6 +30,7 @@ const BecomeCoachPage = lazy(() => import("./pages/BecomeCoachPage"));
const BecomeFilmerPage = lazy(() => import("./pages/BecomeFilmerPage"));
const BecomeEditorPage = lazy(() => import("./pages/BecomeEditorPage"));
const BecomePhotographerPage = lazy(() => import("./pages/BecomePhotographerPage"));
const BecomeBuilderPage = lazy(() => import("./pages/BecomeBuilderPage"));
const CoachDetailPage = lazy(() => import("./pages/CoachDetailPage"));
const TourDetailPage = lazy(() => import("./pages/TourDetailPage"));
const AboutPage = lazy(() => import("./pages/AboutPage"));
@ -82,6 +83,7 @@ export function AppRouter() {
<Route path="/become-filmer" element={<BecomeFilmerPage />} />
<Route path="/become-editor" element={<BecomeEditorPage />} />
<Route path="/become-photographer" element={<BecomePhotographerPage />} />
<Route path="/become-builder" element={<BecomeBuilderPage />} />
<Route path="/coach/:pubkey/:identifier" element={<CoachDetailPage />} />
<Route path="/onboarding" element={<OnboardingPage />} />
<Route path="/pyramid" element={<PyramidPage />} />

View file

@ -1,9 +1,9 @@
import { useQuery } from '@tanstack/react-query';
import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer } from '@/lib/types';
import { SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND, parseSkateCoach, parseSkateFilmer, parseSkateEditor, parseSkatePhotographer, parseSkateBuilder, type SkateCoach, type SkateFilmer, type SkateEditor, type SkatePhotographer, type SkateBuilder } from '@/lib/types';
import { useSkateRelays } from '@/hooks/useSkateRelays';
export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer) & {
profileType: 'coach' | 'filmer' | 'editor' | 'photographer';
export type LabProfile = (SkateCoach | SkateFilmer | SkateEditor | SkatePhotographer | SkateBuilder) & {
profileType: 'coach' | 'filmer' | 'editor' | 'photographer' | 'builder';
};
export function useLabProfiles(limit = 100) {
@ -19,8 +19,8 @@ export function useLabProfiles(limit = 100) {
const events = await querySkateContent(
[
{
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND],
'#t': ['skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer'],
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND],
'#t': ['skatecoach', 'skatefilmer', 'skateeditor', 'skatephotographer', 'skatebuilder'],
limit: limit * 2,
},
],
@ -51,6 +51,11 @@ export function useLabProfiles(limit = 100) {
if (photographer) {
profiles.push({ ...photographer, profileType: 'photographer' });
}
} else if (event.kind === SKATE_BUILDER_KIND) {
const builder = parseSkateBuilder(event);
if (builder) {
profiles.push({ ...builder, profileType: 'builder' });
}
}
}
@ -77,7 +82,7 @@ export function useLabProfile(identifier: string, pubkey?: string) {
authors?: string[];
limit: number;
} = {
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND],
kinds: [SKATE_COACH_KIND, SKATE_FILMER_KIND, SKATE_EDITOR_KIND, SKATE_PHOTOGRAPHER_KIND, SKATE_BUILDER_KIND],
'#d': [identifier],
limit: 1,
};
@ -109,6 +114,9 @@ export function useLabProfile(identifier: string, pubkey?: string) {
} else if (event.kind === SKATE_PHOTOGRAPHER_KIND) {
const photographer = parseSkatePhotographer(event);
return photographer ? { ...photographer, profileType: 'photographer' as const } : null;
} else if (event.kind === SKATE_BUILDER_KIND) {
const builder = parseSkateBuilder(event);
return builder ? { ...builder, profileType: 'builder' as const } : null;
}
return null;

View file

@ -49,6 +49,7 @@ export const SKATE_COACH_KIND = 35001;
export const SKATE_FILMER_KIND = 35002; // Skate filmer (video) profiles
export const SKATE_EDITOR_KIND = 35003; // Skate video editor profiles
export const SKATE_PHOTOGRAPHER_KIND = 35004; // Skate photographer profiles
export const SKATE_BUILDER_KIND = 35005; // Skate ramp / park builder profiles
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
@ -694,6 +695,88 @@ export const PHOTO_STYLE_LABELS: Record<string, string> = {
street: 'Street / Documentary',
};
// ----- Builder (ramp / park / DIY builders) -----
// What kind of things they build
export const BUILDER_SERVICE_LABELS: Record<string, string> = {
diy: 'DIY Spots',
concrete: 'Concrete Parks',
wood: 'Wooden Ramps',
bowls: 'Bowls & Pools',
transitions: 'Transitions & Quarters',
rails: 'Rails & Ledges',
boxes: 'Manny Pads & Boxes',
minis: 'Mini Ramps',
pumptrack: 'Pump Tracks',
foam: 'Foam Pits / Indoor',
repairs: 'Repairs & Resurfacing',
design: 'Design & Consulting',
};
export const BUILDER_SERVICE_DESCRIPTIONS: Record<string, string> = {
diy: 'Community DIY spots and guerrilla builds',
concrete: 'Poured-concrete skateparks and features',
wood: 'Wooden ramps, launch boxes and obstacles',
bowls: 'Bowls, pools and deep transition',
transitions: 'Quarterpipes, banks and transition',
rails: 'Steel rails, ledges and grind features',
boxes: 'Manny pads, funboxes and street features',
minis: 'Mini ramps and backyard setups',
pumptrack: 'Pump tracks and flow lines',
foam: 'Foam pits, resi and indoor training features',
repairs: 'Fixing, patching and resurfacing existing spots',
design: 'Plans, drawings and build consulting',
};
// Materials / trade skills
export const BUILDER_SKILL_LABELS: Record<string, string> = {
concretework: 'Concrete / Shotcrete',
welding: 'Welding / Steel Fab',
carpentry: 'Carpentry / Woodwork',
formwork: 'Formwork & Rebar',
masonry: 'Masonry / Blockwork',
coping: 'Coping (pool / steel)',
surfacing: 'Skatelite / Surfacing',
earthworks: 'Earthworks / Excavation',
};
// Scale of projects taken on
export const BUILDER_SCALE_LABELS: Record<string, string> = {
backyard: 'Backyard / Mini',
diycommunity: 'Community DIY',
publicpark: 'Full Public Park',
indoor: 'Indoor Facility',
event: 'Event / Contest Course',
};
export interface SkateBuilder {
id: string;
pubkey: string;
identifier: string;
title: string;
bio: string;
geohash?: string;
location?: string;
image?: string;
skills: string[]; // Combined build types + materials + scale tags
experience?: string; // Years building
rate?: string; // Freeform rate / quote basis
phone?: string;
email?: string;
website?: string;
portfolio?: string; // Link to past builds (IG, site)
social?: string;
gear?: string; // Freeform "what we bring / how we work"
serviceArea?: string; // How far they travel (e.g. "Catalonia + willing to travel EU")
insured?: boolean; // Licensed / insured (relevant for public park companies)
noffer?: string;
entityType: EntityType;
members: string[];
spotRefs?: string[]; // Spots they've built (kind 30818 refs)
createdAt: number;
event: NostrEvent;
}
export interface SkateCoach {
id: string;
pubkey: string;
@ -1078,6 +1161,66 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
};
}
export function parseSkateBuilder(event: NostrEvent): SkateBuilder | null {
if (event.kind !== SKATE_BUILDER_KIND) return null;
const d = event.tags.find(([name]) => name === 'd')?.[1];
const title = event.tags.find(([name]) => name === 'title')?.[1];
if (!d || !title) return null;
if (title.trim().length < MIN_TITLE_LEN || title.length > MAX_TITLE_LEN) return null;
const location = clampStr(event.tags.find(([name]) => name === 'location')?.[1], MAX_LOCATION_LEN);
const geohash = event.tags.find(([name]) => name === 'g')?.[1];
const image = event.tags.find(([name]) => name === 'image')?.[1];
const skills = event.tags.filter(([name, value]) => name === 't' && value !== 'skatebuilder').map(([, value]) => value);
const experience = event.tags.find(([name]) => name === 'experience')?.[1];
const rate = event.tags.find(([name]) => name === 'rate')?.[1];
const phone = event.tags.find(([name]) => name === 'phone')?.[1];
const email = event.tags.find(([name]) => name === 'email')?.[1];
const website = event.tags.find(([name]) => name === 'website')?.[1];
const portfolio = event.tags.find(([name]) => name === 'portfolio')?.[1];
const social = event.tags.find(([name]) => name === 'social')?.[1];
const gear = event.tags.find(([name]) => name === 'gear')?.[1];
const serviceArea = event.tags.find(([name]) => name === 'service_area')?.[1];
const insured = event.tags.some(([name, value]) => name === 'insured' && value === 'true');
const nofferTag = event.tags.find(([name]) => name === 'noffer')?.[1];
const noffer = isValidNoffer(nofferTag) ? nofferTag : undefined;
const entityType = parseEntityType(event.tags.find(([name]) => name === 'entity')?.[1]);
const members = parseMemberPubkeys(event);
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
.map(([, value]) => value);
return {
id: event.id,
pubkey: event.pubkey,
identifier: d,
title: title.slice(0, MAX_TITLE_LEN),
bio: event.content.slice(0, MAX_DESCRIPTION_LEN),
location,
geohash,
image,
skills,
experience,
rate,
phone,
email,
website,
portfolio,
social,
gear,
serviceArea,
insured,
noffer,
entityType,
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
createdAt: event.created_at,
event,
};
}
export function parseSkateTour(event: NostrEvent): SkateTour | null {
if (event.kind !== SKATE_TOUR_KIND) return null;

View file

@ -0,0 +1,549 @@
import { useState } from 'react';
import { useSeoMeta } from '@unhead/react';
import { useNavigate, Link } from 'react-router-dom';
import ngeohash from 'ngeohash';
import { nip19 } from 'nostr-tools';
import { Layout } from '@/components/Layout';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Textarea } from '@/components/ui/textarea';
import { Label } from '@/components/ui/label';
import { Switch } from '@/components/ui/switch';
import { useNostrPublish } from '@/hooks/useNostrPublish';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useAuthor } from '@/hooks/useAuthor';
import { useUploadFile } from '@/hooks/useUploadFile';
import { useToast } from '@/hooks/useToast';
import { useWallet } from '@/hooks/useWallet';
import { WalletModal } from '@/components/WalletModal';
import { LoginArea } from '@/components/auth/LoginArea';
import { SpotPicker } from '@/components/SpotPicker';
import { NofferField } from '@/components/NofferField';
import { EntityTypeField } from '@/components/EntityTypeField';
import {
SKATE_BUILDER_KIND,
SKATE_SPOT_KIND,
BUILDER_SERVICE_LABELS,
BUILDER_SERVICE_DESCRIPTIONS,
BUILDER_SKILL_LABELS,
BUILDER_SCALE_LABELS,
isValidNoffer,
type EntityType,
} from '@/lib/types';
import { cn } from '@/lib/utils';
import { HardHat, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap, Hammer } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
import { usePlatformFee } from '@/hooks/usePlatformFee';
const SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS);
const SKILL_ENTRIES = Object.entries(BUILDER_SKILL_LABELS);
const SCALE_ENTRIES = Object.entries(BUILDER_SCALE_LABELS);
function ChipGrid({
entries,
selected,
onToggle,
}: {
entries: [string, string][];
selected: string[];
onToggle: (key: string) => void;
}) {
return (
<div className="flex flex-wrap gap-2">
{entries.map(([key, label]) => (
<button
key={key}
type="button"
onClick={() => onToggle(key)}
className={cn(
'px-3 py-2 rounded-xl text-xs font-bold border transition-all active:scale-95',
selected.includes(key)
? 'bg-amber-500 text-black border-amber-500'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700 text-gray-300'
)}
>
{label}
</button>
))}
</div>
);
}
export default function BecomeBuilderPage() {
useSeoMeta({
title: 'Become a Builder - SkateSpots',
description: 'List your ramp, park and DIY building services — what you build, materials and scale.',
});
const navigate = useNavigate();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending: isPublishing } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { webln, activeNWC } = useWallet();
const hasWallet = !!webln || !!activeNWC;
const { isActive: feePaid, isPlatformOwner } = usePlatformFee('labProfile');
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selectedServices, setSelectedServices] = useState<string[]>([]);
const [selectedSkills, setSelectedSkills] = useState<string[]>([]);
const [selectedScale, setSelectedScale] = useState<string[]>([]);
const [gear, setGear] = useState('');
const [serviceArea, setServiceArea] = useState('');
const [insured, setInsured] = useState(false);
const [image, setImage] = useState<string | null>(null);
const [experience, setExperience] = useState('');
const [rate, setRate] = useState('');
const [phone, setPhone] = useState('');
const [email, setEmail] = useState('');
const [website, setWebsite] = useState('');
const [portfolio, setPortfolio] = useState('');
const [social, setSocial] = useState('');
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('collective');
const [members, setMembers] = useState<string[]>([]);
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const getDisplayName = () => {
if (author.data?.metadata?.name) return author.data.metadata.name;
if (user?.pubkey) {
const npub = nip19.npubEncode(user.pubkey);
return `${npub.slice(0, 12)}...${npub.slice(-4)}`;
}
return 'Anonymous Builder';
};
const toggle = (setter: React.Dispatch<React.SetStateAction<string[]>>) => (key: string) => {
setter((prev) => (prev.includes(key) ? prev.filter((s) => s !== key) : [...prev, key]));
};
const getCurrentLocation = () => {
if (!navigator.geolocation) {
toast({ title: 'Geolocation not supported', variant: 'destructive' });
return;
}
setIsGettingLocation(true);
navigator.geolocation.getCurrentPosition(
(position) => {
setLatitude(position.coords.latitude.toFixed(6));
setLongitude(position.coords.longitude.toFixed(6));
setIsGettingLocation(false);
toast({ title: 'Location found' });
},
(error) => {
setIsGettingLocation(false);
toast({ title: 'Location error', description: error.message, variant: 'destructive' });
}
);
};
const handleImageUpload = async (e: React.ChangeEvent<HTMLInputElement>) => {
const files = e.target.files;
if (!files || files.length === 0) return;
try {
const file = files[0];
const tags = await uploadFile(file);
const url = tags[0]?.[1];
if (url) {
setImage(url);
toast({ title: 'Image uploaded' });
}
} catch (error) {
toast({ title: 'Upload failed', description: error instanceof Error ? error.message : 'Failed', variant: 'destructive' });
}
e.target.value = '';
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (selectedServices.length === 0) {
toast({ title: 'Build types required', description: 'Select at least one thing you build.', variant: 'destructive' });
return;
}
try {
const identifier = 'builder-profile';
const displayName = getDisplayName();
const tags: string[][] = [
['d', identifier],
['title', displayName],
['t', 'skatebuilder'],
['alt', `Skate ramp/park builder: ${displayName}`],
];
if (location.trim()) tags.push(['location', location.trim()]);
if (latitude && longitude) {
const lat = parseFloat(latitude);
const lng = parseFloat(longitude);
if (!isNaN(lat) && !isNaN(lng)) {
const geohash = ngeohash.encode(lat, lng, 7);
tags.push(['g', geohash]);
}
}
for (const s of [...selectedServices, ...selectedSkills, ...selectedScale]) {
tags.push(['t', s]);
}
if (gear.trim()) tags.push(['gear', gear.trim()]);
if (serviceArea.trim()) tags.push(['service_area', serviceArea.trim()]);
if (insured) tags.push(['insured', 'true']);
if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.trim()]);
if (rate.trim()) tags.push(['rate', rate.trim()]);
if (phone.trim()) tags.push(['phone', phone.trim()]);
if (email.trim()) tags.push(['email', email.trim()]);
if (website.trim()) tags.push(['website', website.trim()]);
if (portfolio.trim()) tags.push(['portfolio', portfolio.trim()]);
if (social.trim()) tags.push(['social', social.trim()]);
if (isValidNoffer(noffer.trim())) tags.push(['noffer', noffer.trim().toLowerCase()]);
tags.push(['entity', entityType]);
if (entityType === 'collective') {
for (const pk of members) tags.push(['p', pk]);
}
for (const spotId of selectedSpots) {
const [spotPubkey, spotIdentifier] = spotId.split(':');
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
await createEvent({
kind: SKATE_BUILDER_KIND,
content: bio.trim(),
tags,
});
toast({ title: 'Builder profile created!', description: 'Your profile has been published.' });
navigate(`/coach/${user?.pubkey}/${identifier}`);
} catch (error) {
toast({ title: 'Failed to publish', description: error instanceof Error ? error.message : 'Error', variant: 'destructive' });
}
};
if (!user) {
return (
<Layout>
<div className="container px-4 py-8">
<Card className="max-w-xl mx-auto bg-gray-900 border-gray-700">
<CardContent className="py-12 text-center">
<HardHat className="h-12 w-12 mx-auto mb-4 text-gray-600" />
<h2 className="text-xl font-bold mb-2">Login Required</h2>
<p className="text-gray-400 mb-6">You need to log in to create a builder profile.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const hasNip05 = user?.metadata?.nip05;
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
<div className="mb-6">
<Link to="/lab" className="inline-flex items-center text-gray-400 hover:text-white transition-colors mb-4">
<ArrowLeft className="h-4 w-4 mr-2" />
Back to The Lab
</Link>
<h1 className="text-3xl font-black uppercase text-amber-400" style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
Become a Builder
</h1>
<p className="text-gray-400 mt-1">Pour it, weld it, build it — list your ramp & park building</p>
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<div className="text-yellow-500">⚠️</div>
<div>
<h3 className="font-bold text-yellow-400 mb-1">NIP-05 Verification Required</h3>
<p className="text-sm text-gray-300 mb-2">
Add a NIP-05 identifier to your profile to verify your identity and build trust with clients.
</p>
<Link to="/settings">
<Button variant="outline" size="sm" className="border-yellow-500/50 hover:bg-yellow-500/10 text-yellow-400 font-bold">
Add NIP-05 in Settings
</Button>
</Link>
</div>
</div>
</CardContent>
</Card>
)}
{/* Wallet Requirement */}
<Card className="mt-4 bg-amber-950/30 border-2 border-amber-500/50">
<CardContent className="p-4">
<div className="flex items-start gap-3">
<Wallet className="h-6 w-6 text-amber-400 flex-shrink-0 mt-0.5" />
<div className="flex-1">
<h3 className="font-bold text-amber-400 mb-2">⚡ Payment Setup Required</h3>
<p className="text-sm text-gray-300 mb-3">
To get paid for builds, connect a Lightning wallet. Clients zap you directly for your work.
</p>
{hasWallet ? (
<div className="p-3 rounded-lg bg-green-950/30 border border-green-500/50">
<div className="flex items-center gap-2 mb-1">
<div className="h-2 w-2 rounded-full bg-green-500" />
<p className="text-sm font-bold text-green-400">Wallet Connected — Ready to Receive Payments!</p>
</div>
<p className="text-xs text-gray-400">{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}</p>
</div>
) : (
<div className="space-y-3">
<div className="p-3 rounded-lg bg-red-950/30 border border-red-500/50">
<div className="flex items-center gap-2">
<div className="h-2 w-2 rounded-full bg-red-500" />
<p className="text-sm font-bold text-red-400">No Wallet Connected</p>
</div>
</div>
<div className="flex flex-col sm:flex-row gap-2">
<WalletModal>
<Button className="gap-2 bg-amber-500 hover:bg-amber-600 text-black flex-1">
<Wallet className="h-4 w-4" />
Connect Wallet with NWC
</Button>
</WalletModal>
<a href="https://planb.academy/en/tutorials/wallet" target="_blank" rel="noopener noreferrer" className="flex-1">
<Button variant="outline" className="gap-2 w-full border-amber-500/50 text-amber-400 hover:bg-amber-500/10">
<ExternalLink className="h-4 w-4" />
Learn How
</Button>
</a>
</div>
<p className="text-xs text-gray-400 italic">
💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos).
</p>
</div>
)}
</div>
</div>
</CardContent>
</Card>
</div>
<form onSubmit={handleSubmit}>
<Card className="bg-gray-900 border-gray-700">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<HardHat className="h-5 w-5 text-amber-400" /> Builder Profile
</CardTitle>
<CardDescription className="text-gray-400">Tell the scene what you build and how you work</CardDescription>
</CardHeader>
<CardContent className="space-y-6">
<div className="space-y-2">
<Label>Your Builder Identity</Label>
<div className="p-4 rounded-lg border-2 border-amber-500/30 bg-amber-500/5">
<p className="font-black text-lg">{getDisplayName()}</p>
<p className="text-sm text-gray-400 mt-1">From your Nostr profile</p>
</div>
</div>
<div className="space-y-2">
<Label htmlFor="bio">Bio / About Your Builds</Label>
<Textarea id="bio" placeholder="Your crew, notable builds, how you approach a project..." value={bio} onChange={(e) => setBio(e.target.value)} rows={5} className="bg-gray-800 border-gray-700" />
</div>
{/* Individual vs Collective — defaults to collective for builders */}
<EntityTypeField
entityType={entityType}
onEntityTypeChange={setEntityType}
members={members}
onMembersChange={setMembers}
accentBg="bg-amber-500 hover:bg-amber-600 text-black"
accentText="text-amber-400"
/>
{/* Build types */}
<div className="space-y-3">
<Label>What You Build * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
{SERVICE_ENTRIES.map(([service, label]) => (
<button
key={service}
type="button"
onClick={() => toggle(setSelectedServices)(service)}
className={cn(
'p-3 rounded-lg text-left transition-all border text-sm',
selectedServices.includes(service)
? 'bg-amber-500 text-black border-amber-500'
: 'bg-gray-800 hover:bg-gray-700 border-gray-700'
)}
>
<div className="font-bold">{label}</div>
<div className={cn('text-xs mt-1 line-clamp-1', selectedServices.includes(service) ? 'text-black/70' : 'text-gray-400')}>
{BUILDER_SERVICE_DESCRIPTIONS[service]}
</div>
</button>
))}
</div>
</div>
{/* Materials / trade skills */}
<div className="space-y-3">
<Label className="flex items-center gap-2"><Hammer className="h-4 w-4 text-amber-400" /> Materials & Trade Skills</Label>
<ChipGrid entries={SKILL_ENTRIES} selected={selectedSkills} onToggle={toggle(setSelectedSkills)} />
</div>
{/* Scale */}
<div className="space-y-3">
<Label className="flex items-center gap-2">📐 Project Scale</Label>
<ChipGrid entries={SCALE_ENTRIES} selected={selectedScale} onToggle={toggle(setSelectedScale)} />
</div>
{/* Freeform "how we work" */}
<div className="space-y-2">
<Label htmlFor="gear">How You Work / Equipment (optional)</Label>
<Textarea
id="gear"
placeholder="e.g. Full concrete crew, own mixer + shotcrete rig, mobile welding, we handle permits and drainage..."
value={gear}
onChange={(e) => setGear(e.target.value)}
rows={3}
className="bg-gray-800 border-gray-700"
/>
<p className="text-xs text-gray-500">Crews and clients love knowing your kit and process.</p>
</div>
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="experience">Years Building</Label>
<Input id="experience" type="number" placeholder="10" value={experience} onChange={(e) => setExperience(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="rate">Rate / Quote Basis</Label>
<Input id="rate" placeholder="per m² / by project" value={rate} onChange={(e) => setRate(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<div className="space-y-2">
<Label htmlFor="serviceArea">Service Area</Label>
<Input id="serviceArea" placeholder="e.g. Catalonia, will travel across EU" value={serviceArea} onChange={(e) => setServiceArea(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="flex items-center justify-between rounded-lg border border-gray-700 bg-gray-800/60 p-3">
<div>
<Label htmlFor="insured" className="font-bold">Licensed / Insured</Label>
<p className="text-xs text-gray-500">Relevant for public park & council work</p>
</div>
<Switch id="insured" checked={insured} onCheckedChange={setInsured} />
</div>
<div className="space-y-2">
<Label htmlFor="location">City / Base</Label>
<Input id="location" placeholder="Malmö, SE" value={location} onChange={(e) => setLocation(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
{/* Contact */}
<div className="space-y-3">
<Label className="text-base font-bold">Contact & Portfolio</Label>
<p className="text-sm text-gray-400">How the scene can see your builds and reach you (all optional, but recommended)</p>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="portfolio">Past Builds / Instagram</Label>
<Input id="portfolio" type="url" placeholder="https://instagram.com/crew" value={portfolio} onChange={(e) => setPortfolio(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="website">Website</Label>
<Input id="website" type="url" placeholder="https://ourbuilds.com" value={website} onChange={(e) => setWebsite(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="phone">Phone Number</Label>
<Input id="phone" type="tel" placeholder="+1 234 567 8900" value={phone} onChange={(e) => setPhone(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2">
<Label htmlFor="email">Email</Label>
<Input id="email" type="email" placeholder="crew@example.com" value={email} onChange={(e) => setEmail(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<div className="space-y-2 sm:col-span-2">
<Label htmlFor="social">Other Social</Label>
<Input id="social" placeholder="@username or link" value={social} onChange={(e) => setSocial(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
</div>
<NofferField value={noffer} onChange={setNoffer} />
<div className="p-3 rounded-lg border border-amber-500/30 bg-amber-500/5">
<p className="text-sm text-gray-300">💬 Clients can message you directly through SkateSpots' encrypted messaging</p>
</div>
</div>
{/* Spot Picker — spots they've built */}
<SpotPicker
selectedSpots={selectedSpots}
onSpotsChange={setSelectedSpots}
maxSpots={8}
label="Spots You've Built"
description="Link spots and parks you built so people can see your work on the map"
/>
<div className="space-y-2">
<Label>Coordinates (optional)</Label>
<div className="grid grid-cols-2 gap-4">
<Input placeholder="Latitude" value={latitude} onChange={(e) => setLatitude(e.target.value)} className="bg-gray-800 border-gray-700" />
<Input placeholder="Longitude" value={longitude} onChange={(e) => setLongitude(e.target.value)} className="bg-gray-800 border-gray-700" />
</div>
<Button type="button" variant="outline" size="sm" onClick={getCurrentLocation} disabled={isGettingLocation} className="mt-2 border-gray-700">
{isGettingLocation ? <><Loader2 className="h-4 w-4 mr-2 animate-spin" />Getting...</> : <><MapPin className="h-4 w-4 mr-2" />Use current location</>}
</Button>
</div>
<div className="space-y-2">
<Label>Logo / Photo</Label>
{image ? (
<div className="relative w-32 h-32 rounded-full overflow-hidden bg-gray-800">
<img src={image} alt="Profile" className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-2 right-2 p-1 rounded-full bg-black/80 hover:bg-red-500">
<X className="h-4 w-4" />
</button>
</div>
) : (
<label className="w-32 h-32 rounded-full border-2 border-dashed border-gray-700 flex flex-col items-center justify-center cursor-pointer hover:bg-gray-800/50">
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
{isUploading ? <Loader2 className="h-6 w-6 animate-spin" /> : <><Upload className="h-6 w-6 mb-1 text-gray-500" /><span className="text-xs text-gray-500">Upload</span></>}
</label>
)}
</div>
{/* Platform fee gate — only when a rate is set */}
{rate.trim() && !feePaid && !isPlatformOwner && (
<PlatformFeePayment
type="labProfile"
onSuccess={() => { /* hook auto-updates */ }}
context={`You set a rate of "${rate}" — a 5,000 sat listing fee is required to publish monetized profiles.`}
/>
)}
<Button
type="submit"
className="w-full bg-amber-500 hover:bg-amber-600 text-black uppercase font-black"
disabled={isPublishing || isUploading || !hasWallet || (!!rate.trim() && !feePaid && !isPlatformOwner)}
>
{isPublishing ? (
<><Loader2 className="h-4 w-4 mr-2 animate-spin" />Publishing...</>
) : !hasWallet ? (
<><Wallet className="h-4 w-4 mr-2" />CONNECT WALLET TO CONTINUE</>
) : rate.trim() && !feePaid && !isPlatformOwner ? (
<><Zap className="h-4 w-4 mr-2" />PAY LISTING FEE ABOVE TO CONTINUE</>
) : (
<><HardHat className="h-4 w-4 mr-2" />CREATE BUILDER PROFILE</>
)}
</Button>
</CardContent>
</Card>
</form>
</div>
</Layout>
);
}

View file

@ -14,7 +14,7 @@ import { TrustBadge } from '@/components/TrustBadge';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
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 } 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 } from '@/lib/types';
import { sanitizeUrl, sanitizeTel, sanitizeEmail, urlDomain } from '@/lib/sanitizeUrl';
import { OfficialBadge } from '@/components/OfficialBadge';
import NotFound from './NotFound';
@ -33,6 +33,7 @@ import {
Scissors,
Aperture,
Users,
HardHat,
} from 'lucide-react';
import { nip19 } from 'nostr-tools';
@ -75,6 +76,7 @@ export default function CoachDetailPage() {
const isFilmer = profile?.profileType === 'filmer';
const isEditor = profile?.profileType === 'editor';
const isPhotographer = profile?.profileType === 'photographer';
const isBuilder = profile?.profileType === 'builder';
useSeoMeta({
title: profile ? `${profile.title} - SkateSpots` : 'Profile - SkateSpots',
@ -101,12 +103,14 @@ export default function CoachDetailPage() {
? { text: 'text-orange-500', bg: 'bg-orange-500', border: 'border-orange-500/30', hover: 'hover:bg-orange-500/10 border-orange-500' }
: isPhotographer
? { text: 'text-indigo-400', bg: 'bg-indigo-600', border: 'border-indigo-500/30', hover: 'hover:bg-indigo-500/10 border-indigo-500' }
: isBuilder
? { text: 'text-amber-400', bg: 'bg-amber-500', border: 'border-amber-500/30', hover: 'hover:bg-amber-500/10 border-amber-500' }
: { text: 'text-purple-400', bg: 'bg-purple-600', border: 'border-purple-500/30', hover: 'hover:bg-purple-500/10 border-purple-500' };
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : Scissors;
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : isBuilder ? HardHat : Scissors;
// Kind for the collective coordinate (for NIP-32 membership attestations)
const profileKind = isCoach ? 35001 : isFilmer ? 35002 : isEditor ? 35003 : 35004;
const profileKind = isCoach ? 35001 : isFilmer ? 35002 : isEditor ? 35003 : isPhotographer ? 35004 : 35005;
const isCollective = profile.entityType === 'collective' && profile.members.length > 0;
// All photographer label maps merged for chip resolution
@ -114,12 +118,16 @@ export default function CoachDetailPage() {
...PHOTO_SERVICE_LABELS, ...PHOTO_CAMERA_LABELS, ...PHOTO_LENS_LABELS,
...PHOTO_LIGHTING_LABELS, ...PHOTO_SUPPORT_LABELS, ...PHOTO_STYLE_LABELS,
};
const builderLabels: Record<string, string> = {
...BUILDER_SERVICE_LABELS, ...BUILDER_SKILL_LABELS, ...BUILDER_SCALE_LABELS,
};
// Get skill labels and descriptions based on profile type
const getSkillLabel = (skill: string) => {
if (isCoach) return COACH_SKILL_LABELS[skill] || skill;
if (isFilmer) return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (isPhotographer) return photoLabels[skill] || skill;
if (isBuilder) return builderLabels[skill] || skill;
if (isEditor) return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
return skill;
};
@ -128,6 +136,7 @@ export default function CoachDetailPage() {
if (isCoach) return COACH_SKILL_DESCRIPTIONS[skill];
if (isFilmer) return FILMING_SERVICE_DESCRIPTIONS[skill] || FILMING_SKILL_DESCRIPTIONS[skill];
if (isPhotographer) return PHOTO_SERVICE_DESCRIPTIONS[skill];
if (isBuilder) return BUILDER_SERVICE_DESCRIPTIONS[skill];
if (isEditor) return EDITOR_SERVICE_DESCRIPTIONS[skill] || EDITOR_SKILL_DESCRIPTIONS[skill];
return undefined;
};
@ -203,7 +212,7 @@ export default function CoachDetailPage() {
{profile.skills.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3">
{isCoach ? 'Skills' : isPhotographer ? 'Services & Gear' : 'Services & Skills'}
{isCoach ? 'Skills' : isPhotographer ? 'Services & Gear' : isBuilder ? 'What We Build' : 'Services & Skills'}
</h3>
<div className="space-y-3">
{profile.skills.map((skill) => (
@ -225,10 +234,11 @@ export default function CoachDetailPage() {
)}
{/* In the Bag — photographer / filmer gear (freeform) */}
{(isPhotographer || isFilmer) && 'gear' in profile && profile.gear && (
{(isPhotographer || isFilmer || isBuilder) && 'gear' in profile && profile.gear && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-2 flex items-center gap-2">
<Aperture className={`h-4 w-4 ${accent.text}`} /> In the Bag
{isBuilder ? <HardHat className={`h-4 w-4 ${accent.text}`} /> : <Aperture className={`h-4 w-4 ${accent.text}`} />}
{isBuilder ? 'How We Work' : 'In the Bag'}
</h3>
<p className={`text-gray-300 whitespace-pre-wrap text-sm bg-black/50 border rounded-lg p-3 ${accent.border}`}>
{profile.gear}
@ -236,6 +246,26 @@ export default function CoachDetailPage() {
</div>
)}
{/* Builder — service area & insured */}
{isBuilder && 'serviceArea' in profile && (profile.serviceArea || profile.insured) && (
<div className="mb-6 grid grid-cols-1 sm:grid-cols-2 gap-3">
{profile.serviceArea && (
<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}`} /> Service Area
</h4>
<p className="text-sm text-gray-300">{profile.serviceArea}</p>
</div>
)}
{profile.insured && (
<div className={`bg-black/50 border rounded-lg p-3 ${accent.border} flex items-center gap-2`}>
<HardHat className={`h-4 w-4 ${accent.text}`} />
<span className="text-sm font-bold text-gray-200">Licensed / Insured</span>
</div>
)}
</div>
)}
{/* Experience & Rate */}
<div className="grid grid-cols-2 gap-4">
{profile.experience && (
@ -305,7 +335,7 @@ export default function CoachDetailPage() {
</a>
)}
{(isFilmer || isPhotographer || isEditor) && 'social' in profile && sanitizeUrl(profile.social) && (
{(isFilmer || isPhotographer || isEditor || isBuilder) && 'social' in profile && sanitizeUrl(profile.social) && (
<a href={sanitizeUrl(profile.social)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(profile.social!)}`}>
<Globe className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">{urlDomain(profile.social!)}</span>
@ -313,7 +343,7 @@ export default function CoachDetailPage() {
)}
{/* Portfolio / reel (filmers, photographers & editors) */}
{(isPhotographer || isEditor || isFilmer) && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && (
{(isPhotographer || isEditor || isFilmer || isBuilder) && 'portfolio' in profile && sanitizeUrl(profile.portfolio) && (
<a href={sanitizeUrl(profile.portfolio)!} target="_blank" rel="noopener noreferrer" className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors" title={`Opens: ${urlDomain(profile.portfolio!)}`}>
<Aperture className="h-4 w-4 text-[#ccff00]" />
<span className="truncate">Portfolio · {urlDomain(profile.portfolio!)}</span>

View file

@ -24,6 +24,9 @@ import {
PHOTO_LIGHTING_LABELS,
PHOTO_SUPPORT_LABELS,
PHOTO_STYLE_LABELS,
BUILDER_SERVICE_LABELS,
BUILDER_SKILL_LABELS,
BUILDER_SCALE_LABELS,
} from '@/lib/types';
import {
GraduationCap,
@ -34,6 +37,7 @@ import {
Video,
Scissors,
Camera,
HardHat,
CreditCard,
X,
ChevronRight,
@ -58,6 +62,15 @@ const PHOTO_LENS_ENTRIES = Object.entries(PHOTO_LENS_LABELS);
const PHOTO_LIGHTING_ENTRIES = Object.entries(PHOTO_LIGHTING_LABELS);
const PHOTO_SUPPORT_ENTRIES = Object.entries(PHOTO_SUPPORT_LABELS);
const PHOTO_STYLE_ENTRIES = Object.entries(PHOTO_STYLE_LABELS);
const BUILDER_SERVICE_ENTRIES = Object.entries(BUILDER_SERVICE_LABELS);
const BUILDER_SKILL_ENTRIES = Object.entries(BUILDER_SKILL_LABELS);
const BUILDER_SCALE_ENTRIES = Object.entries(BUILDER_SCALE_LABELS);
const BUILDER_ALL_LABELS: Record<string, string> = {
...BUILDER_SERVICE_LABELS,
...BUILDER_SKILL_LABELS,
...BUILDER_SCALE_LABELS,
};
// All photographer label maps merged — for resolving a chip label regardless of category
const PHOTO_ALL_LABELS: Record<string, string> = {
@ -69,7 +82,7 @@ const PHOTO_ALL_LABELS: Record<string, string> = {
...PHOTO_STYLE_LABELS,
};
type ServiceType = 'coaching' | 'filming' | 'editing' | 'photography';
type ServiceType = 'coaching' | 'filming' | 'editing' | 'photography' | 'building';
// ─── Per-category accent colours ─────────────────────────────────────────────
@ -118,6 +131,17 @@ const ACCENT = {
stripe: 'bg-indigo-600',
badgeSkill: 'bg-indigo-900/40 border-indigo-700/50 text-indigo-300',
},
building: {
active: 'bg-amber-500 hover:bg-amber-600 text-black border-amber-500',
chip: 'bg-amber-500 text-black border-amber-500',
chipHover: 'hover:border-amber-500/60 hover:text-amber-400',
border: 'border-amber-500/40',
glow: 'shadow-amber-500/20',
icon: 'text-amber-400',
badge: 'bg-amber-500/15 text-amber-400 border-amber-500/30',
stripe: 'bg-amber-500',
badgeSkill: 'bg-amber-900/40 border-amber-700/50 text-amber-300',
},
};
// ─── FAB — offer services ─────────────────────────────────────────────────────
@ -141,6 +165,7 @@ function OfferFAB() {
{ to: '/become-filmer', label: 'Offer Filming', color: 'bg-orange-500 hover:bg-orange-600', icon: Video },
{ to: '/become-photographer', label: 'Offer Photography', color: 'bg-indigo-600 hover:bg-indigo-700', icon: Camera },
{ to: '/become-editor', label: 'Offer Editing', color: 'bg-purple-600 hover:bg-purple-700', icon: Scissors },
{ to: '/become-builder', label: 'Offer Building', color: 'bg-amber-500 hover:bg-amber-600 text-black', icon: HardHat },
{ to: '/card', label: 'Get Rider Card', color: 'bg-[#ccff00]/20 hover:bg-[#ccff00]/30 border border-[#ccff00]/50 text-[#ccff00]', icon: CreditCard },
];
@ -212,6 +237,7 @@ function getSkillLabelForType(skill: string, serviceType: ServiceType): string {
if (serviceType === 'coaching') return COACH_SKILL_LABELS[skill] || skill;
if (serviceType === 'filming') return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (serviceType === 'photography') return PHOTO_ALL_LABELS[skill] || skill;
if (serviceType === 'building') return BUILDER_ALL_LABELS[skill] || skill;
return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
}
@ -238,6 +264,7 @@ function LabToolbar({
{ key: 'filming', label: 'Filmers', icon: Video },
{ key: 'photography', label: 'Photo', icon: Camera },
{ key: 'editing', label: 'Editors', icon: Scissors },
{ key: 'building', label: 'Builders', icon: HardHat },
];
const accent = ACCENT[serviceType];
@ -252,8 +279,8 @@ function LabToolbar({
return (
<div className="sticky top-14 md:top-16 z-30 bg-black/95 backdrop-blur border-b border-white/5 -mx-4 px-4 py-3 space-y-2.5">
{/* Tab row */}
<div className="flex items-center gap-1.5 sm:gap-2">
{/* Tab row — horizontally scrollable so 5 tabs stay clean on small phones */}
<div className="flex items-center gap-1.5 sm:gap-2 overflow-x-auto scrollbar-none -mx-1 px-1">
{tabs.map(({ key, label, icon: Icon }) => {
const isActive = serviceType === key;
const a = ACCENT[key];
@ -262,7 +289,7 @@ function LabToolbar({
key={key}
onClick={() => onServiceChange(key)}
className={cn(
'relative flex items-center gap-1 sm:gap-1.5 px-2 sm:px-3 py-2 rounded-xl font-black text-[11px] sm:text-xs uppercase tracking-wide transition-all duration-150 active:scale-95 flex-1 justify-center',
'relative flex items-center gap-1 sm:gap-1.5 px-2.5 sm:px-3 py-2 rounded-xl font-black text-[11px] sm:text-xs uppercase tracking-wide transition-all duration-150 active:scale-95 flex-1 min-w-fit justify-center whitespace-nowrap',
isActive
? a.active
: 'text-gray-500 bg-white/5 hover:bg-white/10 hover:text-white border border-transparent'
@ -454,6 +481,12 @@ function FilterDrawer({
{ title: 'Support & Rig', entries: PHOTO_SUPPORT_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
{ title: 'Style', entries: PHOTO_STYLE_ENTRIES, activeColor: 'bg-indigo-400 text-white border-indigo-400' },
]
: serviceType === 'building'
? [
{ title: 'What They Build', entries: BUILDER_SERVICE_ENTRIES, activeColor: accent.chip },
{ title: 'Materials & Trade', entries: BUILDER_SKILL_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-400' },
{ title: 'Project Scale', entries: BUILDER_SCALE_ENTRIES, activeColor: 'bg-amber-400 text-black border-amber-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' },
@ -630,16 +663,18 @@ function ProfileCard({ profile }: { profile: LabProfile }) {
const isFilmer = profile.profileType === 'filmer';
const isEditor = profile.profileType === 'editor';
const isPhotographer = profile.profileType === 'photographer';
const isBuilder = profile.profileType === 'builder';
const serviceType: ServiceType = isCoach ? 'coaching' : isFilmer ? 'filming' : isPhotographer ? 'photography' : 'editing';
const serviceType: ServiceType = isCoach ? 'coaching' : isFilmer ? 'filming' : isPhotographer ? 'photography' : isBuilder ? 'building' : 'editing';
const accent = ACCENT[serviceType];
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : Scissors;
const TypeIcon = isCoach ? GraduationCap : isFilmer ? Video : isPhotographer ? Camera : isBuilder ? HardHat : Scissors;
const getSkillLabel = (skill: string) => {
if (isCoach) return COACH_SKILL_LABELS[skill] || skill;
if (isFilmer) return FILMING_SERVICE_LABELS[skill] || FILMING_SKILL_LABELS[skill] || skill;
if (isPhotographer) return PHOTO_ALL_LABELS[skill] || skill;
if (isBuilder) return BUILDER_ALL_LABELS[skill] || skill;
if (isEditor) return EDITOR_SERVICE_LABELS[skill] || EDITOR_SKILL_LABELS[skill] || skill;
return skill;
};
@ -800,6 +835,14 @@ function EmptyState({ serviceType, user }: { serviceType: ServiceType; user: boo
to: '/become-editor',
color: 'bg-purple-600 hover:bg-purple-700',
},
building: {
icon: HardHat,
title: 'No builders yet',
body: 'Be the first to list your ramp, park or DIY building — concrete, wood, rails and more.',
cta: 'Offer Building',
to: '/become-builder',
color: 'bg-amber-500 hover:bg-amber-600 text-black',
},
};
const c = configs[serviceType];
@ -887,12 +930,13 @@ export default function LabPage() {
// Counts per tab (unfiltered by skills/search/location, just by type)
const counts = useMemo<Record<ServiceType, number>>(() => {
if (!profiles) return { coaching: 0, filming: 0, editing: 0, photography: 0 };
if (!profiles) return { coaching: 0, filming: 0, editing: 0, photography: 0, building: 0 };
return {
coaching: profiles.filter((p) => p.profileType === 'coach').length,
filming: profiles.filter((p) => p.profileType === 'filmer').length,
photography: profiles.filter((p) => p.profileType === 'photographer').length,
editing: profiles.filter((p) => p.profileType === 'editor').length,
building: profiles.filter((p) => p.profileType === 'builder').length,
};
}, [profiles]);
@ -904,6 +948,7 @@ export default function LabPage() {
if (serviceType === 'filming' && profile.profileType !== 'filmer') return false;
if (serviceType === 'photography' && profile.profileType !== 'photographer') return false;
if (serviceType === 'editing' && profile.profileType !== 'editor') return false;
if (serviceType === 'building' && profile.profileType !== 'builder') return false;
// Text search: title, bio, and genUserName fallback
if (searchQuery) {