The pay-to address was duplicated (PRO_LIGHTNING_ADDRESS and
PLATFORM_LIGHTNING_ADDRESS) and hardcoded in the tour zap-split; a typo in any
copy would send money nowhere. Now one source of truth in src/lib/platform.ts,
re-exported by the fee/Pro modules.
Also surface the address in the Sasquatch Pro dialog ('Pays to ⚡ <address>') so
users can see where their sats go, in both the auto-pay and manual-QR paths.
271 lines
8.9 KiB
TypeScript
271 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';
|
|
|
|
|
|
// Platform fee destination (single source of truth: src/lib/platform.ts)
|
|
import { PLATFORM_LIGHTNING_ADDRESS } from '@/lib/platform';
|
|
export { PLATFORM_LIGHTNING_ADDRESS };
|
|
|
|
// 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, metadata } = useCurrentUser();
|
|
const [lastListingRecord] = useLocalStorage<LastListingRecord | null>(
|
|
'last-listing-record',
|
|
null
|
|
);
|
|
|
|
// Check if user has NIP-05 verification
|
|
const hasNip05 = !!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,
|
|
});
|
|
}
|