SkateSpots/src/hooks/usePlatformFee.ts
SkateSpots a8b4b9fdd0 fix(security): tier 1 — money & auth (C1–C5 partial)
- C1 Marketplace payments now verify the invoice amount: usePayPaymentPointer
  calls assertInvoiceAmount(invoice, amountSats) before paying, so a seller
  can't attach a raw BOLT11 or return an inflated LNURL invoice and have an
  auto-approving wallet pay it.
- C2 NIP-17 DMs now verifyEvent(seal) in all four read paths — the seal is the
  only authentication for a message; a crafted seal could impersonate the sender.
- C3 push-service: bind endpoint ownership (reject subscribe by a different
  pubkey; only the owner may unsubscribe) and reject non-HTTPS/private/loopback
  endpoints (SSRF).
- C4 Amber callback: a bare ?event=<pubkey> is no longer treated as a login;
  the callback must match a pending get_public_key op (both callback pages).
  Also stops logging the pending op payload.
- C5a platform-fee record is now per-pubkey, so an account on a shared device
  can't inherit another's paid status.
- C5b (self-asserted featured ranking) deferred: needs a batch-verification pass
  against provider zap receipts before it can gate ordering.
2026-10-02 21:19:10 +07:00

177 lines
6.6 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

import { useState } from 'react';
import { useLocalStorage } from '@/hooks/useLocalStorage';
import { useCurrentUser } from '@/hooks/useCurrentUser';
import { PLATFORM_OWNER_PUBKEYS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions';
// ─── Base monthly (30-day) fee per type, in sats ─────────────────────────────
// Actual charge = base × the selected duration tier's multiplier.
export const PLATFORM_FEES = {
/** Featured event listing */
featuredEvent: 5000,
/** Lab Pro profile (coach / filmer / editor / photographer / builder) */
labProfile: 5000,
/** Featured shop placement on map */
featuredShop: 10000,
/** Featured tour listing */
featuredTour: 5000,
} as const;
export type FeeType = keyof typeof PLATFORM_FEES;
// ─── Duration tiers ───────────────────────────────────────────────────────────
// Price discrimination: the longer the commitment, the cheaper the effective
// per-day rate. `multiplier` is applied to a type's base monthly price.
// 7d → 0.4× (short "try it" window, priced at a premium per-day)
// 30d → 1.0× (baseline monthly)
// 90d → 2.4× (season — ~20% per-day discount) ← best value
// 365d→ 8.0× (year — ~33% per-day discount)
export type DurationTierId = '7d' | '30d' | '90d' | '365d';
export interface DurationTier {
id: DurationTierId;
label: string;
days: number;
multiplier: number;
/** Cosmetic "best value" flag for UI. */
bestValue?: boolean;
}
export const DURATION_TIERS: DurationTier[] = [
{ id: '7d', label: '7 days', days: 7, multiplier: 0.4 },
{ id: '30d', label: '1 month', days: 30, multiplier: 1 },
{ id: '90d', label: '3 months', days: 90, multiplier: 2.4, bestValue: true },
{ id: '365d', label: '12 months', days: 365, multiplier: 8 },
];
export const DEFAULT_TIER_ID: DurationTierId = '30d';
/** Compute the sats price for a fee type at a given duration tier. */
export function tierPriceSats(type: FeeType, tier: DurationTier): number {
// Round to the nearest 100 sats for tidy amounts.
return Math.round((PLATFORM_FEES[type] * tier.multiplier) / 100) * 100;
}
/** Effective per-day sats (for "best value" display). */
export function tierPerDaySats(type: FeeType, tier: DurationTier): number {
return Math.round(tierPriceSats(type, tier) / tier.days);
}
// ─── Display metadata per fee type ───────────────────────────────────────────
export const FEE_META: Record<FeeType, {
label: string;
description: string;
benefit: string;
emoji: string;
}> = {
featuredEvent: {
label: 'Featured Event',
description: 'Feature your event on SkateSpots',
benefit: 'Your event appears at the top of the events page',
emoji: '📅',
},
labProfile: {
label: 'Lab Pro Profile',
description: 'Feature your services in The Lab',
benefit: 'Pro badge + featured placement when skaters browse The Lab',
emoji: '🎓',
},
featuredShop: {
label: 'Featured Shop',
description: 'Feature your skate shop on the map and directory',
benefit: 'Verified badge + featured pin on the map',
emoji: '🏪',
},
featuredTour: {
label: 'Featured Tour',
description: 'Feature your tour on SkateSpots',
benefit: 'Pro badge + top placement when skaters browse tours',
emoji: '🗺️',
},
};
// ─── localStorage key per fee type + account ─────────────────────────────────
// Scoped by pubkey so one account on a shared device can't inherit another's
// paid status (a device-global key let any later login skip the fee).
function storageKey(type: FeeType, pubkey: string): string {
return `platform-fee-paid-${type}-${pubkey}`;
}
interface FeeRecord {
paidAt: number; // unix ms
expiresAt: number; // unix ms
}
// ─── Hook ─────────────────────────────────────────────────────────────────────
export function usePlatformFee(type: FeeType) {
const { user } = useCurrentUser();
const [record, setRecord] = useLocalStorage<FeeRecord | null>(storageKey(type, user?.pubkey ?? 'anon'), null);
// Currently selected duration tier (UI state).
const [tierId, setTierId] = useState<DurationTierId>(DEFAULT_TIER_ID);
const tier = DURATION_TIERS.find((t) => t.id === tierId) ?? DURATION_TIERS[1];
// Platform owners never pay
const isPlatformOwner = !!user?.pubkey && PLATFORM_OWNER_PUBKEYS.includes(user.pubkey);
// Is the fee currently active (paid and not expired)?
const isActive = isPlatformOwner || (
record !== null &&
Date.now() < record.expiresAt
);
// Days remaining
const daysRemaining = isActive && record
? Math.ceil((record.expiresAt - Date.now()) / (24 * 60 * 60 * 1000))
: 0;
// Expiry as unix SECONDS, for writing into a `featured` event tag.
// Platform owners get a long-lived (1 year) featured window.
const featuredUntilSeconds = (() => {
if (isPlatformOwner) {
return Math.floor((Date.now() + 365 * 24 * 60 * 60 * 1000) / 1000);
}
if (record) return Math.floor(record.expiresAt / 1000);
return undefined;
})();
// Price for the currently selected tier.
const amountSats = tierPriceSats(type, tier);
// Record a successful payment for the selected tier's duration.
const recordPayment = () => {
const now = Date.now();
setRecord({
paidAt: now,
expiresAt: now + tier.days * 24 * 60 * 60 * 1000,
});
};
return {
/** Amount in sats for the selected tier */
amountSats,
/** Lightning address to pay */
recipient: PLATFORM_LIGHTNING_ADDRESS,
recipientName: 'SkateSpots',
/** Display metadata */
meta: FEE_META[type],
/** Available duration tiers */
tiers: DURATION_TIERS,
/** Currently selected tier */
tier,
/** Change the selected tier */
setTierId,
/** Is fee currently paid and active? */
isActive,
/** Days remaining on active fee */
daysRemaining,
/** Expiry in unix seconds — write into a `featured` tag when publishing */
featuredUntilSeconds,
/** Platform owners bypass everything */
isPlatformOwner,
/** Call this after successful payment */
recordPayment,
/** Price/per-day helpers for UI */
tierPriceSats: (t: DurationTier) => tierPriceSats(type, t),
tierPerDaySats: (t: DurationTier) => tierPerDaySats(type, t),
};
}