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>
<!-- 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 class="text-white font-medium">{{ content.creator }}</span>
</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 -->
<div class="border-t border-white/10 mb-6"></div>
@@ -245,6 +274,7 @@
:isOpen="showZapModal"
:content="content"
@close="showZapModal = false"
@zapped="handleZapped"
/>
</div>
</Transition>
@@ -347,6 +377,11 @@ const reactionCounts = computed(() => nostr.reactionCounts.value)
const isLoadingComments = computed(() => nostr.isLoading.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)
const userReaction = computed(() => nostr.userContentReaction.value)
const hasVoted = computed(() => nostr.hasVotedOnContent.value)
@@ -468,6 +503,29 @@ function handleZap() {
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() {
const url = `${window.location.origin}/content/${props.content?.id}`
if (navigator.share) {
@@ -691,6 +749,23 @@ function openSubscriptionFromRental() {
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-tag {
display: inline-block;

View File

@@ -125,29 +125,44 @@
Open in wallet app
</a>
<!-- Done Button -->
<button
@click="handleDone"
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>
<p class="text-xs text-white/40 animate-pulse">
Waiting for payment confirmation...
</p>
</div>
</template>
<!-- SUCCESS STATE -->
<!-- SUCCESS STATE: Lightning Celebration -->
<template v-if="paymentState === 'success'">
<div class="text-center py-6">
<div class="w-20 h-20 bg-[#F7931A]/20 rounded-full flex items-center justify-center mx-auto mb-5">
<svg class="w-10 h-10 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
<div class="text-center py-4 relative overflow-hidden">
<!-- Animated lightning bolts background -->
<div class="zap-celebration">
<div v-for="i in 12" :key="i" class="zap-bolt" :style="getBoltStyle(i)">
<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>
<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>
</template>
@@ -158,7 +173,7 @@
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import { ref, computed, watch, onUnmounted } from 'vue'
import QRCode from 'qrcode'
import type { Content } from '../types/content'
import { indeehubApiService } from '../services/indeehub-api.service'
@@ -170,6 +185,7 @@ interface Props {
interface Emits {
(e: 'close'): void
(e: 'zapped', amount: number): void
}
const props = defineProps<Props>()
@@ -179,6 +195,17 @@ type PaymentState = 'select' | 'invoice' | 'success'
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 selectedAmount = ref<number>(100)
const customAmount = ref<string>('')
@@ -186,17 +213,22 @@ const customActive = ref(false)
const isLoading = ref(false)
const errorMessage = ref<string | null>(null)
const creatorName = ref<string | null>(null)
const successQuote = ref('')
// LNURL-pay data
const lnurlCallback = ref<string | null>(null)
const lnurlMinSats = ref(1)
const lnurlMaxSats = ref(10_000_000)
const verifyUrl = ref<string | null>(null)
// Invoice data
const bolt11Invoice = ref('')
const qrCodeDataUrl = ref('')
const copyButtonText = ref('Copy Invoice')
// Polling
let pollInterval: ReturnType<typeof setInterval> | null = null
/** The effective amount to zap */
const zapAmount = computed<number | null>(() => {
if (customActive.value && customAmount.value) {
@@ -216,6 +248,32 @@ function handleCustomInput() {
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
watch(() => props.isOpen, async (open) => {
if (open) {
@@ -229,9 +287,14 @@ watch(() => props.isOpen, async (open) => {
copyButtonText.value = 'Copy Invoice'
creatorName.value = props.content?.creator || null
lnurlCallback.value = null
verifyUrl.value = null
successQuote.value = ''
cleanup()
// Pre-resolve the creator's lightning address
await resolveLightningAddress()
} else {
cleanup()
}
})
@@ -247,7 +310,6 @@ async function resolveLightningAddress() {
if (!props.content) return
try {
// Step 1: Get the project owner's filmmaker profile
const projectId = props.content.id
const ownerData = await indeehubApiService.get<{
id: string
@@ -260,7 +322,6 @@ async function resolveLightningAddress() {
creatorName.value = ownerData.professionalName
}
// Step 2: Fetch the filmmaker's lightning address
const addressData = await indeehubApiService.get<{
lightningAddress: string
}>(`/filmmakers/${ownerData.id}/lightning-address`)
@@ -268,7 +329,6 @@ async function resolveLightningAddress() {
const lightningAddress = addressData?.lightningAddress
if (!lightningAddress) return
// Parse lightning address: user@domain → https://domain/.well-known/lnurlp/user
const [username, domain] = lightningAddress.split('@')
if (!username || !domain) return
@@ -281,7 +341,6 @@ async function resolveLightningAddress() {
if (lnurlData.status === 'ERROR') return
lnurlCallback.value = lnurlData.callback
// LNURL amounts are in millisats
lnurlMinSats.value = Math.ceil((lnurlData.minSendable || 1000) / 1000)
lnurlMaxSats.value = Math.floor((lnurlData.maxSendable || 10_000_000_000) / 1000)
} 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() {
if (!zapAmount.value || zapAmount.value < 1) return
@@ -301,7 +391,6 @@ async function handleZap() {
return
}
// Validate amount against LNURL limits
if (zapAmount.value < lnurlMinSats.value) {
errorMessage.value = `Minimum zap is ${lnurlMinSats.value.toLocaleString()} sats`
return
@@ -330,8 +419,13 @@ async function handleZap() {
}
bolt11Invoice.value = data.pr
// Store verify URL for payment detection polling
verifyUrl.value = data.verify || null
await generateQRCode(data.pr)
paymentState.value = 'invoice'
// Start polling for payment confirmation
startPolling()
} catch (error: any) {
errorMessage.value = error.message || 'Failed to create zap invoice. Please try again.'
} finally {
@@ -362,15 +456,13 @@ async function copyInvoice() {
}
}
function handleDone() {
paymentState.value = 'success'
}
function closeModal() {
cleanup()
paymentState.value = 'select'
errorMessage.value = null
bolt11Invoice.value = ''
qrCodeDataUrl.value = ''
verifyUrl.value = null
emit('close')
}
</script>
@@ -493,6 +585,76 @@ function closeModal() {
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-fade-enter-active,
.modal-fade-leave-active {

View File

@@ -129,13 +129,29 @@ function rebuildStats() {
}
// Process zap receipts (kind 9735) from the EventStore.
// Zap receipts reference content via the 'i' tag (external identifiers)
// or via embedded zap request description.
// Zap receipts reference content via:
// 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] }])
if (zapReceipts) {
for (const event of zapReceipts) {
// Try to find the external content ID from the zap receipt tags
const externalId = getTagValue(event, 'i')
// Try direct 'i' tag first
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
const stats = getOrCreate(externalId)

View File

@@ -120,6 +120,8 @@ export function useNostr(contentId?: string) {
const reactions = ref<NostrEvent[]>([])
// Per-comment reactions: eventId -> NostrEvent[]
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
const profiles = ref<Map<string, any>>(new Map())
// Loading state
@@ -170,9 +172,22 @@ export function useNostr(contentId?: string) {
})
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
refreshComments(externalId)
refreshReactions(externalId)
refreshZaps(externalId)
// Mark loading done after initial data window
setTimeout(() => {
@@ -213,6 +228,76 @@ export function useNostr(contentId?: string) {
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.
*/
@@ -557,6 +642,7 @@ export function useNostr(contentId?: string) {
allComments,
reactions,
commentReactions,
zaps,
profiles,
isLoading,
error,