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:
@@ -100,6 +100,14 @@
|
|||||||
<span class="text-xs">{{ reactionCounts.negative }}</span>
|
<span class="text-xs">{{ reactionCounts.negative }}</span>
|
||||||
</button>
|
</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 -->
|
<!-- Share Button -->
|
||||||
<button @click="handleShare" class="action-btn">
|
<button @click="handleShare" class="action-btn">
|
||||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||||
@@ -232,6 +240,12 @@
|
|||||||
@success="handleRentalSuccess"
|
@success="handleRentalSuccess"
|
||||||
@openSubscription="openSubscriptionFromRental"
|
@openSubscription="openSubscriptionFromRental"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
|
<ZapModal
|
||||||
|
:isOpen="showZapModal"
|
||||||
|
:content="content"
|
||||||
|
@close="showZapModal = false"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
</template>
|
</template>
|
||||||
@@ -246,6 +260,7 @@ import type { Content } from '../types/content'
|
|||||||
import VideoPlayer from './VideoPlayer.vue'
|
import VideoPlayer from './VideoPlayer.vue'
|
||||||
import SubscriptionModal from './SubscriptionModal.vue'
|
import SubscriptionModal from './SubscriptionModal.vue'
|
||||||
import RentalModal from './RentalModal.vue'
|
import RentalModal from './RentalModal.vue'
|
||||||
|
import ZapModal from './ZapModal.vue'
|
||||||
import CommentNode from './CommentNode.vue'
|
import CommentNode from './CommentNode.vue'
|
||||||
|
|
||||||
interface Props {
|
interface Props {
|
||||||
@@ -271,6 +286,7 @@ const isInMyList = ref(false)
|
|||||||
const showVideoPlayer = ref(false)
|
const showVideoPlayer = ref(false)
|
||||||
const showSubscriptionModal = ref(false)
|
const showSubscriptionModal = ref(false)
|
||||||
const showRentalModal = ref(false)
|
const showRentalModal = ref(false)
|
||||||
|
const showZapModal = ref(false)
|
||||||
const relayConnected = ref(true)
|
const relayConnected = ref(true)
|
||||||
|
|
||||||
// ── Rental access state ──────────────────────────────────────────────
|
// ── 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() {
|
function handleShare() {
|
||||||
const url = `${window.location.origin}/content/${props.content?.id}`
|
const url = `${window.location.origin}/content/${props.content?.id}`
|
||||||
if (navigator.share) {
|
if (navigator.share) {
|
||||||
@@ -656,6 +680,17 @@ function openSubscriptionFromRental() {
|
|||||||
color: white;
|
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 Tags */
|
||||||
.category-tag {
|
.category-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
|
|
||||||
<div class="p-6 space-y-6">
|
<div class="p-6 space-y-6">
|
||||||
<!-- Header -->
|
<!-- 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">
|
<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">
|
<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" />
|
<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
508
src/components/ZapModal.vue
Normal 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>
|
||||||
@@ -277,14 +277,23 @@ export function useAccounts() {
|
|||||||
* Whether the active account holds a local private key
|
* Whether the active account holds a local private key
|
||||||
* (generated sovereign identity or imported nsec).
|
* (generated sovereign identity or imported nsec).
|
||||||
* When true, the user can view/export their keys.
|
* 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 hasPrivateKey = computed(() => {
|
||||||
const acct = activeAccount.value
|
const acct = activeAccount.value
|
||||||
if (!acct) return false
|
if (!acct) return false
|
||||||
// PrivateKeyAccount has type 'local' and stores the secret key
|
|
||||||
// in its signer. Check the account type name set by applesauce.
|
// Reliable check: instanceof survives minification
|
||||||
const typeName = acct.constructor?.name ?? acct.type ?? ''
|
if (acct instanceof Accounts.PrivateKeyAccount) return true
|
||||||
return typeName === 'PrivateKeyAccount' || typeName === 'local'
|
|
||||||
|
// 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
|
||||||
})
|
})
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -26,11 +26,44 @@ export interface ContentStats {
|
|||||||
minusCount: number
|
minusCount: number
|
||||||
commentCount: number
|
commentCount: number
|
||||||
reviewCount: number
|
reviewCount: number
|
||||||
|
zapCount: number
|
||||||
|
zapAmountSats: number
|
||||||
recentEvents: NostrEvent[]
|
recentEvents: NostrEvent[]
|
||||||
}
|
}
|
||||||
|
|
||||||
const SEVEN_DAYS = 7 * 24 * 60 * 60
|
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) ---
|
// --- Shared Module State (singleton) ---
|
||||||
|
|
||||||
const activeAlgorithm = ref<AlgorithmId>(null)
|
const activeAlgorithm = ref<AlgorithmId>(null)
|
||||||
@@ -57,6 +90,8 @@ function rebuildStats() {
|
|||||||
minusCount: 0,
|
minusCount: 0,
|
||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
reviewCount: 0,
|
reviewCount: 0,
|
||||||
|
zapCount: 0,
|
||||||
|
zapAmountSats: 0,
|
||||||
recentEvents: [],
|
recentEvents: [],
|
||||||
}
|
}
|
||||||
map.set(id, stats)
|
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
|
contentStatsMap.value = map
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -129,6 +188,17 @@ function initSubscriptions() {
|
|||||||
})
|
})
|
||||||
subscriptionRefs.push(commentSub)
|
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
|
// Initial build from any already-cached events
|
||||||
rebuildStats()
|
rebuildStats()
|
||||||
|
|
||||||
@@ -145,6 +215,8 @@ const EMPTY_STATS: ContentStats = {
|
|||||||
minusCount: 0,
|
minusCount: 0,
|
||||||
commentCount: 0,
|
commentCount: 0,
|
||||||
reviewCount: 0,
|
reviewCount: 0,
|
||||||
|
zapCount: 0,
|
||||||
|
zapAmountSats: 0,
|
||||||
recentEvents: [],
|
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(
|
return [...entries].sort(
|
||||||
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
|
(a: [Content, ContentStats], b: [Content, ContentStats]) =>
|
||||||
b[1].plusCount - a[1].plusCount,
|
zapScore(b[1]) - zapScore(a[1]),
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|
||||||
case 'most-reviews':
|
case 'most-reviews':
|
||||||
return [...entries].sort(
|
return [...entries].sort(
|
||||||
|
|||||||
Reference in New Issue
Block a user