Nutzap:
- Gate sending behind NUTZAP_SENDING_ENABLED=false with clear docs; sending
would require P2PK-locking proofs (fund-loss risk if wrong), so it's disabled
until implementable/auditable. Receiving + reading unchanged. No dead-end
button exists (hook was never wired to UI).
Cross-linking crews ↔ Lab collectives:
- types: parseAddrCoord/parseCrewRef/parseLabRefs helpers + LAB_KINDS; add
crewRef to all 5 Lab interfaces/parsers and labRefs[] to SkateCrew
- CrewLinkField (pick one of your own crews) added to all 5 Become-* forms,
shown only for collectives; publishes an a-tag on the Lab profile
- LabLinkField (pick your own Lab profiles) added to CrewForm; publishes a-tags
- CrossLinkCards: LinkedCrewCard on Lab detail ("Part of a Crew"), LinkedLabCard
on crew detail ("Hire This Crew"), resolving coordinates via useCrew/useLabProfile
Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
556 lines
26 KiB
TypeScript
556 lines
26 KiB
TypeScript
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 { CrewLinkField } from '@/components/CrewLinkField';
|
||
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 [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
||
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]);
|
||
if (crewRef) tags.push(['a', crewRef]);
|
||
}
|
||
|
||
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"
|
||
/>
|
||
|
||
{entityType === 'collective' && (
|
||
<CrewLinkField value={crewRef} onChange={setCrewRef} 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>
|
||
);
|
||
}
|