feat: auto-detect zap payments, show zapper profiles, fix algorithm
- ZapModal now polls LNURL verify URL for automatic payment detection instead of requiring manual "Done" click - Added lightning bolt celebration animation on zap success with random fun quotes - Show "Zapped by" section on film detail modal with profile pics, amounts, and avatar pills (like Primal/Yakihonne) - useNostr now subscribes to kind 9735 zap receipts per content, parses sender pubkey from embedded zap request, and fetches profiles - Fixed most-zapped algorithm to also parse the description tag in zap receipts for content matching (NIP-57 embedded zap requests) Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -138,11 +138,40 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Creator Attribution -->
|
<!-- Creator Attribution -->
|
||||||
<div v-if="content.creator" class="flex items-center gap-3 mb-8 text-white/60 text-sm">
|
<div v-if="content.creator" class="flex items-center gap-3 mb-6 text-white/60 text-sm">
|
||||||
<span>Directed by</span>
|
<span>Directed by</span>
|
||||||
<span class="text-white font-medium">{{ content.creator }}</span>
|
<span class="text-white font-medium">{{ content.creator }}</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<!-- Zaps Section -->
|
||||||
|
<div v-if="zapsList.length > 0" class="mb-6">
|
||||||
|
<div class="flex items-center gap-2 mb-3">
|
||||||
|
<svg class="w-4 h-4 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
|
||||||
|
</svg>
|
||||||
|
<span class="text-sm font-semibold text-white">Zapped by</span>
|
||||||
|
<span class="text-xs text-white/40">({{ totalZapSats.toLocaleString() }} sats)</span>
|
||||||
|
</div>
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
|
<div
|
||||||
|
v-for="(zap, idx) in displayZaps"
|
||||||
|
:key="idx"
|
||||||
|
class="zap-avatar-pill"
|
||||||
|
:title="getZapperName(zap.pubkey) + ' — ' + zap.amount.toLocaleString() + ' sats'"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="getZapperPicture(zap.pubkey)"
|
||||||
|
:alt="getZapperName(zap.pubkey)"
|
||||||
|
class="w-7 h-7 rounded-full object-cover"
|
||||||
|
/>
|
||||||
|
<span class="text-xs font-medium text-[#F7931A]">{{ formatZapAmount(zap.amount) }}</span>
|
||||||
|
</div>
|
||||||
|
<span v-if="zapsList.length > 8" class="text-xs text-white/40 ml-1">
|
||||||
|
+{{ zapsList.length - 8 }} more
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Divider -->
|
<!-- Divider -->
|
||||||
<div class="border-t border-white/10 mb-6"></div>
|
<div class="border-t border-white/10 mb-6"></div>
|
||||||
|
|
||||||
@@ -245,6 +274,7 @@
|
|||||||
:isOpen="showZapModal"
|
:isOpen="showZapModal"
|
||||||
:content="content"
|
:content="content"
|
||||||
@close="showZapModal = false"
|
@close="showZapModal = false"
|
||||||
|
@zapped="handleZapped"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</Transition>
|
</Transition>
|
||||||
@@ -347,6 +377,11 @@ const reactionCounts = computed(() => nostr.reactionCounts.value)
|
|||||||
const isLoadingComments = computed(() => nostr.isLoading.value)
|
const isLoadingComments = computed(() => nostr.isLoading.value)
|
||||||
const commentCount = computed(() => nostr.commentCount.value)
|
const commentCount = computed(() => nostr.commentCount.value)
|
||||||
|
|
||||||
|
// Zap data from relay
|
||||||
|
const zapsList = computed(() => nostr.zaps.value)
|
||||||
|
const displayZaps = computed(() => zapsList.value.slice(0, 8))
|
||||||
|
const totalZapSats = computed(() => zapsList.value.reduce((sum, z) => sum + z.amount, 0))
|
||||||
|
|
||||||
// User's existing reaction read from relay (not local state)
|
// User's existing reaction read from relay (not local state)
|
||||||
const userReaction = computed(() => nostr.userContentReaction.value)
|
const userReaction = computed(() => nostr.userContentReaction.value)
|
||||||
const hasVoted = computed(() => nostr.hasVotedOnContent.value)
|
const hasVoted = computed(() => nostr.hasVotedOnContent.value)
|
||||||
@@ -468,6 +503,29 @@ function handleZap() {
|
|||||||
showZapModal.value = true
|
showZapModal.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function handleZapped(_amount: number) {
|
||||||
|
// The zap was confirmed — the relay subscription will pick up
|
||||||
|
// the zap receipt automatically and update zapsList.
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZapperName(pubkey: string): string {
|
||||||
|
const profile = nostr.profiles.value.get(pubkey)
|
||||||
|
if (profile?.display_name) return profile.display_name
|
||||||
|
if (profile?.name) return profile.name
|
||||||
|
return pubkey.slice(0, 8) + '...'
|
||||||
|
}
|
||||||
|
|
||||||
|
function getZapperPicture(pubkey: string): string {
|
||||||
|
const profile = nostr.profiles.value.get(pubkey)
|
||||||
|
return profile?.picture || `https://robohash.org/${pubkey}.png`
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatZapAmount(sats: number): string {
|
||||||
|
if (sats >= 1_000_000) return (sats / 1_000_000).toFixed(1) + 'M'
|
||||||
|
if (sats >= 1_000) return (sats / 1_000).toFixed(sats >= 10_000 ? 0 : 1) + 'k'
|
||||||
|
return sats.toLocaleString()
|
||||||
|
}
|
||||||
|
|
||||||
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) {
|
||||||
@@ -691,6 +749,23 @@ function openSubscriptionFromRental() {
|
|||||||
color: #F7931A;
|
color: #F7931A;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* Zap avatar pill */
|
||||||
|
.zap-avatar-pill {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 4px;
|
||||||
|
padding: 3px 10px 3px 3px;
|
||||||
|
border-radius: 999px;
|
||||||
|
background: rgba(247, 147, 26, 0.08);
|
||||||
|
border: 1px solid rgba(247, 147, 26, 0.15);
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-avatar-pill:hover {
|
||||||
|
background: rgba(247, 147, 26, 0.15);
|
||||||
|
border-color: rgba(247, 147, 26, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
/* Category Tags */
|
/* Category Tags */
|
||||||
.category-tag {
|
.category-tag {
|
||||||
display: inline-block;
|
display: inline-block;
|
||||||
|
|||||||
@@ -125,29 +125,44 @@
|
|||||||
Open in wallet app
|
Open in wallet app
|
||||||
</a>
|
</a>
|
||||||
|
|
||||||
<!-- Done Button -->
|
<p class="text-xs text-white/40 animate-pulse">
|
||||||
<button
|
Waiting for payment confirmation...
|
||||||
@click="handleDone"
|
</p>
|
||||||
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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<!-- ─── SUCCESS STATE ─── -->
|
<!-- ─── SUCCESS STATE: Lightning Celebration ─── -->
|
||||||
<template v-if="paymentState === 'success'">
|
<template v-if="paymentState === 'success'">
|
||||||
<div class="text-center py-6">
|
<div class="text-center py-4 relative overflow-hidden">
|
||||||
<div class="w-20 h-20 bg-[#F7931A]/20 rounded-full flex items-center justify-center mx-auto mb-5">
|
<!-- Animated lightning bolts background -->
|
||||||
<svg class="w-10 h-10 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
|
<div class="zap-celebration">
|
||||||
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
|
<div v-for="i in 12" :key="i" class="zap-bolt" :style="getBoltStyle(i)">
|
||||||
</svg>
|
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||||
|
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
|
||||||
|
</svg>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Glowing center icon -->
|
||||||
|
<div class="relative z-10">
|
||||||
|
<div class="zap-success-icon mx-auto mb-5">
|
||||||
|
<svg class="w-12 h-12 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-3xl font-bold text-white mb-2">ZAP!</h2>
|
||||||
|
<p class="text-[#F7931A] text-lg font-semibold mb-1">
|
||||||
|
{{ zapAmount?.toLocaleString() }} sats sent
|
||||||
|
</p>
|
||||||
|
<p class="text-white/40 text-sm mb-6">
|
||||||
|
{{ successQuote }}
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<button @click="closeModal" class="zap-pay-button w-full flex items-center justify-center">
|
||||||
|
Nice!
|
||||||
|
</button>
|
||||||
</div>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
@@ -158,7 +173,7 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, watch } from 'vue'
|
import { ref, computed, watch, onUnmounted } from 'vue'
|
||||||
import QRCode from 'qrcode'
|
import QRCode from 'qrcode'
|
||||||
import type { Content } from '../types/content'
|
import type { Content } from '../types/content'
|
||||||
import { indeehubApiService } from '../services/indeehub-api.service'
|
import { indeehubApiService } from '../services/indeehub-api.service'
|
||||||
@@ -170,6 +185,7 @@ interface Props {
|
|||||||
|
|
||||||
interface Emits {
|
interface Emits {
|
||||||
(e: 'close'): void
|
(e: 'close'): void
|
||||||
|
(e: 'zapped', amount: number): void
|
||||||
}
|
}
|
||||||
|
|
||||||
const props = defineProps<Props>()
|
const props = defineProps<Props>()
|
||||||
@@ -179,6 +195,17 @@ type PaymentState = 'select' | 'invoice' | 'success'
|
|||||||
|
|
||||||
const presetAmounts = [21, 100, 1000, 5000]
|
const presetAmounts = [21, 100, 1000, 5000]
|
||||||
|
|
||||||
|
const successQuotes = [
|
||||||
|
'The creator just felt a tingle in their wallet',
|
||||||
|
'Proof of appreciation, verified on the Lightning Network',
|
||||||
|
'Sats well spent. The creator thanks you!',
|
||||||
|
'You just made someone\'s day brighter',
|
||||||
|
'Value for value. This is the way.',
|
||||||
|
'Stack sats, zap creators. Repeat.',
|
||||||
|
'Money at the speed of light. Literally.',
|
||||||
|
'Another brick in the Lightning wall!',
|
||||||
|
]
|
||||||
|
|
||||||
const paymentState = ref<PaymentState>('select')
|
const paymentState = ref<PaymentState>('select')
|
||||||
const selectedAmount = ref<number>(100)
|
const selectedAmount = ref<number>(100)
|
||||||
const customAmount = ref<string>('')
|
const customAmount = ref<string>('')
|
||||||
@@ -186,17 +213,22 @@ 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)
|
||||||
|
const successQuote = ref('')
|
||||||
|
|
||||||
// LNURL-pay data
|
// LNURL-pay data
|
||||||
const lnurlCallback = ref<string | null>(null)
|
const lnurlCallback = ref<string | null>(null)
|
||||||
const lnurlMinSats = ref(1)
|
const lnurlMinSats = ref(1)
|
||||||
const lnurlMaxSats = ref(10_000_000)
|
const lnurlMaxSats = ref(10_000_000)
|
||||||
|
const verifyUrl = ref<string | null>(null)
|
||||||
|
|
||||||
// Invoice data
|
// Invoice data
|
||||||
const bolt11Invoice = ref('')
|
const bolt11Invoice = ref('')
|
||||||
const qrCodeDataUrl = ref('')
|
const qrCodeDataUrl = ref('')
|
||||||
const copyButtonText = ref('Copy Invoice')
|
const copyButtonText = ref('Copy Invoice')
|
||||||
|
|
||||||
|
// Polling
|
||||||
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
||||||
|
|
||||||
/** The effective amount to zap */
|
/** The effective amount to zap */
|
||||||
const zapAmount = computed<number | null>(() => {
|
const zapAmount = computed<number | null>(() => {
|
||||||
if (customActive.value && customAmount.value) {
|
if (customActive.value && customAmount.value) {
|
||||||
@@ -216,6 +248,32 @@ function handleCustomInput() {
|
|||||||
customActive.value = true
|
customActive.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generate random style for each celebration lightning bolt
|
||||||
|
*/
|
||||||
|
function getBoltStyle(index: number) {
|
||||||
|
const left = (index * 8.3) % 100
|
||||||
|
const delay = (index * 0.15) % 2
|
||||||
|
const size = 14 + (index % 4) * 6
|
||||||
|
const duration = 1.5 + (index % 3) * 0.5
|
||||||
|
return {
|
||||||
|
left: `${left}%`,
|
||||||
|
animationDelay: `${delay}s`,
|
||||||
|
width: `${size}px`,
|
||||||
|
height: `${size}px`,
|
||||||
|
animationDuration: `${duration}s`,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function cleanup() {
|
||||||
|
if (pollInterval) {
|
||||||
|
clearInterval(pollInterval)
|
||||||
|
pollInterval = null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onUnmounted(cleanup)
|
||||||
|
|
||||||
// Reset when modal opens/closes
|
// Reset when modal opens/closes
|
||||||
watch(() => props.isOpen, async (open) => {
|
watch(() => props.isOpen, async (open) => {
|
||||||
if (open) {
|
if (open) {
|
||||||
@@ -229,9 +287,14 @@ watch(() => props.isOpen, async (open) => {
|
|||||||
copyButtonText.value = 'Copy Invoice'
|
copyButtonText.value = 'Copy Invoice'
|
||||||
creatorName.value = props.content?.creator || null
|
creatorName.value = props.content?.creator || null
|
||||||
lnurlCallback.value = null
|
lnurlCallback.value = null
|
||||||
|
verifyUrl.value = null
|
||||||
|
successQuote.value = ''
|
||||||
|
cleanup()
|
||||||
|
|
||||||
// Pre-resolve the creator's lightning address
|
// Pre-resolve the creator's lightning address
|
||||||
await resolveLightningAddress()
|
await resolveLightningAddress()
|
||||||
|
} else {
|
||||||
|
cleanup()
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
@@ -247,7 +310,6 @@ async function resolveLightningAddress() {
|
|||||||
if (!props.content) return
|
if (!props.content) return
|
||||||
|
|
||||||
try {
|
try {
|
||||||
// Step 1: Get the project owner's filmmaker profile
|
|
||||||
const projectId = props.content.id
|
const projectId = props.content.id
|
||||||
const ownerData = await indeehubApiService.get<{
|
const ownerData = await indeehubApiService.get<{
|
||||||
id: string
|
id: string
|
||||||
@@ -260,7 +322,6 @@ async function resolveLightningAddress() {
|
|||||||
creatorName.value = ownerData.professionalName
|
creatorName.value = ownerData.professionalName
|
||||||
}
|
}
|
||||||
|
|
||||||
// Step 2: Fetch the filmmaker's lightning address
|
|
||||||
const addressData = await indeehubApiService.get<{
|
const addressData = await indeehubApiService.get<{
|
||||||
lightningAddress: string
|
lightningAddress: string
|
||||||
}>(`/filmmakers/${ownerData.id}/lightning-address`)
|
}>(`/filmmakers/${ownerData.id}/lightning-address`)
|
||||||
@@ -268,7 +329,6 @@ async function resolveLightningAddress() {
|
|||||||
const lightningAddress = addressData?.lightningAddress
|
const lightningAddress = addressData?.lightningAddress
|
||||||
if (!lightningAddress) return
|
if (!lightningAddress) return
|
||||||
|
|
||||||
// Parse lightning address: user@domain → https://domain/.well-known/lnurlp/user
|
|
||||||
const [username, domain] = lightningAddress.split('@')
|
const [username, domain] = lightningAddress.split('@')
|
||||||
if (!username || !domain) return
|
if (!username || !domain) return
|
||||||
|
|
||||||
@@ -281,7 +341,6 @@ async function resolveLightningAddress() {
|
|||||||
if (lnurlData.status === 'ERROR') return
|
if (lnurlData.status === 'ERROR') return
|
||||||
|
|
||||||
lnurlCallback.value = lnurlData.callback
|
lnurlCallback.value = lnurlData.callback
|
||||||
// LNURL amounts are in millisats
|
|
||||||
lnurlMinSats.value = Math.ceil((lnurlData.minSendable || 1000) / 1000)
|
lnurlMinSats.value = Math.ceil((lnurlData.minSendable || 1000) / 1000)
|
||||||
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) {
|
||||||
@@ -289,6 +348,37 @@ async function resolveLightningAddress() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Poll the LNURL verify URL to detect payment.
|
||||||
|
* The verify URL is returned alongside the invoice and responds
|
||||||
|
* with { settled: true } once the invoice is paid.
|
||||||
|
*/
|
||||||
|
function startPolling() {
|
||||||
|
if (!verifyUrl.value) return
|
||||||
|
|
||||||
|
const url = verifyUrl.value
|
||||||
|
pollInterval = setInterval(async () => {
|
||||||
|
try {
|
||||||
|
const response = await fetch(url)
|
||||||
|
if (!response.ok) return
|
||||||
|
|
||||||
|
const data = await response.json()
|
||||||
|
if (data.settled === true || data.preimage) {
|
||||||
|
cleanup()
|
||||||
|
showSuccess()
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Silently retry
|
||||||
|
}
|
||||||
|
}, 3000)
|
||||||
|
}
|
||||||
|
|
||||||
|
function showSuccess() {
|
||||||
|
successQuote.value = successQuotes[Math.floor(Math.random() * successQuotes.length)]
|
||||||
|
paymentState.value = 'success'
|
||||||
|
emit('zapped', zapAmount.value || 0)
|
||||||
|
}
|
||||||
|
|
||||||
async function handleZap() {
|
async function handleZap() {
|
||||||
if (!zapAmount.value || zapAmount.value < 1) return
|
if (!zapAmount.value || zapAmount.value < 1) return
|
||||||
|
|
||||||
@@ -301,7 +391,6 @@ async function handleZap() {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
// Validate amount against LNURL limits
|
|
||||||
if (zapAmount.value < lnurlMinSats.value) {
|
if (zapAmount.value < lnurlMinSats.value) {
|
||||||
errorMessage.value = `Minimum zap is ${lnurlMinSats.value.toLocaleString()} sats`
|
errorMessage.value = `Minimum zap is ${lnurlMinSats.value.toLocaleString()} sats`
|
||||||
return
|
return
|
||||||
@@ -330,8 +419,13 @@ async function handleZap() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
bolt11Invoice.value = data.pr
|
bolt11Invoice.value = data.pr
|
||||||
|
// Store verify URL for payment detection polling
|
||||||
|
verifyUrl.value = data.verify || null
|
||||||
await generateQRCode(data.pr)
|
await generateQRCode(data.pr)
|
||||||
paymentState.value = 'invoice'
|
paymentState.value = 'invoice'
|
||||||
|
|
||||||
|
// Start polling for payment confirmation
|
||||||
|
startPolling()
|
||||||
} catch (error: any) {
|
} catch (error: any) {
|
||||||
errorMessage.value = error.message || 'Failed to create zap invoice. Please try again.'
|
errorMessage.value = error.message || 'Failed to create zap invoice. Please try again.'
|
||||||
} finally {
|
} finally {
|
||||||
@@ -362,15 +456,13 @@ async function copyInvoice() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
function handleDone() {
|
|
||||||
paymentState.value = 'success'
|
|
||||||
}
|
|
||||||
|
|
||||||
function closeModal() {
|
function closeModal() {
|
||||||
|
cleanup()
|
||||||
paymentState.value = 'select'
|
paymentState.value = 'select'
|
||||||
errorMessage.value = null
|
errorMessage.value = null
|
||||||
bolt11Invoice.value = ''
|
bolt11Invoice.value = ''
|
||||||
qrCodeDataUrl.value = ''
|
qrCodeDataUrl.value = ''
|
||||||
|
verifyUrl.value = null
|
||||||
emit('close')
|
emit('close')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
@@ -493,6 +585,76 @@ function closeModal() {
|
|||||||
transform: translateY(0);
|
transform: translateY(0);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ─── Success Celebration ─── */
|
||||||
|
.zap-celebration {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0;
|
||||||
|
pointer-events: none;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-bolt {
|
||||||
|
position: absolute;
|
||||||
|
top: -20px;
|
||||||
|
color: rgba(247, 147, 26, 0.3);
|
||||||
|
animation: zapFall linear infinite;
|
||||||
|
filter: blur(0.5px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-bolt:nth-child(odd) {
|
||||||
|
color: rgba(247, 147, 26, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-bolt:nth-child(3n) {
|
||||||
|
color: rgba(255, 200, 50, 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes zapFall {
|
||||||
|
0% {
|
||||||
|
transform: translateY(-20px) rotate(-15deg) scale(0.5);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
15% {
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
85% {
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
100% {
|
||||||
|
transform: translateY(350px) rotate(15deg) scale(1);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.zap-success-icon {
|
||||||
|
width: 88px;
|
||||||
|
height: 88px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: radial-gradient(circle, rgba(247, 147, 26, 0.25), rgba(247, 147, 26, 0.05));
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
animation: zapPulse 1.5s ease-in-out infinite;
|
||||||
|
box-shadow:
|
||||||
|
0 0 40px rgba(247, 147, 26, 0.3),
|
||||||
|
0 0 80px rgba(247, 147, 26, 0.15);
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes zapPulse {
|
||||||
|
0%, 100% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 40px rgba(247, 147, 26, 0.3),
|
||||||
|
0 0 80px rgba(247, 147, 26, 0.15);
|
||||||
|
transform: scale(1);
|
||||||
|
}
|
||||||
|
50% {
|
||||||
|
box-shadow:
|
||||||
|
0 0 60px rgba(247, 147, 26, 0.4),
|
||||||
|
0 0 120px rgba(247, 147, 26, 0.2);
|
||||||
|
transform: scale(1.05);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
/* Modal Transitions */
|
/* Modal Transitions */
|
||||||
.modal-fade-enter-active,
|
.modal-fade-enter-active,
|
||||||
.modal-fade-leave-active {
|
.modal-fade-leave-active {
|
||||||
|
|||||||
@@ -129,13 +129,29 @@ function rebuildStats() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
// Process zap receipts (kind 9735) from the EventStore.
|
// Process zap receipts (kind 9735) from the EventStore.
|
||||||
// Zap receipts reference content via the 'i' tag (external identifiers)
|
// Zap receipts reference content via:
|
||||||
// or via embedded zap request description.
|
// 1. Direct 'i' tag on the receipt itself
|
||||||
|
// 2. 'i' tag embedded in the zap request JSON (description tag)
|
||||||
const zapReceipts = eventStore.getByFilters([{ kinds: [9735] }])
|
const zapReceipts = eventStore.getByFilters([{ kinds: [9735] }])
|
||||||
if (zapReceipts) {
|
if (zapReceipts) {
|
||||||
for (const event of zapReceipts) {
|
for (const event of zapReceipts) {
|
||||||
// Try to find the external content ID from the zap receipt tags
|
// Try direct 'i' tag first
|
||||||
const externalId = getTagValue(event, 'i')
|
let externalId = getTagValue(event, 'i')
|
||||||
|
|
||||||
|
// Fallback: parse the embedded zap request from the description tag
|
||||||
|
if (!externalId) {
|
||||||
|
const descTag = event.tags.find((t) => t[0] === 'description')?.[1]
|
||||||
|
if (descTag) {
|
||||||
|
try {
|
||||||
|
const zapRequest = JSON.parse(descTag)
|
||||||
|
if (zapRequest.tags) {
|
||||||
|
const iTag = zapRequest.tags.find((t: string[]) => t[0] === 'i')
|
||||||
|
if (iTag) externalId = iTag[1]
|
||||||
|
}
|
||||||
|
} catch { /* not valid JSON */ }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
if (!externalId) continue
|
if (!externalId) continue
|
||||||
|
|
||||||
const stats = getOrCreate(externalId)
|
const stats = getOrCreate(externalId)
|
||||||
|
|||||||
@@ -120,6 +120,8 @@ export function useNostr(contentId?: string) {
|
|||||||
const reactions = ref<NostrEvent[]>([])
|
const reactions = ref<NostrEvent[]>([])
|
||||||
// Per-comment reactions: eventId -> NostrEvent[]
|
// Per-comment reactions: eventId -> NostrEvent[]
|
||||||
const commentReactions = ref<Map<string, NostrEvent[]>>(new Map())
|
const commentReactions = ref<Map<string, NostrEvent[]>>(new Map())
|
||||||
|
// Zap receipts for the current content
|
||||||
|
const zaps = ref<{ pubkey: string; amount: number; timestamp: number }[]>([])
|
||||||
// User profiles
|
// User profiles
|
||||||
const profiles = ref<Map<string, any>>(new Map())
|
const profiles = ref<Map<string, any>>(new Map())
|
||||||
// Loading state
|
// Loading state
|
||||||
@@ -170,9 +172,22 @@ export function useNostr(contentId?: string) {
|
|||||||
})
|
})
|
||||||
subscriptions.push(reactionSub)
|
subscriptions.push(reactionSub)
|
||||||
|
|
||||||
|
// Subscribe to zap receipts (kind 9735) for this content
|
||||||
|
const zapSub = pool
|
||||||
|
.subscription(currentRelays, [
|
||||||
|
{ kinds: [9735], '#i': [externalId] },
|
||||||
|
])
|
||||||
|
.pipe(onlyEvents(), mapEventsToStore(eventStore))
|
||||||
|
.subscribe({
|
||||||
|
next: () => refreshZaps(externalId),
|
||||||
|
error: (err) => console.error('Zap subscription error:', err),
|
||||||
|
})
|
||||||
|
subscriptions.push(zapSub)
|
||||||
|
|
||||||
// Read existing data from store immediately
|
// Read existing data from store immediately
|
||||||
refreshComments(externalId)
|
refreshComments(externalId)
|
||||||
refreshReactions(externalId)
|
refreshReactions(externalId)
|
||||||
|
refreshZaps(externalId)
|
||||||
|
|
||||||
// Mark loading done after initial data window
|
// Mark loading done after initial data window
|
||||||
setTimeout(() => {
|
setTimeout(() => {
|
||||||
@@ -213,6 +228,76 @@ export function useNostr(contentId?: string) {
|
|||||||
loadCommentReactions(allEvents)
|
loadCommentReactions(allEvents)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Parse sats from a BOLT11 invoice string.
|
||||||
|
*/
|
||||||
|
function parseBolt11Sats(bolt11: string): number {
|
||||||
|
try {
|
||||||
|
const match = bolt11.toLowerCase().match(/^lnbc(\d+)([munp]?)1/)
|
||||||
|
if (!match) return 0
|
||||||
|
const value = parseInt(match[1], 10)
|
||||||
|
if (isNaN(value)) return 0
|
||||||
|
switch (match[2]) {
|
||||||
|
case 'm': return value * 100_000
|
||||||
|
case 'u': return value * 100
|
||||||
|
case 'n': return Math.round(value * 0.1)
|
||||||
|
case 'p': return Math.round(value * 0.0001)
|
||||||
|
case '': return value * 100_000_000
|
||||||
|
default: return 0
|
||||||
|
}
|
||||||
|
} catch { return 0 }
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Read zap receipts from the EventStore and extract sender info.
|
||||||
|
*/
|
||||||
|
function refreshZaps(externalId: string) {
|
||||||
|
const events = eventStore.getByFilters([
|
||||||
|
{ kinds: [9735], '#i': [externalId] },
|
||||||
|
])
|
||||||
|
if (!events) return
|
||||||
|
|
||||||
|
const zapList: { pubkey: string; amount: number; timestamp: number }[] = []
|
||||||
|
const zapperPubkeys: string[] = []
|
||||||
|
|
||||||
|
for (const event of events) {
|
||||||
|
// The zap receipt's pubkey is the LNURL provider, not the sender.
|
||||||
|
// The sender's pubkey is in the embedded zap request (description tag).
|
||||||
|
let senderPubkey = event.pubkey
|
||||||
|
let amount = 0
|
||||||
|
|
||||||
|
// Try to extract sender from the description tag (NIP-57)
|
||||||
|
const descTag = event.tags.find((t) => t[0] === 'description')?.[1]
|
||||||
|
if (descTag) {
|
||||||
|
try {
|
||||||
|
const zapRequest = JSON.parse(descTag)
|
||||||
|
if (zapRequest.pubkey) senderPubkey = zapRequest.pubkey
|
||||||
|
} catch { /* not valid JSON */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Extract amount from bolt11 tag
|
||||||
|
const bolt11 = event.tags.find((t) => t[0] === 'bolt11')?.[1]
|
||||||
|
if (bolt11) {
|
||||||
|
amount = parseBolt11Sats(bolt11)
|
||||||
|
}
|
||||||
|
|
||||||
|
zapList.push({
|
||||||
|
pubkey: senderPubkey,
|
||||||
|
amount,
|
||||||
|
timestamp: event.created_at,
|
||||||
|
})
|
||||||
|
zapperPubkeys.push(senderPubkey)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Sort by most recent first
|
||||||
|
zapList.sort((a, b) => b.timestamp - a.timestamp)
|
||||||
|
zaps.value = zapList
|
||||||
|
|
||||||
|
// Fetch profiles for zap senders
|
||||||
|
const uniquePubkeys = [...new Set(zapperPubkeys)]
|
||||||
|
fetchProfiles(uniquePubkeys)
|
||||||
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Read movie-level reactions from the EventStore.
|
* Read movie-level reactions from the EventStore.
|
||||||
*/
|
*/
|
||||||
@@ -557,6 +642,7 @@ export function useNostr(contentId?: string) {
|
|||||||
allComments,
|
allComments,
|
||||||
reactions,
|
reactions,
|
||||||
commentReactions,
|
commentReactions,
|
||||||
|
zaps,
|
||||||
profiles,
|
profiles,
|
||||||
isLoading,
|
isLoading,
|
||||||
error,
|
error,
|
||||||
|
|||||||
Reference in New Issue
Block a user