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 { useAuthor } from '@/hooks/useAuthor';
|
||||||
import { genUserName } from '@/lib/genUserName';
|
import { genUserName } from '@/lib/genUserName';
|
||||||
import { SpotPicker } from '@/components/SpotPicker';
|
import { SpotPicker } from '@/components/SpotPicker';
|
||||||
|
import { LabLinkField } from '@/components/LabLinkField';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import {
|
import {
|
||||||
SKATE_CREW_KIND,
|
SKATE_CREW_KIND,
|
||||||
|
|
@ -94,6 +95,7 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
return `${parts[1]}:${parts[2]}`;
|
return `${parts[1]}:${parts[2]}`;
|
||||||
})
|
})
|
||||||
);
|
);
|
||||||
|
const [labRefs, setLabRefs] = useState<string[]>(existing?.labRefs ?? []);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
||||||
const toggleVibe = (v: string) =>
|
const toggleVibe = (v: string) =>
|
||||||
|
|
@ -239,6 +241,11 @@ export function CrewForm({ existing }: CrewFormProps) {
|
||||||
tags.push(['a', `${SKATE_SPOT_KIND}:${spotPubkey}:${spotIdentifier}`]);
|
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 });
|
await createEvent({ kind: SKATE_CREW_KIND, content: bio.trim(), tags });
|
||||||
|
|
||||||
toast({ title: existing ? 'Crew updated!' : 'Crew created!', description: 'Published to the scene.' });
|
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"
|
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="grid grid-cols-2 gap-4">
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
<Label htmlFor="crew-location">Home Turf (City / Area)</Label>
|
<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
|
* Whether in-app nutzap SENDING is available.
|
||||||
* Note: This is a simplified implementation. Full implementation would require:
|
*
|
||||||
* - P2PK locking of tokens
|
* Nutzap sending (NIP-61) requires minting Cashu proofs that are P2PK-locked to
|
||||||
* - DLEQ proofs
|
* the recipient's `p2pkPubkey` plus DLEQ proofs. Getting P2PK locking wrong would
|
||||||
* - Proper token minting/swapping
|
* 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() {
|
export function useSendNutzap() {
|
||||||
const { user } = useCurrentUser();
|
const { user } = useCurrentUser();
|
||||||
const { mutateAsync: publishEvent } = useNostrPublish();
|
|
||||||
const { coco, repositories } = useCashu();
|
|
||||||
const { toast } = useToast();
|
const { toast } = useToast();
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
return useMutation({
|
return useMutation({
|
||||||
mutationFn: async ({
|
mutationFn: async (_params: {
|
||||||
recipientPubkey,
|
|
||||||
recipientNutzapInfo,
|
|
||||||
eventId,
|
|
||||||
eventKind,
|
|
||||||
amount,
|
|
||||||
comment,
|
|
||||||
}: {
|
|
||||||
recipientPubkey: string;
|
recipientPubkey: string;
|
||||||
recipientNutzapInfo: NutzapInfo;
|
recipientNutzapInfo: NutzapInfo;
|
||||||
eventId?: string;
|
eventId?: string;
|
||||||
|
|
@ -291,26 +304,18 @@ export function useSendNutzap() {
|
||||||
comment?: string;
|
comment?: string;
|
||||||
}) => {
|
}) => {
|
||||||
if (!user) throw new Error('Must be logged in');
|
if (!user) throw new Error('Must be logged in');
|
||||||
if (!coco || !repositories) throw new Error('Cashu wallet not initialized');
|
// Guarded off to avoid locking funds to the wrong key. See NUTZAP_SENDING_ENABLED.
|
||||||
if (!recipientNutzapInfo.p2pkPubkey) throw new Error('Recipient has no P2PK pubkey configured');
|
throw new Error(
|
||||||
if (recipientNutzapInfo.mints.length === 0) throw new Error('Recipient has no mints configured');
|
'Nutzap sending is not available yet — use a regular Lightning zap (you can pay it straight from your Cashu balance).'
|
||||||
|
);
|
||||||
// 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.');
|
|
||||||
},
|
},
|
||||||
onSuccess: () => {
|
onSuccess: () => {
|
||||||
queryClient.invalidateQueries({ queryKey: ['nutzaps'] });
|
queryClient.invalidateQueries({ queryKey: ['nutzaps'] });
|
||||||
},
|
},
|
||||||
onError: (error) => {
|
onError: (error) => {
|
||||||
toast({
|
toast({
|
||||||
title: 'Nutzap failed',
|
title: 'Nutzap sending unavailable',
|
||||||
description: error instanceof Error ? error.message : 'Failed to send nutzap',
|
description: error instanceof Error ? error.message : 'Use a Lightning zap instead',
|
||||||
variant: 'destructive',
|
variant: 'destructive',
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -84,6 +84,59 @@ export function parseMemberPubkeys(event: NostrEvent): string[] {
|
||||||
.map(([, value]) => value.toLowerCase());
|
.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
|
* Validate a CLINK Offer code (noffer1...) — a Nostr-native, self-custodial
|
||||||
* static payment code (see https://github.com/shocknet/clink, kind 21001).
|
* 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)
|
noffer?: string; // Optional shared tip jar (CLINK)
|
||||||
members: string[]; // Member pubkeys (hex) via `p` tags
|
members: string[]; // Member pubkeys (hex) via `p` tags
|
||||||
spotRefs: string[]; // Home spots (kind 30818 refs)
|
spotRefs: string[]; // Home spots (kind 30818 refs)
|
||||||
|
labRefs: string[]; // Linked Lab profile coordinates (kind:pubkey:d)
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -812,6 +866,7 @@ export interface SkateBuilder {
|
||||||
entityType: EntityType;
|
entityType: EntityType;
|
||||||
members: string[];
|
members: string[];
|
||||||
spotRefs?: string[]; // Spots they've built (kind 30818 refs)
|
spotRefs?: string[]; // Spots they've built (kind 30818 refs)
|
||||||
|
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -836,6 +891,7 @@ export interface SkateCoach {
|
||||||
entityType: EntityType; // individual (default) or collective
|
entityType: EntityType; // individual (default) or collective
|
||||||
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
|
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
|
||||||
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
||||||
|
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -862,6 +918,7 @@ export interface SkateFilmer {
|
||||||
entityType: EntityType; // individual (default) or collective
|
entityType: EntityType; // individual (default) or collective
|
||||||
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
|
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
|
||||||
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
||||||
|
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -888,6 +945,7 @@ export interface SkateEditor {
|
||||||
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
|
noffer?: string; // Optional CLINK Offer code (noffer1...) for self-custodial payments
|
||||||
entityType: EntityType; // individual (default) or collective
|
entityType: EntityType; // individual (default) or collective
|
||||||
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
|
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
|
||||||
|
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -914,6 +972,7 @@ export interface SkatePhotographer {
|
||||||
entityType: EntityType; // individual (default) or collective
|
entityType: EntityType; // individual (default) or collective
|
||||||
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
|
members: string[]; // Member pubkeys (hex) claimed via `p` tags (collective only)
|
||||||
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
spotRefs?: string[]; // Array of spot references: "30818:pubkey:d-tag"
|
||||||
|
crewRef?: string; // Linked crew coordinate (35006:pubkey:d)
|
||||||
createdAt: number;
|
createdAt: number;
|
||||||
event: NostrEvent;
|
event: NostrEvent;
|
||||||
}
|
}
|
||||||
|
|
@ -1028,6 +1087,7 @@ export function parseSkateCoach(event: NostrEvent): SkateCoach | null {
|
||||||
entityType,
|
entityType,
|
||||||
members,
|
members,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
|
crewRef: parseCrewRef(event),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
@ -1085,6 +1145,7 @@ export function parseSkateFilmer(event: NostrEvent): SkateFilmer | null {
|
||||||
entityType,
|
entityType,
|
||||||
members,
|
members,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
|
crewRef: parseCrewRef(event),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
@ -1139,6 +1200,7 @@ export function parseSkateEditor(event: NostrEvent): SkateEditor | null {
|
||||||
noffer,
|
noffer,
|
||||||
entityType,
|
entityType,
|
||||||
members,
|
members,
|
||||||
|
crewRef: parseCrewRef(event),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
@ -1195,6 +1257,7 @@ export function parseSkatePhotographer(event: NostrEvent): SkatePhotographer | n
|
||||||
entityType,
|
entityType,
|
||||||
members,
|
members,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
|
crewRef: parseCrewRef(event),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
@ -1255,6 +1318,7 @@ export function parseSkateBuilder(event: NostrEvent): SkateBuilder | null {
|
||||||
entityType,
|
entityType,
|
||||||
members,
|
members,
|
||||||
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
spotRefs: spotRefs.length > 0 ? spotRefs : undefined,
|
||||||
|
crewRef: parseCrewRef(event),
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
@ -1289,6 +1353,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||||
const spotRefs = event.tags
|
const spotRefs = event.tags
|
||||||
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
.filter(([name, value]) => name === 'a' && value?.startsWith('30818:'))
|
||||||
.map(([, value]) => value);
|
.map(([, value]) => value);
|
||||||
|
const labRefs = parseLabRefs(event);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: event.id,
|
id: event.id,
|
||||||
|
|
@ -1308,6 +1373,7 @@ export function parseSkateCrew(event: NostrEvent): SkateCrew | null {
|
||||||
noffer,
|
noffer,
|
||||||
members,
|
members,
|
||||||
spotRefs,
|
spotRefs,
|
||||||
|
labRefs,
|
||||||
createdAt: event.created_at,
|
createdAt: event.created_at,
|
||||||
event,
|
event,
|
||||||
};
|
};
|
||||||
|
|
|
||||||
|
|
@ -21,6 +21,7 @@ import { LoginArea } from '@/components/auth/LoginArea';
|
||||||
import { SpotPicker } from '@/components/SpotPicker';
|
import { SpotPicker } from '@/components/SpotPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import { EntityTypeField } from '@/components/EntityTypeField';
|
import { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
|
import { CrewLinkField } from '@/components/CrewLinkField';
|
||||||
import {
|
import {
|
||||||
SKATE_BUILDER_KIND,
|
SKATE_BUILDER_KIND,
|
||||||
SKATE_SPOT_KIND,
|
SKATE_SPOT_KIND,
|
||||||
|
|
@ -108,6 +109,7 @@ export default function BecomeBuilderPage() {
|
||||||
const [noffer, setNoffer] = useState('');
|
const [noffer, setNoffer] = useState('');
|
||||||
const [entityType, setEntityType] = useState<EntityType>('collective');
|
const [entityType, setEntityType] = useState<EntityType>('collective');
|
||||||
const [members, setMembers] = useState<string[]>([]);
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
|
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
|
@ -211,6 +213,7 @@ export default function BecomeBuilderPage() {
|
||||||
tags.push(['entity', entityType]);
|
tags.push(['entity', entityType]);
|
||||||
if (entityType === 'collective') {
|
if (entityType === 'collective') {
|
||||||
for (const pk of members) tags.push(['p', pk]);
|
for (const pk of members) tags.push(['p', pk]);
|
||||||
|
if (crewRef) tags.push(['a', crewRef]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const spotId of selectedSpots) {
|
for (const spotId of selectedSpots) {
|
||||||
|
|
@ -367,6 +370,10 @@ export default function BecomeBuilderPage() {
|
||||||
accentText="text-amber-400"
|
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 */}
|
{/* Build types */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>What You Build * (select at least one)</Label>
|
<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 { SpotPicker } from '@/components/SpotPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import { EntityTypeField } from '@/components/EntityTypeField';
|
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 { SKATE_COACH_KIND, SKATE_SPOT_KIND, COACH_SKILL_LABELS, COACH_SKILL_DESCRIPTIONS, isValidNoffer, type EntityType } from '@/lib/types';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { GraduationCap, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
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 [noffer, setNoffer] = useState('');
|
||||||
const [entityType, setEntityType] = useState<EntityType>('individual');
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||||||
const [members, setMembers] = useState<string[]>([]);
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
|
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
|
@ -161,6 +163,7 @@ export default function BecomeCoachPage() {
|
||||||
tags.push(['entity', entityType]);
|
tags.push(['entity', entityType]);
|
||||||
if (entityType === 'collective') {
|
if (entityType === 'collective') {
|
||||||
for (const pk of members) tags.push(['p', pk]);
|
for (const pk of members) tags.push(['p', pk]);
|
||||||
|
if (crewRef) tags.push(['a', crewRef]);
|
||||||
}
|
}
|
||||||
|
|
||||||
// Add spot references
|
// Add spot references
|
||||||
|
|
@ -325,6 +328,10 @@ export default function BecomeCoachPage() {
|
||||||
accentText="text-[#ff0040]"
|
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">
|
<div className="space-y-3">
|
||||||
<Label>Skills I Teach * (select at least one)</Label>
|
<Label>Skills I Teach * (select at least one)</Label>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ import {
|
||||||
type EntityType,
|
type EntityType,
|
||||||
} from '@/lib/types';
|
} from '@/lib/types';
|
||||||
import { EntityTypeField } from '@/components/EntityTypeField';
|
import { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
|
import { CrewLinkField } from '@/components/CrewLinkField';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
|
import { Scissors, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Monitor, Zap } from 'lucide-react';
|
||||||
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
import { PlatformFeePayment } from '@/components/PlatformFeePayment';
|
||||||
|
|
@ -72,6 +73,7 @@ export default function BecomeEditorPage() {
|
||||||
const [noffer, setNoffer] = useState('');
|
const [noffer, setNoffer] = useState('');
|
||||||
const [entityType, setEntityType] = useState<EntityType>('individual');
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||||||
const [members, setMembers] = useState<string[]>([]);
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
|
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
||||||
const [hardware, setHardware] = useState('');
|
const [hardware, setHardware] = useState('');
|
||||||
const [software, setSoftware] = useState('');
|
const [software, setSoftware] = useState('');
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
@ -164,6 +166,7 @@ export default function BecomeEditorPage() {
|
||||||
tags.push(['entity', entityType]);
|
tags.push(['entity', entityType]);
|
||||||
if (entityType === 'collective') {
|
if (entityType === 'collective') {
|
||||||
for (const pk of members) tags.push(['p', pk]);
|
for (const pk of members) tags.push(['p', pk]);
|
||||||
|
if (crewRef) tags.push(['a', crewRef]);
|
||||||
}
|
}
|
||||||
if (hardware.trim()) tags.push(['hardware', hardware.trim()]);
|
if (hardware.trim()) tags.push(['hardware', hardware.trim()]);
|
||||||
if (software.trim()) tags.push(['software', software.trim()]);
|
if (software.trim()) tags.push(['software', software.trim()]);
|
||||||
|
|
@ -316,6 +319,10 @@ export default function BecomeEditorPage() {
|
||||||
accentText="text-purple-400"
|
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 */}
|
{/* Edit Types */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Edit Types Offered * (select at least one)</Label>
|
<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 { SpotPicker } from '@/components/SpotPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import { EntityTypeField } from '@/components/EntityTypeField';
|
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 { 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 { cn } from '@/lib/utils';
|
||||||
import { Video, Upload, X, Loader2, ArrowLeft, MapPin, Wallet, ExternalLink, Zap } from 'lucide-react';
|
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 [noffer, setNoffer] = useState('');
|
||||||
const [entityType, setEntityType] = useState<EntityType>('individual');
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||||||
const [members, setMembers] = useState<string[]>([]);
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
|
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
||||||
const [gear, setGear] = useState('');
|
const [gear, setGear] = useState('');
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
@ -176,6 +178,7 @@ export default function BecomeFilmerPage() {
|
||||||
tags.push(['entity', entityType]);
|
tags.push(['entity', entityType]);
|
||||||
if (entityType === 'collective') {
|
if (entityType === 'collective') {
|
||||||
for (const pk of members) tags.push(['p', pk]);
|
for (const pk of members) tags.push(['p', pk]);
|
||||||
|
if (crewRef) tags.push(['a', crewRef]);
|
||||||
}
|
}
|
||||||
if (gear.trim()) tags.push(['gear', gear.trim()]);
|
if (gear.trim()) tags.push(['gear', gear.trim()]);
|
||||||
|
|
||||||
|
|
@ -341,6 +344,10 @@ export default function BecomeFilmerPage() {
|
||||||
accentText="text-blue-400"
|
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">
|
<div className="space-y-3">
|
||||||
<Label>Services Offered * (select at least one)</Label>
|
<Label>Services Offered * (select at least one)</Label>
|
||||||
<div className="grid grid-cols-1 sm:grid-cols-2 gap-2">
|
<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 { SpotPicker } from '@/components/SpotPicker';
|
||||||
import { NofferField } from '@/components/NofferField';
|
import { NofferField } from '@/components/NofferField';
|
||||||
import { EntityTypeField } from '@/components/EntityTypeField';
|
import { EntityTypeField } from '@/components/EntityTypeField';
|
||||||
|
import { CrewLinkField } from '@/components/CrewLinkField';
|
||||||
import type { EntityType } from '@/lib/types';
|
import type { EntityType } from '@/lib/types';
|
||||||
import {
|
import {
|
||||||
SKATE_PHOTOGRAPHER_KIND,
|
SKATE_PHOTOGRAPHER_KIND,
|
||||||
|
|
@ -115,6 +116,7 @@ export default function BecomePhotographerPage() {
|
||||||
const [noffer, setNoffer] = useState('');
|
const [noffer, setNoffer] = useState('');
|
||||||
const [entityType, setEntityType] = useState<EntityType>('individual');
|
const [entityType, setEntityType] = useState<EntityType>('individual');
|
||||||
const [members, setMembers] = useState<string[]>([]);
|
const [members, setMembers] = useState<string[]>([]);
|
||||||
|
const [crewRef, setCrewRef] = useState<string | undefined>(undefined);
|
||||||
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
const [selectedSpots, setSelectedSpots] = useState<string[]>([]);
|
||||||
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
const [isGettingLocation, setIsGettingLocation] = useState(false);
|
||||||
|
|
||||||
|
|
@ -228,6 +230,7 @@ export default function BecomePhotographerPage() {
|
||||||
tags.push(['entity', entityType]);
|
tags.push(['entity', entityType]);
|
||||||
if (entityType === 'collective') {
|
if (entityType === 'collective') {
|
||||||
for (const pk of members) tags.push(['p', pk]);
|
for (const pk of members) tags.push(['p', pk]);
|
||||||
|
if (crewRef) tags.push(['a', crewRef]);
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const spotId of selectedSpots) {
|
for (const spotId of selectedSpots) {
|
||||||
|
|
@ -384,6 +387,10 @@ export default function BecomePhotographerPage() {
|
||||||
accentText="text-indigo-400"
|
accentText="text-indigo-400"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
{entityType === 'collective' && (
|
||||||
|
<CrewLinkField value={crewRef} onChange={setCrewRef} accentBg="bg-indigo-600" accentText="text-indigo-400" />
|
||||||
|
)}
|
||||||
|
|
||||||
{/* Services */}
|
{/* Services */}
|
||||||
<div className="space-y-3">
|
<div className="space-y-3">
|
||||||
<Label>Services Offered * (select at least one)</Label>
|
<Label>Services Offered * (select at least one)</Label>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@ import { ZapButton } from '@/components/ZapButton';
|
||||||
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
import { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||||
|
import { LinkedCrewCard } from '@/components/CrossLinkCards';
|
||||||
import { TrustBadge } from '@/components/TrustBadge';
|
import { TrustBadge } from '@/components/TrustBadge';
|
||||||
import { useLabProfile } from '@/hooks/useLabProfiles';
|
import { useLabProfile } from '@/hooks/useLabProfiles';
|
||||||
import { useAuthor } from '@/hooks/useAuthor';
|
import { useAuthor } from '@/hooks/useAuthor';
|
||||||
|
|
@ -389,6 +390,9 @@ export default function CoachDetailPage() {
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* Linked crew (cross-link) */}
|
||||||
|
<LinkedCrewCard crewRef={profile.crewRef} />
|
||||||
|
|
||||||
{/* Location Card */}
|
{/* Location Card */}
|
||||||
{profile.location && (
|
{profile.location && (
|
||||||
<Card className="bg-black/90 border-2 border-gray-700">
|
<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 { ClinkPayButton } from '@/components/ClinkPayButton';
|
||||||
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
import { CollectiveMembers } from '@/components/CollectiveMembers';
|
||||||
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
import { ConfirmMembershipCard } from '@/components/ConfirmMembershipCard';
|
||||||
|
import { LinkedLabCard } from '@/components/CrossLinkCards';
|
||||||
import { NoteContent } from '@/components/NoteContent';
|
import { NoteContent } from '@/components/NoteContent';
|
||||||
import { useCrew } from '@/hooks/useCrews';
|
import { useCrew } from '@/hooks/useCrews';
|
||||||
import { useCrewFeed } from '@/hooks/useCrewFeed';
|
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 */}
|
{/* Home spots */}
|
||||||
{crew.spotRefs.length > 0 && (
|
{crew.spotRefs.length > 0 && (
|
||||||
<Card className="bg-black/90 border-2 border-white/5">
|
<Card className="bg-black/90 border-2 border-white/5">
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue