feat: add zap button with lightning invoice modal and fix keys detection

- Add Zap button to film detail modal with LNURL-pay invoice generation
- Create ZapModal with 4 preset amounts, custom input, and QR code display
- Fix hasPrivateKey detection for production builds (use instanceof)
- Fix KeysModal header centering (pr-8 → px-8)
- Update most-zapped algorithm to track real Nostr zap receipts (kind 9735)
- Add BOLT11 amount decoder and zap receipt relay subscription

Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
Dorian
2026-02-14 12:27:34 +00:00
parent 2a16802404
commit 8f1a28e825
5 changed files with 640 additions and 7 deletions

View File

@@ -100,6 +100,14 @@
<span class="text-xs">{{ reactionCounts.negative }}</span>
</button>
<!-- Zap Button -->
<button @click="handleZap" class="action-btn action-btn-zap">
<svg class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
<span class="hidden sm:inline">Zap</span>
</button>
<!-- Share Button -->
<button @click="handleShare" class="action-btn">
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
@@ -232,6 +240,12 @@
@success="handleRentalSuccess"
@openSubscription="openSubscriptionFromRental"
/>
<ZapModal
:isOpen="showZapModal"
:content="content"
@close="showZapModal = false"
/>
</div>
</Transition>
</template>
@@ -246,6 +260,7 @@ import type { Content } from '../types/content'
import VideoPlayer from './VideoPlayer.vue'
import SubscriptionModal from './SubscriptionModal.vue'
import RentalModal from './RentalModal.vue'
import ZapModal from './ZapModal.vue'
import CommentNode from './CommentNode.vue'
interface Props {
@@ -271,6 +286,7 @@ const isInMyList = ref(false)
const showVideoPlayer = ref(false)
const showSubscriptionModal = ref(false)
const showRentalModal = ref(false)
const showZapModal = ref(false)
const relayConnected = ref(true)
// ── Rental access state ──────────────────────────────────────────────
@@ -444,6 +460,14 @@ async function handleDislike() {
}
}
function handleZap() {
if (!isNostrLoggedIn.value && !isAuthenticated.value) {
emit('openAuth')
return
}
showZapModal.value = true
}
function handleShare() {
const url = `${window.location.origin}/content/${props.content?.id}`
if (navigator.share) {
@@ -656,6 +680,17 @@ function openSubscriptionFromRental() {
color: white;
}
.action-btn-zap {
color: #F7931A;
border-color: rgba(247, 147, 26, 0.25);
}
.action-btn-zap:hover {
background: rgba(247, 147, 26, 0.15);
border-color: rgba(247, 147, 26, 0.4);
color: #F7931A;
}
/* Category Tags */
.category-tag {
display: inline-block;

View File

@@ -15,7 +15,7 @@
<div class="p-6 space-y-6">
<!-- Header -->
<div class="text-center pr-8">
<div class="text-center px-8">
<div class="w-12 h-12 mx-auto mb-3 rounded-full bg-[#F7931A]/15 flex items-center justify-center">
<svg class="w-6 h-6 text-[#F7931A]" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 7a2 2 0 012 2m4 0a6 6 0 01-7.743 5.743L11 17H9v2H7v2H4a1 1 0 01-1-1v-2.586a1 1 0 01.293-.707l5.964-5.964A6 6 0 1121 9z" />

508
src/components/ZapModal.vue Normal file
View File

@@ -0,0 +1,508 @@
<template>
<Transition name="modal-fade">
<div v-if="isOpen" class="modal-overlay" @click.self="closeModal">
<div class="modal-container">
<div class="modal-content">
<!-- Close Button -->
<button @click="closeModal" class="absolute top-4 right-4 text-white/60 hover:text-white transition-colors z-10">
<svg class="w-6 h-6" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- AMOUNT SELECTION STATE -->
<template v-if="paymentState === 'select'">
<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">
<svg class="w-7 h-7 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
</div>
<h2 class="text-2xl font-bold text-white">Zap {{ content?.title }}</h2>
<p class="text-white/50 text-sm mt-1">Send sats to the creator via Lightning</p>
</div>
<!-- Creator info -->
<div v-if="creatorName" class="flex items-center justify-center gap-2 mb-6 text-sm text-white/60">
<span>To:</span>
<span class="text-white font-medium">{{ creatorName }}</span>
</div>
<!-- Preset Amounts -->
<div class="grid grid-cols-4 gap-3 mb-5">
<button
v-for="amount in presetAmounts"
:key="amount"
@click="selectAmount(amount)"
class="preset-btn"
:class="{ 'preset-btn-active': selectedAmount === amount && !customActive }"
>
<svg class="w-4 h-4 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
<span class="font-bold text-white">{{ amount.toLocaleString() }}</span>
<span class="text-white/40 text-[10px]">sats</span>
</button>
</div>
<!-- Custom Amount -->
<div class="mb-6">
<label class="block text-white/50 text-xs font-medium uppercase tracking-wider mb-2">Custom Amount</label>
<div class="relative">
<input
v-model="customAmount"
type="number"
min="1"
placeholder="Enter sats..."
class="zap-input"
@focus="customActive = true"
@input="handleCustomInput"
/>
<span class="absolute right-4 top-1/2 -translate-y-1/2 text-white/30 text-sm">sats</span>
</div>
</div>
<!-- Error Message -->
<div v-if="errorMessage" class="mb-4 p-3 rounded-xl bg-red-500/15 border border-red-500/20 text-red-400 text-sm">
{{ errorMessage }}
</div>
<!-- Zap Button -->
<button
@click="handleZap"
:disabled="isLoading || !zapAmount || zapAmount < 1"
class="zap-pay-button w-full flex items-center justify-center gap-2"
:class="{ 'opacity-40 cursor-not-allowed': !zapAmount || zapAmount < 1 }"
>
<svg v-if="!isLoading" class="w-5 h-5" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
<span v-if="!isLoading">Zap {{ zapAmount ? zapAmount.toLocaleString() : '0' }} sats</span>
<span v-else>Creating invoice...</span>
</button>
</template>
<!-- INVOICE STATE: QR Code + BOLT11 -->
<template v-if="paymentState === 'invoice'">
<div class="text-center">
<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>
<!-- QR Code -->
<div class="bg-white rounded-2xl p-4 inline-block mb-5">
<img v-if="qrCodeDataUrl" :src="qrCodeDataUrl" alt="Lightning Invoice QR" class="w-56 h-56" />
<div v-else class="w-56 h-56 flex items-center justify-center text-gray-400">
Generating QR...
</div>
</div>
<!-- Amount -->
<div class="mb-4">
<div class="text-lg font-bold text-white flex items-center justify-center gap-1">
<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>
{{ zapAmount?.toLocaleString() }} sats
</div>
</div>
<!-- Copy BOLT11 -->
<button
@click="copyInvoice"
class="w-full bg-white/10 hover:bg-white/15 text-white rounded-xl px-4 py-3 text-sm font-medium transition-colors flex items-center justify-center gap-2 mb-4"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 5H6a2 2 0 00-2 2v12a2 2 0 002 2h10a2 2 0 002-2v-1M8 5a2 2 0 002 2h2a2 2 0 002-2M8 5a2 2 0 012-2h2a2 2 0 012 2m0 0h2a2 2 0 012 2v3m2 4H10m0 0l3-3m-3 3l3 3" />
</svg>
{{ copyButtonText }}
</button>
<!-- Open in wallet -->
<a
:href="'lightning:' + bolt11Invoice"
class="block text-center text-[#F7931A] hover:text-[#F7931A]/80 text-sm font-medium transition-colors mb-4"
>
Open in wallet app
</a>
<!-- Done Button -->
<button
@click="handleDone"
class="w-full bg-white/5 hover:bg-white/10 text-white/70 rounded-xl px-4 py-3 text-sm font-medium transition-colors"
>
Done
</button>
</div>
</template>
<!-- SUCCESS STATE -->
<template v-if="paymentState === 'success'">
<div class="text-center py-6">
<div class="w-20 h-20 bg-[#F7931A]/20 rounded-full flex items-center justify-center mx-auto mb-5">
<svg class="w-10 h-10 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
</div>
<h2 class="text-2xl font-bold text-white mb-2">Zap Sent!</h2>
<p class="text-white/50 mb-6">You zapped {{ zapAmount?.toLocaleString() }} sats to the creator</p>
<button @click="closeModal" class="zap-pay-button w-full flex items-center justify-center">
Done
</button>
</div>
</template>
</div>
</div>
</div>
</Transition>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue'
import QRCode from 'qrcode'
import type { Content } from '../types/content'
import { indeehubApiService } from '../services/indeehub-api.service'
interface Props {
isOpen: boolean
content: Content | null
}
interface Emits {
(e: 'close'): void
}
const props = defineProps<Props>()
const emit = defineEmits<Emits>()
type PaymentState = 'select' | 'invoice' | 'success'
const presetAmounts = [21, 100, 1000, 5000]
const paymentState = ref<PaymentState>('select')
const selectedAmount = ref<number>(100)
const customAmount = ref<string>('')
const customActive = ref(false)
const isLoading = ref(false)
const errorMessage = ref<string | null>(null)
const creatorName = ref<string | null>(null)
// LNURL-pay data
const lnurlCallback = ref<string | null>(null)
const lnurlMinSats = ref(1)
const lnurlMaxSats = ref(10_000_000)
// Invoice data
const bolt11Invoice = ref('')
const qrCodeDataUrl = ref('')
const copyButtonText = ref('Copy Invoice')
/** The effective amount to zap */
const zapAmount = computed<number | null>(() => {
if (customActive.value && customAmount.value) {
const val = parseInt(customAmount.value, 10)
return isNaN(val) ? null : val
}
return selectedAmount.value
})
function selectAmount(amount: number) {
selectedAmount.value = amount
customActive.value = false
customAmount.value = ''
}
function handleCustomInput() {
customActive.value = true
}
// Reset when modal opens/closes
watch(() => props.isOpen, async (open) => {
if (open) {
paymentState.value = 'select'
selectedAmount.value = 100
customAmount.value = ''
customActive.value = false
errorMessage.value = null
bolt11Invoice.value = ''
qrCodeDataUrl.value = ''
copyButtonText.value = 'Copy Invoice'
creatorName.value = props.content?.creator || null
lnurlCallback.value = null
// Pre-resolve the creator's lightning address
await resolveLightningAddress()
}
})
/**
* Resolve the content creator's lightning address via the backend,
* then fetch the LNURL-pay endpoint for invoice generation.
*/
async function resolveLightningAddress() {
if (!props.content) return
try {
// Get the project owner's lightning address from the backend
const projectId = props.content.id
const ownerData = await indeehubApiService.get<{
id: string
professionalName?: string
lightningAddress?: string
}>(`/filmmakers/project/${projectId}/owner`)
if (ownerData?.professionalName) {
creatorName.value = ownerData.professionalName
}
const lightningAddress = ownerData?.lightningAddress
if (!lightningAddress) {
// No lightning address on the owner, try fetching from payment methods
return
}
// Parse lightning address: user@domain → https://domain/.well-known/lnurlp/user
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
// LNURL amounts are in millisats
lnurlMinSats.value = Math.ceil((lnurlData.minSendable || 1000) / 1000)
lnurlMaxSats.value = Math.floor((lnurlData.maxSendable || 10_000_000_000) / 1000)
} catch (err) {
console.warn('[ZapModal] Failed to resolve lightning address:', err)
}
}
async function handleZap() {
if (!zapAmount.value || zapAmount.value < 1) return
isLoading.value = true
errorMessage.value = null
try {
if (!lnurlCallback.value) {
errorMessage.value = 'No lightning address found for this creator. They may not have set one up yet.'
return
}
// Validate amount against LNURL limits
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`
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
await generateQRCode(data.pr)
paymentState.value = 'invoice'
} catch (error: any) {
errorMessage.value = error.message || 'Failed to create zap invoice. Please try again.'
} finally {
isLoading.value = false
}
}
async function generateQRCode(bolt11: string) {
try {
qrCodeDataUrl.value = await QRCode.toDataURL(bolt11.toUpperCase(), {
width: 224,
margin: 2,
color: { dark: '#000000', light: '#FFFFFF' },
errorCorrectionLevel: 'M',
})
} catch (err) {
console.error('QR Code generation failed:', err)
}
}
async function copyInvoice() {
try {
await navigator.clipboard.writeText(bolt11Invoice.value)
copyButtonText.value = 'Copied!'
setTimeout(() => { copyButtonText.value = 'Copy Invoice' }, 2000)
} catch {
copyButtonText.value = 'Copy failed'
}
}
function handleDone() {
paymentState.value = 'success'
}
function closeModal() {
paymentState.value = 'select'
errorMessage.value = null
bolt11Invoice.value = ''
qrCodeDataUrl.value = ''
emit('close')
}
</script>
<style scoped>
.modal-overlay {
position: fixed;
inset: 0;
z-index: 9999;
background: rgba(0, 0, 0, 0.85);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
display: flex;
align-items: center;
justify-content: center;
padding: 16px;
}
.modal-container {
width: 100%;
max-width: 440px;
max-height: 90vh;
overflow-y: auto;
}
.modal-content {
position: relative;
background: rgba(0, 0, 0, 0.65);
backdrop-filter: blur(40px);
-webkit-backdrop-filter: blur(40px);
border-radius: 24px;
border: 1px solid rgba(255, 255, 255, 0.08);
box-shadow:
0 20px 60px rgba(0, 0, 0, 0.5),
inset 0 1px 0 rgba(255, 255, 255, 0.1);
padding: 32px;
}
/* Preset amount buttons */
.preset-btn {
display: flex;
flex-direction: column;
align-items: center;
gap: 2px;
padding: 14px 8px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
border: 2px solid rgba(255, 255, 255, 0.08);
cursor: pointer;
transition: all 0.2s ease;
}
.preset-btn:hover {
background: rgba(255, 255, 255, 0.08);
border-color: rgba(255, 255, 255, 0.15);
transform: translateY(-2px);
}
.preset-btn-active {
background: rgba(247, 147, 26, 0.12);
border-color: #F7931A;
box-shadow: 0 0 16px rgba(247, 147, 26, 0.25);
}
/* Custom amount input */
.zap-input {
width: 100%;
padding: 14px 60px 14px 16px;
border-radius: 16px;
background: rgba(255, 255, 255, 0.05);
border: 2px solid rgba(255, 255, 255, 0.08);
color: white;
font-size: 16px;
font-weight: 500;
outline: none;
transition: all 0.2s ease;
-moz-appearance: textfield;
}
.zap-input::-webkit-outer-spin-button,
.zap-input::-webkit-inner-spin-button {
-webkit-appearance: none;
margin: 0;
}
.zap-input::placeholder {
color: rgba(255, 255, 255, 0.25);
}
.zap-input:focus {
border-color: #F7931A;
background: rgba(247, 147, 26, 0.05);
box-shadow: 0 0 0 3px rgba(247, 147, 26, 0.1);
}
/* Zap pay button */
.zap-pay-button {
padding: 14px 28px;
font-size: 15px;
font-weight: 600;
border-radius: 16px;
background: linear-gradient(135deg, #F7931A, #e8820a);
color: white;
border: none;
cursor: pointer;
transition: all 0.3s ease;
box-shadow:
0 8px 24px rgba(247, 147, 26, 0.35),
inset 0 1px 0 rgba(255, 255, 255, 0.2);
}
.zap-pay-button:hover:not(:disabled) {
transform: translateY(-2px);
box-shadow:
0 12px 32px rgba(247, 147, 26, 0.45),
inset 0 1px 0 rgba(255, 255, 255, 0.25);
}
.zap-pay-button:active:not(:disabled) {
transform: translateY(0);
}
/* Modal Transitions */
.modal-fade-enter-active,
.modal-fade-leave-active {
transition: opacity 0.3s ease;
}
.modal-fade-enter-from,
.modal-fade-leave-to {
opacity: 0;
}
.modal-fade-enter-active .modal-content {
transition: transform 0.3s ease, opacity 0.3s ease;
}
.modal-fade-enter-from .modal-content {
transform: scale(0.95);
opacity: 0;
}
</style>

View File

@@ -277,14 +277,23 @@ export function useAccounts() {
* Whether the active account holds a local private key
* (generated sovereign identity or imported nsec).
* When true, the user can view/export their keys.
*
* Uses `instanceof` for reliable detection in minified production
* builds (constructor.name is mangled by bundlers). Falls back to
* checking for the presence of a secret key on the signer.
*/
const hasPrivateKey = computed(() => {
const acct = activeAccount.value
if (!acct) return false
// PrivateKeyAccount has type 'local' and stores the secret key
// in its signer. Check the account type name set by applesauce.
const typeName = acct.constructor?.name ?? acct.type ?? ''
return typeName === 'PrivateKeyAccount' || typeName === 'local'
// Reliable check: instanceof survives minification
if (acct instanceof Accounts.PrivateKeyAccount) return true
// Fallback: check if the account's signer holds a secret key
const signer = (acct as any).signer ?? (acct as any)._signer
if (signer?.key || signer?.secretKey || signer?._key) return true
return false
})
/**

View File

@@ -26,11 +26,44 @@ export interface ContentStats {
minusCount: number
commentCount: number
reviewCount: number
zapCount: number
zapAmountSats: number
recentEvents: NostrEvent[]
}
const SEVEN_DAYS = 7 * 24 * 60 * 60
/**
* Decode the amount in satoshis from a BOLT11 invoice string.
* BOLT11 encodes the amount after 'lnbc' with a multiplier suffix:
* m = milli (0.001), u = micro (0.000001), n = nano, p = pico
* Returns 0 if the amount cannot be parsed.
*/
function decodeBolt11Amount(bolt11: string): number {
try {
const lower = bolt11.toLowerCase()
// Match: lnbc<amount><multiplier>1...
const match = lower.match(/^lnbc(\d+)([munp]?)1/)
if (!match) return 0
const value = parseInt(match[1], 10)
if (isNaN(value)) return 0
const multiplier = match[2]
// Convert BTC amount to sats (1 BTC = 100,000,000 sats)
switch (multiplier) {
case 'm': return value * 100_000 // milli-BTC
case 'u': return value * 100 // micro-BTC
case 'n': return Math.round(value * 0.1) // nano-BTC
case 'p': return Math.round(value * 0.0001) // pico-BTC
case '': return value * 100_000_000 // whole BTC
default: return 0
}
} catch {
return 0
}
}
// --- Shared Module State (singleton) ---
const activeAlgorithm = ref<AlgorithmId>(null)
@@ -57,6 +90,8 @@ function rebuildStats() {
minusCount: 0,
commentCount: 0,
reviewCount: 0,
zapCount: 0,
zapAmountSats: 0,
recentEvents: [],
}
map.set(id, stats)
@@ -93,6 +128,30 @@ function rebuildStats() {
}
}
// Process zap receipts (kind 9735) from the EventStore.
// Zap receipts reference content via the 'i' tag (external identifiers)
// or via embedded zap request description.
const zapReceipts = eventStore.getByFilters([{ kinds: [9735] }])
if (zapReceipts) {
for (const event of zapReceipts) {
// Try to find the external content ID from the zap receipt tags
const externalId = getTagValue(event, 'i')
if (!externalId) continue
const stats = getOrCreate(externalId)
stats.zapCount++
// Extract amount from the bolt11 tag if present
const bolt11 = getTagValue(event, 'bolt11')
if (bolt11) {
const sats = decodeBolt11Amount(bolt11)
if (sats > 0) stats.zapAmountSats += sats
}
stats.recentEvents.push(event)
}
}
contentStatsMap.value = map
}
@@ -129,6 +188,17 @@ function initSubscriptions() {
})
subscriptionRefs.push(commentSub)
// Subscribe to zap receipts (kind 9735) that reference external web content.
// These are published by LNURL providers when a zap is paid.
const zapSub = pool
.subscription(currentRelays, [{ kinds: [9735] }])
.pipe(onlyEvents(), mapEventsToStore(eventStore))
.subscribe({
next: () => rebuildStats(),
error: (err) => console.error('Discovery zap subscription error:', err),
})
subscriptionRefs.push(zapSub)
// Initial build from any already-cached events
rebuildStats()
@@ -145,6 +215,8 @@ const EMPTY_STATS: ContentStats = {
minusCount: 0,
commentCount: 0,
reviewCount: 0,
zapCount: 0,
zapAmountSats: 0,
recentEvents: [],
}
@@ -263,11 +335,20 @@ function sortContentEntries(
)
}
case 'most-zapped':
case 'most-zapped': {
// Sort by total sats zapped first, then by zap count,
// falling back to positive reactions if no zap data exists.
const zapScore = (stats: ContentStats) =>
stats.zapAmountSats > 0
? stats.zapAmountSats
: stats.zapCount > 0
? stats.zapCount * 100 // Weight each zap receipt as 100 sats
: stats.plusCount // Fallback to likes if no zaps yet
return [...entries].sort(
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
b[1].plusCount - a[1].plusCount,
zapScore(b[1]) - zapScore(a[1]),
)
}
case 'most-reviews':
return [...entries].sort(