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

@@ -39,6 +39,7 @@ import { DiscountsModule } from './discounts/discounts.module';
import { DiscountRedemptionModule } from './discount-redemption/discount-redemption.module';
import { NostrAuthModule } from './nostr-auth/nostr-auth.module';
import { LibraryModule } from './library/library.module';
import { ZapsModule } from './zaps/zaps.module';
@Module({
imports: [
@@ -94,6 +95,7 @@ import { LibraryModule } from './library/library.module';
DiscountRedemptionModule,
NostrAuthModule,
LibraryModule,
ZapsModule,
],
controllers: [AppController],
providers: [

View File

@@ -402,6 +402,33 @@ export class BTCPayService implements LightningService {
}
}
/**
* Pay sats to a Lightning address (e.g. for zap payouts).
* Resolves LNURL-pay to get BOLT11, then pays via our node.
*/
async payToLightningAddress(
address: string,
sats: number,
comment?: string,
): Promise<LightningPaymentDTO> {
const bolt11 = await this.resolveLightningAddress(address, sats, comment);
const payUrl = this.storeUrl('/lightning/BTC/invoices/pay');
const { data } = await axios.post<BTCPayLightningPayResponse>(
payUrl,
{ BOLT11: bolt11 },
{ headers: this.headers },
);
const result = data?.result;
const paymentHash =
data?.paymentHash ?? (data as any)?.payment_hash ?? 'btcpay-payment';
if (result && result !== 'Ok') {
throw new Error(
`Lightning payment failed: ${result}${data?.errorDetail || 'unknown error'}`,
);
}
return { id: paymentHash, status: 'COMPLETED' };
}
/**
* Validate a Lightning address by attempting to resolve it.
*/

View File

@@ -4,10 +4,16 @@ import { WebhooksService } from './webhooks.service';
import { RentsModule } from 'src/rents/rents.module';
import { SeasonModule } from 'src/season/season.module';
import { SubscriptionsModule } from 'src/subscriptions/subscriptions.module';
import { ZapsModule } from 'src/zaps/zaps.module';
@Module({
controllers: [WebhooksController],
imports: [SubscriptionsModule, RentsModule, forwardRef(() => SeasonModule)],
imports: [
SubscriptionsModule,
RentsModule,
forwardRef(() => SeasonModule),
ZapsModule,
],
providers: [WebhooksService],
})
export class WebhooksModule {}

View File

@@ -3,6 +3,7 @@ import { RentsService } from 'src/rents/rents.service';
import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
import { SeasonRentsService } from 'src/season/season-rents.service';
import { SubscriptionsService } from 'src/subscriptions/subscriptions.service';
import { ZapsService } from 'src/zaps/zaps.service';
import type { BTCPayWebhookEvent } from 'src/payment/providers/dto/btcpay/btcpay-invoice';
@Injectable()
@@ -14,6 +15,8 @@ export class WebhooksService implements OnModuleInit {
private readonly seasonRentService: SeasonRentsService,
@Inject(SubscriptionsService)
private readonly subscriptionsService: SubscriptionsService,
@Inject(ZapsService)
private readonly zapsService: ZapsService,
@Inject(BTCPayService)
private readonly btcpayService: BTCPayService,
) {}
@@ -89,7 +92,12 @@ export class WebhooksService implements OnModuleInit {
// Not a subscription — continue to season check
}
// Otherwise check if it's a season rental
// Check if it's a zap (user paid us → we pay out to creator)
if (invoice.correlationId?.startsWith('zap:')) {
return await this.zapsService.handleZapPaid(invoiceId, invoice);
}
// Otherwise treat as season rental
return await this.seasonRentService.lightningPaid(invoiceId, invoice);
} catch (error) {
Logger.error(

View File

@@ -0,0 +1,13 @@
import { IsNumber, IsString, Min } from 'class-validator';
export class CreateZapInvoiceDto {
@IsString()
projectId: string;
@IsString()
filmmakerId: string;
@IsNumber()
@Min(1, { message: 'Amount must be at least 1 satoshi' })
amountSats: number;
}

View File

@@ -0,0 +1,22 @@
import { Body, Controller, Get, Param, Post } from '@nestjs/common';
import { ZapsService } from './zaps.service';
import { CreateZapInvoiceDto } from './dto/create-zap-invoice.dto';
@Controller('zaps')
export class ZapsController {
constructor(private readonly zapsService: ZapsService) {}
@Post('invoice')
async createInvoice(@Body() dto: CreateZapInvoiceDto) {
return this.zapsService.createInvoice(
dto.projectId,
dto.filmmakerId,
dto.amountSats,
);
}
@Get('invoice/:invoiceId/quote')
async getQuote(@Param('invoiceId') invoiceId: string) {
return this.zapsService.getQuote(invoiceId);
}
}

View File

@@ -0,0 +1,13 @@
import { Module } from '@nestjs/common';
import { ZapsController } from './zaps.controller';
import { ZapsService } from './zaps.service';
import { PaymentModule } from 'src/payment/payment.module';
import { FilmmakersModule } from 'src/filmmakers/filmmakers.module';
@Module({
imports: [PaymentModule, FilmmakersModule],
controllers: [ZapsController],
providers: [ZapsService],
exports: [ZapsService],
})
export class ZapsModule {}

View File

@@ -0,0 +1,91 @@
import { BadRequestException, Injectable, Logger, NotFoundException } from '@nestjs/common';
import { BTCPayService } from 'src/payment/providers/services/btcpay.service';
import { FilmmakersService } from 'src/filmmakers/filmmakers.service';
import Invoice from 'src/payment/providers/dto/strike/invoice';
import InvoiceQuote from 'src/payment/providers/dto/strike/invoice-quote';
const ZAP_ORDER_PREFIX = 'zap:';
@Injectable()
export class ZapsService {
private readonly logger = new Logger(ZapsService.name);
constructor(
private readonly btcpayService: BTCPayService,
private readonly filmmakersService: FilmmakersService,
) {}
/**
* Create a BTCPay invoice for a zap. User pays this invoice; on settlement
* we pay out to the creator's Lightning address. Correlation is stored so
* the webhook can route the payout.
*/
async createInvoice(projectId: string, filmmakerId: string, amountSats: number) {
await this.filmmakersService.getFilmmakerLightningAddress(filmmakerId);
const correlationId = `${ZAP_ORDER_PREFIX}${projectId}:${filmmakerId}`;
const invoice = await this.btcpayService.issueInvoice(
amountSats,
`Zap — project ${projectId}`,
correlationId,
);
return { invoiceId: invoice.invoiceId };
}
/**
* Get quote (BOLT11 + settled) for a zap invoice. Only allowed for invoices
* whose metadata marks them as zap (so we don't expose rent/subscription).
*/
async getQuote(invoiceId: string): Promise<InvoiceQuote> {
const invoice = await this.btcpayService.getInvoice(invoiceId);
if (!invoice.correlationId?.startsWith(ZAP_ORDER_PREFIX)) {
throw new NotFoundException('Zap invoice not found');
}
return this.btcpayService.issueQuote(invoiceId);
}
/**
* Called from webhook when a zap invoice is settled. Pays the creator's
* Lightning address via our BTCPay node.
*/
async handleZapPaid(invoiceId: string, invoice?: Invoice): Promise<void> {
if (!invoice) {
invoice = await this.btcpayService.getInvoice(invoiceId);
}
if (!invoice.correlationId?.startsWith(ZAP_ORDER_PREFIX)) {
return;
}
const parts = invoice.correlationId.slice(ZAP_ORDER_PREFIX.length).split(':');
if (parts.length < 2) {
this.logger.warn(`Invalid zap correlation: ${invoice.correlationId}`);
return;
}
const [_projectId, filmmakerId] = parts;
const amountBtc = parseFloat(invoice.amount?.amount ?? '0');
const sats = Math.floor(amountBtc * 100_000_000);
if (sats < 1) {
this.logger.warn(`Zap invoice ${invoiceId} amount too small: ${amountBtc} BTC`);
return;
}
let address: string;
try {
const res = await this.filmmakersService.getFilmmakerLightningAddress(filmmakerId);
address = res.lightningAddress;
} catch (e) {
this.logger.error(`Zap payout: no Lightning address for filmmaker ${filmmakerId}: ${e.message}`);
throw new BadRequestException('Creator has no Lightning address configured');
}
this.logger.log(`Zap payout: ${sats} sats to ${address} (invoice ${invoiceId})`);
await this.btcpayService.payToLightningAddress(
address,
sats,
`IndeeHub zap — project ${parts[0]}`,
);
this.logger.log(`Zap payout completed: ${invoiceId}`);
}
}

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: [],
}