- 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.
177 lines
6.6 KiB
TypeScript
177 lines
6.6 KiB
TypeScript
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),
|
||
};
|
||
}
|