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:
parent
ed27f719fa
commit
d8d1ba32c9
13 changed files with 435 additions and 29 deletions
|
|
@ -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>
|
||||
|
|
|
|||
82
src/components/CrewLinkField.tsx
Normal file
82
src/components/CrewLinkField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
109
src/components/CrossLinkCards.tsx
Normal file
109
src/components/CrossLinkCards.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
91
src/components/LabLinkField.tsx
Normal file
91
src/components/LabLinkField.tsx
Normal 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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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',
|
||||
});
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
Loading…
Reference in a new issue