SkateSpots/src/hooks/usePlatformFee.ts

200 lines
7.5 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';
/** `feesUi.*` keys for each duration tier label (single source for the UI). */
export const DURATION_TIER_KEYS: Record<DurationTierId, string> = {
'7d': 'feesUi.duration7d',
'30d': 'feesUi.duration30d',
'90d': 'feesUi.duration90d',
'365d': 'feesUi.duration365d',
};
/** 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: '🗺️',
},
};
/** `feesUi.*` keys for each fee type's label/benefit (UI translates these). */
export const FEE_LABEL_KEYS: Record<FeeType, string> = {
featuredEvent: 'feesUi.feeFeaturedEventLabel',
labProfile: 'feesUi.feeLabProfileLabel',
featuredShop: 'feesUi.feeFeaturedShopLabel',
featuredTour: 'feesUi.feeFeaturedTourLabel',
};
export const FEE_BENEFIT_KEYS: Record<FeeType, string> = {
featuredEvent: 'feesUi.feeFeaturedEventBenefit',
labProfile: 'feesUi.feeLabProfileBenefit',
featuredShop: 'feesUi.feeFeaturedShopBenefit',
featuredTour: 'feesUi.feeFeaturedTourBenefit',
};
// ─── 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),
};
}