Crew form UX: expand Crew Type to 7 scenes (Art, Music, Wellness) mapping to new Lab crafts; rethink Crew Band with Lab-music interconnect nudge; surface member visibility to founders; add Artist/Musician open-call options; update Crews page filters + NIP.md

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-10 22:14:21 +07:00
parent 96f7c3192d
commit 6e11aaab7d
5 changed files with 126 additions and 15 deletions

6
NIP.md
View file

@ -1890,17 +1890,17 @@ bare `p` tag on the crew is only a claim until the member self-labels.
| `location` | Human-readable home location | | `location` | Human-readable home location |
| `founded` | Year founded (cosmetic) | | `founded` | Year founded (cosmetic) |
| `t: <vibe>` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. | | `t: <vibe>` | Style/focus tags. Preset vibes plus any user-defined custom hashtag (lowercased, hyphenated slug). Examples: `street`, `transition`, `park`, `diy`, `oldschool`, `allterrain`, `cruising`, `girls`, `youth`, `adaptive`, `downhill`, `night-sessions`, etc. |
| `t: crew-focus-<scene>` | Optional **Lab scene** for the crew, surfacing it inside The Lab's matching category. One of `crew-focus-builder` (DIY & Builders), `crew-focus-photo` (Photo & Film — covers filming, photography & editing), or `crew-focus-coaching`. Absent = a "Mixed / General" crew that only appears on the Crews page. Queryable at the relay level via `#t`. | | `t: crew-focus-<scene>` | Optional **Lab scene** for the crew, surfacing it inside The Lab's matching category. One of `crew-focus-builder` (DIY & Builders), `crew-focus-photo` (Photo & Film — filming, photography & editing), `crew-focus-coaching` (Coaching), `crew-focus-art` (Art & Design — designers/artists, graffiti & tattoo), `crew-focus-music` (Band & Music — musicians), or `crew-focus-wellness` (Training & Physio — trainers). Absent = a "Mixed / General" crew that only appears on the Crews page. Queryable at the relay level via `#t`. |
| `t: crew-agent` | Optional marker that the crew has enabled its **AI Crew Agent** ("🐝 Sasquatch"). When present, clients may derive the crew's agent identity (see below) and expose an "Ask Sasquatch" experience for members. Absent = no agent. | | `t: crew-agent` | Optional marker that the crew has enabled its **AI Crew Agent** ("🐝 Sasquatch"). When present, clients may derive the crew's agent identity (see below) and expose an "Ask Sasquatch" experience for members. Absent = no agent. |
| `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). | | `p` | Member pubkeys (hex). Confirmed only when the member self-attests (see below). An optional 4th element `"admin"` marks the member as a delegated admin (see Governance). |
| `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. | | `a` | Home spots the crew claims/haunts — references to kind `30818` skate spots (up to 8). Cross-links the crew to the map. |
| `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. | | `nip05` | Optional crew NIP-05 identifier (`local@domain`). Verified against the **founder** pubkey — see NIP-05 Verification. |
| `website`, `instagram`, `social` | Links | | `website`, `instagram`, `social` | Links |
| `band` | Optional name of the crew's **band / music project**. Skate scenes and local music are tightly linked, so a crew may double as a band. Freeform text (≤100 chars). | | `band` | Optional name of the crew's **band / music project**. Skate scenes and local music are tightly linked, so a crew may double as a band. Freeform text (≤100 chars). For a full band profile (genres, instruments, gigs) a crew should instead create a **Musician** Lab profile (kind `35011`), set its focus to `crew-focus-music`, and cross-link the profile via an `a` tag. |
| `band-url` | Optional link to the crew band's music (Spotify, Bandcamp, YouTube, a Nostr music profile, etc.). Must be an `http(s)` URL. Only meaningful alongside `band`. | | `band-url` | Optional link to the crew band's music (Spotify, Bandcamp, YouTube, a Nostr music profile, etc.). Must be an `http(s)` URL. Only meaningful alongside `band`. |
| `noffer` | Optional shared tip jar (CLINK Offer — see below) | | `noffer` | Optional shared tip jar (CLINK Offer — see below) |
| `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. | | `goal` | Optional **crew treasury** — the event id of a NIP-75 Zap Goal (kind `9041`) the crew is raising funds for. See Crew Treasury below. |
| `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. | | `looking-for` | Optional **open call** — a Lab craft the crew is hiring for. Repeatable. Slugs: `filmer`, `photographer`, `editor`, `coach`, `builder`, `artist`, `musician`, `members`. Surfaces the crew's needs so matching Lab providers can reach out. |
### Crew-Hosted Events ### Crew-Hosted Events

View file

@ -34,14 +34,17 @@ import {
import { useCrewGoal } from '@/hooks/useCrewGoal'; import { useCrewGoal } from '@/hooks/useCrewGoal';
import { useAppContext } from '@/hooks/useAppContext'; import { useAppContext } from '@/hooks/useAppContext';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music } from 'lucide-react'; import { Users, Upload, X, Loader2, MapPin, Plus, ShieldCheck, AtSign, HardHat, Camera, GraduationCap, Sparkles, Target, Megaphone, Music, Palette, Dumbbell, Eye } from 'lucide-react';
const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching']; const FOCUS_ORDER: CrewFocus[] = ['mixed', 'builder', 'photo', 'coaching', 'art', 'music', 'wellness'];
const FOCUS_ICONS: Record<CrewFocus, typeof Users> = { const FOCUS_ICONS: Record<CrewFocus, typeof Users> = {
mixed: Sparkles, mixed: Sparkles,
builder: HardHat, builder: HardHat,
photo: Camera, photo: Camera,
coaching: GraduationCap, coaching: GraduationCap,
art: Palette,
music: Music,
wellness: Dumbbell,
}; };
const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS); const VIBE_ENTRIES = Object.entries(CREW_VIBE_LABELS);
@ -670,6 +673,14 @@ export function CrewForm({ existing }: CrewFormProps) {
owner of the crew's identity. owner of the crew's identity.
</span> </span>
</p> </p>
<p className="mt-1 flex items-start gap-1 text-xs text-gray-500">
<Eye className="mt-0.5 h-3 w-3 shrink-0 text-teal-400" />
<span>
Each member controls their <strong className="text-teal-300">own visibility</strong> — when they
confirm from their key they can choose <em>Public</em> or <em>Crew-only</em> (hidden from
outsiders). You can't set this for them; it's their call.
</span>
</p>
</div> </div>
<div className="flex gap-2"> <div className="flex gap-2">
<Input <Input
@ -810,7 +821,9 @@ export function CrewForm({ existing }: CrewFormProps) {
<NofferField value={noffer} onChange={setNoffer} /> <NofferField value={noffer} onChange={setNoffer} />
</div> </div>
{/* Crew Band — many crews double as local bands. Optional. */} {/* Crew Band — many crews double as local bands. Optional.
Interconnects with the Lab: a serious band can set its Crew Type to
"Band & Music" and link a full Musician profile below. */}
<div className="space-y-3 rounded-xl border border-purple-500/20 bg-purple-500/[0.04] p-4"> <div className="space-y-3 rounded-xl border border-purple-500/20 bg-purple-500/[0.04] p-4">
<div className="flex items-center gap-2"> <div className="flex items-center gap-2">
<Music className="h-4 w-4 text-purple-400" /> <Music className="h-4 w-4 text-purple-400" />
@ -845,6 +858,28 @@ export function CrewForm({ existing }: CrewFormProps) {
/> />
</div> </div>
</div> </div>
{/* Interconnect nudge: a band should live in the Lab's Music scene. */}
{bandName.trim() && focus !== 'music' && (
<button
type="button"
onClick={() => setFocus('music')}
className="flex w-full items-start gap-2 rounded-lg border border-purple-500/40 bg-purple-500/10 px-3 py-2.5 text-left transition-colors hover:bg-purple-500/20"
>
<Sparkles className="mt-0.5 h-4 w-4 shrink-0 text-purple-300" />
<span className="text-xs text-purple-200">
You've got a band — tap to set your <strong>Crew Type</strong> to{' '}
<strong>Band &amp; Music</strong> so you also show up in The Lab's Music scene.
</span>
</button>
)}
<p className="flex items-start gap-1.5 text-[11px] leading-snug text-gray-500">
<Music className="mt-0.5 h-3 w-3 shrink-0 text-purple-400" />
For a full band profile (genres, instruments, gigs), create a{' '}
<strong className="text-gray-300">Musician</strong> in The Lab, then link it in the{' '}
<strong className="text-gray-300">Linked Lab profiles</strong> section above.
</p>
</div> </div>
{/* Logo */} {/* Logo */}

View file

@ -2,10 +2,33 @@ import { Link } from 'react-router-dom';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Users, ChevronRight, MapPin } from 'lucide-react'; import { Users, ChevronRight, MapPin } from 'lucide-react';
import { useCrewsByFocus } from '@/hooks/useCrews'; import { useCrewsByFocus } from '@/hooks/useCrews';
import { CREW_FOCUS_LABELS, crewFocusForServiceType, type SkateCrew } from '@/lib/types'; import { CREW_FOCUS_LABELS, crewFocusForServiceType, type SkateCrew, type CrewFocus } from '@/lib/types';
import { Skeleton } from '@/components/ui/skeleton'; import { Skeleton } from '@/components/ui/skeleton';
type ServiceType = 'coaching' | 'filming' | 'editing' | 'photography' | 'building'; /** The Lab page's own ServiceType union (kept in sync with LabPage). */
type ServiceType =
| 'coaching' | 'filming' | 'editing' | 'photography' | 'building'
| 'art' | 'graffiti' | 'tattoo' | 'training' | 'music';
/** Translate the Lab page's ServiceType into a crew focus scene. */
function focusForLabService(serviceType: ServiceType): Exclude<CrewFocus, 'mixed'> {
switch (serviceType) {
case 'art':
case 'graffiti':
case 'tattoo':
return 'art';
case 'music':
return 'music';
case 'training':
return 'wellness';
case 'building':
return crewFocusForServiceType('building');
case 'coaching':
return crewFocusForServiceType('coaching');
default:
return crewFocusForServiceType('filming'); // filming / editing / photography → photo
}
}
function CrewChip({ crew }: { crew: SkateCrew }) { function CrewChip({ crew }: { crew: SkateCrew }) {
return ( return (
@ -44,7 +67,7 @@ function CrewChip({ crew }: { crew: SkateCrew }) {
* cross-reference each other. Renders nothing when there are no matching crews. * cross-reference each other. Renders nothing when there are no matching crews.
*/ */
export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) { export function LabCrewsStrip({ serviceType }: { serviceType: ServiceType }) {
const focus = crewFocusForServiceType(serviceType); const focus = focusForLabService(serviceType);
const { data: crews, isLoading } = useCrewsByFocus(focus, 20); const { data: crews, isLoading } = useCrewsByFocus(focus, 20);
// Only render the section when there's something to show (or while loading). // Only render the section when there's something to show (or while loading).

View file

@ -1252,13 +1252,23 @@ export const CREW_VIBE_LABELS: Record<string, string> = {
* Stored on the crew event as a single-letter, relay-queryable `t` tag using * Stored on the crew event as a single-letter, relay-queryable `t` tag using
* the `CREW_FOCUS_TAG` map (e.g. `["t", "crew-focus-builder"]`). * the `CREW_FOCUS_TAG` map (e.g. `["t", "crew-focus-builder"]`).
*/ */
export type CrewFocus = 'mixed' | 'builder' | 'photo' | 'coaching'; export type CrewFocus =
| 'mixed'
| 'builder'
| 'photo'
| 'coaching'
| 'art'
| 'music'
| 'wellness';
export const CREW_FOCUS_LABELS: Record<CrewFocus, string> = { export const CREW_FOCUS_LABELS: Record<CrewFocus, string> = {
mixed: 'Mixed / General', mixed: 'Mixed / General',
builder: 'DIY & Builders', builder: 'DIY & Builders',
photo: 'Photo & Film', photo: 'Photo & Film',
coaching: 'Coaching', coaching: 'Coaching',
art: 'Art & Design',
music: 'Band & Music',
wellness: 'Training & Physio',
}; };
export const CREW_FOCUS_DESCRIPTIONS: Record<CrewFocus, string> = { export const CREW_FOCUS_DESCRIPTIONS: Record<CrewFocus, string> = {
@ -1266,6 +1276,9 @@ export const CREW_FOCUS_DESCRIPTIONS: Record<CrewFocus, string> = {
builder: 'A DIY / park-building crew — also shows in The Lab under Builders.', builder: 'A DIY / park-building crew — also shows in The Lab under Builders.',
photo: 'A filming, photography or editing crew — also shows in The Lab under Photo & Film.', photo: 'A filming, photography or editing crew — also shows in The Lab under Photo & Film.',
coaching: 'A coaching / lessons crew — also shows in The Lab under Coaching.', coaching: 'A coaching / lessons crew — also shows in The Lab under Coaching.',
art: 'An art, design, graffiti or tattoo collective — also shows in The Lab under Art & Design.',
music: 'A band or music collective — also shows in The Lab under Music.',
wellness: 'A training, fitness or physio crew — also shows in The Lab under Training.',
}; };
/** `t`-tag value stored on the crew event for each non-mixed focus. */ /** `t`-tag value stored on the crew event for each non-mixed focus. */
@ -1273,6 +1286,9 @@ export const CREW_FOCUS_TAG: Record<Exclude<CrewFocus, 'mixed'>, string> = {
builder: 'crew-focus-builder', builder: 'crew-focus-builder',
photo: 'crew-focus-photo', photo: 'crew-focus-photo',
coaching: 'crew-focus-coaching', coaching: 'crew-focus-coaching',
art: 'crew-focus-art',
music: 'crew-focus-music',
wellness: 'crew-focus-wellness',
}; };
/** Reverse lookup: `t`-tag value → CrewFocus. */ /** Reverse lookup: `t`-tag value → CrewFocus. */
@ -1280,6 +1296,9 @@ export const CREW_FOCUS_FROM_TAG: Record<string, CrewFocus> = {
'crew-focus-builder': 'builder', 'crew-focus-builder': 'builder',
'crew-focus-photo': 'photo', 'crew-focus-photo': 'photo',
'crew-focus-coaching': 'coaching', 'crew-focus-coaching': 'coaching',
'crew-focus-art': 'art',
'crew-focus-music': 'music',
'crew-focus-wellness': 'wellness',
}; };
/** /**
@ -1293,6 +1312,8 @@ export const CREW_LOOKING_FOR_OPTIONS = [
{ slug: 'editor', label: 'Video Editor', emoji: '✂️' }, { slug: 'editor', label: 'Video Editor', emoji: '✂️' },
{ slug: 'coach', label: 'Coach', emoji: '🎓' }, { slug: 'coach', label: 'Coach', emoji: '🎓' },
{ slug: 'builder', label: 'Ramp Builder', emoji: '🔨' }, { slug: 'builder', label: 'Ramp Builder', emoji: '🔨' },
{ slug: 'artist', label: 'Artist / Designer', emoji: '🎨' },
{ slug: 'musician', label: 'Musician / Band', emoji: '🎵' },
{ slug: 'members', label: 'New Members', emoji: '🛹' }, { slug: 'members', label: 'New Members', emoji: '🛹' },
] as const; ] as const;
@ -1304,13 +1325,39 @@ export const CREW_LOOKING_FOR_LABELS: Record<string, { label: string; emoji: str
/** NIP-75 Zap Goal kind — used for crew funding goals / treasury. */ /** NIP-75 Zap Goal kind — used for crew funding goals / treasury. */
export const ZAP_GOAL_KIND = 9041; export const ZAP_GOAL_KIND = 9041;
/** Lab service types that map onto a crew scene (used by LabCrewsStrip). */
export type LabServiceType =
| 'coaching'
| 'filming'
| 'editing'
| 'photography'
| 'building'
| 'artist'
| 'graffiti'
| 'tattoo'
| 'trainer'
| 'musician';
/** Map a Lab service type to the crew focus that belongs in that scene. */ /** Map a Lab service type to the crew focus that belongs in that scene. */
export function crewFocusForServiceType( export function crewFocusForServiceType(
serviceType: 'coaching' | 'filming' | 'editing' | 'photography' | 'building' serviceType: LabServiceType
): Exclude<CrewFocus, 'mixed'> { ): Exclude<CrewFocus, 'mixed'> {
if (serviceType === 'building') return 'builder'; switch (serviceType) {
if (serviceType === 'coaching') return 'coaching'; case 'building':
return 'photo'; // filming, editing, photography return 'builder';
case 'coaching':
return 'coaching';
case 'artist':
case 'graffiti':
case 'tattoo':
return 'art';
case 'musician':
return 'music';
case 'trainer':
return 'wellness';
default:
return 'photo'; // filming, editing, photography
}
} }
export interface SkateCrew { export interface SkateCrew {

View file

@ -14,13 +14,16 @@ import { crewAccent, crewDistanceKm, formatDistance, type LatLng } from '@/lib/c
import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types'; import { CREW_VIBE_LABELS, CREW_FOCUS_LABELS, type SkateCrew, type CrewFocus } from '@/lib/types';
import type { CrewActivity } from '@/lib/crewHelpers'; import type { CrewActivity } from '@/lib/crewHelpers';
import { cn } from '@/lib/utils'; import { cn } from '@/lib/utils';
import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music } from 'lucide-react'; import { Users, Plus, Search, MapPin, X, HardHat, Camera, GraduationCap, Sparkles, Navigation, Loader2, Music, Palette, Dumbbell } from 'lucide-react';
const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; icon: typeof Users }[] = [ const FOCUS_FILTERS: { key: CrewFocus | 'all'; label: string; icon: typeof Users }[] = [
{ key: 'all', label: 'All', icon: Users }, { key: 'all', label: 'All', icon: Users },
{ key: 'builder', label: CREW_FOCUS_LABELS.builder, icon: HardHat }, { key: 'builder', label: CREW_FOCUS_LABELS.builder, icon: HardHat },
{ key: 'photo', label: CREW_FOCUS_LABELS.photo, icon: Camera }, { key: 'photo', label: CREW_FOCUS_LABELS.photo, icon: Camera },
{ key: 'coaching', label: CREW_FOCUS_LABELS.coaching, icon: GraduationCap }, { key: 'coaching', label: CREW_FOCUS_LABELS.coaching, icon: GraduationCap },
{ key: 'art', label: CREW_FOCUS_LABELS.art, icon: Palette },
{ key: 'music', label: CREW_FOCUS_LABELS.music, icon: Music },
{ key: 'wellness', label: CREW_FOCUS_LABELS.wellness, icon: Dumbbell },
{ key: 'mixed', label: CREW_FOCUS_LABELS.mixed, icon: Sparkles }, { key: 'mixed', label: CREW_FOCUS_LABELS.mixed, icon: Sparkles },
]; ];
@ -29,6 +32,9 @@ const FOCUS_BADGE_ICONS: Record<CrewFocus, typeof Users> = {
builder: HardHat, builder: HardHat,
photo: Camera, photo: Camera,
coaching: GraduationCap, coaching: GraduationCap,
art: Palette,
music: Music,
wellness: Dumbbell,
}; };
function CrewCard({ function CrewCard({