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>
|
||||
</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;
|
||||
|
||||
@@ -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
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
|
||||
* (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
|
||||
})
|
||||
|
||||
/**
|
||||
|
||||
@@ -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(
|
||||
|
||||
Reference in New Issue
Block a user