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 { DiscountRedemptionModule } from './discount-redemption/discount-redemption.module';
import { NostrAuthModule } from './nostr-auth/nostr-auth.module'; import { NostrAuthModule } from './nostr-auth/nostr-auth.module';
import { LibraryModule } from './library/library.module'; import { LibraryModule } from './library/library.module';
import { ZapsModule } from './zaps/zaps.module';
@Module({ @Module({
imports: [ imports: [
@@ -94,6 +95,7 @@ import { LibraryModule } from './library/library.module';
DiscountRedemptionModule, DiscountRedemptionModule,
NostrAuthModule, NostrAuthModule,
LibraryModule, LibraryModule,
ZapsModule,
], ],
controllers: [AppController], controllers: [AppController],
providers: [ 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. * 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 { RentsModule } from 'src/rents/rents.module';
import { SeasonModule } from 'src/season/season.module'; import { SeasonModule } from 'src/season/season.module';
import { SubscriptionsModule } from 'src/subscriptions/subscriptions.module'; import { SubscriptionsModule } from 'src/subscriptions/subscriptions.module';
import { ZapsModule } from 'src/zaps/zaps.module';
@Module({ @Module({
controllers: [WebhooksController], controllers: [WebhooksController],
imports: [SubscriptionsModule, RentsModule, forwardRef(() => SeasonModule)], imports: [
SubscriptionsModule,
RentsModule,
forwardRef(() => SeasonModule),
ZapsModule,
],
providers: [WebhooksService], providers: [WebhooksService],
}) })
export class WebhooksModule {} 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 { BTCPayService } from 'src/payment/providers/services/btcpay.service';
import { SeasonRentsService } from 'src/season/season-rents.service'; import { SeasonRentsService } from 'src/season/season-rents.service';
import { SubscriptionsService } from 'src/subscriptions/subscriptions.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'; import type { BTCPayWebhookEvent } from 'src/payment/providers/dto/btcpay/btcpay-invoice';
@Injectable() @Injectable()
@@ -14,6 +15,8 @@ export class WebhooksService implements OnModuleInit {
private readonly seasonRentService: SeasonRentsService, private readonly seasonRentService: SeasonRentsService,
@Inject(SubscriptionsService) @Inject(SubscriptionsService)
private readonly subscriptionsService: SubscriptionsService, private readonly subscriptionsService: SubscriptionsService,
@Inject(ZapsService)
private readonly zapsService: ZapsService,
@Inject(BTCPayService) @Inject(BTCPayService)
private readonly btcpayService: BTCPayService, private readonly btcpayService: BTCPayService,
) {} ) {}
@@ -89,7 +92,12 @@ export class WebhooksService implements OnModuleInit {
// Not a subscription — continue to season check // 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); return await this.seasonRentService.lightningPaid(invoiceId, invoice);
} catch (error) { } catch (error) {
Logger.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> <span class="text-white font-medium">{{ content.creator }}</span>
</div> </div>
<!-- Zaps Section --> <!-- Zaps Section (Primal/Yakihonne style: profile pics + amounts) -->
<div v-if="zapsList.length > 0" class="mb-6"> <div v-if="zapsList.length > 0" class="mb-6">
<div class="flex items-center gap-2 mb-3"> <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"/> <path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg> </svg>
<span class="text-sm font-semibold text-white">Zapped by</span> <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>
<div class="flex flex-wrap items-center gap-1.5"> <div class="flex flex-wrap items-center gap-2">
<div <div
v-for="(zap, idx) in displayZaps" v-for="(zap, idx) in displayZaps"
:key="idx" :key="zap.pubkey + '-' + zap.timestamp + '-' + idx"
class="zap-avatar-pill" class="zap-avatar-pill"
:title="getZapperName(zap.pubkey) + ' — ' + zap.amount.toLocaleString() + ' sats'" :title="getZapperName(zap.pubkey) + ' — ' + zap.amount.toLocaleString() + ' sats'"
> >
<img <img
:src="getZapperPicture(zap.pubkey)" :src="getZapperPicture(zap.pubkey)"
:alt="getZapperName(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> </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 +{{ zapsList.length - 8 }} more
</span> </span>
</div> </div>
@@ -749,21 +750,21 @@ function openSubscriptionFromRental() {
color: #F7931A; color: #F7931A;
} }
/* Zap avatar pill */ /* Zap avatar pill (Primal/Yakihonne style: clear profile pic + amount) */
.zap-avatar-pill { .zap-avatar-pill {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 4px; gap: 6px;
padding: 3px 10px 3px 3px; padding: 4px 12px 4px 4px;
border-radius: 999px; border-radius: 9999px;
background: rgba(247, 147, 26, 0.08); 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; transition: all 0.2s ease;
} }
.zap-avatar-pill:hover { .zap-avatar-pill:hover {
background: rgba(247, 147, 26, 0.15); 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 */ /* Category Tags */

View File

@@ -36,7 +36,7 @@
loading="lazy" loading="lazy"
/> />
<!-- Social Indicators --> <!-- 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"> <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> <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) }} {{ 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> <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) }} {{ getCommentCount(content.id) }}
</span> </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> </div>
<div class="mt-2"> <div class="mt-2">
@@ -71,6 +91,7 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, onMounted, onUnmounted } from 'vue' import { ref, onMounted, onUnmounted } from 'vue'
import type { Content } from '../types/content' import type { Content } from '../types/content'
import { useContentDiscovery } from '../composables/useContentDiscovery'
interface Props { interface Props {
title: string title: string
@@ -82,14 +103,26 @@ defineEmits<{
'content-click': [content: Content] 'content-click': [content: Content]
}>() }>()
// Social counts are now fetched from the relay when the detail modal opens. const { getStats } = useContentDiscovery()
// We no longer show mock badges on cards -- the real data lives on the relay.
function getReactionCount(_contentId: string): number { function getReactionCount(contentId: string): number {
return 0 return getStats(contentId).plusCount ?? 0
} }
function getCommentCount(_contentId: string): number { function getCommentCount(contentId: string): number {
return 0 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) const sliderRef = ref<HTMLElement | null>(null)

View File

@@ -139,8 +139,8 @@
Open in wallet app Open in wallet app
</a> </a>
<!-- When provider gives a verify URL we poll; otherwise user confirms manually --> <!-- Backend (BTCPay) and LNURL verify: we poll. Otherwise user confirms manually -->
<p v-if="verifyUrl" class="text-xs text-white/40 animate-pulse"> <p v-if="zapInvoiceId || verifyUrl" class="text-xs text-white/40 animate-pulse">
Waiting for payment confirmation... Waiting for payment confirmation...
</p> </p>
<template v-else> <template v-else>
@@ -244,7 +244,11 @@ const creatorName = ref<string | null>(null)
const noCreator = ref(false) const noCreator = ref(false)
const successQuote = ref('') 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 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)
@@ -316,12 +320,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
noCreator.value = false noCreator.value = false
ownerFilmmakerId.value = null
zapInvoiceId.value = null
lnurlCallback.value = null lnurlCallback.value = null
verifyUrl.value = null verifyUrl.value = null
successQuote.value = '' successQuote.value = ''
cleanup() cleanup()
// Pre-resolve the creator's lightning address // Pre-resolve the creator (for name + filmmaker id; backend will create invoice)
await resolveLightningAddress() await resolveLightningAddress()
} else { } else {
cleanup() cleanup()
@@ -357,44 +363,44 @@ async function resolveLightningAddress() {
return return
} }
ownerFilmmakerId.value = ownerData.id
if (ownerData.professionalName) { if (ownerData.professionalName) {
creatorName.value = ownerData.professionalName creatorName.value = ownerData.professionalName
} }
const addressData = await indeehubApiService.get<{ // Ensure creator has Lightning address (backend will use it for payout)
lightningAddress: string await indeehubApiService.get<{ lightningAddress: string }>(
}>(`/filmmakers/${ownerData.id}/lightning-address`) `/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)
} catch (err) { } catch (err) {
console.warn('[ZapModal] Failed to resolve lightning address:', err) console.warn('[ZapModal] Failed to resolve lightning address:', err)
noCreator.value = true 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. * Poll the LNURL verify URL to detect payment (direct Lightning-address flow).
* The verify URL is returned alongside the invoice and responds * Only used when backend zap is not used.
* with { settled: true } once the invoice is paid.
*/ */
function startPolling() { function startPolling() {
if (!verifyUrl.value) return if (!verifyUrl.value) return
@@ -433,53 +439,59 @@ function markAsPaid() {
async function handleZap() { async function handleZap() {
if (!zapAmount.value || zapAmount.value < 1) return 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 isLoading.value = true
errorMessage.value = null 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 { try {
if (!lnurlCallback.value) { // Route through our BTCPay: user pays us → we pay creator. We can always validate.
errorMessage.value = 'No lightning address found for this creator. They may not have set one up yet.' 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 return
} }
if (zapAmount.value < lnurlMinSats.value) { if (!quote.lnInvoice) {
errorMessage.value = `Minimum zap is ${lnurlMinSats.value.toLocaleString()} sats` errorMessage.value = 'No invoice returned. Please try again.'
return isLoading.value = false
}
if (zapAmount.value > lnurlMaxSats.value) {
errorMessage.value = `Maximum zap is ${lnurlMaxSats.value.toLocaleString()} sats`
return return
} }
// Request invoice from LNURL callback (amount in millisats) bolt11Invoice.value = quote.lnInvoice
const amountMsats = zapAmount.value * 1000 await generateQRCode(quote.lnInvoice)
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)
paymentState.value = 'invoice' paymentState.value = 'invoice'
startPollingBackend()
// 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.response?.data?.message ||
error.message ||
'Failed to create zap invoice. Please try again.'
} finally { } finally {
isLoading.value = false isLoading.value = false
} }
@@ -514,6 +526,7 @@ function closeModal() {
errorMessage.value = null errorMessage.value = null
bolt11Invoice.value = '' bolt11Invoice.value = ''
qrCodeDataUrl.value = '' qrCodeDataUrl.value = ''
zapInvoiceId.value = null
verifyUrl.value = null verifyUrl.value = null
emit('close') emit('close')
} }

View File

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