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:
@@ -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: [
|
||||
|
||||
@@ -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.
|
||||
*/
|
||||
|
||||
@@ -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 {}
|
||||
|
||||
@@ -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(
|
||||
|
||||
13
backend/src/zaps/dto/create-zap-invoice.dto.ts
Normal file
13
backend/src/zaps/dto/create-zap-invoice.dto.ts
Normal 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;
|
||||
}
|
||||
22
backend/src/zaps/zaps.controller.ts
Normal file
22
backend/src/zaps/zaps.controller.ts
Normal 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);
|
||||
}
|
||||
}
|
||||
13
backend/src/zaps/zaps.module.ts
Normal file
13
backend/src/zaps/zaps.module.ts
Normal 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 {}
|
||||
91
backend/src/zaps/zaps.service.ts
Normal file
91
backend/src/zaps/zaps.service.ts
Normal 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}`);
|
||||
}
|
||||
}
|
||||
@@ -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 */
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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')
|
||||
}
|
||||
|
||||
@@ -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: [],
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user