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>
161 lines
5 KiB
TypeScript
161 lines
5 KiB
TypeScript
import { useNostr } from '@nostrify/react';
|
|
import { useQuery } from '@tanstack/react-query';
|
|
import { MARKETPLACE_ITEM_KIND } from '@/lib/types';
|
|
import { useAppContext } from '@/hooks/useAppContext';
|
|
|
|
export interface SellerInfo {
|
|
pubkey: string;
|
|
accountAge: number; // Days since first event
|
|
listingCount: number;
|
|
activeListingCount: number;
|
|
hasNip05: boolean;
|
|
nip05?: string;
|
|
firstSeen?: number; // Unix timestamp of first event
|
|
}
|
|
|
|
/**
|
|
* Hook to fetch seller information including account age and listing count.
|
|
*/
|
|
export function useSellerInfo(pubkey: string | undefined) {
|
|
const { nostr } = useNostr();
|
|
const { config } = useAppContext();
|
|
|
|
const relayUrls = config.relayMetadata.relays
|
|
.filter(r => r.read)
|
|
.map(r => r.url)
|
|
.sort()
|
|
.join(',');
|
|
|
|
return useQuery({
|
|
queryKey: ['seller-info', pubkey, relayUrls],
|
|
queryFn: async (c) => {
|
|
if (!pubkey) return null;
|
|
|
|
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(10000)]);
|
|
|
|
try {
|
|
const readRelays = config.relayMetadata.relays
|
|
.filter(r => r.read)
|
|
.map(r => r.url);
|
|
|
|
// Query profile (kind 0) and marketplace listings in parallel
|
|
const profileEventsPromise: Promise<any[]>[] = [];
|
|
const listingEventsPromise: Promise<any[]>[] = [];
|
|
|
|
for (const relayUrl of readRelays) {
|
|
try {
|
|
const relay = nostr.relay(relayUrl);
|
|
|
|
profileEventsPromise.push(
|
|
relay.query(
|
|
[{ kinds: [0], authors: [pubkey], limit: 1 }],
|
|
{ signal }
|
|
).catch(() => [])
|
|
);
|
|
|
|
listingEventsPromise.push(
|
|
relay.query(
|
|
[{
|
|
kinds: [MARKETPLACE_ITEM_KIND],
|
|
authors: [pubkey],
|
|
'#t': ['skateboarding'],
|
|
limit: 100
|
|
}],
|
|
{ signal }
|
|
).catch(() => [])
|
|
);
|
|
} catch (error) {
|
|
// Ignore individual relay errors
|
|
}
|
|
}
|
|
|
|
const [profileResults, listingResults] = await Promise.all([
|
|
Promise.all(profileEventsPromise),
|
|
Promise.all(listingEventsPromise),
|
|
]);
|
|
|
|
// Flatten results
|
|
const profileEvents = profileResults.flat();
|
|
const listingEvents = listingResults.flat();
|
|
|
|
// Get profile metadata
|
|
let hasNip05 = false;
|
|
let nip05: string | undefined;
|
|
let profileCreatedAt: number | undefined;
|
|
|
|
if (profileEvents.length > 0) {
|
|
const profile = profileEvents.reduce((latest, current) =>
|
|
current.created_at > latest.created_at ? current : latest
|
|
);
|
|
|
|
try {
|
|
const metadata = JSON.parse(profile.content);
|
|
hasNip05 = !!metadata.nip05;
|
|
nip05 = metadata.nip05;
|
|
} catch (e) {
|
|
// Ignore JSON parse errors
|
|
}
|
|
|
|
profileCreatedAt = profile.created_at;
|
|
}
|
|
|
|
// Deduplicate listings by d-tag
|
|
const listingMap = new Map<string, any>();
|
|
for (const event of listingEvents) {
|
|
const d = event.tags.find(([name]: [string]) => name === 'd')?.[1];
|
|
if (d) {
|
|
const key = `${event.pubkey}:${event.kind}:${d}`;
|
|
const existing = listingMap.get(key);
|
|
if (!existing || event.created_at > existing.created_at) {
|
|
listingMap.set(key, event);
|
|
}
|
|
}
|
|
}
|
|
|
|
const uniqueListings = Array.from(listingMap.values());
|
|
|
|
// Count active listings (not expired, status = active)
|
|
const now = Math.floor(Date.now() / 1000);
|
|
const activeListings = uniqueListings.filter(event => {
|
|
const status = event.tags.find(([name]: [string]) => name === 'status')?.[1];
|
|
const expiration = event.tags.find(([name]: [string]) => name === 'expiration')?.[1];
|
|
|
|
if (status && status !== 'active') return false;
|
|
if (expiration && parseInt(expiration) < now) return false;
|
|
|
|
return true;
|
|
});
|
|
|
|
// Find oldest event timestamp (approximation of account age)
|
|
const allTimestamps = [
|
|
...uniqueListings.map(e => e.created_at),
|
|
...(profileCreatedAt ? [profileCreatedAt] : []),
|
|
];
|
|
|
|
const firstSeen = allTimestamps.length > 0
|
|
? Math.min(...allTimestamps)
|
|
: undefined;
|
|
|
|
const accountAge = firstSeen
|
|
? Math.floor((now - firstSeen) / (24 * 60 * 60)) // Days
|
|
: 0;
|
|
|
|
return {
|
|
pubkey,
|
|
accountAge,
|
|
listingCount: uniqueListings.length,
|
|
activeListingCount: activeListings.length,
|
|
hasNip05,
|
|
nip05,
|
|
firstSeen,
|
|
} as SellerInfo;
|
|
} catch (error) {
|
|
console.warn('[useSellerInfo] Error fetching seller info:', error);
|
|
return null;
|
|
}
|
|
},
|
|
enabled: !!pubkey,
|
|
staleTime: 300000, // Cache for 5 minutes
|
|
gcTime: 600000, // Keep in cache for 10 minutes
|
|
});
|
|
}
|