Interconnect Music ↔ Events: featured musician on events + gig list on musician profiles; count new Lab crafts in growth stats; document visibility/vouch/gig conventions in NIP.md

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
This commit is contained in:
shakespeare.diy 2026-08-10 22:02:22 +07:00
parent 926470a86e
commit 96f7c3192d
7 changed files with 273 additions and 12 deletions

73
NIP.md
View file

@ -1579,6 +1579,53 @@ attestations. A member is:
Removing a member: the founder drops the `p` tag, or the member deletes their
label event (NIP-09). Either direction flips the member out of "confirmed".
#### Member visibility (opt-in privacy)
A member controls their own discovery visibility **on their own attestation**
(not the founder's event), via an optional `visibility` tag:
```json
["visibility", "public" | "crew"]
```
| Value | Meaning |
|-------|---------|
| `public` *(default, tag absent)* | Listed everywhere — public crew page, search, discovery. |
| `crew` | Hidden from non-members. To outsiders the member renders as an anonymous "hidden member" slot (`+N hidden`); to the founder and confirmed crewmates they render normally. |
This is **discovery privacy** at the app layer, controlled by the member's own
key — so a public crew can contain a fully-hidden member. Note that the `p` tag
on the founder's event is public regardless; `visibility` governs how clients
*display* the member, not whether the pubkey exists on relays. Clients determine
"is the viewer a crewmate?" by checking whether the viewer is the founder or a
confirmed member before revealing `crew`-scoped members.
### Scene vouches (NIP-32 web-of-trust anti-fraud)
Any user can **vouch** for a Lab profile — an independent, signed attestation
that the provider is legit. This is a lightweight web-of-trust layer to fight
impersonation and fraud without gatekeeping. A vouch is a kind `1985` label
signed with the voucher's own key, referencing the provider's coordinate:
```json
{
"kind": 1985,
"content": "Filmed a full part with them, 100% legit.",
"tags": [
["L", "org.skatespots.vouch"],
["l", "vouch", "org.skatespots.vouch"],
["a", "35011:<providerPubkey>:<d-tag>"],
["p", "<providerPubkey>"],
["alt", "Scene vouch for a SkateSpots Lab provider"]
]
}
```
Clients aggregate vouches for a coordinate and **trust-weight** them using each
voucher's web-of-trust score, so a handful of vouches from trusted skaters
outweighs many from throwaway keys. A provider cannot vouch for themselves
(self-vouches are ignored). Vouches are revocable via NIP-09 deletion.
### Photographer (kind 35004)
Skate photography is a distinct craft from filming — it is shot-, light-, and
@ -1879,6 +1926,32 @@ carries an `a` tag pointing back at the crew coordinate, plus a readable
Clients query a crew's events with `{ kinds: [31923], "#a": ["35006:<founder>:<crewDtag>"] }`
and display "Hosted by [crew]" on the event, closing the Crews ↔ Events ↔ Zaps loop.
### Events ↔ Musicians (live music)
An event may feature a **musician** (Lab kind `35011`) — e.g. a band playing a
jam or comp afterparty. This reuses the exact `a`-tag pattern as crew hosting: the
event carries an `a` tag pointing at the musician's coordinate, plus a readable
`music-act` tag.
```json
{
"kind": 31923,
"tags": [
["d", "bcn-jam-afterparty"],
["title", "BCN Jam Afterparty"],
["start", "1730000000"],
["t", "skateboarding"],
["a", "35011:<musicianPubkey>:<musicianDtag>"],
["music-act", "The Curb Grinders"]
]
}
```
The event page shows a "🎵 Live: [act]" pill. Reciprocally, a musician's Lab
profile queries `{ kinds: [31923], "#a": ["35011:<pubkey>:<dtag>"] }` to list
its **upcoming gigs**, closing the Music ↔ Events loop. An event may carry both a
crew `a` tag and a musician `a` tag.
### Crew Treasury (NIP-75 Zap Goal)
A crew may run a transparent, **non-custodial** funding goal for a shared cause

View file

@ -12,6 +12,11 @@ const LAB_TYPE_LABEL: Record<number, string> = {
35003: 'Editor',
35004: 'Photographer',
35005: 'Builder',
35007: 'Artist',
35008: 'Graffiti',
35009: 'Tattoo',
35010: 'Trainer',
35011: 'Musician',
};
/** A single linked-crew row, resolved from a `35006:pubkey:d` coordinate. */

55
src/hooks/useGigs.ts Normal file
View file

@ -0,0 +1,55 @@
import { useQuery } from '@tanstack/react-query';
import { useSkateRelays } from '@/hooks/useSkateRelays';
import {
SKATE_MUSICIAN_KIND,
CALENDAR_EVENT_KIND,
parseSkateEvent,
type SkateEvent,
} from '@/lib/types';
/**
* Gigs for a musician — calendar events (kind 31923) carrying an `a` tag that
* points back at the musician's Lab coordinate (`35011:pubkey:d`).
*
* This powers the "Upcoming gigs" list on a musician's Lab detail page and
* closes the Music ↔ Events loop: an event promoter tags a musician when
* creating an event, and the gig automatically surfaces on the act's profile.
*/
export function useGigs(musicianPubkey?: string, musicianIdentifier?: string, limit = 30) {
const { querySkateContent, getQueryKeySuffix } = useSkateRelays();
const musicianCoord =
musicianPubkey && musicianIdentifier
? `${SKATE_MUSICIAN_KIND}:${musicianPubkey}:${musicianIdentifier}`
: '';
return useQuery({
queryKey: ['gigs', musicianCoord, getQueryKeySuffix()],
enabled: !!musicianCoord,
staleTime: 60 * 1000,
gcTime: 30 * 60 * 1000,
queryFn: async () => {
const signal = AbortSignal.timeout(10000);
const events = await querySkateContent(
[{ kinds: [CALENDAR_EVENT_KIND], '#a': [musicianCoord], limit }],
{ signal },
);
const parsed: SkateEvent[] = [];
for (const ev of events) {
const skateEvent = parseSkateEvent(ev);
// Guard against loose relay matches — the `a` tag must be present.
const hasRef = ev.tags.some(([t, v]) => t === 'a' && v === musicianCoord);
if (skateEvent && hasRef) parsed.push(skateEvent);
}
// Soonest upcoming first, then past.
const now = Math.floor(Date.now() / 1000);
return parsed.sort((a, b) => {
const aFuture = a.start >= now ? 0 : 1;
const bFuture = b.start >= now ? 0 : 1;
if (aFuture !== bFuture) return aFuture - bFuture;
return a.start - b.start;
});
},
});
}

View file

@ -9,6 +9,11 @@ import {
SKATE_EDITOR_KIND,
SKATE_PHOTOGRAPHER_KIND,
SKATE_BUILDER_KIND,
SKATE_ARTIST_KIND,
SKATE_GRAFFITI_KIND,
SKATE_TATTOO_KIND,
SKATE_TRAINER_KIND,
SKATE_MUSICIAN_KIND,
SKATE_TOUR_KIND,
SKATE_CREW_KIND,
SKATE_RELAY,
@ -128,6 +133,7 @@ export function useGrowthStats() {
builderEvents,
tourEvents,
crewEvents,
otherLabEvents,
] = await Promise.all([
relay.query([{ kinds: [SKATE_SPOT_KIND], '#t': ['skatespot'], limit: 2000 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_SHOP_KIND], limit: 2000 }], { signal }).catch(() => []),
@ -139,6 +145,9 @@ export function useGrowthStats() {
relay.query([{ kinds: [SKATE_BUILDER_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_TOUR_KIND], limit: 500 }], { signal }).catch(() => []),
relay.query([{ kinds: [SKATE_CREW_KIND], limit: 500 }], { signal }).catch(() => []),
// New Lab crafts (artist, graffiti, tattoo, trainer, musician) — folded
// into community totals/mappers without their own chart series.
relay.query([{ kinds: [SKATE_ARTIST_KIND, SKATE_GRAFFITI_KIND, SKATE_TATTOO_KIND, SKATE_TRAINER_KIND, SKATE_MUSICIAN_KIND], limit: 1000 }], { signal }).catch(() => []),
]);
// Deduplicate addressable events (keep newest per pubkey+kind+d)
@ -163,6 +172,7 @@ export function useGrowthStats() {
const builders = dedup(builderEvents);
const tours = dedup(tourEvents);
const crews = dedup(crewEvents);
const otherLab = dedup(otherLabEvents);
// ── Monthly bucketing ───────────────────────────────────────────────
@ -195,6 +205,8 @@ export function useGrowthStats() {
for (const e of builders) { const b = ensureBucket(toMonthKey(e.created_at)); b.builders.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of tours) { const b = ensureBucket(toMonthKey(e.created_at)); b.tours.add(e.id); b.pubkeys.add(e.pubkey); }
for (const e of crews) { const b = ensureBucket(toMonthKey(e.created_at)); b.crews.add(e.id); b.pubkeys.add(e.pubkey); }
// Other Lab crafts contribute to active-mapper counts (no dedicated series).
for (const e of otherLab) { const b = ensureBucket(toMonthKey(e.created_at)); b.pubkeys.add(e.pubkey); }
// Fill any gaps between first and last month so charts have continuous X axis
const allKeys = fillMonthGaps(Array.from(bucketMap.keys()));
@ -235,7 +247,7 @@ export function useGrowthStats() {
const mapperTotal = new Map<string, number>();
for (const e of spots) { mapperSpots.set(e.pubkey, (mapperSpots.get(e.pubkey) ?? 0) + 1); }
const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...photographers, ...builders, ...tours, ...crews];
const allContent = [...spots, ...shops, ...events, ...coaches, ...filmers, ...editors, ...photographers, ...builders, ...tours, ...crews, ...otherLab];
for (const e of allContent) { mapperTotal.set(e.pubkey, (mapperTotal.get(e.pubkey) ?? 0) + 1); }
const topMappers: TopMapper[] = Array.from(mapperSpots.entries())

View file

@ -14,6 +14,7 @@ import { VouchCard } from '@/components/VouchCard';
import { LinkedCrewCard } from '@/components/CrossLinkCards';
import { TrustBadge } from '@/components/TrustBadge';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { useGigs } from '@/hooks/useGigs';
import { useAuthor } from '@/hooks/useAuthor';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { DeleteEntityDialog } from '@/components/DeleteEntityDialog';
@ -83,6 +84,12 @@ export default function CoachDetailPage() {
const { user } = useCurrentUser();
const navigate = useNavigate();
// Gigs — only relevant for musicians. The hook is disabled for other types.
const { data: gigs = [] } = useGigs(
profile?.profileType === 'musician' ? profile.pubkey : undefined,
profile?.profileType === 'musician' ? profile.identifier : undefined,
);
const isCoach = profile?.profileType === 'coach';
const isFilmer = profile?.profileType === 'filmer';
const isEditor = profile?.profileType === 'editor';
@ -396,6 +403,42 @@ export default function CoachDetailPage() {
</div>
)}
{/* Upcoming gigs — events that feature this musician */}
{isMusician && gigs.length > 0 && (
<div className="mb-6">
<h3 className="text-lg font-bold text-[#ccff00] uppercase mb-3 flex items-center gap-2">
<Calendar className={`h-4 w-4 ${accent.text}`} />
Upcoming Gigs
</h3>
<div className="space-y-2">
{gigs.slice(0, 6).map((gig) => {
const isUpcoming = gig.start >= Math.floor(Date.now() / 1000);
return (
<Link
key={gig.event.id}
to={`/event/${gig.pubkey}/${gig.identifier}`}
className={`flex items-center gap-3 rounded-lg border ${accent.border} bg-black/50 p-3 transition-colors hover:bg-white/5`}
>
<div className={`flex h-10 w-10 shrink-0 items-center justify-center rounded-lg ${accent.bg}/20`}>
<Music className={`h-4 w-4 ${accent.text}`} />
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{gig.title}</p>
<p className="text-xs text-gray-400">
{new Date(gig.start * 1000).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })}
{gig.location ? ` · ${gig.location}` : ''}
</p>
</div>
{!isUpcoming && (
<Badge variant="secondary" className="shrink-0 text-[10px]">Past</Badge>
)}
</Link>
);
})}
</div>
</div>
)}
{/* Experience & Rate */}
<div className="grid grid-cols-2 gap-4">
{profile.experience && (
@ -436,6 +479,22 @@ export default function CoachDetailPage() {
Send Message
</Button>
{/* Book this act for an event — deep-links to event creation
pre-tagged with this musician (Music → Events). */}
{isMusician && (
<Button
className={`w-full gap-2 ${accent.bg} text-white font-bold hover:opacity-90`}
onClick={() =>
navigate(
`/create-event?type=concert&musician=${profile.pubkey}:${profile.identifier}`,
)
}
>
<Calendar className="h-4 w-4" />
Book for a Gig
</Button>
)}
{/* Contact Info from Profile */}
{sanitizeTel(profile.phone) && (
<a href={sanitizeTel(profile.phone)!} className="flex items-center gap-2 text-sm text-gray-300 p-2 bg-black/50 rounded hover:bg-black/70 transition-colors">

View file

@ -19,8 +19,9 @@ import { useSkateSpots } from '@/hooks/useSkateSpots';
import { useToast } from '@/hooks/useToast';
import { useTranslation } from '@/contexts/LanguageContext';
import { LoginArea } from '@/components/auth/LoginArea';
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types';
import { CALENDAR_EVENT_KIND, EVENT_TYPE_LABELS, TOUR_CATEGORIES, SKATE_TOUR_KIND, SKATE_SPOT_KIND, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND, SPOT_CATEGORIES, type SkateTour, type SkateSpot } from '@/lib/types';
import { useCrew } from '@/hooks/useCrews';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { Users } from 'lucide-react';
import { cn } from '@/lib/utils';
import {
@ -37,6 +38,7 @@ import {
Globe,
Map,
Zap,
Music,
} from 'lucide-react';
import { FeaturedEventUpgrade } from '@/components/FeaturedEventUpgrade';
import { usePlatformFee } from '@/hooks/usePlatformFee';
@ -60,6 +62,13 @@ export default function CreateEventPage() {
? crewParam.split(':')
: ['', ''];
const { data: hostCrew } = useCrew(crewHostIdentifier, crewHostPubkey);
// Optional featured musician: `?musician=<pubkey>:<identifier>` — a music act
// (Lab kind 35011) playing this event. Connects Music ↔ Events.
const musicianParam = searchParams.get('musician') ?? '';
const [musicianPubkey, musicianIdentifier] = musicianParam.includes(':')
? musicianParam.split(':')
: ['', ''];
const { data: musicAct } = useLabProfile(musicianIdentifier, musicianPubkey);
// Optional deep-link prefill: `?type=concert&title=...` — used by e.g. the
// "Book for a gig" action on a crew that has a band.
const prefillType = searchParams.get('type') ?? '';
@ -351,6 +360,13 @@ export default function CreateEventPage() {
tags.push(['crew-name', hostCrew.name]);
}
// Featured musician — link a music act (Lab kind 35011) to the event so it
// shows "🎸 Live: [act]" and surfaces on the musician's gig list.
if (musicAct && musicAct.profileType === 'musician') {
tags.push(['a', `${SKATE_MUSICIAN_KIND}:${musicAct.pubkey}:${musicAct.identifier}`]);
tags.push(['music-act', musicAct.title]);
}
await createEvent({
kind: CALENDAR_EVENT_KIND,
content: description.trim(),
@ -424,6 +440,22 @@ export default function CreateEventPage() {
</Card>
)}
{musicAct && musicAct.profileType === 'musician' && (
<Card className="mt-4 border-2 border-fuchsia-500/40 bg-fuchsia-500/10">
<CardContent className="flex items-center gap-3 p-4">
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-fuchsia-500/20">
<Music className="h-5 w-5 text-fuchsia-400" />
</div>
<div className="min-w-0">
<p className="text-sm font-bold text-fuchsia-300">Featuring {musicAct.title}</p>
<p className="text-xs text-gray-400">
This act will be tagged as live music and surfaced on their gig list.
</p>
</div>
</CardContent>
</Card>
)}
{!hasNip05 && (
<Card className="mt-4 bg-yellow-950/30 border-2 border-yellow-500/50">
<CardContent className="p-4">

View file

@ -14,7 +14,7 @@ import { CommentsSection } from '@/components/comments/CommentsSection';
import { useSkateEvent } from '@/hooks/useSkateEvents';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND } from '@/lib/types';
import { EVENT_TYPE_LABELS, SKATE_CREW_KIND, SKATE_MUSICIAN_KIND } from '@/lib/types';
import { Users } from 'lucide-react';
import NotFound from './NotFound';
import {
@ -26,6 +26,7 @@ import {
Navigation,
CalendarPlus,
Pencil,
Music,
} from 'lucide-react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
@ -112,6 +113,19 @@ export default function EventDetailPage() {
return hPubkey && hId ? { pubkey: hPubkey, identifier: hId, name } : null;
}, [event]);
// Featured musician — an `a` tag pointing at a musician coordinate (35011:pubkey:d).
const musicAct = useMemo(() => {
if (!event) return null;
const ref = event.event.tags
.filter(([t]) => t === 'a')
.map(([, v]) => v)
.find((v) => v?.startsWith(`${SKATE_MUSICIAN_KIND}:`));
if (!ref) return null;
const [, mPubkey, mId] = ref.split(':');
const name = event.event.tags.find(([t]) => t === 'music-act')?.[1];
return mPubkey && mId ? { pubkey: mPubkey, identifier: mId, name } : null;
}, [event]);
const handleShare = async () => {
if (navigator.share) {
try {
@ -220,15 +234,26 @@ export default function EventDetailPage() {
)}
</div>
<h1 className="text-3xl font-bold">{event.title}</h1>
{crewHost && (
<Link
to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`}
className="mt-2 inline-flex items-center gap-1.5 rounded-full border border-teal-500/40 bg-teal-500/10 px-3 py-1 text-sm font-bold text-teal-300 transition-colors hover:bg-teal-500/20"
>
<Users className="h-3.5 w-3.5" />
Hosted by {crewHost.name || 'a crew'}
</Link>
)}
<div className="mt-2 flex flex-wrap items-center gap-2">
{crewHost && (
<Link
to={`/crew/${crewHost.pubkey}/${crewHost.identifier}`}
className="inline-flex items-center gap-1.5 rounded-full border border-teal-500/40 bg-teal-500/10 px-3 py-1 text-sm font-bold text-teal-300 transition-colors hover:bg-teal-500/20"
>
<Users className="h-3.5 w-3.5" />
Hosted by {crewHost.name || 'a crew'}
</Link>
)}
{musicAct && (
<Link
to={`/coach/${musicAct.pubkey}/${musicAct.identifier}`}
className="inline-flex items-center gap-1.5 rounded-full border border-fuchsia-500/40 bg-fuchsia-500/10 px-3 py-1 text-sm font-bold text-fuchsia-300 transition-colors hover:bg-fuchsia-500/20"
>
<Music className="h-3.5 w-3.5" />
Live: {musicAct.name || 'live music'}
</Link>
)}
</div>
</div>
<div className="flex items-center gap-2">
{isOwner && (