diff --git a/NIP.md b/NIP.md index 5fdd67f..aa6f2d8 100644 --- a/NIP.md +++ b/NIP.md @@ -1459,10 +1459,10 @@ below), `p` (collective member pubkeys — see Collective Membership below), ### Pro / Featured Listings (`featured` tag) -Lab profiles and calendar events (kind 31923) support an optional **Pro / -featured** upgrade. Basic listings are always free; paying the optional -platform fee adds a `featured` tag whose value is the **unix-seconds expiry** -of the featured window: +Lab profiles, calendar events (kind 31923), and skate tours (kind 30823) +support an optional **Pro / featured** upgrade. Basic listings are always free; +paying the optional platform fee adds a `featured` tag whose value is the +**unix-seconds expiry** of the featured window: ```json ["featured", "1767225600"] @@ -1473,11 +1473,48 @@ and sort the listing ahead of non-featured ones. Once the timestamp passes, the listing simply reverts to a normal free listing (no badge, no boost) — it is never unpublished. -> **Trust note:** the `featured` tag is *self-attested* — the publishing client -> writes it after recording payment locally, rather than proving a Lightning -> payment cryptographically. This matches the platform-fee trust model used -> elsewhere in SkateSpots. A future revision could verify the tag against a -> NIP-57 zap receipt to the platform Lightning address. +#### Duration tiers + +The featured window is purchased at one of four duration tiers, with a +per-day discount that grows with the commitment length (price discrimination). +The base is a 30-day monthly price per listing type; the charged amount is +`base × multiplier`: + +| Tier | Days | Multiplier | Note | +|------|------|-----------|------| +| 7 days | 7 | 0.4× | short "try it" window | +| 1 month | 30 | 1.0× | baseline | +| 3 months | 90 | 2.4× | best value (~20%/day off) | +| 12 months | 365 | 8.0× | biggest discount (~33%/day off) | + +The chosen tier only determines the computed expiry written into the +`featured` tag — the tag schema itself is unchanged. + +#### Verification via NIP-57 zap receipts (layered / best-effort) + +To make Pro tamper-proof where possible, payment is attempted as a **NIP-57 +zap** rather than a plain LNURL-pay. The client signs a kind `9734` zap request +tagging the platform pubkey (`p`) and, when known, the featured entity's +addressable coordinate (`a` = `kind:pubkey:d`), and passes it to the platform +Lightning address's LNURL callback. If the provider is zap-enabled +(`allowsNostr` + `nostrPubkey`), it publishes a kind `9735` zap receipt. + +Clients verify a `featured` listing by querying for a matching kind `9735` +receipt (`#a` = entity coordinate, `#p` = platform pubkey). Three states result: + +| State | Meaning | Badge | +|-------|---------|-------| +| `verified` | A matching zap receipt was found. Cryptographically backed. | Green ✓ Pro | +| `claimed` | Unexpired `featured` tag but no receipt found (provider doesn't publish receipts, or relays lacked it). Self-attested. | Gold Pro | +| `none` | Not featured / window expired. | (none) | + +> **Trust note:** the fallback is **graceful** — if the platform's Lightning +> provider does not publish zap receipts (as with some custodial/ecash +> addresses), verification degrades to the older *self-attested* `claimed` +> level and Pro never silently breaks. When receipts are available the badge +> becomes genuinely verifiable. For events and tours whose `d` identifier is +> generated at publish time (non-deterministic), the zap request omits the `a` +> coordinate, so those fall back to `claimed`. ### Entity Type (individual vs. collective) diff --git a/src/components/FeaturedEventUpgrade.tsx b/src/components/FeaturedEventUpgrade.tsx index 98508be..6c63438 100644 --- a/src/components/FeaturedEventUpgrade.tsx +++ b/src/components/FeaturedEventUpgrade.tsx @@ -1,30 +1,37 @@ import { Sparkles, Check } from 'lucide-react'; import { Switch } from '@/components/ui/switch'; import { PlatformFeePayment } from '@/components/PlatformFeePayment'; -import { usePlatformFee } from '@/hooks/usePlatformFee'; +import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee'; import { cn } from '@/lib/utils'; -interface FeaturedEventUpgradeProps { +interface FeaturedUpgradeProps { + /** Which platform fee this upgrade drives. */ + type: Extract; + /** Noun shown to the user, e.g. "event" or "tour". */ + noun: string; + /** Where featured items appear, e.g. "events page" or "tours". */ + placement: string; enabled: boolean; onEnabledChange: (enabled: boolean) => void; className?: string; } /** - * Opt-in "Feature this event" upgrade. + * Opt-in "Feature this " upgrade. * - * Every event is free to post. Toggling this on reveals the Lightning fee and, - * once paid (or for platform owners), the event is published with a `featured` - * tag that gives it a badge + top placement on the Events page. - * - * No hard paywall — even paid/ticketed events post for free. + * Every listing posts for free. Toggling this on reveals the Lightning fee and, + * once paid (or for platform owners), the item is published with a `featured` + * tag that gives it a badge + top placement. */ -export function FeaturedEventUpgrade({ +export function FeaturedUpgrade({ + type, + noun, + placement, enabled, onEnabledChange, className, -}: FeaturedEventUpgradeProps) { - const { isActive, isPlatformOwner, amountSats, daysRemaining } = usePlatformFee('featuredEvent'); +}: FeaturedUpgradeProps) { + const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee(type); return (

