feat: integrate ZapsModule and enhance zap payment handling

- Added ZapsModule to the application, integrating it into the main app and webhooks modules.
- Introduced a new method in BTCPayService for processing payments to Lightning addresses, improving zap payout functionality.
- Updated WebhooksService to handle zap payment events, allowing for seamless integration of zap transactions.
- Enhanced UI components to display zap-related information, including zaps count and avatar stacks, improving user engagement.

These changes enhance the overall zap payment experience and ensure better integration of zap functionalities across the application.
This commit is contained in:
Dorian
2026-02-14 13:21:27 +00:00
parent 1a5cbbfbf1
commit edf8be014e
12 changed files with 337 additions and 88 deletions

View File

@@ -143,30 +143,31 @@
<span class="text-white font-medium">{{ content.creator }}</span>
</div>
<!-- Zaps Section -->
<!-- Zaps Section (Primal/Yakihonne style: profile pics + amounts) -->
<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">
<svg class="w-5 h-5 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>
<span class="text-sm font-medium text-[#F7931A]">{{ totalZapSats.toLocaleString() }} sats</span>
</div>
<div class="flex flex-wrap items-center gap-1.5">
<div class="flex flex-wrap items-center gap-2">
<div
v-for="(zap, idx) in displayZaps"
:key="idx"
:key="zap.pubkey + '-' + zap.timestamp + '-' + 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"
class="w-8 h-8 rounded-full object-cover ring-2 ring-white/10 flex-shrink-0"
loading="lazy"
/>
<span class="text-xs font-medium text-[#F7931A]">{{ formatZapAmount(zap.amount) }}</span>
<span class="text-sm font-medium text-[#F7931A]">{{ formatZapAmount(zap.amount) }}</span>
</div>
<span v-if="zapsList.length > 8" class="text-xs text-white/40 ml-1">
<span v-if="zapsList.length > 8" class="text-xs text-white/40 self-center">
+{{ zapsList.length - 8 }} more
</span>
</div>
@@ -749,21 +750,21 @@ function openSubscriptionFromRental() {
color: #F7931A;
}
/* Zap avatar pill */
/* Zap avatar pill (Primal/Yakihonne style: clear profile pic + amount) */
.zap-avatar-pill {
display: flex;
align-items: center;
gap: 4px;
padding: 3px 10px 3px 3px;
border-radius: 999px;
gap: 6px;
padding: 4px 12px 4px 4px;
border-radius: 9999px;
background: rgba(247, 147, 26, 0.08);
border: 1px solid rgba(247, 147, 26, 0.15);
border: 1px solid rgba(247, 147, 26, 0.2);
transition: all 0.2s ease;
}
.zap-avatar-pill:hover {
background: rgba(247, 147, 26, 0.15);
border-color: rgba(247, 147, 26, 0.3);
border-color: rgba(247, 147, 26, 0.35);
}
/* Category Tags */

View File

