SkateSpots/src/hooks/useListingRestrictions.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

270 lines
8.9 KiB
TypeScript

import { useMemo } from 'react';
import { useQuery } from '@tanstack/react-query';
import { useNostr } from '@nostrify/react';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { MARKETPLACE_ITEM_KIND, SKATE_SPOT_KIND, SKATE_RELAY } from '@/lib/types';
import type { NostrEvent } from '@nostrify/nostrify';
// Platform fee destination
export const PLATFORM_LIGHTNING_ADDRESS = 'skatespots@minibits.cash';
// Platform owner pubkeys - always trusted, never pay fees
export const PLATFORM_OWNER_PUBKEYS = [
'20e7c953439e66f3882ab96fe0faa8ba246d6fb12c03b0cb4e2dc4623b8bcb21', // npub1yrnuj56rnen08zp2h9h7p74ghgjx6ma39spmpj6w9hzxywutevsst7k5cx
'caba497f7beda0650d6b78ab211216e4d2ebabeec9ca6eb2a05cee8e55c6871b', // npub1e2ayjlmmaksx2rtt0z4jzyskunfwh2lwe89xav4qtnhgu4wxsudsp98a4l
];
// Trust levels based on activity
export type TrustLevel = 'unknown' | 'newcomer' | 'active' | 'trusted';
// Listing limits per trust level (per 30-day period)
export const LISTING_LIMITS: Record<TrustLevel, number> = {
unknown: 0, // No NIP-05 = can't list at all
newcomer: 3, // Has NIP-05 but no activity yet - REQUIRES FEE
active: 10, // Has spots or 2+ listings - NO FEE
trusted: 50, // Established member (spots + listings) - NO FEE
};
// Cooldown in milliseconds (24 hours for unknown users)
export const COOLDOWN_MS = 24 * 60 * 60 * 1000;
// Fee in sats for unknown users
export const LISTING_FEE_SATS = 1000;
interface ListingRestrictions {
trustLevel: TrustLevel;
listingLimit: number;
listingsThisMonth: number;
remainingListings: number;
canCreateListing: boolean;
requiresFee: boolean;
feeRecipient: string | null; // Lightning address - spot owner or platform
feeRecipientName: string | null; // Name for display
cooldownEndsAt: number | null;
isInCooldown: boolean;
timeUntilCooldownEnds: number | null; // milliseconds
hasNip05: boolean;
isPlatformOwner: boolean; // Platform owners bypass all restrictions
hasSpotsCreated: boolean;
hasListingsCreated: boolean;
isLoading: boolean;
}
interface LastListingRecord {
timestamp: number;
spotOwnerPubkey?: string;
}
/**
* Hook to manage listing restrictions based on user trust level
*/
export function useListingRestrictions(linkedSpotPubkey?: string): ListingRestrictions {
const { nostr } = useNostr();
const { user } = useCurrentUser();
const [lastListingRecord, setLastListingRecord] = useLocalStorage<LastListingRecord | null>(
'last-listing-record',
null
);
// Check if user has NIP-05 verification
const hasNip05 = !!user?.metadata?.nip05;
// Check if user is a platform owner (bypasses all restrictions)
const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
// Query user's spots
const { data: userSpots, isLoading: spotsLoading } = useQuery({
queryKey: ['user-spots', user?.pubkey],
queryFn: async (c) => {
if (!user?.pubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const relay = nostr.relay(SKATE_RELAY);
const events = await relay.query([{
kinds: [SKATE_SPOT_KIND],
authors: [user.pubkey],
limit: 50,
}], { signal });
return events;
},
enabled: !!user?.pubkey,
staleTime: 60000,
});
// Query user's listings (last 30 days)
const thirtyDaysAgo = Math.floor((Date.now() - 30 * 24 * 60 * 60 * 1000) / 1000);
const { data: userListings, isLoading: listingsLoading } = useQuery({
queryKey: ['user-listings', user?.pubkey, thirtyDaysAgo],
queryFn: async (c) => {
if (!user?.pubkey) return [];
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const relay = nostr.relay(SKATE_RELAY);
const events = await relay.query([{
kinds: [MARKETPLACE_ITEM_KIND],
authors: [user.pubkey],
since: thirtyDaysAgo,
limit: 100,
}], { signal });
return events;
},
enabled: !!user?.pubkey,
staleTime: 30000,
});
// Query spot owner's metadata if we have a linked spot
const { data: spotOwnerData } = useQuery({
queryKey: ['spot-owner', linkedSpotPubkey],
queryFn: async (c) => {
if (!linkedSpotPubkey) return null;
const signal = AbortSignal.any([c.signal, AbortSignal.timeout(5000)]);
const events = await nostr.query([{
kinds: [0],
authors: [linkedSpotPubkey],
limit: 1,
}], { signal });
if (events.length === 0) return null;
try {
const metadata = JSON.parse(events[0].content);
return {
pubkey: linkedSpotPubkey,
lud16: metadata.lud16 || null,
name: metadata.name || metadata.display_name || null,
};
} catch {
return null;
}
},
enabled: !!linkedSpotPubkey,
staleTime: 300000,
});
const isLoading = spotsLoading || listingsLoading;
// Calculate trust level
const trustLevel = useMemo((): TrustLevel => {
if (!user) return 'unknown';
// Platform owners are ALWAYS trusted - no exceptions, no other checks needed
// This must be checked FIRST before any other conditions
if (PLATFORM_OWNER_PUBKEYS.includes(user.pubkey)) {
return 'trusted';
}
// For non-platform-owners, NIP-05 is required
if (!hasNip05) return 'unknown';
// While loading, assume user is at least active if they have NIP-05
// This prevents showing the fee payment UI while data loads
// Better to temporarily give benefit of the doubt than show fee to trusted users
if (isLoading && hasNip05) {
return 'active';
}
const spotsCreated = userSpots?.length || 0;
const listingsCreated = userListings?.length || 0;
// Trusted: Has created spots AND has successful listings
if (spotsCreated >= 1 && listingsCreated >= 3) {
return 'trusted';
}
// Active: Has created spots OR has multiple listings
if (spotsCreated >= 1 || listingsCreated >= 2) {
return 'active';
}
// Newcomer: Has NIP-05 but limited activity
if (hasNip05) {
return 'newcomer';
}
return 'unknown';
}, [user, hasNip05, userSpots, userListings, isLoading]);
// Calculate cooldown status (only for unknown users)
const cooldownStatus = useMemo(() => {
if (trustLevel !== 'unknown' || !lastListingRecord) {
return { isInCooldown: false, cooldownEndsAt: null, timeUntilCooldownEnds: null };
}
const cooldownEndsAt = lastListingRecord.timestamp + COOLDOWN_MS;
const now = Date.now();
const isInCooldown = now < cooldownEndsAt;
const timeUntilCooldownEnds = isInCooldown ? cooldownEndsAt - now : null;
return { isInCooldown, cooldownEndsAt, timeUntilCooldownEnds };
}, [trustLevel, lastListingRecord]);
// Calculate listings this month
const listingsThisMonth = userListings?.length || 0;
const listingLimit = LISTING_LIMITS[trustLevel];
const remainingListings = Math.max(0, listingLimit - listingsThisMonth);
// Determine if fee is required - only for newcomers (NIP-05 but no activity yet)
// Users without NIP-05 can't list at all, so we don't need to handle that case here
const requiresFee = trustLevel === 'newcomer';
// Determine fee recipient
const feeRecipient = useMemo(() => {
if (!requiresFee) return null;
// If spot has owner with lightning address, pay them
if (spotOwnerData?.lud16) {
return spotOwnerData.lud16;
}
// Otherwise pay platform
return PLATFORM_LIGHTNING_ADDRESS;
}, [requiresFee, spotOwnerData]);
const feeRecipientName = useMemo(() => {
if (!requiresFee) return null;
if (spotOwnerData?.lud16) {
return spotOwnerData.name || 'Spot Owner';
}
return 'SkateSpots';
}, [requiresFee, spotOwnerData]);
// Can create listing?
const canCreateListing = useMemo(() => {
if (!user) return false;
// Platform owners can always create listings
if (isPlatformOwner) return true;
if (!hasNip05) return false;
if (remainingListings <= 0) return false;
if (cooldownStatus.isInCooldown) return false;
return true;
}, [user, isPlatformOwner, hasNip05, remainingListings, cooldownStatus.isInCooldown]);
return {
trustLevel,
listingLimit,
listingsThisMonth,
remainingListings,
canCreateListing,
requiresFee,
feeRecipient,
feeRecipientName,
cooldownEndsAt: cooldownStatus.cooldownEndsAt,
isInCooldown: cooldownStatus.isInCooldown,
timeUntilCooldownEnds: cooldownStatus.timeUntilCooldownEnds,
hasNip05,
isPlatformOwner,
hasSpotsCreated: (userSpots?.length || 0) > 0,
hasListingsCreated: (userListings?.length || 0) > 0,
isLoading,
};
}
/**
* Record a listing creation for cooldown tracking
*/
export function recordListingCreation(
setLastListingRecord: (record: LastListingRecord) => void,
spotOwnerPubkey?: string
) {
setLastListingRecord({
timestamp: Date.now(),
spotOwnerPubkey,
});
}