Nutzap safety gate + crew↔Lab cross-linking

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>
This commit is contained in:
shakespeare.diy 2026-07-24 14:27:51 +07:00
parent ed27f719fa
commit d8d1ba32c9
13 changed files with 435 additions and 29 deletions

View file

@ -15,6 +15,7 @@ import { useToast } from '@/hooks/useToast';
import { useAuthor } from '@/hooks/useAuthor';
import { genUserName } from '@/lib/genUserName';
import { SpotPicker } from '@/components/SpotPicker';
import { LabLinkField } from '@/components/LabLinkField';
import { NofferField } from '@/components/NofferField';
import {
SKATE_CREW_KIND,
@ -94,6 +95,7 @@ export function CrewForm({ existing }: CrewFormProps) {
return `${parts[1]}:${parts[2]}`;
})
);
const [labRefs, setLabRefs] = useState<string[]>(existing?.labRefs ?? []);
const [isGettingLocation, setIsGettingLocation] = useState(false);
const toggleVibe = (v: string) =>
@ -239,6 +241,11 @@ export function CrewForm({ existing }: CrewFormProps) {
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
}
// Linked Lab profiles (cross-link) — a-tags pointing at Lab kinds
for (const coord of labRefs) {
tags.push(['a', coord]);
}
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
@ -373,6 +380,9 @@ export function CrewForm({ existing }: CrewFormProps) {
description="Link the spots your crew haunts — they'll connect to your crew on the map"
/>
{/* Linked Lab profiles (cross-link) */}
<LabLinkField value={labRefs} onChange={setLabRefs} />
<div className="grid grid-cols-2 gap-4">
<div className="space-y-2">
<Label htmlFor="crew-location">Home Turf (City / Area)</Label>

View file

@ -0,0 +1,82 @@
import { useMemo } from 'react';
import { Label } from '@/components/ui/label';
import { Badge } from '@/components/ui/badge';
import { Users, X, Link2 } from 'lucide-react';
import { useCrews } from '@/hooks/useCrews';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { SKATE_CREW_KIND } from '@/lib/types';
import { cn } from '@/lib/utils';
interface CrewLinkFieldProps {
/** Selected crew coordinate `35006:pubkey:d`, or undefined. */
value?: string;
onChange: (coord: string | undefined) => void;
accentText?: string;
accentBg?: string;
}
/**
* Lets a Lab-profile owner link ONE of their own crews. Cross-links a collective
* Lab profile (studio) to its crew so each surfaces the other.
*/
export function CrewLinkField({ value, onChange, accentText = 'text-teal-400', accentBg = 'bg-teal-500' }: CrewLinkFieldProps) {
const { user } = useCurrentUser();
const { data: allCrews } = useCrews(200);
// Only offer the crews this user founded — you can only vouch a link from a crew you control.
const myCrews = useMemo(
() => (allCrews ?? []).filter((c) => c.pubkey === user?.pubkey),
[allCrews, user?.pubkey]
);
const coordFor = (pubkey: string, identifier: string) => `${SKATE_CREW_KIND}:${pubkey}:${identifier}`;
return (
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>
<Label className={cn('flex items-center gap-2', accentText)}>
<Link2 className="h-4 w-4" /> Linked Crew
</Label>
<p className="mt-1 text-xs text-gray-500">
Run a crew too? Link it so people can jump between your studio and your crew. Optional.
</p>
</div>
{myCrews.length === 0 ? (
<p className="text-xs text-gray-500 italic">
You don't have any crews yet. Create one on the Crews page to link it here.
</p>
) : (
<div className="flex flex-wrap gap-2">
{myCrews.map((crew) => {
const coord = coordFor(crew.pubkey, crew.identifier);
const selected = value === coord;
return (
<button
key={coord}
type="button"
onClick={() => onChange(selected ? undefined : coord)}
className={cn(
'inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-bold transition-all active:scale-95',
selected
? `${accentBg} border-transparent text-white`
: 'border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700'
)}
>
<Users className="h-3.5 w-3.5" />
{crew.name}
{selected && <X className="h-3 w-3 opacity-80" />}
</button>
);
})}
</div>
)}
{value && !myCrews.some((c) => coordFor(c.pubkey, c.identifier) === value) && (
<Badge variant="outline" className="gap-1 text-[10px] text-gray-400">
<Users className="h-3 w-3" /> Linked crew ({value.split(':')[2]})
</Badge>
)}
</div>
);
}

View file

@ -0,0 +1,109 @@
import { Link } from 'react-router-dom';
import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Users, GraduationCap, ChevronRight, Link2 } from 'lucide-react';
import { useCrew } from '@/hooks/useCrews';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { parseAddrCoord } from '@/lib/types';
const LAB_TYPE_LABEL: Record<number, string> = {
35001: 'Coach',
35002: 'Filmer',
35003: 'Editor',
35004: 'Photographer',
35005: 'Builder',
};
/** A single linked-crew row, resolved from a `35006:pubkey:d` coordinate. */
function CrewRow({ coord }: { coord: string }) {
const parsed = parseAddrCoord(coord);
const { data: crew } = useCrew(parsed?.identifier ?? '', parsed?.pubkey);
if (!parsed) return null;
return (
<Link
to={`/crew/${parsed.pubkey}/${parsed.identifier}`}
className="flex items-center gap-3 rounded-lg border border-gray-700 bg-black/40 p-2.5 hover:bg-black/60 hover:border-teal-500/40 transition-colors"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-teal-500/15">
{crew?.image ? (
<img src={crew.image} alt={crew.name} className="h-full w-full object-cover" />
) : (
<Users className="h-4 w-4 text-teal-400" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{crew?.name ?? 'Skate Crew'}</p>
<p className="text-xs text-teal-400">Crew{crew?.location ? ` · ${crew.location}` : ''}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-gray-500" />
</Link>
);
}
/** A single linked-Lab-profile row, resolved from a `kind:pubkey:d` coordinate. */
function LabRow({ coord }: { coord: string }) {
const parsed = parseAddrCoord(coord);
const { data: profile } = useLabProfile(parsed?.identifier ?? '', parsed?.pubkey);
if (!parsed) return null;
const typeLabel = LAB_TYPE_LABEL[parsed.kind] ?? 'Lab';
return (
<Link
to={`/coach/${parsed.pubkey}/${parsed.identifier}`}
className="flex items-center gap-3 rounded-lg border border-gray-700 bg-black/40 p-2.5 hover:bg-black/60 hover:border-teal-500/40 transition-colors"
>
<div className="flex h-9 w-9 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-white/5">
{profile?.image ? (
<img src={profile.image} alt={profile.title} className="h-full w-full object-cover" />
) : (
<GraduationCap className="h-4 w-4 text-gray-300" />
)}
</div>
<div className="min-w-0 flex-1">
<p className="truncate text-sm font-bold text-white">{profile?.title ?? `${typeLabel} profile`}</p>
<p className="text-xs text-gray-400">In The Lab · {typeLabel}</p>
</div>
<ChevronRight className="h-4 w-4 shrink-0 text-gray-500" />
</Link>
);
}
/** Card shown on a Lab profile: the crew it belongs to. */
export function LinkedCrewCard({ crewRef }: { crewRef?: string }) {
if (!crewRef) return null;
return (
<Card className="bg-gray-900 border-gray-700">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Link2 className="h-4 w-4 text-teal-400" /> Part of a Crew
</CardTitle>
</CardHeader>
<CardContent>
<CrewRow coord={crewRef} />
</CardContent>
</Card>
);
}
/** Card shown on a crew: the Lab service profiles it links to. */
export function LinkedLabCard({ labRefs }: { labRefs: string[] }) {
if (!labRefs || labRefs.length === 0) return null;
return (
<Card className="bg-gray-900 border-gray-700">
<CardHeader className="pb-3">
<CardTitle className="flex items-center gap-2 text-base">
<Link2 className="h-4 w-4 text-teal-400" /> Hire This Crew
</CardTitle>
<p className="text-xs text-gray-500">Services this crew offers in The Lab</p>
</CardHeader>
<CardContent className="space-y-2">
{labRefs.map((coord) => (
<LabRow key={coord} coord={coord} />
))}
</CardContent>
</Card>
);
}

View file

@ -0,0 +1,91 @@
import { useMemo } from 'react';
import { Label } from '@/components/ui/label';
import { Link2, X, GraduationCap } from 'lucide-react';
import { useLabProfiles } from '@/hooks/useLabProfiles';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { cn } from '@/lib/utils';
const TYPE_LABEL: Record<string, string> = {
coach: 'Coach',
filmer: 'Filmer',
editor: 'Editor',
photographer: 'Photographer',
builder: 'Builder',
};
interface LabLinkFieldProps {
/** Selected Lab profile coordinates (`kind:pubkey:d`). */
value: string[];
onChange: (coords: string[]) => void;
maxLinks?: number;
}
/**
* Lets a crew owner link their own Lab profiles (studio/coach/builder listings)
* so a crew and its service listings cross-reference each other.
*/
export function LabLinkField({ value, onChange, maxLinks = 8 }: LabLinkFieldProps) {
const { user } = useCurrentUser();
const { data: allProfiles } = useLabProfiles(200);
// Only your own Lab profiles — a crew can only vouch a link to listings it controls.
const myProfiles = useMemo(
() => (allProfiles ?? []).filter((p) => p.pubkey === user?.pubkey),
[allProfiles, user?.pubkey]
);
const coordFor = (kind: number, pubkey: string, identifier: string) => `${kind}:${pubkey}:${identifier}`;
const toggle = (coord: string) => {
if (value.includes(coord)) {
onChange(value.filter((c) => c !== coord));
} else if (value.length < maxLinks) {
onChange([...value, coord]);
}
};
return (
<div className="space-y-3 rounded-lg border border-gray-700 bg-gray-900/60 p-4">
<div>
<Label className="flex items-center gap-2 text-teal-400">
<Link2 className="h-4 w-4" /> Linked Lab Profiles
</Label>
<p className="mt-1 text-xs text-gray-500">
Run a studio, coaching or build service too? Link your Lab listings so people can hire your crew. Optional.
</p>
</div>
{myProfiles.length === 0 ? (
<p className="text-xs text-gray-500 italic">
You don't have any Lab profiles yet. Create one in The Lab to link it here.
</p>
) : (
<div className="flex flex-wrap gap-2">
{myProfiles.map((p) => {
const coord = coordFor(p.event.kind, p.pubkey, p.identifier);
const selected = value.includes(coord);
return (
<button
key={coord}
type="button"
onClick={() => toggle(coord)}
disabled={!selected && value.length >= maxLinks}
className={cn(
'inline-flex items-center gap-1.5 rounded-xl border px-3 py-2 text-xs font-bold transition-all active:scale-95 disabled:opacity-40',
selected
? 'bg-teal-500 border-transparent text-white'
: 'border-gray-700 bg-gray-800 text-gray-300 hover:bg-gray-700'
)}
>
<GraduationCap className="h-3.5 w-3.5" />
<span className="truncate max-w-[10rem]">{p.title}</span>
<span className="opacity-60">· {TYPE_LABEL[p.profileType] ?? 'Lab'}</span>
{selected && <X className="h-3 w-3 opacity-80" />}
</button>
);
})}
</div>
)}
</div>
);
}

View file

@ -261,28 +261,41 @@ export function useSetupNutzapReceiving() {
}
/**
* Send a nutzap to someone
* Note: This is a simplified implementation. Full implementation would require:
* - P2PK locking of tokens
* - DLEQ proofs
* - Proper token minting/swapping
* Whether in-app nutzap SENDING is available.
*
* Nutzap sending (NIP-61) requires minting Cashu proofs that are P2PK-locked to
* the recipient's `p2pkPubkey` plus DLEQ proofs. Getting P2PK locking wrong would
* lock funds to the wrong key — i.e. lost sats — so sending is intentionally
* DISABLED until it can be implemented and audited against the wallet's P2PK
* capabilities. Nutzap RECEIVING setup (kind 10019) and reading received nutzaps
* work today.
*
* UI should gate any "send nutzap" affordance behind this flag and fall back to
* regular Lightning zaps (which support paying from the Cashu balance anyway).
*/
export const NUTZAP_SENDING_ENABLED = false;
/**
* Send a nutzap to someone.
*
* NOT YET IMPLEMENTED — see {@link NUTZAP_SENDING_ENABLED}. This mutation always
* rejects with a clear message so nothing silently appears to succeed. Do not
* wire a visible "send" button to this until sending is implemented; use the
* regular Lightning zap flow (ZapDialog), which can pay from the Cashu balance.
*
* Full implementation would require:
* 1. Check for tokens on one of the recipient's mints (swap in if needed)
* 2. Mint/swap proofs P2PK-locked to recipient.p2pkPubkey
* 3. Include DLEQ proofs
* 4. Publish kind:9321 with `proof`, `u` (mint), `p` (recipient), optional `e`
*/
export function useSendNutzap() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { coco, repositories } = useCashu();
const { toast } = useToast();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
recipientPubkey,
recipientNutzapInfo,
eventId,
eventKind,
amount,
comment,
}: {
mutationFn: async (_params: {
recipientPubkey: string;
recipientNutzapInfo: NutzapInfo;
eventId?: string;
@ -291,26 +304,18 @@ export function useSendNutzap() {
comment?: string;
}) => {
if (!user) throw new Error('Must be logged in');
if (!coco || !repositories) throw new Error('Cashu wallet not initialized');
if (!recipientNutzapInfo.p2pkPubkey) throw new Error('Recipient has no P2PK pubkey configured');
if (recipientNutzapInfo.mints.length === 0) throw new Error('Recipient has no mints configured');
// For now, we'll show a message that full nutzap support is coming
// Full implementation would require:
// 1. Check if we have tokens on any of recipient's mints
// 2. Mint/swap tokens P2PK-locked to recipient's p2pkPubkey
// 3. Include DLEQ proofs
// 4. Publish kind:9321 event
throw new Error('Full nutzap sending coming soon! For now, use regular Lightning zaps.');
// Guarded off to avoid locking funds to the wrong key. See NUTZAP_SENDING_ENABLED.
throw new Error(
'Nutzap sending is not available yet — use a regular Lightning zap (you can pay it straight from your Cashu balance).'
);
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['nutzaps'] });
},
onError: (error) => {
toast({
title: 'Nutzap failed',
description: error instanceof Error ? error.message : 'Failed to send nutzap',
title: 'Nutzap sending unavailable',
description: error instanceof Error ? error.message : 'Use a Lightning zap instead',
variant: 'destructive',
});
},

View file

@ -84,6 +84,59 @@ export function parseMemberPubkeys(event: NostrEvent): string[] {
.map(([, value]) => value.toLowerCase());
}
/** All Lab provider kinds (The Lab service profiles). */
export const LAB_KINDS = [35001, 35002, 35003, 35004, 35005] as const;
/**
* A parsed addressable coordinate: `<kind>:<pubkey>:<d>`.
*/
export interface AddrCoordinate {
kind: number;
pubkey: string;
identifier: string;
/** The raw `kind:pubkey:d` string. */
coord: string;
}
/** Parse an `a`-tag coordinate string (`kind:pubkey:d`) into its parts. */
export function parseAddrCoord(value: string | undefined | null): AddrCoordinate | null {
if (!value) return null;
const parts = value.split(':');
if (parts.length < 3) return null;
const kind = parseInt(parts[0], 10);
const pubkey = parts[1];
const identifier = parts.slice(2).join(':');
if (isNaN(kind) || !/^[0-9a-f]{64}$/i.test(pubkey) || !identifier) return null;
return { kind, pubkey: pubkey.toLowerCase(), identifier, coord: `${kind}:${pubkey.toLowerCase()}:${identifier}` };
}
/**
* Extract the crew this Lab profile links to (its `a` tag pointing at a
* kind-35006 crew coordinate). Only the first valid one is used.
*/
export function parseCrewRef(event: NostrEvent): string | undefined {
for (const [name, value] of event.tags) {
if (name !== 'a') continue;
const c = parseAddrCoord(value);
if (c && c.kind === SKATE_CREW_KIND) return c.coord;
}
return undefined;
}
/**
* Extract the Lab profile coordinates a crew links to (its `a` tags pointing at
* any Lab kind).
*/
export function parseLabRefs(event: NostrEvent): string[] {
const refs: string[] = [];
for (const [name, value] of event.tags) {
if (name !== 'a') continue;
const c = parseAddrCoord(value);
if (c && (LAB_KINDS as readonly number[]).includes(c.kind)) refs.push(c.coord);
}
return refs;
}
/**
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
* static payment code (see https://github.com/shocknet/clink, kind 21001).
@ -784,6 +837,7 @@ export interface SkateCrew {
noffer?: string; // Optional shared tip jar (CLINK)
members: string[]; // Member pubkeys (hex) via `p` tags
spotRefs: string[]; // Home spots (kind 30818 refs)
labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d)
createdAt: number;
event: NostrEvent;
}
@ -812,6 +866,7 @@ export interface SkateBuilder {
entityType: EntityType;
members: string[];
spotRefs?: string[]; // Spots they've built (kind 30818 refs)
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
createdAt: number;
event: NostrEvent;
}
@ -836,6 +891,7 @@ export interface SkateCoach {
entityType: EntityType; // individual (default) or collective
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
createdAt: number;
event: NostrEvent;
}
@ -862,6 +918,7 @@ export interface SkateFilmer {
entityType: EntityType; // individual (default) or collective
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
createdAt: number;
event: NostrEvent;
}
@ -888,6 +945,7 @@ export interface SkateEditor {
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
entityType: EntityType; // individual (default) or collective
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
createdAt: number;
event: NostrEvent;
}
@ -914,6 +972,7 @@ export interface SkatePhotographer {
entityType: EntityType; // individual (default) or collective
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
createdAt: number;
event: NostrEvent;
}
@ -1028,6 +1087,7 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
entityType,
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
crewRef: parseCrewRef(event),
createdAt: event.created_at,
event,
};
@ -1085,6 +1145,7 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
entityType,
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
crewRef: parseCrewRef(event),
createdAt: event.created_at,
event,
};
@ -1139,6 +1200,7 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
noffer,
entityType,
members,
crewRef: parseCrewRef(event),
createdAt: event.created_at,
event,
};
@ -1195,6 +1257,7 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
entityType,
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
crewRef: parseCrewRef(event),
createdAt: event.created_at,
event,
};
@ -1255,6 +1318,7 @@ export function parseSkateBuilder(event: NostrEvent): SkateBuilder | null {
entityType,
members,
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
crewRef: parseCrewRef(event),
createdAt: event.created_at,
event,
};
@ -1289,6 +1353,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
const spotRefs = event.tags
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
.map(([, value]) => value);
const labRefs = parseLabRefs(event);
return {
id: event.id,
@ -1308,6 +1373,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
noffer,
members,
spotRefs,
labRefs,
createdAt: event.created_at,
event,
};

View file

@ -21,6 +21,7 @@ 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,
@ -108,6 +109,7 @@ export default function BecomeBuilderPage() {
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);
@ -211,6 +213,7 @@ export default function BecomeBuilderPage() {
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) {
@ -367,6 +370,10 @@ export default function BecomeBuilderPage() {
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>

View file

@ -20,6 +20,7 @@ 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_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
@ -63,6 +64,7 @@ export default function BecomeCoachPage() {
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -161,6 +163,7 @@ export default function BecomeCoachPage() {
tags.push(['entity', entityType]);
if (entityType === 'collective') {
for (const pk of members) tags.push(['p', pk]);
if (crewRef) tags.push(['a', crewRef]);
}
// Add spot references
@ -325,6 +328,10 @@ export default function BecomeCoachPage() {
accentText="text-[#ff0040]"
/>
{entityType === 'collective' && (
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-[#ff0040] hover:bg-[#ff0040]/90" accentText="text-[#ff0040]" />
)}
<div className="space-y-3">
<Label>Skills I Teach * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">

View file

@ -28,6 +28,7 @@ import {
type EntityType,
} from '@/lib/types';
import { EntityTypeField } from '@/components/EntityTypeField';
import { CrewLinkField } from '@/components/CrewLinkField';
import { cn } from '@/lib/utils';
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
@ -72,6 +73,7 @@ export default function BecomeEditorPage() {
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
const [hardware, setHardware] = useState('');
const [software, setSoftware] = useState('');
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -164,6 +166,7 @@ export default function BecomeEditorPage() {
tags.push(['entity', entityType]);
if (entityType === 'collective') {
for (const pk of members) tags.push(['p', pk]);
if (crewRef) tags.push(['a', crewRef]);
}
if (hardware.trim()) tags.push(['hardware', hardware.trim()]);
if (software.trim()) tags.push(['software', software.trim()]);
@ -316,6 +319,10 @@ export default function BecomeEditorPage() {
accentText="text-purple-400"
/>
{entityType === 'collective' && (
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-purple-600 hover:bg-purple-700" accentText="text-purple-400" />
)}
{/* Edit Types */}
<div className="space-y-3">
<Label>Edit Types Offered * (select at least one)</Label>

View file

@ -20,6 +20,7 @@ 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_FILMER_KIND, SKATE_SPOT_KIND, FILMING_SERVICE_LABELS, FILMING_SERVICE_DESCRIPTIONS, FILMING_SKILL_LABELS, FILMING_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
import { cn } from '@/lib/utils';
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
@ -66,6 +67,7 @@ export default function BecomeFilmerPage() {
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
const [gear, setGear] = useState('');
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -176,6 +178,7 @@ export default function BecomeFilmerPage() {
tags.push(['entity', entityType]);
if (entityType === 'collective') {
for (const pk of members) tags.push(['p', pk]);
if (crewRef) tags.push(['a', crewRef]);
}
if (gear.trim()) tags.push(['gear', gear.trim()]);
@ -341,6 +344,10 @@ export default function BecomeFilmerPage() {
accentText="text-blue-400"
/>
{entityType === 'collective' && (
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-blue-600 hover:bg-blue-700" accentText="text-blue-400" />
)}
<div className="space-y-3">
<Label>Services Offered * (select at least one)</Label>
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">

View file

@ -20,6 +20,7 @@ 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 type { EntityType } from '@/lib/types';
import {
SKATE_PHOTOGRAPHER_KIND,
@ -115,6 +116,7 @@ export default function BecomePhotographerPage() {
const [noffer, setNoffer] = useState('');
const [entityType, setEntityType] = useState<EntityType>('individual');
const [members, setMembers] = useState<string[]>([]);
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
const [isGettingLocation, setIsGettingLocation] = useState(false);
@ -228,6 +230,7 @@ export default function BecomePhotographerPage() {
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) {
@ -384,6 +387,10 @@ export default function BecomePhotographerPage() {
accentText="text-indigo-400"
/>
{entityType === 'collective' && (
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-indigo-600" accentText="text-indigo-400" />
)}
{/* Services */}
<div className="space-y-3">
<Label>Services Offered * (select at least one)</Label>

View file

@ -10,6 +10,7 @@ import { ZapButton } from '@/components/ZapButton';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { LinkedCrewCard } from '@/components/CrossLinkCards';
import { TrustBadge } from '@/components/TrustBadge';
import { useLabProfile } from '@/hooks/useLabProfiles';
import { useAuthor } from '@/hooks/useAuthor';
@ -389,6 +390,9 @@ export default function CoachDetailPage() {
/>
)}
{/* Linked crew (cross-link) */}
<LinkedCrewCard crewRef={profile.crewRef} />
{/* Location Card */}
{profile.location && (
<Card className="bg-black/90 border-2 border-gray-700">

View file

@ -11,6 +11,7 @@ import { ZapButton } from '@/components/ZapButton';
import { ClinkPayButton } from '@/components/ClinkPayButton';
import { CollectiveMembers } from '@/components/CollectiveMembers';
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
import { LinkedLabCard } from '@/components/CrossLinkCards';
import { NoteContent } from '@/components/NoteContent';
import { useCrew } from '@/hooks/useCrews';
import { useCrewFeed } from '@/hooks/useCrewFeed';
@ -276,6 +277,9 @@ export default function CrewDetailPage() {
/>
)}
{/* Linked Lab profiles — hire this crew */}
<LinkedLabCard labRefs={crew.labRefs} />
{/* Home spots */}
{crew.spotRefs.length > 0 && (
<Card className="bg-black/90 border-2 border-white/5">