From 657c36d264e997dd57ab04aadf0adcd9719e17fe Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 13:33:52 +0700 Subject: [PATCH] Add Builder category to The Lab (kind 35005), collective-aware + spot-linked MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- NIP.md | 61 +++- src/AppRouter.tsx | 2 + src/hooks/useLabProfiles.ts | 20 +- src/lib/types.ts | 143 +++++++++ src/pages/BecomeBuilderPage.tsx | 549 ++++++++++++++++++++++++++++++++ src/pages/CoachDetailPage.tsx | 46 ++- src/pages/LabPage.tsx | 59 +++- 7 files changed, 857 insertions(+), 23 deletions(-) create mode 100644 src/pages/BecomeBuilderPage.tsx diff --git a/NIP.md b/NIP.md index f572008..a49c261 100644 --- a/NIP.md +++ b/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", ""], + ["p", ""], + ["a", "30818::"], + ["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 } ``` diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 8842b9e..bdf37fa 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -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() { } /> } /> } /> + } /> } /> } /> } /> diff --git a/src/hooks/useLabProfiles.ts b/src/hooks/useLabProfiles.ts index fbd4b63..ce23b5c 100644 --- a/src/hooks/useLabProfiles.ts +++ b/src/hooks/useLabProfiles.ts @@ -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; diff --git a/src/lib/types.ts b/src/lib/types.ts index f44efbd..8e6c7a6 100644 --- a/src/lib/types.ts +++ b/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 = { street: 'Street / Documentary', }; +// ----- Builder (ramp / park / DIY builders) ----- + +// What kind of things they build +export const BUILDER_SERVICE_LABELS: Record = { + 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 = { + 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 = { + 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 = { + 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; diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx new file mode 100644 index 0000000..bd75aaf --- /dev/null +++ b/src/pages/BecomeBuilderPage.tsx @@ -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 ( +
+ {entries.map(([key, label]) => ( + + ))} +
+ ); +} + +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([]); + const [selectedSkills, setSelectedSkills] = useState([]); + const [selectedScale, setSelectedScale] = useState([]); + const [gear, setGear] = useState(''); + const [serviceArea, setServiceArea] = useState(''); + const [insured, setInsured] = useState(false); + const [image, setImage] = useState(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('collective'); + const [members, setMembers] = useState([]); + const [selectedSpots, setSelectedSpots] = useState([]); + 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>) => (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) => { + 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 ( + +
+ + + +

Login Required

+

You need to log in to create a builder profile.

+ +
+
+
+
+ ); + } + + const hasNip05 = user?.metadata?.nip05; + + return ( + +
+
+ + + Back to The Lab + +

+ Become a Builder +

+

Pour it, weld it, build it — list your ramp & park building

+ + {!hasNip05 && ( + + +
+
⚠️
+
+

NIP-05 Verification Required

+

+ Add a NIP-05 identifier to your profile to verify your identity and build trust with clients. +

+ + + +
+
+
+
+ )} + + {/* Wallet Requirement */} + + +
+ +
+

⚡ Payment Setup Required

+

+ To get paid for builds, connect a Lightning wallet. Clients zap you directly for your work. +

+ {hasWallet ? ( +
+
+
+

Wallet Connected — Ready to Receive Payments!

+
+

{webln ? 'Using WebLN browser extension' : 'Using Nostr Wallet Connect'}

+
+ ) : ( +
+
+
+
+

No Wallet Connected

+
+
+
+ + + + + + +
+

+ 💡 Tip: Most Lightning wallets support NWC (Alby, Minibits, Zeus, Coinos). +

+
+ )} +
+
+ + +
+ +
+ + + + Builder Profile + + Tell the scene what you build and how you work + + +
+ +
+

{getDisplayName()}

+

From your Nostr profile

+
+
+ +
+ +