SkateSpots/src/hooks/useNutzap.ts
shakespeare.diy e29d35f1e4 fix: restore full skate-spots source tree from misplaced src/src/ copy
The entire skate-spots codebase was accidentally copied into src/src/ and
public/public/ instead of src/ and public/ directly. This left the real
src/ directory as blank MKStack template stubs, explaining why the landing
page showed the default template instead of SkateSpots.

Changes made:
- Copied all files from src/src/ up to src/ (App.tsx, AppRouter.tsx, main.tsx,
  all pages, components, hooks, contexts, lib, i18n, test)
- Copied all files from public/public/ up to public/ (images, manifest, etc.)
- Deleted src/src/ and public/public/ nested directories
- Removed HelloWorld.tsx template stub from pages/
- Retained the previously fixed index.css (Tailwind v3 directives)

Full skate-spots feature set is now active:
- All 30+ routes (map, events, spots, coaches, marketplace, wallet, etc.)
- NWCProvider, LanguageProvider, PWAInstallPrompt, AmberCallbackHandler
- Skate community relays (goskatespots.nostr1.com, wotr.relatr.xyz, etc.)
- Outfit Variable font, Amber pre-check in main.tsx
- All hooks (useSkateSpots, useCoaches, useZaps, etc.)
- i18n support (en/es)

Co-authored-by: shakespeare.diy <assistant@shakespeare.diy>
2026-06-15 22:18:28 +07:00

334 lines
8.6 KiB
TypeScript

