From d8d1ba32c9f340005d843d030be0ef882152bc93 Mon Sep 17 00:00:00 2001 From: "shakespeare.diy" Date: Fri, 24 Jul 2026 14:27:51 +0700 Subject: [PATCH] =?UTF-8?q?Nutzap=20safety=20gate=20+=20crew=E2=86=94Lab?= =?UTF-8?q?=20cross-linking?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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 --- src/components/CrewForm.tsx | 10 +++ src/components/CrewLinkField.tsx | 82 ++++++++++++++++++++ src/components/CrossLinkCards.tsx | 109 +++++++++++++++++++++++++++ src/components/LabLinkField.tsx | 91 ++++++++++++++++++++++ src/hooks/useNutzap.ts | 63 +++++++++------- src/lib/types.ts | 66 ++++++++++++++++ src/pages/BecomeBuilderPage.tsx | 7 ++ src/pages/BecomeCoachPage.tsx | 7 ++ src/pages/BecomeEditorPage.tsx | 7 ++ src/pages/BecomeFilmerPage.tsx | 7 ++ src/pages/BecomePhotographerPage.tsx | 7 ++ src/pages/CoachDetailPage.tsx | 4 + src/pages/CrewDetailPage.tsx | 4 + 13 files changed, 435 insertions(+), 29 deletions(-) create mode 100644 src/components/CrewLinkField.tsx create mode 100644 src/components/CrossLinkCards.tsx create mode 100644 src/components/LabLinkField.tsx diff --git a/src/components/CrewForm.tsx b/src/components/CrewForm.tsx index ef102f2..4660b53 100644 --- a/src/components/CrewForm.tsx +++ b/src/components/CrewForm.tsx @@ -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(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) */} + +
diff --git a/src/components/CrewLinkField.tsx b/src/components/CrewLinkField.tsx new file mode 100644 index 0000000..e136d6d --- /dev/null +++ b/src/components/CrewLinkField.tsx @@ -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 ( +
+
+ +

+ Run a crew too? Link it so people can jump between your studio and your crew. Optional. +

+
+ + {myCrews.length === 0 ? ( +

+ You don't have any crews yet. Create one on the Crews page to link it here. +

+ ) : ( +
+ {myCrews.map((crew) => { + const coord = coordFor(crew.pubkey, crew.identifier); + const selected = value === coord; + return ( + + ); + })} +
+ )} + + {value && !myCrews.some((c) => coordFor(c.pubkey, c.identifier) === value) && ( + + Linked crew ({value.split(':')[2]}) + + )} +
+ ); +} diff --git a/src/components/CrossLinkCards.tsx b/src/components/CrossLinkCards.tsx new file mode 100644 index 0000000..b619e55 --- /dev/null +++ b/src/components/CrossLinkCards.tsx @@ -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 = { + 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 ( + +
+ {crew?.image ? ( + {crew.name} + ) : ( + + )} +
+
+

{crew?.name ?? 'Skate Crew'}

+

Crew{crew?.location ? ` · ${crew.location}` : ''}

+
+ + + ); +} + +/** 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 ( + +
+ {profile?.image ? ( + {profile.title} + ) : ( + + )} +
+
+

{profile?.title ?? `${typeLabel} profile`}

+

In The Lab · {typeLabel}

+
+ + + ); +} + +/** Card shown on a Lab profile: the crew it belongs to. */ +export function LinkedCrewCard({ crewRef }: { crewRef?: string }) { + if (!crewRef) return null; + return ( + + + + Part of a Crew + + + + + + + ); +} + +/** 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 ( + + + + Hire This Crew + +

Services this crew offers in The Lab

+
+ + {labRefs.map((coord) => ( + + ))} + +
+ ); +} diff --git a/src/components/LabLinkField.tsx b/src/components/LabLinkField.tsx new file mode 100644 index 0000000..f57e0a8 --- /dev/null +++ b/src/components/LabLinkField.tsx @@ -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 = { + 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 ( +
+
+ +

+ Run a studio, coaching or build service too? Link your Lab listings so people can hire your crew. Optional. +

+
+ + {myProfiles.length === 0 ? ( +

+ You don't have any Lab profiles yet. Create one in The Lab to link it here. +

+ ) : ( +
+ {myProfiles.map((p) => { + const coord = coordFor(p.event.kind, p.pubkey, p.identifier); + const selected = value.includes(coord); + return ( + + ); + })} +
+ )} +
+ ); +} diff --git a/src/hooks/useNutzap.ts b/src/hooks/useNutzap.ts index 366f6e9..fcc32e5 100644 --- a/src/hooks/useNutzap.ts +++ b/src/hooks/useNutzap.ts @@ -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', }); }, diff --git a/src/lib/types.ts b/src/lib/types.ts index 8708d7f..646a5cc 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -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: `::`. + */ +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, }; diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index bd75aaf..a27a565 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -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('collective'); const [members, setMembers] = useState([]); + const [crewRef, setCrewRef] = useState(undefined); const [selectedSpots, setSelectedSpots] = useState([]); 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' && ( + + )} + {/* Build types */}
diff --git a/src/pages/BecomeCoachPage.tsx b/src/pages/BecomeCoachPage.tsx index 185b083..92f6709 100644 --- a/src/pages/BecomeCoachPage.tsx +++ b/src/pages/BecomeCoachPage.tsx @@ -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('individual'); const [members, setMembers] = useState([]); + const [crewRef, setCrewRef] = useState(undefined); const [selectedSpots, setSelectedSpots] = useState([]); 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' && ( + + )} +
diff --git a/src/pages/BecomeEditorPage.tsx b/src/pages/BecomeEditorPage.tsx index edebda0..be0df92 100644 --- a/src/pages/BecomeEditorPage.tsx +++ b/src/pages/BecomeEditorPage.tsx @@ -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('individual'); const [members, setMembers] = useState([]); + const [crewRef, setCrewRef] = useState(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' && ( + + )} + {/* Edit Types */}
diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index ca0802c..0ba8ea2 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -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('individual'); const [members, setMembers] = useState([]); + const [crewRef, setCrewRef] = useState(undefined); const [gear, setGear] = useState(''); const [selectedSpots, setSelectedSpots] = useState([]); 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' && ( + + )} +
diff --git a/src/pages/BecomePhotographerPage.tsx b/src/pages/BecomePhotographerPage.tsx index 983bdb3..a5f587e 100644 --- a/src/pages/BecomePhotographerPage.tsx +++ b/src/pages/BecomePhotographerPage.tsx @@ -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('individual'); const [members, setMembers] = useState([]); + const [crewRef, setCrewRef] = useState(undefined); const [selectedSpots, setSelectedSpots] = useState([]); 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' && ( + + )} + {/* Services */}
diff --git a/src/pages/CoachDetailPage.tsx b/src/pages/CoachDetailPage.tsx index 8a43091..b3cb419 100644 --- a/src/pages/CoachDetailPage.tsx +++ b/src/pages/CoachDetailPage.tsx @@ -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) */} + + {/* Location Card */} {profile.location && ( diff --git a/src/pages/CrewDetailPage.tsx b/src/pages/CrewDetailPage.tsx index 8f7e2a8..b2cd6bf 100644 --- a/src/pages/CrewDetailPage.tsx +++ b/src/pages/CrewDetailPage.tsx @@ -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 */} + + {/* Home spots */} {crew.spotRefs.length > 0 && (