@@ -36,7 +36,7 @@
loading="lazy"
/>
<!-- Social Indicators -->
<div class="absolute bottom-3 left-3 flex items-center gap-2">
<div class="absolute bottom-3 left-3 flex items-center gap-2 flex-wrap">
<span class="social-badge" v-if="getReactionCount(content.id) > 0">
<svg class="w-3 h-3" fill="currentColor" viewBox="0 0 24 24"><path d="M14 10h4.764a2 2 0 011.789 2.894l-3.5 7A2 2 0 0115.263 21h-4.017c-.163 0-.326-.02-.485-.06L7 20m7-10V5a2 2 0 00-2-2h-.095c-.5 0-.905.405-.905.905 0 .714-.211 1.412-.608 2.006L7 11v9m7-10h-2M7 20H5a2 2 0 01-2-2v-6a2 2 0 012-2h2.5"/></svg>
{{ getReactionCount(content.id) }}
@@ -45,6 +45,26 @@
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
{{ getCommentCount(content.id) }}
</span>
<!-- Zaps: avatar stack + count (Primal/Yakihonne style) -->
<span
v-if="getZapCount(content.id) > 0"
class="social-badge zap-badge flex items-center gap-1"
:title="getZapCount(content.id) + ' zap(s)'"
>
<span class="flex -space-x-1.5">
<img
v-for="(pk, i) in getZapperPubkeys(content.id).slice(0, 3)"
:key="pk"
:src="zapperAvatarUrl(pk)"
:alt="''"
class="w-4 h-4 rounded-full border border-black/40 object-cover ring-1 ring-white/20"
/>
</span>
<svg class="w-3 h-3 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
{{ getZapCount(content.id) }}
</span>
</div>
</div>
<div class="mt-2">
@@ -71,6 +91,7 @@
<script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue'
import type { Content } from '../types/content'
import { useContentDiscovery } from '../composables/useContentDiscovery'
interface Props {
title: string
@@ -82,14 +103,26 @@ defineEmits<{
'content-click': [content: Content]
}>()
// Social counts are now fetched from the relay when the detail modal opens.
// We no longer show mock badges on cards -- the real data lives on the relay.
function getReactionCount(_contentId: string): number {
return 0
const { getStats } = useContentDiscovery()
function getReactionCount(contentId: string): number {
return getStats(contentId).plusCount ?? 0
}
function getCommentCount(_contentId: string): number {
return 0
function getCommentCount(contentId: string): number {
return getStats(contentId).commentCount ?? 0
}
function getZapCount(contentId: string): number {
return getStats(contentId).zapCount ?? 0
}
function getZapperPubkeys(contentId: string): string[] {
return getStats(contentId).recentZapperPubkeys ?? []
}
function zapperAvatarUrl(pubkey: string): string {
return `https://robohash.org/${pubkey}.png`
}
const sliderRef = ref<HTMLElement | null>(null)

View File

@@ -139,8 +139,8 @@
Open in wallet app
</a>
<!-- When provider gives a verify URL we poll; otherwise user confirms manually -->
<p v-if="verifyUrl" class="text-xs text-white/40 animate-pulse">
<!-- Backend (BTCPay) and LNURL verify: we poll. Otherwise user confirms manually -->
<p v-if="zapInvoiceId || verifyUrl" class="text-xs text-white/40 animate-pulse">
Waiting for payment confirmation...
</p>
<template v-else>
@@ -244,7 +244,11 @@ const creatorName = ref<string | null>(null)
const noCreator = ref(false)
const successQuote = ref('')
// LNURL-pay data
// Creator id for backend zap invoice (we pay user → BTCPay → we pay creator)
const ownerFilmmakerId = ref<string | null>(null)
const zapInvoiceId = ref<string | null>(null)
// LNURL fallback (only used when backend zap not available)
const lnurlCallback = ref<string | null>(null)
const lnurlMinSats = ref(1)
const lnurlMaxSats = ref(10_000_000)
@@ -316,12 +320,14 @@ watch(() => props.isOpen, async (open) => {
copyButtonText.value = 'Copy Invoice'
creatorName.value = props.content?.creator || null
noCreator.value = false
ownerFilmmakerId.value = null
zapInvoiceId.value = null
lnurlCallback.value = null
verifyUrl.value = null
successQuote.value = ''
cleanup()
// Pre-resolve the creator's lightning address
// Pre-resolve the creator (for name + filmmaker id; backend will create invoice)
await resolveLightningAddress()
} else {
cleanup()
@@ -357,44 +363,44 @@ async function resolveLightningAddress() {
return
}
ownerFilmmakerId.value = ownerData.id
if (ownerData.professionalName) {
creatorName.value = ownerData.professionalName
}
const addressData = await indeehubApiService.get<{
lightningAddress: string
}>(`/filmmakers/${ownerData.id}/lightning-address`)
const lightningAddress = addressData?.lightningAddress
if (!lightningAddress) {
noCreator.value = true
return
}
const [username, domain] = lightningAddress.split('@')
if (!username || !domain) return
const lnurlUrl = `https://${domain}/.well-known/lnurlp/${username}`
const response = await fetch(lnurlUrl)
if (!response.ok) return
const lnurlData = await response.json()
if (lnurlData.status === 'ERROR') return
lnurlCallback.value = lnurlData.callback
lnurlMinSats.value = Math.ceil((lnurlData.minSendable || 1000) / 1000)
lnurlMaxSats.value = Math.floor((lnurlData.maxSendable || 10_000_000_000) / 1000)
// Ensure creator has Lightning address (backend will use it for payout)
await indeehubApiService.get<{ lightningAddress: string }>(
`/filmmakers/${ownerData.id}/lightning-address`,
)
} catch (err) {
console.warn('[ZapModal] Failed to resolve lightning address:', err)
noCreator.value = true
}
}
/** Poll our backend for zap invoice settlement (BTCPay flow). */
function startPollingBackend() {
const invoiceId = zapInvoiceId.value
if (!invoiceId) return
pollInterval = setInterval(async () => {
try {
const quote = await indeehubApiService.get<{ paid?: boolean }>(
`/zaps/invoice/${invoiceId}/quote`,
)
if (quote.paid === true) {
cleanup()
showSuccess()
}
} catch {
// Silently retry
}
}, 3000)
}
/**
* 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.
* Poll the LNURL verify URL to detect payment (direct Lightning-address flow).
* Only used when backend zap is not used.
*/
function startPolling() {
if (!verifyUrl.value) return
@@ -433,53 +439,59 @@ function markAsPaid() {
async function handleZap() {
if (!zapAmount.value || zapAmount.value < 1) return
if (!props.content?.id || !ownerFilmmakerId.value) {
errorMessage.value = 'No creator to zap. They may not have set up Lightning yet.'
return
}
isLoading.value = true
errorMessage.value = null
const amountSats = zapAmount.value
if (amountSats < 1 || amountSats > 10_000_000) {
errorMessage.value = 'Amount must be between 1 and 10,000,000 sats'
isLoading.value = false
return
}
try {
if (!lnurlCallback.value) {
errorMessage.value = 'No lightning address found for this creator. They may not have set one up yet.'
// Route through our BTCPay: user pays us → we pay creator. We can always validate.
const { invoiceId } = await indeehubApiService.post<{ invoiceId: string }>(
'/zaps/invoice',
{
projectId: props.content.id,
filmmakerId: ownerFilmmakerId.value,
amountSats,
},
)
zapInvoiceId.value = invoiceId
const quote = await indeehubApiService.get<{
lnInvoice: string
paid?: boolean
}>(`/zaps/invoice/${invoiceId}/quote`)
if (quote.paid === true) {
showSuccess()
isLoading.value = false
return
}
if (zapAmount.value < lnurlMinSats.value) {
errorMessage.value = `Minimum zap is ${lnurlMinSats.value.toLocaleString()} sats`
return
}
if (zapAmount.value > lnurlMaxSats.value) {
errorMessage.value = `Maximum zap is ${lnurlMaxSats.value.toLocaleString()} sats`
if (!quote.lnInvoice) {
errorMessage.value = 'No invoice returned. Please try again.'
isLoading.value = false
return
}
// Request invoice from LNURL callback (amount in millisats)
const amountMsats = zapAmount.value * 1000
const separator = lnurlCallback.value.includes('?') ? '&' : '?'
const invoiceUrl = `${lnurlCallback.value}${separator}amount=${amountMsats}`
const response = await fetch(invoiceUrl)
const data = await response.json()
if (data.status === 'ERROR') {
errorMessage.value = data.reason || 'Failed to create invoice'
return
}
if (!data.pr) {
errorMessage.value = 'No invoice returned from the lightning provider'
return
}
bolt11Invoice.value = data.pr
// Store verify URL for payment detection polling
verifyUrl.value = data.verify || null
await generateQRCode(data.pr)
bolt11Invoice.value = quote.lnInvoice
await generateQRCode(quote.lnInvoice)
paymentState.value = 'invoice'
// Start polling for payment confirmation
startPolling()
startPollingBackend()
} catch (error: any) {
errorMessage.value = error.message || 'Failed to create zap invoice. Please try again.'
errorMessage.value =
error.response?.data?.message ||
error.message ||
'Failed to create zap invoice. Please try again.'
} finally {
isLoading.value = false
}
@@ -514,6 +526,7 @@ function closeModal() {
errorMessage.value = null
bolt11Invoice.value = ''
qrCodeDataUrl.value = ''
zapInvoiceId.value = null
verifyUrl.value = null
emit('close')
}

View File

@@ -28,6 +28,8 @@ export interface ContentStats {
reviewCount: number
zapCount: number
zapAmountSats: number
/** Pubkeys of recent zappers (for avatar stack on cards); max 5 */
recentZapperPubkeys: string[]
recentEvents: NostrEvent[]
}
@@ -92,6 +94,7 @@ function rebuildStats() {
reviewCount: 0,
zapCount: 0,
zapAmountSats: 0,
recentZapperPubkeys: [],
recentEvents: [],
}
map.set(id, stats)
@@ -164,6 +167,22 @@ function rebuildStats() {
if (sats > 0) stats.zapAmountSats += sats
}
// Sender pubkey for avatar stack (NIP-57: in description zap request)
let senderPubkey = event.pubkey
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 { /* ignore */ }
}
if (
stats.recentZapperPubkeys.length < 5 &&
!stats.recentZapperPubkeys.includes(senderPubkey)
) {
stats.recentZapperPubkeys.push(senderPubkey)
}
stats.recentEvents.push(event)
}
}
@@ -233,6 +252,7 @@ const EMPTY_STATS: ContentStats = {
reviewCount: 0,
zapCount: 0,
zapAmountSats: 0,
recentZapperPubkeys: [],
recentEvents: [],
}