feat: auto-detect zap payments, show zapper profiles, fix algorithm

- ZapModal now polls LNURL verify URL for automatic payment detection
  instead of requiring manual "Done" click
- Added lightning bolt celebration animation on zap success with
  random fun quotes
- Show "Zapped by" section on film detail modal with profile pics,
  amounts, and avatar pills (like Primal/Yakihonne)
- useNostr now subscribes to kind 9735 zap receipts per content,
  parses sender pubkey from embedded zap request, and fetches profiles
- Fixed most-zapped algorithm to also parse the description tag in
  zap receipts for content matching (NIP-57 embedded zap requests)

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-14 12:42:57 +00:00
parent 92182ca2ad
commit e774d20821
4 changed files with 372 additions and 33 deletions

View File

@@ -138,11 +138,40 @@
</div> </div>
<!-- Creator Attribution --> <!-- Creator Attribution -->
<div v-if="content.creator" class="flex items-center gap-3 mb-8 text-white/60 text-sm"> <div v-if="content.creator" class="flex items-center gap-3 mb-6 text-white/60 text-sm">
<span>Directed by</span> <span>Directed by</span>
<span class="text-white font-medium">{{ content.creator }}</span> <span class="text-white font-medium">{{ content.creator }}</span>
</div> </div>
<!-- Zaps Section -->
<div v-if="zapsList.length > 0" class="mb-6">
<div class="flex items-center gap-2 mb-3">
<svg class="w-4 h-4 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
<span class="text-sm font-semibold text-white">Zapped by</span>
<span class="text-xs text-white/40">({{ totalZapSats.toLocaleString() }} sats)</span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<div
v-for="(zap, idx) in displayZaps"
:key="idx"
class="zap-avatar-pill"
:title="getZapperName(zap.pubkey) + ' — ' + zap.amount.toLocaleString() + ' sats'"
>
<img
:src="getZapperPicture(zap.pubkey)"
:alt="getZapperName(zap.pubkey)"
class="w-7 h-7 rounded-full object-cover"
/>
<span class="text-xs font-medium text-[#F7931A]">{{ formatZapAmount(zap.amount) }}</span>
</div>
<span v-if="zapsList.length > 8" class="text-xs text-white/40 ml-1">
+{{ zapsList.length - 8 }} more
</span>
</div>
</div>
<!-- Divider --> <!-- Divider -->
<div class="border-t border-white/10 mb-6"></div> <div class="border-t border-white/10 mb-6"></div>
@@ -245,6 +274,7 @@
:isOpen="showZapModal" :isOpen="showZapModal"
:content="content" :content="content"
@close="showZapModal = false" @close="showZapModal = false"
@zapped="handleZapped"
/> />
</div> </div>
</Transition> </Transition>
@@ -347,6 +377,11 @@ const reactionCounts = computed(() => nostr.reactionCounts.value)
const isLoadingComments = computed(() => nostr.isLoading.value) const isLoadingComments = computed(() => nostr.isLoading.value)
const commentCount = computed(() => nostr.commentCount.value) const commentCount = computed(() => nostr.commentCount.value)
// Zap data from relay
const zapsList = computed(() => nostr.zaps.value)
const displayZaps = computed(() => zapsList.value.slice(0, 8))
const totalZapSats = computed(() => zapsList.value.reduce((sum, z) => sum + z.amount, 0))
// User's existing reaction read from relay (not local state) // User's existing reaction read from relay (not local state)
const userReaction = computed(() => nostr.userContentReaction.value) const userReaction = computed(() => nostr.userContentReaction.value)
const hasVoted = computed(() => nostr.hasVotedOnContent.value) const hasVoted = computed(() => nostr.hasVotedOnContent.value)
@@ -468,6 +503,29 @@ function handleZap() {
showZapModal.value = true showZapModal.value = true
} }
function handleZapped(_amount: number) {
// The zap was confirmed — the relay subscription will pick up
// the zap receipt automatically and update zapsList.
}
function getZapperName(pubkey: string): string {
const profile = nostr.profiles.value.get(pubkey)
if (profile?.display_name) return profile.display_name
if (profile?.name) return profile.name
return pubkey.slice(0, 8) + '...'
}
function getZapperPicture(pubkey: string): string {
const profile = nostr.profiles.value.get(pubkey)
return profile?.picture || `https://robohash.org/${pubkey}.png`
}
function formatZapAmount(sats: number): string {
if (sats >= 1_000_000) return (sats / 1_000_000).toFixed(1) + 'M'
if (sats >= 1_000) return (sats / 1_000).toFixed(sats >= 10_000 ? 0 : 1) + 'k'
return sats.toLocaleString()
}
function handleShare() { function handleShare() {
const url = `${window.location.origin}/content/${props.content?.id}` const url = `${window.location.origin}/content/${props.content?.id}`
if (navigator.share) { if (navigator.share) {
@@ -691,6 +749,23 @@ function openSubscriptionFromRental() {
color: #F7931A; color: #F7931A;
} }
/* Zap avatar pill */
.zap-avatar-pill {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 10px 3px 3px;
border-radius: 999px;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.15);
transition: all 0.2s ease;
}
.zap-avatar-pill:hover {
background: rgba(247, 147, 26, 0.15);
border-color: rgba(247, 147, 26, 0.3);
}
/* Category Tags */ /* Category Tags */
.category-tag { .category-tag {
display: inline-block; display: inline-block;

View File

@@ -125,29 +125,44 @@
Open in wallet app Open in wallet app
</a> </a>
<!-- Done Button --> <p class="text-xs text-white/40 animate-pulse">
<button Waiting for payment confirmation...
@click="handleDone" </p>
class="w-full bg-white/5 hover:bg-white/10 text-white/70 rounded-xl px-4 py-3 text-sm font-medium transition-colors"
>
Done
</button>
</div> </div>
</template> </template>
<!-- SUCCESS STATE --> <!-- SUCCESS STATE: Lightning Celebration -->
<template v-if="paymentState === 'success'"> <template v-if="paymentState === 'success'">
<div class="text-center py-6"> <div class="text-center py-4 relative overflow-hidden">
<div class="w-20 h-20 bg-[#F7931A]/20 rounded-full flex items-center justify-center mx-auto mb-5"> <!-- Animated lightning bolts background -->
<svg class="w-10 h-10 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor"> <div class="zap-celebration">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/> <div v-for="i in 12" :key="i" class="zap-bolt" :style="getBoltStyle(i)">
</svg> <svg viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
</div>
</div>
<!-- Glowing center icon -->
<div class="relative z-10">
<div class="zap-success-icon mx-auto mb-5">
<svg class="w-12 h-12 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
</div>
<h2 class="text-3xl font-bold text-white mb-2">ZAP!</h2>
<p class="text-[#F7931A] text-lg font-semibold mb-1">
{{ zapAmount?.toLocaleString() }} sats sent
</p>
<p class="text-white/40 text-sm mb-6">
{{ successQuote }}
</p>
<button @click="closeModal" class="zap-pay-button w-full flex items-center justify-center">
Nice!
</button>
</div> </div>
<h2 class="text-2xl font-bold text-white mb-2">Zap Sent!</h2>
<p class="text-white/50 mb-6">You zapped {{ zapAmount?.toLocaleString() }} sats to the creator</p>
<button @click="closeModal" class="zap-pay-button w-full flex items-center justify-center">
Done
</button>
</div> </div>
</template> </template>
@@ -158,7 +173,7 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch } from 'vue' import { ref, computed, watch, onUnmounted } from 'vue'
import QRCode from 'qrcode' import QRCode from 'qrcode'
import type { Content } from '../types/content' import type { Content } from '../types/content'
import { indeehubApiService } from '../services/indeehub-api.service' import { indeehubApiService } from '../services/indeehub-api.service'
@@ -170,6 +185,7 @@ interface Props {
interface Emits { interface Emits {
(e: 'close'): void (e: 'close'): void
(e: 'zapped', amount: number): void
} }
const props = defineProps<Props>() const props = defineProps<Props>()
@@ -179,6 +195,17 @@ type PaymentState = 'select' | 'invoice' | 'success'
const presetAmounts = [21, 100, 1000, 5000] const presetAmounts = [21, 100, 1000, 5000]
const successQuotes = [
'The creator just felt a tingle in their wallet',
'Proof of appreciation, verified on the Lightning Network',
'Sats well spent. The creator thanks you!',
'You just made someone\'s day brighter',
'Value for value. This is the way.',
'Stack sats, zap creators. Repeat.',
'Money at the speed of light. Literally.',
'Another brick in the Lightning wall!',
]
const paymentState = ref<PaymentState>('select') const paymentState = ref<PaymentState>('select')
const selectedAmount = ref<number>(100) const selectedAmount = ref<number>(100)
const customAmount = ref<string>('') const customAmount = ref<string>('')
@@ -186,17 +213,22 @@ const customActive = ref(false)
const isLoading = ref(false) const isLoading = ref(false)
const errorMessage = ref<string | null>(null) const errorMessage = ref<string | null>(null)
const creatorName = ref<string | null>(null) const creatorName = ref<string | null>(null)
const successQuote = ref('')
// LNURL-pay data // LNURL-pay data
const lnurlCallback = ref<string | null>(null) const lnurlCallback = ref<string | null>(null)
const lnurlMinSats = ref(1) const lnurlMinSats = ref(1)
const lnurlMaxSats = ref(10_000_000) const lnurlMaxSats = ref(10_000_000)
const verifyUrl = ref<string | null>(null)
// Invoice data // Invoice data
const bolt11Invoice = ref('') const bolt11Invoice = ref('')
const qrCodeDataUrl = ref('') const qrCodeDataUrl = ref('')
const copyButtonText = ref('Copy Invoice') const copyButtonText = ref('Copy Invoice')
// Polling
let pollInterval: ReturnType<typeof setInterval> | null = null
/** The effective amount to zap */ /** The effective amount to zap */
const zapAmount = computed<number | null>(() => { const zapAmount = computed<number | null>(() => {
if (customActive.value && customAmount.value) { if (customActive.value && customAmount.value) {
@@ -216,6 +248,32 @@ function handleCustomInput() {
customActive.value = true customActive.value = true
} }
/**
* Generate random style for each celebration lightning bolt
*/
function getBoltStyle(index: number) {
const left = (index * 8.3) % 100
const delay = (index * 0.15) % 2
const size = 14 + (index % 4) * 6
const duration = 1.5 + (index % 3) * 0.5
return {
left: `${left}%`,
animationDelay: `${delay}s`,
width: `${size}px`,
height: `${size}px`,
animationDuration: `${duration}s`,
}
}
function cleanup() {
if (pollInterval) {
clearInterval(pollInterval)
pollInterval = null
}
}
onUnmounted(cleanup)
// Reset when modal opens/closes // Reset when modal opens/closes
watch(() => props.isOpen, async (open) => { watch(() => props.isOpen, async (open) => {
if (open) { if (open) {
@@ -229,9 +287,14 @@ watch(() => props.isOpen, async (open) => {
copyButtonText.value = 'Copy Invoice' copyButtonText.value = 'Copy Invoice'
creatorName.value = props.content?.creator || null creatorName.value = props.content?.creator || null
lnurlCallback.value = null lnurlCallback.value = null
verifyUrl.value = null
successQuote.value = ''
cleanup()
// Pre-resolve the creator's lightning address // Pre-resolve the creator's lightning address
await resolveLightningAddress() await resolveLightningAddress()
} else {
cleanup()
} }
}) })
@@ -247,7 +310,6 @@ async function resolveLightningAddress() {
if (!props.content) return if (!props.content) return
try { try {
// Step 1: Get the project owner's filmmaker profile
const projectId = props.content.id const projectId = props.content.id
const ownerData = await indeehubApiService.get<{ const ownerData = await indeehubApiService.get<{
id: string id: string
@@ -260,7 +322,6 @@ async function resolveLightningAddress() {
creatorName.value = ownerData.professionalName creatorName.value = ownerData.professionalName
} }
// Step 2: Fetch the filmmaker's lightning address
const addressData = await indeehubApiService.get<{ const addressData = await indeehubApiService.get<{
lightningAddress: string lightningAddress: string
}>(`/filmmakers/${ownerData.id}/lightning-address`) }>(`/filmmakers/${ownerData.id}/lightning-address`)
@@ -268,7 +329,6 @@ async function resolveLightningAddress() {
const lightningAddress = addressData?.lightningAddress const lightningAddress = addressData?.lightningAddress
if (!lightningAddress) return if (!lightningAddress) return
// Parse lightning address: user@domain → https://domain/.well-known/lnurlp/user
const [username, domain] = lightningAddress.split('@') const [username, domain] = lightningAddress.split('@')
if (!username || !domain) return if (!username || !domain) return
@@ -281,7 +341,6 @@ async function resolveLightningAddress() {
if (lnurlData.status === 'ERROR') return if (lnurlData.status === 'ERROR') return
lnurlCallback.value = lnurlData.callback lnurlCallback.value = lnurlData.callback
// LNURL amounts are in millisats
lnurlMinSats.value = Math.ceil((lnurlData.minSendable || 1000) / 1000) lnurlMinSats.value = Math.ceil((lnurlData.minSendable || 1000) / 1000)
lnurlMaxSats.value = Math.floor((lnurlData.maxSendable || 10_000_000_000) / 1000) lnurlMaxSats.value = Math.floor((lnurlData.maxSendable || 10_000_000_000) / 1000)
} catch (err) { } catch (err) {
@@ -289,6 +348,37 @@ async function resolveLightningAddress() {
} }
} }
/**
* Poll the LNURL verify URL to detect payment.
* The verify URL is returned alongside the invoice and responds
* with { settled: true } once the invoice is paid.
*/
function startPolling() {
if (!verifyUrl.value) return
const url = verifyUrl.value
pollInterval = setInterval(async () => {
try {
const response = await fetch(url)
if (!response.ok) return
const data = await response.json()
if (data.settled === true || data.preimage) {
cleanup()
showSuccess()
}
} catch {
// Silently retry
}
}, 3000)
}
function showSuccess() {
successQuote.value = successQuotes[Math.floor(Math.random() * successQuotes.length)]
paymentState.value = 'success'
emit('zapped', zapAmount.value || 0)
}
async function handleZap() { async function handleZap() {
if (!zapAmount.value || zapAmount.value < 1) return if (!zapAmount.value || zapAmount.value < 1) return
@@ -301,7 +391,6 @@ async function handleZap() {
return return
} }
// Validate amount against LNURL limits
if (zapAmount.value < lnurlMinSats.value) { if (zapAmount.value < lnurlMinSats.value) {
errorMessage.value = `Minimum zap is ${lnurlMinSats.value.toLocaleString()} sats` errorMessage.value = `Minimum zap is ${lnurlMinSats.value.toLocaleString()} sats`
return return
@@ -330,8 +419,13 @@ async function handleZap() {
} }
bolt11Invoice.value = data.pr bolt11Invoice.value = data.pr
// Store verify URL for payment detection polling
verifyUrl.value = data.verify || null
await generateQRCode(data.pr) await generateQRCode(data.pr)
paymentState.value = 'invoice' paymentState.value = 'invoice'
// Start polling for payment confirmation
startPolling()
} catch (error: any) { } catch (error: any) {
errorMessage.value = error.message || 'Failed to create zap invoice. Please try again.' errorMessage.value = error.message || 'Failed to create zap invoice. Please try again.'
} finally { } finally {
@@ -362,15 +456,13 @@ async function copyInvoice() {
} }
} }
function handleDone() {
paymentState.value = 'success'
}
function closeModal() { function closeModal() {
cleanup()
paymentState.value = 'select' paymentState.value = 'select'
errorMessage.value = null errorMessage.value = null
bolt11Invoice.value = '' bolt11Invoice.value = ''
qrCodeDataUrl.value = '' qrCodeDataUrl.value = ''
verifyUrl.value = null
emit('close') emit('close')
} }
</script> </script>
@@ -493,6 +585,76 @@ function closeModal() {
transform: translateY(0); transform: translateY(0);
} }
/* ─── Success Celebration ─── */
.zap-celebration {
position: absolute;
inset: 0;
pointer-events: none;
overflow: hidden;
}
.zap-bolt {
position: absolute;
top: -20px;
color: rgba(247, 147, 26, 0.3);
animation: zapFall linear infinite;
filter: blur(0.5px);
}
.zap-bolt:nth-child(odd) {
color: rgba(247, 147, 26, 0.15);
}
.zap-bolt:nth-child(3n) {
color: rgba(255, 200, 50, 0.25);
}
@keyframes zapFall {
0% {
transform: translateY(-20px) rotate(-15deg) scale(0.5);
opacity: 0;
}
15% {
opacity: 1;
}
85% {
opacity: 0.6;
}
100% {
transform: translateY(350px) rotate(15deg) scale(1);
opacity: 0;
}
}
.zap-success-icon {
width: 88px;
height: 88px;
border-radius: 50%;
background: radial-gradient(circle, rgba(247, 147, 26, 0.25), rgba(247, 147, 26, 0.05));
display: flex;
align-items: center;
justify-content: center;
animation: zapPulse 1.5s ease-in-out infinite;
box-shadow:
0 0 40px rgba(247, 147, 26, 0.3),
0 0 80px rgba(247, 147, 26, 0.15);
}
@keyframes zapPulse {
0%, 100% {
box-shadow:
0 0 40px rgba(247, 147, 26, 0.3),
0 0 80px rgba(247, 147, 26, 0.15);
transform: scale(1);
}
50% {
box-shadow:
0 0 60px rgba(247, 147, 26, 0.4),
0 0 120px rgba(247, 147, 26, 0.2);
transform: scale(1.05);
}
}
/* Modal Transitions */ /* Modal Transitions */
.modal-fade-enter-active, .modal-fade-enter-active,
.modal-fade-leave-active { .modal-fade-leave-active {

View File

@@ -129,13 +129,29 @@ function rebuildStats() {
} }
// Process zap receipts (kind 9735) from the EventStore. // Process zap receipts (kind 9735) from the EventStore.
// Zap receipts reference content via the 'i' tag (external identifiers) // Zap receipts reference content via:
// or via embedded zap request description. // 1. Direct 'i' tag on the receipt itself
// 2. 'i' tag embedded in the zap request JSON (description tag)
const zapReceipts = eventStore.getByFilters([{ kinds: [9735] }]) const zapReceipts = eventStore.getByFilters([{ kinds: [9735] }])
if (zapReceipts) { if (zapReceipts) {
for (const event of zapReceipts) { for (const event of zapReceipts) {
// Try to find the external content ID from the zap receipt tags // Try direct 'i' tag first
const externalId = getTagValue(event, 'i') let externalId = getTagValue(event, 'i')
// Fallback: parse the embedded zap request from the description tag
if (!externalId) {
const descTag = event.tags.find((t) => t[0] === 'description')?.[1]
if (descTag) {
try {
const zapRequest = JSON.parse(descTag)
if (zapRequest.tags) {
const iTag = zapRequest.tags.find((t: string[]) => t[0] === 'i')
if (iTag) externalId = iTag[1]
}
} catch { /* not valid JSON */ }
}
}
if (!externalId) continue if (!externalId) continue
const stats = getOrCreate(externalId) const stats = getOrCreate(externalId)

View File

@@ -120,6 +120,8 @@ export function useNostr(contentId?: string) {
const reactions = ref<NostrEvent[]>([]) const reactions = ref<NostrEvent[]>([])
// Per-comment reactions: eventId -> NostrEvent[] // Per-comment reactions: eventId -> NostrEvent[]
const commentReactions = ref<Map<string, NostrEvent[]>>(new Map()) const commentReactions = ref<Map<string, NostrEvent[]>>(new Map())
// Zap receipts for the current content
const zaps = ref<{ pubkey: string; amount: number; timestamp: number }[]>([])
// User profiles // User profiles
const profiles = ref<Map<string, any>>(new Map()) const profiles = ref<Map<string, any>>(new Map())
// Loading state // Loading state
@@ -170,9 +172,22 @@ export function useNostr(contentId?: string) {
}) })
subscriptions.push(reactionSub) subscriptions.push(reactionSub)
// Subscribe to zap receipts (kind 9735) for this content
const zapSub = pool
.subscription(currentRelays, [
{ kinds: [9735], '#i': [externalId] },
])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => refreshZaps(externalId),
error: (err) => console.error('Zap subscription error:', err),
})
subscriptions.push(zapSub)
// Read existing data from store immediately // Read existing data from store immediately
refreshComments(externalId) refreshComments(externalId)
refreshReactions(externalId) refreshReactions(externalId)
refreshZaps(externalId)
// Mark loading done after initial data window // Mark loading done after initial data window
setTimeout(() => { setTimeout(() => {
@@ -213,6 +228,76 @@ export function useNostr(contentId?: string) {
loadCommentReactions(allEvents) loadCommentReactions(allEvents)
} }
/**
* Parse sats from a BOLT11 invoice string.
*/
function parseBolt11Sats(bolt11: string): number {
try {
const match = bolt11.toLowerCase().match(/^lnbc(\d+)([munp]?)1/)
if (!match) return 0
const value = parseInt(match[1], 10)
if (isNaN(value)) return 0
switch (match[2]) {
case 'm': return value * 100_000
case 'u': return value * 100
case 'n': return Math.round(value * 0.1)
case 'p': return Math.round(value * 0.0001)
case '': return value * 100_000_000
default: return 0
}
} catch { return 0 }
}
/**
* Read zap receipts from the EventStore and extract sender info.
*/
function refreshZaps(externalId: string) {
const events = eventStore.getByFilters([
{ kinds: [9735], '#i': [externalId] },
])
if (!events) return
const zapList: { pubkey: string; amount: number; timestamp: number }[] = []
const zapperPubkeys: string[] = []
for (const event of events) {
// The zap receipt's pubkey is the LNURL provider, not the sender.
// The sender's pubkey is in the embedded zap request (description tag).
let senderPubkey = event.pubkey
let amount = 0
// Try to extract sender from the description tag (NIP-57)
const descTag = event.tags.find((t) => t[0] === 'description')?.[1]
if (descTag) {
try {
const zapRequest = JSON.parse(descTag)
if (zapRequest.pubkey) senderPubkey = zapRequest.pubkey
} catch { /* not valid JSON */ }
}
// Extract amount from bolt11 tag
const bolt11 = event.tags.find((t) => t[0] === 'bolt11')?.[1]
if (bolt11) {
amount = parseBolt11Sats(bolt11)
}
zapList.push({
pubkey: senderPubkey,
amount,
timestamp: event.created_at,
})
zapperPubkeys.push(senderPubkey)
}
// Sort by most recent first
zapList.sort((a, b) => b.timestamp - a.timestamp)
zaps.value = zapList
// Fetch profiles for zap senders
const uniquePubkeys = [...new Set(zapperPubkeys)]
fetchProfiles(uniquePubkeys)
}
/** /**
* Read movie-level reactions from the EventStore. * Read movie-level reactions from the EventStore.
*/ */
@@ -557,6 +642,7 @@ export function useNostr(contentId?: string) {
allComments, allComments,
reactions, reactions,
commentReactions, commentReactions,
zaps,
profiles, profiles,
isLoading, isLoading,
error, error,