feat: add comment support for Lightning payments in BTCPay and Strike services
- Enhanced the sendPaymentWithAddress method in BTCPayService and StrikeService to accept an optional comment parameter. - Updated resolveLightningAddress to include the comment in the callback URL if supported by the LNURL-pay endpoint. - Modified PaymentService to construct a descriptive comment for Lightning invoices, improving clarity for users. These changes enhance the payment experience by allowing users to include contextual information with their transactions.
This commit is contained in:
@@ -345,12 +345,13 @@ export class BTCPayService implements LightningService {
|
|||||||
async sendPaymentWithAddress(
|
async sendPaymentWithAddress(
|
||||||
address: string,
|
address: string,
|
||||||
payment: Payment,
|
payment: Payment,
|
||||||
|
comment?: string,
|
||||||
): Promise<LightningPaymentDTO> {
|
): Promise<LightningPaymentDTO> {
|
||||||
try {
|
try {
|
||||||
const sats = Math.floor(payment.milisatsAmount / 1000);
|
const sats = Math.floor(payment.milisatsAmount / 1000);
|
||||||
|
|
||||||
// Resolve the Lightning address to a BOLT11 invoice
|
// Resolve the Lightning address to a BOLT11 invoice
|
||||||
const bolt11 = await this.resolveLightningAddress(address, sats);
|
const bolt11 = await this.resolveLightningAddress(address, sats, comment);
|
||||||
|
|
||||||
// Pay the BOLT11 via BTCPay's internal Lightning node
|
// Pay the BOLT11 via BTCPay's internal Lightning node
|
||||||
const payUrl = this.storeUrl('/lightning/BTC/invoices/pay');
|
const payUrl = this.storeUrl('/lightning/BTC/invoices/pay');
|
||||||
@@ -578,6 +579,7 @@ export class BTCPayService implements LightningService {
|
|||||||
private async resolveLightningAddress(
|
private async resolveLightningAddress(
|
||||||
address: string,
|
address: string,
|
||||||
amountSats: number,
|
amountSats: number,
|
||||||
|
comment?: string,
|
||||||
): Promise<string> {
|
): Promise<string> {
|
||||||
const [username, domain] = address.split('@');
|
const [username, domain] = address.split('@');
|
||||||
if (!username || !domain) {
|
if (!username || !domain) {
|
||||||
@@ -606,6 +608,16 @@ export class BTCPayService implements LightningService {
|
|||||||
const callbackUrl = new URL(lnurlData.callback);
|
const callbackUrl = new URL(lnurlData.callback);
|
||||||
callbackUrl.searchParams.set('amount', amountMillisats.toString());
|
callbackUrl.searchParams.set('amount', amountMillisats.toString());
|
||||||
|
|
||||||
|
// Include a descriptive comment if the endpoint supports it.
|
||||||
|
// LNURL-pay endpoints advertise commentAllowed (max char length).
|
||||||
|
const commentAllowed = lnurlData.commentAllowed || 0;
|
||||||
|
if (comment && commentAllowed > 0) {
|
||||||
|
callbackUrl.searchParams.set(
|
||||||
|
'comment',
|
||||||
|
comment.slice(0, commentAllowed),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
const { data: invoiceData } = await axios.get(callbackUrl.toString(), { timeout: 10_000 });
|
const { data: invoiceData } = await axios.get(callbackUrl.toString(), { timeout: 10_000 });
|
||||||
|
|
||||||
if (invoiceData.status === 'ERROR') {
|
if (invoiceData.status === 'ERROR') {
|
||||||
|
|||||||
@@ -5,6 +5,7 @@ export interface LightningService {
|
|||||||
sendPaymentWithAddress(
|
sendPaymentWithAddress(
|
||||||
address: string,
|
address: string,
|
||||||
payment: Payment,
|
payment: Payment,
|
||||||
|
comment?: string,
|
||||||
): Promise<LightningPaymentDTO>;
|
): Promise<LightningPaymentDTO>;
|
||||||
|
|
||||||
validateAddress(address: string): Promise<boolean>;
|
validateAddress(address: string): Promise<boolean>;
|
||||||
|
|||||||
@@ -23,6 +23,7 @@ export class StrikeService implements LightningService {
|
|||||||
async sendPaymentWithAddress(
|
async sendPaymentWithAddress(
|
||||||
address: string,
|
address: string,
|
||||||
payment: Payment,
|
payment: Payment,
|
||||||
|
comment?: string,
|
||||||
currency: 'USD' | 'BTC' = 'BTC',
|
currency: 'USD' | 'BTC' = 'BTC',
|
||||||
): Promise<LightningPaymentDTO> {
|
): Promise<LightningPaymentDTO> {
|
||||||
try {
|
try {
|
||||||
@@ -39,7 +40,7 @@ export class StrikeService implements LightningService {
|
|||||||
: payment.usdAmount,
|
: payment.usdAmount,
|
||||||
currency,
|
currency,
|
||||||
},
|
},
|
||||||
description: isBlinkWallet ? undefined : 'Tip from IndeeHub',
|
description: isBlinkWallet ? undefined : (comment || 'Tip from IndeeHub'),
|
||||||
});
|
});
|
||||||
|
|
||||||
const createPaymentConfig = {
|
const createPaymentConfig = {
|
||||||
|
|||||||
@@ -233,7 +233,7 @@ export class PaymentService {
|
|||||||
order: {
|
order: {
|
||||||
[column]: 'DESC',
|
[column]: 'DESC',
|
||||||
},
|
},
|
||||||
relations: ['filmmaker', 'filmmaker.paymentMethods'],
|
relations: ['filmmaker', 'filmmaker.paymentMethods', 'content'],
|
||||||
take: 5,
|
take: 5,
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -347,10 +347,16 @@ export class PaymentService {
|
|||||||
}
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
|
// Build a descriptive comment for the Lightning invoice so the
|
||||||
|
// creator's wallet shows what the payment is for.
|
||||||
|
const contentTitle = shareholder.content?.title || 'content';
|
||||||
|
const payoutComment = `IndeeHub ${type} payout - ${contentTitle}`;
|
||||||
|
|
||||||
this.logger.log(`[payout:${type}] Sending ${rounded} sats to ${selectedLightningAddress.lightningAddress}...`);
|
this.logger.log(`[payout:${type}] Sending ${rounded} sats to ${selectedLightningAddress.lightningAddress}...`);
|
||||||
const providerPayment = await this.provider.sendPaymentWithAddress(
|
const providerPayment = await this.provider.sendPaymentWithAddress(
|
||||||
selectedLightningAddress.lightningAddress,
|
selectedLightningAddress.lightningAddress,
|
||||||
payment,
|
payment,
|
||||||
|
payoutComment,
|
||||||
);
|
);
|
||||||
|
|
||||||
payment.providerId = providerPayment.id;
|
payment.providerId = providerPayment.id;
|
||||||
|
|||||||
@@ -269,7 +269,28 @@
|
|||||||
{{ userInitials }}
|
{{ userInitials }}
|
||||||
</div>
|
</div>
|
||||||
<span class="text-white text-sm font-medium">{{ userName }}</span>
|
<span class="text-white text-sm font-medium">{{ userName }}</span>
|
||||||
|
<svg class="w-4 h-4 transition-transform" :class="{ 'rotate-180': dropdownOpen }" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M19 9l-7 7-7-7" />
|
||||||
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<div v-if="dropdownOpen" class="profile-menu absolute right-0 mt-2 w-48">
|
||||||
|
<div class="floating-glass-header py-2 rounded-xl">
|
||||||
|
<button @click="navigateTo('/profile')" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 w-full text-left">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M16 7a4 4 0 11-8 0 4 4 0 018 0zM12 14a7 7 0 00-7 7h14a7 7 0 00-7-7z" />
|
||||||
|
</svg>
|
||||||
|
<span>Profile</span>
|
||||||
|
</button>
|
||||||
|
<div class="border-t border-white/10 my-1"></div>
|
||||||
|
<button @click="handleLogout" class="profile-menu-item flex items-center gap-3 px-4 py-2.5 text-red-400 w-full text-left">
|
||||||
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M17 16l4-4m0 0l-4-4m4 4H7m6 4v1a3 3 0 01-3 3H6a3 3 0 01-3-3V7a3 3 0 013-3h4a3 3 0 013 3v1" />
|
||||||
|
</svg>
|
||||||
|
<span>Sign Out</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Mobile -->
|
<!-- Mobile -->
|
||||||
@@ -374,6 +395,29 @@ const hasNostrSession = computed(() => {
|
|||||||
return !!(sessionStorage.getItem('nostr_token') || sessionStorage.getItem('auth_token'))
|
return !!(sessionStorage.getItem('nostr_token') || sessionStorage.getItem('auth_token'))
|
||||||
})
|
})
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Auto-detect stale auth state: backend thinks we're authenticated
|
||||||
|
* (sessionStorage tokens exist) but accountManager has no active
|
||||||
|
* Nostr account (localStorage was cleared). This causes a dead-end
|
||||||
|
* "Nostr" profile button with no dropdown. Fix by auto-logging out.
|
||||||
|
*/
|
||||||
|
watch(
|
||||||
|
[() => isAuthenticated.value, () => nostrLoggedIn.value],
|
||||||
|
([authed, nostr]) => {
|
||||||
|
if (authed && !nostr) {
|
||||||
|
// Only auto-logout when the auth type is nostr-based —
|
||||||
|
// check if there's a nostr token but no active Nostr account
|
||||||
|
const hasNostrToken = !!sessionStorage.getItem('nostr_token')
|
||||||
|
const hasNostrPubkey = !!sessionStorage.getItem('nostr_pubkey')
|
||||||
|
if (hasNostrToken || hasNostrPubkey) {
|
||||||
|
console.warn('[AppHeader] Stale Nostr session detected (tokens exist but no active account). Auto-logging out.')
|
||||||
|
handleLogout()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{ immediate: true },
|
||||||
|
)
|
||||||
|
|
||||||
/** Switch content source and reload */
|
/** Switch content source and reload */
|
||||||
function handleSourceSelect(sourceId: string) {
|
function handleSourceSelect(sourceId: string) {
|
||||||
contentSourceStore.setSource(sourceId as any)
|
contentSourceStore.setSource(sourceId as any)
|
||||||
|
|||||||
@@ -64,7 +64,8 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Nostr Login Button (NIP-07 Browser Extension) -->
|
<!-- Nostr Login Button (NIP-07 Browser Extension) — only shown when extension is installed -->
|
||||||
|
<template v-if="hasNostrExtension">
|
||||||
<button
|
<button
|
||||||
@click="handleNostrLogin"
|
@click="handleNostrLogin"
|
||||||
:disabled="isLoading"
|
:disabled="isLoading"
|
||||||
@@ -85,6 +86,7 @@
|
|||||||
<span class="px-4 bg-transparent text-white/30 text-xs">or</span>
|
<span class="px-4 bg-transparent text-white/30 text-xs">or</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- Amber Login (NIP-55 Android Signer) — 3-phase flow -->
|
<!-- Amber Login (NIP-55 Android Signer) — 3-phase flow -->
|
||||||
<button
|
<button
|
||||||
@@ -254,6 +256,9 @@ const mode = ref<'login' | 'register' | 'forgot'>(props.defaultMode)
|
|||||||
const errorMessage = ref<string | null>(null)
|
const errorMessage = ref<string | null>(null)
|
||||||
const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
|
const isLoading = computed(() => authLoading.value || sovereignGenerating.value)
|
||||||
|
|
||||||
|
// NIP-07 extension detection — hide the button entirely when no extension is installed
|
||||||
|
const hasNostrExtension = ref(!!(window as any).nostr)
|
||||||
|
|
||||||
// Amber three-phase login state
|
// Amber three-phase login state
|
||||||
const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature'>('idle')
|
const amberPhase = ref<'idle' | 'waiting-pubkey' | 'waiting-signature'>('idle')
|
||||||
const amberPubkey = ref<string | null>(null)
|
const amberPubkey = ref<string | null>(null)
|
||||||
|
|||||||
@@ -10,8 +10,22 @@
|
|||||||
</svg>
|
</svg>
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
<!-- ─── NO CREATOR STATE ─── -->
|
||||||
|
<template v-if="noCreator">
|
||||||
|
<div class="text-center py-4">
|
||||||
|
<div class="text-5xl mb-5">🤷♂️</div>
|
||||||
|
<h2 class="text-2xl font-bold text-white mb-3">This is free content</h2>
|
||||||
|
<p class="text-white/50 text-sm mb-6 max-w-xs mx-auto leading-relaxed">
|
||||||
|
No creator to zap — this content doesn't have a Lightning-enabled creator on IndeeHub yet.
|
||||||
|
</p>
|
||||||
|
<button @click="closeModal" class="zap-pay-button w-full flex items-center justify-center">
|
||||||
|
Got it
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
<!-- ─── AMOUNT SELECTION STATE ─── -->
|
<!-- ─── AMOUNT SELECTION STATE ─── -->
|
||||||
<template v-if="paymentState === 'select'">
|
<template v-else-if="paymentState === 'select'">
|
||||||
<div class="text-center mb-6">
|
<div class="text-center mb-6">
|
||||||
<div class="w-14 h-14 mx-auto mb-4 rounded-full bg-[#F7931A]/15 flex items-center justify-center">
|
<div class="w-14 h-14 mx-auto mb-4 rounded-full bg-[#F7931A]/15 flex items-center justify-center">
|
||||||
<svg class="w-7 h-7 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
|
<svg class="w-7 h-7 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
|
||||||
@@ -83,7 +97,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ─── INVOICE STATE: QR Code + BOLT11 ─── -->
|
<!-- ─── INVOICE STATE: QR Code + BOLT11 ─── -->
|
||||||
<template v-if="paymentState === 'invoice'">
|
<template v-else-if="paymentState === 'invoice'">
|
||||||
<div class="text-center">
|
<div class="text-center">
|
||||||
<h2 class="text-2xl font-bold text-white mb-2">Pay with Lightning</h2>
|
<h2 class="text-2xl font-bold text-white mb-2">Pay with Lightning</h2>
|
||||||
<p class="text-white/50 text-sm mb-6">Scan the QR code or copy the invoice</p>
|
<p class="text-white/50 text-sm mb-6">Scan the QR code or copy the invoice</p>
|
||||||
@@ -132,7 +146,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ─── SUCCESS STATE: Lightning Celebration ─── -->
|
<!-- ─── SUCCESS STATE: Lightning Celebration ─── -->
|
||||||
<template v-if="paymentState === 'success'">
|
<template v-else-if="paymentState === 'success'">
|
||||||
<div class="text-center py-4 relative overflow-hidden">
|
<div class="text-center py-4 relative overflow-hidden">
|
||||||
<!-- Animated lightning bolts background -->
|
<!-- Animated lightning bolts background -->
|
||||||
<div class="zap-celebration">
|
<div class="zap-celebration">
|
||||||
@@ -213,6 +227,8 @@ const customActive = ref(false)
|
|||||||
const isLoading = ref(false)
|
const isLoading = ref(false)
|
||||||
const errorMessage = ref<string | null>(null)
|
const errorMessage = ref<string | null>(null)
|
||||||
const creatorName = ref<string | null>(null)
|
const creatorName = ref<string | null>(null)
|
||||||
|
/** True when the content has no zappable creator (free/partner content) */
|
||||||
|
const noCreator = ref(false)
|
||||||
const successQuote = ref('')
|
const successQuote = ref('')
|
||||||
|
|
||||||
// LNURL-pay data
|
// LNURL-pay data
|
||||||
@@ -286,6 +302,7 @@ watch(() => props.isOpen, async (open) => {
|
|||||||
qrCodeDataUrl.value = ''
|
qrCodeDataUrl.value = ''
|
||||||
copyButtonText.value = 'Copy Invoice'
|
copyButtonText.value = 'Copy Invoice'
|
||||||
creatorName.value = props.content?.creator || null
|
creatorName.value = props.content?.creator || null
|
||||||
|
noCreator.value = false
|
||||||
lnurlCallback.value = null
|
lnurlCallback.value = null
|
||||||
verifyUrl.value = null
|
verifyUrl.value = null
|
||||||
successQuote.value = ''
|
successQuote.value = ''
|
||||||
@@ -311,12 +328,21 @@ async function resolveLightningAddress() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
const projectId = props.content.id
|
const projectId = props.content.id
|
||||||
const ownerData = await indeehubApiService.get<{
|
let ownerData: { id: string; professionalName?: string } | null = null
|
||||||
|
|
||||||
|
try {
|
||||||
|
ownerData = await indeehubApiService.get<{
|
||||||
id: string
|
id: string
|
||||||
professionalName?: string
|
professionalName?: string
|
||||||
}>(`/filmmakers/project/${projectId}/owner`)
|
}>(`/filmmakers/project/${projectId}/owner`)
|
||||||
|
} catch {
|
||||||
|
// API returned error (e.g. 404 for free/partner content)
|
||||||
|
}
|
||||||
|
|
||||||
if (!ownerData?.id) return
|
if (!ownerData?.id) {
|
||||||
|
noCreator.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
if (ownerData.professionalName) {
|
if (ownerData.professionalName) {
|
||||||
creatorName.value = ownerData.professionalName
|
creatorName.value = ownerData.professionalName
|
||||||
@@ -327,7 +353,10 @@ async function resolveLightningAddress() {
|
|||||||
}>(`/filmmakers/${ownerData.id}/lightning-address`)
|
}>(`/filmmakers/${ownerData.id}/lightning-address`)
|
||||||
|
|
||||||
const lightningAddress = addressData?.lightningAddress
|
const lightningAddress = addressData?.lightningAddress
|
||||||
if (!lightningAddress) return
|
if (!lightningAddress) {
|
||||||
|
noCreator.value = true
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
const [username, domain] = lightningAddress.split('@')
|
const [username, domain] = lightningAddress.split('@')
|
||||||
if (!username || !domain) return
|
if (!username || !domain) return
|
||||||
@@ -345,6 +374,7 @@ async function resolveLightningAddress() {
|
|||||||
lnurlMaxSats.value = Math.floor((lnurlData.maxSendable || 10_000_000_000) / 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
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -20,17 +20,18 @@ function isConnectionError(error: any): boolean {
|
|||||||
/** Build a mock Nostr user with filmmaker profile + subscription */
|
/** Build a mock Nostr user with filmmaker profile + subscription */
|
||||||
function buildMockNostrUser(pubkey: string) {
|
function buildMockNostrUser(pubkey: string) {
|
||||||
const mockUserId = 'mock-nostr-user-' + pubkey.slice(0, 8)
|
const mockUserId = 'mock-nostr-user-' + pubkey.slice(0, 8)
|
||||||
|
const shortPub = pubkey.slice(0, 8)
|
||||||
return {
|
return {
|
||||||
id: mockUserId,
|
id: mockUserId,
|
||||||
email: `${pubkey.slice(0, 8)}@nostr.local`,
|
email: `${shortPub}@nostr.local`,
|
||||||
legalName: 'Nostr User',
|
legalName: `npub...${pubkey.slice(-6)}`,
|
||||||
nostrPubkey: pubkey,
|
nostrPubkey: pubkey,
|
||||||
createdAt: new Date().toISOString(),
|
createdAt: new Date().toISOString(),
|
||||||
updatedAt: new Date().toISOString(),
|
updatedAt: new Date().toISOString(),
|
||||||
filmmaker: {
|
filmmaker: {
|
||||||
id: 'mock-filmmaker-' + pubkey.slice(0, 8),
|
id: 'mock-filmmaker-' + shortPub,
|
||||||
userId: mockUserId,
|
userId: mockUserId,
|
||||||
professionalName: 'Nostr Filmmaker',
|
professionalName: `npub...${pubkey.slice(-6)}`,
|
||||||
bio: 'Independent filmmaker and content creator.',
|
bio: 'Independent filmmaker and content creator.',
|
||||||
},
|
},
|
||||||
subscriptions: [{
|
subscriptions: [{
|
||||||
|
|||||||
Reference in New Issue
Block a user