- Feature this event · optional + Feature this {noun} · optional

- Free to post. Featured events get a badge and sit - {' '}at the top of the events page. + Free to post. Featured {noun}s get a badge and sit + {' '}at the top of {placement}.

@@ -74,12 +81,28 @@ export function FeaturedEventUpgrade({ {enabled && !isActive && (
{ /* hook auto-updates isActive */ }} - context={`One-time ${amountSats.toLocaleString()} sat fee for 30 days of featured placement. Your event stays listed for free either way.`} + context={`Your ${noun} stays listed for free either way — featured just adds the badge & boost.`} />
)} ); } + +/** Convenience wrapper for events. */ +export function FeaturedEventUpgrade(props: { + enabled: boolean; + onEnabledChange: (enabled: boolean) => void; + className?: string; +}) { + return ( + + ); +} diff --git a/src/components/LabProUpgrade.tsx b/src/components/LabProUpgrade.tsx index 43ca92f..0866ed4 100644 --- a/src/components/LabProUpgrade.tsx +++ b/src/components/LabProUpgrade.tsx @@ -10,6 +10,8 @@ interface LabProUpgradeProps { onEnabledChange: (enabled: boolean) => void; /** Accent color class for the entity (e.g. "text-orange-400"). */ accentText?: string; + /** Addressable coordinate `kind:pubkey:d` for zap-receipt verification. */ + entityCoord?: string; className?: string; } @@ -26,9 +28,10 @@ export function LabProUpgrade({ enabled, onEnabledChange, accentText = 'text-amber-400', + entityCoord, className, }: LabProUpgradeProps) { - const { isActive, isPlatformOwner, amountSats, daysRemaining } = usePlatformFee('labProfile'); + const { isActive, isPlatformOwner, daysRemaining } = usePlatformFee('labProfile'); return (
{ /* hook auto-updates isActive */ }} - context={`One-time ${amountSats.toLocaleString()} sat fee for 30 days of Pro placement. Your profile stays live for free after it expires — you just lose the badge & boost.`} + context="Your profile stays live for free after Pro expires — you just lose the badge & boost." />
)} diff --git a/src/components/PlatformFeePayment.tsx b/src/components/PlatformFeePayment.tsx index 0f24115..fdf878d 100644 --- a/src/components/PlatformFeePayment.tsx +++ b/src/components/PlatformFeePayment.tsx @@ -3,8 +3,14 @@ import { Button } from '@/components/ui/button'; import { useToast } from '@/hooks/useToast'; import { useWallet } from '@/hooks/useWallet'; import { useNWC } from '@/hooks/useNWCContext'; -import { getInvoiceFromLightningAddress } from '@/lib/lightningAddress'; +import { useCurrentUser } from '@/hooks/useCurrentUser'; +import { + getInvoiceFromLightningAddress, + getZapInvoiceFromLightningAddress, + getZapProviderPubkey, +} from '@/lib/lightningAddress'; import { usePlatformFee, type FeeType } from '@/hooks/usePlatformFee'; +import { PLATFORM_OWNER_PUBKEYS } from '@/hooks/useListingRestrictions'; import { cn } from '@/lib/utils'; import { Zap, @@ -23,15 +29,33 @@ interface PlatformFeePaymentProps { onSuccess: () => void; /** Optional extra context shown to user */ context?: string; + /** + * The addressable coordinate (`kind:pubkey:d`) of the entity being featured. + * When provided and the provider supports NIP-57, the zap request tags this + * `a` coordinate so the payment can later be verified against the entity. + */ + entityCoord?: string; } +// Relays the provider should publish the zap receipt to (so we can verify it). +const ZAP_RECEIPT_RELAYS = [ + 'wss://goskatespots.nostr1.com', + 'wss://relay.nostr.band', + 'wss://relay.damus.io', +]; + type PaymentState = 'idle' | 'generating' | 'waiting' | 'paying' | 'success' | 'error'; -export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaymentProps) { - const { amountSats, recipient, recipientName, meta, isActive, daysRemaining, recordPayment } = usePlatformFee(type); +export function PlatformFeePayment({ type, onSuccess, context, entityCoord }: PlatformFeePaymentProps) { + const { + amountSats, recipient, recipientName, meta, tier, tiers, setTierId, + isActive, daysRemaining, recordPayment, + tierPriceSats: feeTierPrice, tierPerDaySats: feeTierPerDay, + } = usePlatformFee(type); const { toast } = useToast(); const { webln, activeNWC } = useWallet(); const { sendPayment } = useNWC(); + const { user } = useCurrentUser(); const [paymentState, setPaymentState] = useState('idle'); const [invoice, setInvoice] = useState(null); @@ -64,11 +88,47 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym setError(null); try { - const bolt11 = await getInvoiceFromLightningAddress({ - lightningAddress: recipient, - amountSats, - comment: `SkateSpots ${meta.label}`, - }); + let bolt11: string | null = null; + + // Prefer a NIP-57 zap so the payment produces a verifiable zap receipt. + // Falls back to a plain LNURL invoice if the provider doesn't support zaps + // or the user can't sign. + try { + const providerPubkey = await getZapProviderPubkey(recipient); + if (providerPubkey && user?.signer) { + const platformPubkey = PLATFORM_OWNER_PUBKEYS[0]; + const tags: string[][] = [ + ['relays', ...ZAP_RECEIPT_RELAYS], + ['amount', String(amountSats * 1000)], + ['p', platformPubkey], + ]; + if (entityCoord) { + tags.push(['a', entityCoord]); + } + const zapRequest = await user.signer.signEvent({ + kind: 9734, + content: `SkateSpots ${meta.label} (${tier.label})`, + created_at: Math.floor(Date.now() / 1000), + tags, + }); + bolt11 = await getZapInvoiceFromLightningAddress({ + lightningAddress: recipient, + amountSats, + zapRequest: JSON.stringify(zapRequest), + }); + } + } catch { + // Zap path failed — fall back to a normal invoice below. + bolt11 = null; + } + + if (!bolt11) { + bolt11 = await getInvoiceFromLightningAddress({ + lightningAddress: recipient, + amountSats, + comment: `SkateSpots ${meta.label} (${tier.label})`, + }); + } setInvoice(bolt11); @@ -150,6 +210,48 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym
+ {/* Duration tier selector — only before payment has started */} + {paymentState === 'idle' && ( +
+

+ Choose duration +

+
+ {tiers.map((t) => { + const price = feeTierPrice(t); + const perDay = feeTierPerDay(t); + const selected = t.id === tier.id; + return ( + + ); + })} +
+
+ )} + {/* What you get */}
@@ -158,7 +260,7 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym
- Valid for {meta.duration} + Valid for {tier.label}
@@ -170,7 +272,7 @@ export function PlatformFeePayment({ type, onSuccess, context }: PlatformFeePaym

⚡ {amountSats.toLocaleString()} sats

- ~${(amountSats * 0.0006).toFixed(2)} USD · to {recipientName} · {meta.duration} + ~${(amountSats * 0.0006).toFixed(2)} USD · to {recipientName} · {tier.label}

diff --git a/src/components/ProBadge.tsx b/src/components/ProBadge.tsx index 1f33aff..487afb2 100644 --- a/src/components/ProBadge.tsx +++ b/src/components/ProBadge.tsx @@ -1,7 +1,8 @@ -import { Sparkles } from 'lucide-react'; +import { Sparkles, BadgeCheck } from 'lucide-react'; import { Badge } from '@/components/ui/badge'; import { cn } from '@/lib/utils'; import { isFeaturedActive } from '@/lib/types'; +import { useFeaturedVerification } from '@/hooks/useFeaturedVerification'; interface ProBadgeProps { /** The `featuredUntil` unix-seconds timestamp from a parsed entity. */ @@ -9,25 +10,49 @@ interface ProBadgeProps { className?: string; /** Compact = icon only (for dense card corners). */ compact?: boolean; + /** + * When set (an addressable `kind:pubkey:d` coordinate), the badge verifies the + * featured status against a NIP-57 zap receipt and shows a "Verified" state + * when found. Use on detail pages; omit in dense lists to avoid over-fetching. + */ + entityCoord?: string; } /** - * "Pro" badge shown on featured Lab profiles and events. + * "Pro" badge shown on featured Lab profiles, events, and tours. * Renders nothing if the entity isn't currently featured. */ -export function ProBadge({ featuredUntil, className, compact = false }: ProBadgeProps) { +export function ProBadge({ featuredUntil, className, compact = false, entityCoord }: ProBadgeProps) { + const { data: status } = useFeaturedVerification( + entityCoord, + entityCoord ? featuredUntil : undefined, + ); + if (!isFeaturedActive(featuredUntil)) return null; + const verified = status === 'verified'; + return ( - + {verified ? ( + + ) : ( + + )} Pro ); diff --git a/src/components/TourCard.tsx b/src/components/TourCard.tsx index 011ba40..6340379 100644 --- a/src/components/TourCard.tsx +++ b/src/components/TourCard.tsx @@ -17,6 +17,7 @@ import { useCurrentUser } from '@/hooks/useCurrentUser'; import { genUserName } from '@/lib/genUserName'; import { ZapButton } from '@/components/ZapButton'; import { TrustBadge } from '@/components/TrustBadge'; +import { ProBadge } from '@/components/ProBadge'; import { ReportDialog } from '@/components/ReportDialog'; import type { SkateTour, TourCategory } from '@/lib/types'; @@ -142,14 +143,17 @@ export function TourCard({ tour, compact = false }: TourCardProps) { > {tour.title} - {tourStatus && ( - - {tourStatus.label} - - )} +
+ + {tourStatus && ( + + {tourStatus.label} + + )} +
@@ -234,6 +238,7 @@ export function TourCard({ tour, compact = false }: TourCardProps) { {/* Status/Price overlay */}
+ {tourStatus && ( ({ + queryKey: ['featured-verification', entityCoord, featuredUntil], + queryFn: async (c) => { + // Not featured at all. + if (!claimed || !entityCoord) return 'none'; + + const signal = AbortSignal.any([c.signal, AbortSignal.timeout(6000)]); + + try { + // Zap receipts paying the platform that reference this entity. + const receipts = await nostr.query( + [ + { + kinds: [9735], + '#a': [entityCoord], + '#p': PLATFORM_OWNER_PUBKEYS, + limit: 20, + }, + ], + { signal }, + ); + + // Any matching receipt authorizes the featured status. + if (receipts.length > 0) return 'verified'; + } catch { + // Relay error — fall through to claimed. + } + + // Featured tag present but unverifiable → self-attested. + return 'claimed'; + }, + enabled: claimed && !!entityCoord, + staleTime: 5 * 60 * 1000, // 5 min + gcTime: 30 * 60 * 1000, + retry: 1, + }); +} diff --git a/src/hooks/usePlatformFee.ts b/src/hooks/usePlatformFee.ts index 5812e4d..ed80f4f 100644 --- a/src/hooks/usePlatformFee.ts +++ b/src/hooks/usePlatformFee.ts @@ -1,48 +1,92 @@ +import { useState } from 'react'; import { useLocalStorage } from '@/hooks/useLocalStorage'; import { useCurrentUser } from '@/hooks/useCurrentUser'; import { PLATFORM_OWNER_PUBKEYS, PLATFORM_LIGHTNING_ADDRESS } from '@/hooks/useListingRestrictions'; -// ─── Fee amounts (sats) ─────────────────────────────────────────────────────── +// ─── 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 — paid/ticketed events · 30 days */ + /** Featured event listing */ featuredEvent: 5000, - /** Lab profile with a rate set (coach / filmer / editor) · 30 days */ + /** Lab Pro profile (coach / filmer / editor / photographer / builder) */ labProfile: 5000, - /** Featured shop placement on map · 30 days */ + /** 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 = { featuredEvent: { - label: 'Featured Event Listing', - description: 'List a paid or ticketed event on SkateSpots', - duration: '30 days', - benefit: 'Your event appears at the top of the events page for 30 days', + 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: 'List your services with a rate in The Lab', - duration: '30 days', - benefit: 'Your profile is discoverable by skaters looking to hire for 30 days', + 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', - duration: '30 days', - benefit: 'Verified badge + featured pin on the map for 30 days', + 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 ─────────────────────────────────────────── @@ -60,12 +104,13 @@ export function usePlatformFee(type: FeeType) { const { user } = useCurrentUser(); const [record, setRecord] = useLocalStorage(storageKey(type), null); + // Currently selected duration tier (UI state). + const [tierId, setTierId] = useState(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); - // 30 days in ms - const THIRTY_DAYS_MS = 30 * 24 * 60 * 60 * 1000; - // Is the fee currently active (paid and not expired)? const isActive = isPlatformOwner || ( record !== null && @@ -87,23 +132,32 @@ export function usePlatformFee(type: FeeType) { return undefined; })(); - // Record a successful payment + // 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 + THIRTY_DAYS_MS, + expiresAt: now + tier.days * 24 * 60 * 60 * 1000, }); }; return { - /** Amount in sats */ - amountSats: PLATFORM_FEES[type], + /** 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 */ @@ -114,5 +168,8 @@ export function usePlatformFee(type: FeeType) { 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), }; } diff --git a/src/hooks/useTours.ts b/src/hooks/useTours.ts index 0cf6264..c6c7ab9 100644 --- a/src/hooks/useTours.ts +++ b/src/hooks/useTours.ts @@ -1,7 +1,15 @@ import { useQuery } from '@tanstack/react-query'; -import { SKATE_TOUR_KIND, parseSkateTour, type SkateTour } from '@/lib/types'; +import { SKATE_TOUR_KIND, parseSkateTour, isFeaturedActive, type SkateTour } from '@/lib/types'; import { useSkateRelays } from '@/hooks/useSkateRelays'; +/** Sort featured (Pro) tours first, then newest. */ +function sortTours(a: SkateTour, b: SkateTour): number { + const af = isFeaturedActive(a.featuredUntil) ? 1 : 0; + const bf = isFeaturedActive(b.featuredUntil) ? 1 : 0; + if (af !== bf) return bf - af; + return b.createdAt - a.createdAt; +} + const STALE_TIME = 5 * 60 * 1000; // 5 min const GC_TIME = 30 * 60 * 1000; // 30 min const TIMEOUT = 8000; @@ -31,7 +39,7 @@ export function useTours(limit = 100) { if (tour) tours.push(tour); } - return tours.sort((a, b) => b.createdAt - a.createdAt); + return tours.sort(sortTours); }, staleTime: STALE_TIME, gcTime: GC_TIME, diff --git a/src/lib/lightningAddress.ts b/src/lib/lightningAddress.ts index e9b1689..78c7c72 100644 --- a/src/lib/lightningAddress.ts +++ b/src/lib/lightningAddress.ts @@ -92,6 +92,75 @@ export interface GetInvoiceParams { comment?: string; } +/** + * Does this Lightning address support NIP-57 zaps? + * Returns the provider's nostr pubkey (hex) if so, else null. + */ +export async function getZapProviderPubkey( + lightningAddress: string, +): Promise { + try { + const data = await resolveLightningAddress(lightningAddress); + // `allowsNostr` + a valid `nostrPubkey` means the provider publishes zap receipts. + const withNostr = data as LNURLPayResponse & { + allowsNostr?: boolean; + nostrPubkey?: string; + }; + if (withNostr.allowsNostr && /^[0-9a-f]{64}$/i.test(withNostr.nostrPubkey ?? '')) { + return withNostr.nostrPubkey!.toLowerCase(); + } + return null; + } catch { + return null; + } +} + +export interface GetZapInvoiceParams { + lightningAddress: string; + amountSats: number; + /** Signed kind-9734 zap request event (JSON string via JSON.stringify). */ + zapRequest: string; +} + +/** + * Get a BOLT11 invoice for a NIP-57 zap by passing a signed zap request to the + * LNURL callback. The provider will publish a kind-9735 zap receipt when paid. + */ +export async function getZapInvoiceFromLightningAddress( + params: GetZapInvoiceParams, +): Promise { + const { lightningAddress, amountSats, zapRequest } = params; + + if (!isValidLightningAddress(lightningAddress)) { + throw new Error('Invalid Lightning address format. Expected: username@domain.com'); + } + if (amountSats <= 0) throw new Error('Amount must be greater than 0'); + + const lnurlData = await resolveLightningAddress(lightningAddress); + const minSats = lnurlData.minSendable / 1000; + const maxSats = lnurlData.maxSendable / 1000; + if (amountSats < minSats) throw new Error(`Amount ${amountSats} sats is below minimum ${minSats} sats`); + if (amountSats > maxSats) throw new Error(`Amount ${amountSats} sats exceeds maximum ${maxSats} sats`); + + const amountMsats = amountSats * 1000; + const callbackUrl = new URL(lnurlData.callback); + callbackUrl.searchParams.set('amount', amountMsats.toString()); + callbackUrl.searchParams.set('nostr', zapRequest); + + const invoiceResponse = await fetch(callbackUrl.toString(), { + headers: { Accept: 'application/json' }, + }); + if (!invoiceResponse.ok) { + throw new Error(`Failed to get zap invoice: ${invoiceResponse.status} ${invoiceResponse.statusText}`); + } + const invoiceData: LNURLInvoiceResponse = await invoiceResponse.json(); + if (invoiceData.pr) return invoiceData.pr; + if (invoiceData.status && invoiceData.status !== 'OK') { + throw new Error(`Zap invoice generation failed: ${invoiceData.reason || 'unknown'}`); + } + throw new Error('No invoice returned from provider'); +} + /** * Get BOLT11 invoice from Lightning address */ diff --git a/src/lib/types.ts b/src/lib/types.ts index fae35fd..0b6c6f3 100644 --- a/src/lib/types.ts +++ b/src/lib/types.ts @@ -1058,6 +1058,7 @@ export interface SkateTour { }; // Sponsors (for future use) sponsors?: TourSponsor[]; + featuredUntil?: number; // Featured tour listing expiry (unix seconds), if paid createdAt: number; event: NostrEvent; } @@ -1604,6 +1605,7 @@ export function parseSkateTour(event: NostrEvent): SkateTour | null { acceptedClipSources, filmRelease, sponsors, + featuredUntil: parseFeaturedUntil(event), createdAt: event.created_at, event, }; diff --git a/src/pages/BecomeBuilderPage.tsx b/src/pages/BecomeBuilderPage.tsx index d8fc8e2..f69c820 100644 --- a/src/pages/BecomeBuilderPage.tsx +++ b/src/pages/BecomeBuilderPage.tsx @@ -530,7 +530,7 @@ export default function BecomeBuilderPage() {
{/* Optional Pro upgrade — free to list without it */} - +
diff --git a/src/pages/BecomeFilmerPage.tsx b/src/pages/BecomeFilmerPage.tsx index 4fedf0e..47065e8 100644 --- a/src/pages/BecomeFilmerPage.tsx +++ b/src/pages/BecomeFilmerPage.tsx @@ -506,7 +506,7 @@ export default function BecomeFilmerPage() {
{/* Optional Pro upgrade — free to list without it */} - +