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:
parent
9cb8d1181d
commit
657c36d264
7 changed files with 857 additions and 23 deletions
61
NIP.md
61
NIP.md
|
|
@ -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
|
||||
}
|
||||
```
|
||||
|
|
|
|||
|
|
@ -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 />} />
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
143
src/lib/types.ts
143
src/lib/types.ts
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
549
src/pages/BecomeBuilderPage.tsx
Normal file
549
src/pages/BecomeBuilderPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
Loading…
Reference in a new issue