import { useNostr } from '@nostrify/react';
import { useQuery, useMutation, useQueryClient } from '@tanstack/react-query';
import { useCurrentUser } from './useCurrentUser';
import { useNostrPublish } from './useNostrPublish';
import { useCashu } from './wallet/useCashu';
import { useToast } from './useToast';
import type { NostrEvent } from '@nostrify/nostrify';
// NIP-61 Kinds
export const NUTZAP_INFO_KIND = 10019; // User's nutzap receiving preferences
export const NUTZAP_KIND = 9321; // Nutzap event
export const NUTZAP_HISTORY_KIND = 7376; // Nutzap redemption history
export interface NutzapInfo {
pubkey: string;
relays: string[];
mints: Array<{ url: string; units: string[] }>;
p2pkPubkey?: string;
}
export interface Nutzap {
id: string;
pubkey: string; // sender
recipientPubkey: string;
eventId?: string;
mintUrl: string;
amount: number;
unit: string;
comment?: string;
proofs: string[];
createdAt: number;
}
// Parse a kind:10019 nutzap info event
function parseNutzapInfo(event: NostrEvent): NutzapInfo | null {
const relays: string[] = [];
const mints: Array<{ url: string; units: string[] }> = [];
let p2pkPubkey: string | undefined;
for (const tag of event.tags) {
if (tag[0] === 'relay' && tag[1]) {
relays.push(tag[1]);
} else if (tag[0] === 'mint' && tag[1]) {
const units = tag.slice(2);
mints.push({ url: tag[1], units: units.length > 0 ? units : ['sat'] });
} else if (tag[0] === 'pubkey' && tag[1]) {
p2pkPubkey = tag[1];
}
}
if (relays.length === 0 && mints.length === 0) {
return null;
}
return {
pubkey: event.pubkey,
relays,
mints,
p2pkPubkey,
};
}
// Parse a kind:9321 nutzap event
function parseNutzap(event: NostrEvent): Nutzap | null {
const proofs: string[] = [];
let mintUrl = '';
let unit = 'sat';
let recipientPubkey = '';
let eventId: string | undefined;
let amount = 0;
for (const tag of event.tags) {
if (tag[0] === 'proof' && tag[1]) {
proofs.push(tag[1]);
// Try to extract amount from proof
try {
const proof = JSON.parse(tag[1]);
if (proof.amount) {
amount += proof.amount;
}
} catch {
// Ignore parse errors
}
} else if (tag[0] === 'u' && tag[1]) {
mintUrl = tag[1];
} else if (tag[0] === 'unit' && tag[1]) {
unit = tag[1];
} else if (tag[0] === 'p' && tag[1]) {
recipientPubkey = tag[1];
} else if (tag[0] === 'e' && tag[1]) {
eventId = tag[1];
}
}
if (!mintUrl || proofs.length === 0 || !recipientPubkey) {
return null;
}
return {
id: event.id,
pubkey: event.pubkey,
recipientPubkey,
eventId,
mintUrl,
amount,
unit,
comment: event.content || undefined,
proofs,
createdAt: event.created_at,
};
}
/**
* Get a user's nutzap info (kind:10019)
*/
export function useNutzapInfo(pubkey: string | undefined) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['nutzap-info', pubkey],
queryFn: async (c) => {
if (!pubkey) return null;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const events = await nostr.query([
{
kinds: [NUTZAP_INFO_KIND],
authors: [pubkey],
limit: 1,
},
], { signal });
if (events.length === 0) return null;
return parseNutzapInfo(events[0]);
},
enabled: !!pubkey,
staleTime: 1000 * 60 * 5, // 5 minutes
});
}
/**
* Get nutzaps received by a user or for a specific event
*/
export function useNutzaps(params: { pubkey?: string; eventId?: string }) {
const { nostr } = useNostr();
return useQuery({
queryKey: ['nutzaps', params.pubkey, params.eventId],
queryFn: async (c) => {
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(8000)]);
const filters: Parameters<typeof nostr.query>[0] = [];
if (params.eventId) {
filters.push({
kinds: [NUTZAP_KIND],
'#e': [params.eventId],
limit: 100,
});
} else if (params.pubkey) {
filters.push({
kinds: [NUTZAP_KIND],
'#p': [params.pubkey],
limit: 100,
});
}
if (filters.length === 0) return [];
const events = await nostr.query(filters, { signal });
const nutzaps: Nutzap[] = [];
for (const event of events) {
const nutzap = parseNutzap(event);
if (nutzap) {
nutzaps.push(nutzap);
}
}
return nutzaps.sort((a, b) => b.createdAt - a.createdAt);
},
enabled: !!(params.pubkey || params.eventId),
staleTime: 1000 * 30, // 30 seconds
});
}
/**
* Check if the current user can receive nutzaps
*/
export function useCanReceiveNutzaps() {
const { user } = useCurrentUser();
const { data: nutzapInfo } = useNutzapInfo(user?.pubkey);
return {
canReceive: !!(nutzapInfo && nutzapInfo.mints.length > 0 && nutzapInfo.p2pkPubkey),
nutzapInfo,
};
}
/**
* Setup nutzap receiving (publish kind:10019)
*/
export function useSetupNutzapReceiving() {
const { user } = useCurrentUser();
const { mutateAsync: publishEvent } = useNostrPublish();
const { toast } = useToast();
const queryClient = useQueryClient();
return useMutation({
mutationFn: async ({
mints,
relays,
p2pkPubkey,
}: {
mints: Array<{ url: string; units?: string[] }>;
relays: string[];
p2pkPubkey: string;
}) => {
if (!user) throw new Error('Must be logged in');
const tags: string[][] = [];
// Add relays
for (const relay of relays) {
tags.push(['relay', relay]);
}
// Add mints with units
for (const mint of mints) {
const mintTag = ['mint', mint.url, ...(mint.units || ['sat'])];
tags.push(mintTag);
}
// Add P2PK pubkey (MUST be different from main Nostr pubkey)
tags.push(['pubkey', p2pkPubkey]);
await publishEvent({
kind: NUTZAP_INFO_KIND,
content: '',
tags,
});
return { mints, relays, p2pkPubkey };
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['nutzap-info'] });
toast({
title: 'Nutzap receiving enabled',
description: 'You can now receive ecash zaps!',
});
},
onError: (error) => {
toast({
title: 'Setup failed',
description: error instanceof Error ? error.message : 'Failed to setup nutzap receiving',
variant: 'destructive',
});
},
});
}
/**
* 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
*/
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,
}: {
recipientPubkey: string;
recipientNutzapInfo: NutzapInfo;
eventId?: string;
eventKind?: number;
amount: number;
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.');
},
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ['nutzaps'] });
},
onError: (error) => {
toast({
title: 'Nutzap failed',
description: error instanceof Error ? error.message : 'Failed to send nutzap',
variant: 'destructive',
});
},
});
}
/**
* Get total nutzaps received for an event
*/
export function useNutzapTotal(eventId: string | undefined) {
const { data: nutzaps } = useNutzaps({ eventId });
if (!nutzaps) {
return { count: 0, totalAmount: 0 };
}
return {
count: nutzaps.length,
totalAmount: nutzaps.reduce((sum, nz) => sum + nz.amount, 0),
};
}