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>
334 lines
8.6 KiB
TypeScript
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),
|
|
};
|
|
}
|