SkateSpots/src/components/CraftProfileForm.tsx
shakespeare.diy a89fca8e28 Lab: payment-readiness prompt on every provider form + drop downhill/longboard from judge
- New PaymentReadinessNote component: checks the logged-in user's Nostr profile
  (kind 0) for a Lightning address / Silent Payment and either confirms "you can
  get paid" or links them to Edit Profile to add one. Wired into CraftProfileForm
  (coach, filmer, editor, photographer, builder, artist, graffiti, tattoo,
  trainer, musician, judge) and the standalone coach/filmer/editor/photographer/
  builder forms, alongside the existing CLINK (noffer) field. Lightning address
  stays a single source of truth on kind 0 — never duplicated/stale per event.
- become-judge: removed the "Downhill / Longboard" event format (not part of the
  skateboarding activities this app covers).

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-09-03 15:21:34 +07:00

494 lines
22 KiB
TypeScript
Raw Blame History

This file contains invisible Unicode characters

This file contains invisible Unicode characters that are indistinguishable to humans but may be processed differently by a computer. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from '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 } 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 { LoginArea } from '@/components/auth/LoginArea';
import { NofferField } from '@/components/NofferField';
import { PaymentReadinessNote } from '@/components/PaymentReadinessNote';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { LabProUpgrade } from '@/components/LabProUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
import { isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Upload, X, Loader2, ArrowLeft, MapPin } from 'lucide-react';
/** A group of selectable tag chips (e.g. "Genre", "Instruments"). */
export interface SkillSection {
title: string;
/** [tagValue, label] pairs. */
entries: [string, string][];
/** If true, at least one must be selected before publishing. */
required?: boolean;
}
/** A freeform extra field specific to a craft (e.g. tattoo studio, music links). */
export interface ExtraField {
/** Nostr tag name to write, e.g. "studio", "spotify". */
tag: string;
label: string;
placeholder?: string;
/** "text" (default) | "url" | "toggle" */
type?: 'text' | 'url' | 'toggle';
helper?: string;
}
export interface CraftFormConfig {
kind: number;
selfTag: string;
/** Fixed `d` identifier for this craft's single profile per user. */
identifier: string;
/** Page heading, e.g. "Become a Tattoo Artist". */
heading: string;
/** Sub-line under the heading. */
tagline: string;
/** Human noun for toasts / alt text, e.g. "tattoo artist". */
noun: string;
/** Tailwind text color for the accent, e.g. "text-rose-400". */
accentText: string;
/** Tailwind bg color for the accent, e.g. "bg-rose-600". */
accentBg: string;
/** Active-chip classes, e.g. "bg-rose-600 text-white border-rose-600". */
accentChip: string;
/** Hover classes for inactive chips. */
accentChipHover: string;
/** Icon component for the login-gate + header. */
icon: React.ComponentType<{ className?: string }>;
/** Tag chip sections. */
skillSections: SkillSection[];
/** Craft-specific freeform fields. */
extraFields?: ExtraField[];
/** Whether this craft can be a collective (default true). */
allowCollective?: boolean;
/** Whether to show the rate field (default true). */
showRate?: boolean;
/** Label for the rate field. */
rateLabel?: string;
/** Label for the portfolio field. */
portfolioLabel?: string;
}
export function CraftProfileForm({ config }: { config: CraftFormConfig }) {
const {
kind, selfTag, identifier, heading, tagline, noun,
accentText, accentBg, accentChip, accentChipHover, icon: Icon,
skillSections, extraFields = [], allowCollective = true,
showRate = true, rateLabel = 'Rate', portfolioLabel = 'Portfolio Link',
} = config;
const navigate = useNavigate();
const { user } = useCurrentUser();
const author = useAuthor(user?.pubkey || '');
const { mutateAsync: createEvent, isPending } = useNostrPublish();
const { mutateAsync: uploadFile, isPending: isUploading } = useUploadFile();
const { toast } = useToast();
const { isActive: isPro, featuredUntilSeconds } = usePlatformFee('labProfile');
const [goPro, setGoPro] = useState(false);
const [bio, setBio] = useState('');
const [location, setLocation] = useState('');
const [latitude, setLatitude] = useState('');
const [longitude, setLongitude] = useState('');
const [selected, setSelected] = useState<string[]>([]);
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>('individual');
const [members, setMembers] = useState<string[]>([]);
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
const [extras, setExtras] = useState<Record<string, 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 ${noun}`;
};
const toggle = (key: string) =>
setSelected((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 tags = await uploadFile(files[0]);
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 requiredSections = skillSections.filter((s) => s.required);
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
// Enforce required skill sections
for (const section of requiredSections) {
const keys = section.entries.map(([k]) => k);
if (!selected.some((s) => keys.includes(s))) {
toast({ title: `${section.title} required`, description: `Select at least one option under "${section.title}".`, variant: 'destructive' });
return;
}
}
try {
const displayName = getDisplayName();
const tags: string[][] = [
['d', identifier],
['title', displayName],
['t', selfTag],
['alt', `Skate ${noun}: ${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)) tags.push(['g', ngeohash.encode(lat, lng, 7)]);
}
for (const s of selected) tags.push(['t', s]);
if (image) tags.push(['image', image]);
if (experience.trim()) tags.push(['experience', experience.trim()]);
if (showRate && 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()]);
// Craft-specific extras
for (const f of extraFields) {
const v = (extras[f.tag] ?? '').trim();
if (f.type === 'toggle') {
if (extras[f.tag] === 'true') tags.push([f.tag, 'true']);
} else if (v) {
if (f.type === 'url' && !/^https?:\/\//i.test(v)) continue;
tags.push([f.tag, v]);
}
}
tags.push(['entity', entityType]);
if (allowCollective && entityType === 'collective') {
for (const pk of members) tags.push(['p', pk]);
if (crewRef) tags.push(['a', crewRef]);
}
if (isPro && goPro && featuredUntilSeconds) tags.push(['featured', String(featuredUntilSeconds)]);
await createEvent({ kind, content: bio.trim(), tags });
toast({ title: `${heading.replace(/^Become an?/, '').trim()} 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">
<Icon className={cn('h-12 w-12 mx-auto mb-4', accentText)} />
<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 {noun} profile.</p>
<LoginArea className="justify-center max-w-60 mx-auto" />
</CardContent>
</Card>
</div>
</Layout>
);
}
const hasNip05 = user?.metadata?.nip05;
const entityCoord = `${kind}:${user.pubkey}:${identifier}`;
return (
<Layout>
<div className="container px-4 sm:px-6 py-6 sm:py-8 max-w-2xl">
{/* Header */}
<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>
<div className="flex items-center gap-3">
<div className={cn('w-11 h-11 rounded-xl flex items-center justify-center', accentChip)}>
<Icon className="h-6 w-6" />
</div>
<div>
<h1 className={cn('text-2xl sm:text-3xl font-black uppercase leading-none', accentText)} style={{ fontFamily: 'Impact, "Arial Black", sans-serif' }}>
{heading}
</h1>
<p className="text-gray-400 mt-1 text-sm">{tagline}</p>
</div>
</div>
{!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">Add NIP-05 to build trust</h3>
<p className="text-sm text-gray-300 mb-2">
A NIP-05 identifier helps skaters verify you're the real deal. Highly recommended before listing.
</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>
)}
</div>
<form onSubmit={handleSubmit} className="space-y-6">
{/* Image + Bio */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<div className="flex items-center gap-4">
<div className={cn('relative w-20 h-20 rounded-2xl border-2 flex items-center justify-center overflow-hidden flex-shrink-0', image ? 'border-white/20' : 'border-dashed border-white/20')}>
{image ? (
<>
<img src={image} alt="Profile" className="w-full h-full object-cover" />
<button type="button" onClick={() => setImage(null)} className="absolute top-0.5 right-0.5 bg-black/70 rounded-full p-0.5">
<X className="h-3 w-3 text-white" />
</button>
</>
) : (
<Icon className="h-7 w-7 text-gray-600" />
)}
</div>
<div className="flex-1">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">Profile image</Label>
<label className={cn('mt-1.5 flex items-center gap-2 px-3 py-2 rounded-lg border border-white/10 bg-white/5 hover:bg-white/10 cursor-pointer text-sm w-fit transition-colors', isUploading && 'opacity-50 pointer-events-none')}>
{isUploading ? <Loader2 className="h-4 w-4 animate-spin" /> : <Upload className="h-4 w-4" />}
{isUploading ? 'Uploading…' : 'Upload'}
<input type="file" accept="image/*" className="hidden" onChange={handleImageUpload} disabled={isUploading} />
</label>
</div>
</div>
<div>
<Label htmlFor="bio" className="text-xs font-bold uppercase tracking-widest text-gray-400">About</Label>
<Textarea
id="bio"
value={bio}
onChange={(e) => setBio(e.target.value)}
placeholder={`Tell skaters about your ${noun} work…`}
className="mt-1.5 bg-white/5 border-white/10 min-h-[100px] resize-none"
maxLength={3000}
/>
</div>
</CardContent>
</Card>
{/* Skill sections */}
{skillSections.map((section) => (
<Card key={section.title} className="bg-black/60 border-white/10">
<CardContent className="p-5">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400 flex items-center gap-2">
{section.title}
{section.required && <span className={cn('text-[10px]', accentText)}>• required</span>}
</Label>
<div className="mt-3 flex flex-wrap gap-2">
{section.entries.map(([key, label]) => {
const active = selected.includes(key);
return (
<button
key={key}
type="button"
onClick={() => toggle(key)}
className={cn(
'px-3 py-1.5 rounded-full text-xs font-bold border transition-all active:scale-95',
active ? accentChip : cn('bg-white/5 border-white/10 text-gray-400', accentChipHover)
)}
>
{label}
</button>
);
})}
</div>
</CardContent>
</Card>
))}
{/* Craft-specific extras */}
{extraFields.length > 0 && (
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
{extraFields.map((f) => (
f.type === 'toggle' ? (
<div key={f.tag} className="flex items-center justify-between gap-3">
<div>
<Label className="text-sm font-bold text-gray-200">{f.label}</Label>
{f.helper && <p className="text-xs text-gray-500">{f.helper}</p>}
</div>
<Switch
checked={extras[f.tag] === 'true'}
onCheckedChange={(v) => setExtras((prev) => ({ ...prev, [f.tag]: v ? 'true' : 'false' }))}
/>
</div>
) : (
<div key={f.tag}>
<Label htmlFor={f.tag} className="text-xs font-bold uppercase tracking-widest text-gray-400">{f.label}</Label>
<Input
id={f.tag}
value={extras[f.tag] ?? ''}
onChange={(e) => setExtras((prev) => ({ ...prev, [f.tag]: e.target.value }))}
placeholder={f.placeholder}
inputMode={f.type === 'url' ? 'url' : undefined}
className="mt-1.5 bg-white/5 border-white/10"
/>
{f.helper && <p className="text-xs text-gray-500 mt-1">{f.helper}</p>}
</div>
)
))}
</CardContent>
</Card>
)}
{/* Location + experience + rate */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<div>
<Label htmlFor="location" className="text-xs font-bold uppercase tracking-widest text-gray-400">Location</Label>
<div className="mt-1.5 flex gap-2">
<Input id="location" value={location} onChange={(e) => setLocation(e.target.value)} placeholder="City, Country" className="bg-white/5 border-white/10" />
<Button type="button" variant="outline" onClick={getCurrentLocation} disabled={isGettingLocation} className="border-white/10 flex-shrink-0">
{isGettingLocation ? <Loader2 className="h-4 w-4 animate-spin" /> : <MapPin className="h-4 w-4" />}
</Button>
</div>
{latitude && longitude && <p className="text-xs text-green-400 mt-1">📍 Pinned to map ({latitude}, {longitude})</p>}
</div>
<div className={cn('grid gap-4', showRate ? 'grid-cols-2' : 'grid-cols-1')}>
<div>
<Label htmlFor="exp" className="text-xs font-bold uppercase tracking-widest text-gray-400">Experience (years)</Label>
<Input id="exp" value={experience} onChange={(e) => setExperience(e.target.value)} placeholder="e.g. 5" inputMode="numeric" className="mt-1.5 bg-white/5 border-white/10" />
</div>
{showRate && (
<div>
<Label htmlFor="rate" className="text-xs font-bold uppercase tracking-widest text-gray-400">{rateLabel}</Label>
<Input id="rate" value={rate} onChange={(e) => setRate(e.target.value)} placeholder="e.g. 20k sats / by project" className="mt-1.5 bg-white/5 border-white/10" />
</div>
)}
</div>
</CardContent>
</Card>
{/* Contact + links */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">Contact & Links</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-3">
<Input value={phone} onChange={(e) => setPhone(e.target.value)} placeholder="Phone (optional)" className="bg-white/5 border-white/10" />
<Input value={email} onChange={(e) => setEmail(e.target.value)} placeholder="Email (optional)" type="email" className="bg-white/5 border-white/10" />
<Input value={website} onChange={(e) => setWebsite(e.target.value)} placeholder="Website" inputMode="url" className="bg-white/5 border-white/10" />
<Input value={social} onChange={(e) => setSocial(e.target.value)} placeholder="Social (Instagram, etc.)" className="bg-white/5 border-white/10" />
<Input value={portfolio} onChange={(e) => setPortfolio(e.target.value)} placeholder={portfolioLabel} inputMode="url" className="bg-white/5 border-white/10 sm:col-span-2" />
</div>
</CardContent>
</Card>
{/* Payments — Lightning address (from profile) + optional CLINK code */}
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<Label className="text-xs font-bold uppercase tracking-widest text-gray-400">
Getting Paid
</Label>
<PaymentReadinessNote />
<NofferField value={noffer} onChange={setNoffer} />
</CardContent>
</Card>
{/* Entity type + crew link (collective-capable crafts) */}
{allowCollective && (
<Card className="bg-black/60 border-white/10">
<CardContent className="p-5 space-y-4">
<EntityTypeField
entityType={entityType}
onEntityTypeChange={setEntityType}
members={members}
onMembersChange={setMembers}
accentBg={accentBg}
accentText={accentText}
/>
{entityType === 'collective' && (
<CrewLinkField value={crewRef} onChange={setCrewRef} accentText={accentText} accentBg={accentBg} />
)}
</CardContent>
</Card>
)}
{/* Pro upgrade (optional) */}
<LabProUpgrade enabled={goPro} onEnabledChange={setGoPro} accentText={accentText} entityCoord={entityCoord} />
{/* Submit */}
<Button
type="submit"
disabled={isPending}
className={cn('w-full h-12 rounded-xl font-black text-base uppercase tracking-wide', accentChip)}
>
{isPending ? <Loader2 className="h-5 w-5 animate-spin" /> : 'Publish Profile'}
</Button>
</form>
</div>
</Layout>
);
}