diff --git a/NIP.md b/NIP.md index 8c95882..92f4e67 100644 --- a/NIP.md +++ b/NIP.md @@ -1417,6 +1417,13 @@ They share a common base schema and are distinguished by kind and a self-tag. | 35003 | Video Editor | `skateeditor` | | 35004 | Photographer | `skatephotographer` | | 35005 | Builder (ramp/park/DIY) | `skatebuilder` | +| 35007 | Designer / Artist | `skateartist` | +| 35008 | Graffiti / Mural artist | `skategraffiti` | +| 35009 | Tattoo artist | `skatetattoo` | +| 35010 | Trainer / Physiotherapist | `skatetrainer` | +| 35011 | Musician / Band | `skatemusician` | + +> Kind `35006` is **not** a Lab provider — it is the Skate Crew (see below). ### Common Base Schema @@ -1677,12 +1684,103 @@ on the map. } ``` +### Designer / Artist (kind 35007) + +Skate designers and artists — board graphics, logos, posters, merch, zines and +illustration. Uses the common base schema with `t`-tag categories. + +Categories encoded as `t` tags: + +- **What they make**: `boardart`, `logos`, `posters`, `merch`, `zines`, `illustration`, `stickers`, `webdesign`, `typography`, `animation` +- **Medium**: `digital`, `handdrawn`, `screenprint`, `paint`, `collage`, `photo`, `3d` +- **Software / tools**: `illustrator`, `photoshop`, `procreate`, `affinity`, `figma`, `blender`, `aftereffects`, `clipstudio` + +Optional tags: `portfolio`, `social` (shared base). + +### Graffiti / Mural Artist (kind 35008) + +Graffiti writers and muralists. Uses the common base schema. + +Categories encoded as `t` tags: + +- **Style**: `throwups`, `pieces`, `murals`, `characters`, `lettering`, `stencil`, `handstyles`, `3dstyle`, `abstract` +- **Medium**: `spray`, `rollers`, `markers`, `brush`, `digital` +- **Work context**: `legalwalls`, `commissions`, `liveart`, `skateparks`, `interiors`, `workshops` + +Graffiti-specific optional tags: + +| Tag | Description | +|-----|-------------| +| `crew` | Freeform **graffiti** crew name (distinct from a SkateSpots skate crew) | +| `a` | Walls/spots painted — references to kind `30818` skate spots | + +### Tattoo Artist (kind 35009) + +Skate-culture tattoo artists. Uses the common base schema. + +Categories encoded as `t` tags: + +- **Styles**: `traditional`, `neotraditional`, `fineline`, `blackwork`, `realism`, `lettering`, `illustrative`, `skateflash`, `ignorant`, `japanese` +- **Method**: `machine`, `handpoke`, `flash`, `custom`, `coverup` + +Tattoo-specific optional tags: + +| Tag | Description | +|-----|-------------| +| `studio` | Studio / shop name | +| `books_open` | `"true"` if currently taking bookings | + +### Trainer / Physiotherapist (kind 35010) + +Physical trainers, physiotherapists and sports therapists who work with skaters. +Uses the common base schema. + +Categories encoded as `t` tags: + +- **Focus**: `strength`, `mobility`, `injuryprevention`, `rehab`, `recovery`, `balance`, `cardio`, `nutrition` +- **Injuries treated**: `ankles`, `knees`, `wrists`, `hips`, `back`, `shoulders`, `heelbruise`, `concussion` +- **Credentials / setting**: `physio`, `osteo`, `certtrainer`, `sportstherapist`, `massage`, `inperson`, `remote` + +Trainer-specific optional tags: + +| Tag | Description | +|-----|-------------| +| `credentials` | Freeform certifications (e.g. "BSc Physiotherapy, CSCS") | +| `remote` | `"true"` if remote / online coaching is offered | + +### Musician / Band (kind 35011) + +Skaters who make music — solo artists, bands, DJs and producers. Skate scenes and +local music are tightly linked (demos, jams, after-parties), so musicians are a +first-class Lab craft. A skate **crew** may also carry `band`/`band-url` tags +(see Crews) for a lighter-weight association; a full musician profile is the +first-class, bookable identity. Uses the common base schema. + +Categories encoded as `t` tags: + +- **Genre**: `punk`, `hardcore`, `metal`, `rock`, `indie`, `hiphop`, `electronic`, `lofi`, `reggae`, `ska`, `jazz`, `funk`, `flamenco`, `folk`, `experimental`, `grunge` +- **Instruments / role**: `vocals`, `guitar`, `bass`, `drums`, `keys`, `dj`, `producer`, `sax`, `strings`, `other` + +Musician-specific optional tags: + +| Tag | Description | +|-----|-------------| +| `act` | Act type: `solo`, `band`, `dj`, `producer`, `collective` | +| `spotify` | Spotify link (`http(s)` only) | +| `bandcamp` | Bandcamp link (`http(s)` only) | +| `soundcloud` | SoundCloud link (`http(s)` only) | +| `youtube` | YouTube link (`http(s)` only) | + +A band can be a `collective` with member `p` tags (bilateral NIP-32 attestation, +same as other collectives), and may link to its skate crew via an `a` tag +(`35006:pubkey:d`). + ### Querying Lab Profiles ```json { - "kinds": [35001, 35002, 35003, 35004, 35005], - "#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder"], + "kinds": [35001, 35002, 35003, 35004, 35005, 35007, 35008, 35009, 35010, 35011], + "#t": ["skatecoach", "skatefilmer", "skateeditor", "skatephotographer", "skatebuilder", "skateartist", "skategraffiti", "skatetattoo", "skatetrainer", "skatemusician"], "limit": 100 } ``` diff --git a/src/AppRouter.tsx b/src/AppRouter.tsx index 13887cb..f87f1c5 100644 --- a/src/AppRouter.tsx +++ b/src/AppRouter.tsx @@ -31,6 +31,11 @@ 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 BecomeArtistPage = lazy(() => import("./pages/BecomeArtistPage")); +const BecomeGraffitiPage = lazy(() => import("./pages/BecomeGraffitiPage")); +const BecomeTattooPage = lazy(() => import("./pages/BecomeTattooPage")); +const BecomeTrainerPage = lazy(() => import("./pages/BecomeTrainerPage")); +const BecomeMusicianPage = lazy(() => import("./pages/BecomeMusicianPage")); const CrewsPage = lazy(() => import("./pages/CrewsPage")); const CreateCrewPage = lazy(() => import("./pages/CreateCrewPage")); const EditCrewPage = lazy(() => import("./pages/EditCrewPage")); @@ -90,6 +95,11 @@ export function AppRouter() { } /> } /> } /> + } /> + } /> + } /> + } /> + } /> } /> } /> } /> diff --git a/src/components/CraftProfileForm.tsx b/src/components/CraftProfileForm.tsx new file mode 100644 index 0000000..cfb9da9 --- /dev/null +++ b/src/components/CraftProfileForm.tsx @@ -0,0 +1,489 @@ +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 { 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([]); + 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('individual'); + const [members, setMembers] = useState([]); + const [crewRef, setCrewRef] = useState(undefined); + const [extras, setExtras] = 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 ${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) => { + 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 ( + +
+ + + +

Login Required

+

You need to log in to create a {noun} profile.

+ +
+
+
+
+ ); + } + + const hasNip05 = user?.metadata?.nip05; + const entityCoord = `${kind}:${user.pubkey}:${identifier}`; + + return ( + +
+ {/* Header */} +
+ + + Back to The Lab + +
+
+ +
+
+

+ {heading} +

+

{tagline}

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

Add NIP-05 to build trust

+

+ A NIP-05 identifier helps skaters verify you're the real deal. Highly recommended before listing. +

+ + + +
+
+
+
+ )} +
+ +
+ {/* Image + Bio */} + + +
+
+ {image ? ( + <> + Profile + + + ) : ( + + )} +
+
+ + +
+
+ +
+ +