feat: enhance zap integration with backend stats and UI improvements

- Updated ContentDetailModal to display zap statistics from both Nostr and backend sources, improving visibility of zap activities.
- Refactored zap data handling to merge Nostr relay and backend zap stats, ensuring accurate totals and recent zapper information.
- Introduced a new function to fetch backend zap stats, enhancing the modal's responsiveness to user interactions.
- Enhanced error handling and added mock data support in the IndeehubApiService for development purposes.

These changes improve the user experience by providing comprehensive zap insights and ensuring the UI reflects real-time data accurately.
This commit is contained in:
Dorian
2026-02-14 15:55:02 +00:00
parent 66db9376ed
commit e48e5f5b4d
3 changed files with 86 additions and 46 deletions

View File

@@ -143,8 +143,8 @@
<span class="text-white font-medium">{{ content.creator }}</span>
</div>
<!-- Zaps Section (Primal/Yakihonne style: profile pics + amounts) -->
<div v-if="zapsList.length > 0" class="mb-6">
<!-- Zaps Section (Nostr + backend BTCPay zaps: profile pics + amounts) -->
<div v-if="totalZapSats > 0" class="mb-6">
<div class="flex items-center gap-2 mb-3">
<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"/>
@@ -157,7 +157,7 @@
v-for="(zap, idx) in displayZaps"
:key="zap.pubkey + '-' + zap.timestamp + '-' + idx"
class="zap-avatar-pill"
:title="getZapperName(zap.pubkey) + ' — ' + zap.amount.toLocaleString() + ' sats'"
:title="zap.amount > 0 ? getZapperName(zap.pubkey) + ' — ' + zap.amount.toLocaleString() + ' sats' : getZapperName(zap.pubkey) + ' zapped'"
>
<img
:src="getZapperPicture(zap.pubkey)"
@@ -287,6 +287,7 @@ import { useAuth } from '../composables/useAuth'
import { useAccounts } from '../composables/useAccounts'
import { useNostr } from '../composables/useNostr'
import { libraryService } from '../services/library.service'
import { indeehubApiService } from '../services/indeehub-api.service'
import type { Content } from '../types/content'
import VideoPlayer from './VideoPlayer.vue'
import SubscriptionModal from './SubscriptionModal.vue'
@@ -378,10 +379,35 @@ const reactionCounts = computed(() => nostr.reactionCounts.value)
const isLoadingComments = computed(() => nostr.isLoading.value)
const commentCount = computed(() => nostr.commentCount.value)
// Zap data from relay
const zapsList = computed(() => nostr.zaps.value)
// Backend zap stats (BTCPay in-app zaps) so modal shows total + who zapped
const backendZapStats = ref<{
zapCount: number
zapAmountSats: number
recentZapperPubkeys: string[]
} | null>(null)
// Zap data: merge Nostr relay (9735) + backend so in-app zaps show too
const zapsList = computed(() => {
const fromNostr = nostr.zaps.value
const backend = backendZapStats.value
const nostrPubkeys = new Set(fromNostr.map((z) => z.pubkey))
const fromBackend: { pubkey: string; amount: number; timestamp: number }[] = []
if (backend?.recentZapperPubkeys?.length) {
for (const pk of backend.recentZapperPubkeys) {
if (!nostrPubkeys.has(pk)) {
fromBackend.push({ pubkey: pk, amount: 0, timestamp: 0 })
}
}
}
const merged = [...fromNostr, ...fromBackend]
return merged.sort((a, b) => b.timestamp - a.timestamp)
})
const displayZaps = computed(() => zapsList.value.slice(0, 8))
const totalZapSats = computed(() => zapsList.value.reduce((sum, z) => sum + z.amount, 0))
const totalZapSats = computed(() => {
const fromNostr = nostr.zaps.value.reduce((sum, z) => sum + z.amount, 0)
const fromBackend = backendZapStats.value?.zapAmountSats ?? 0
return fromNostr + fromBackend
})
// User's existing reaction read from relay (not local state)
const userReaction = computed(() => nostr.userContentReaction.value)
@@ -400,6 +426,7 @@ watch(() => props.content?.id, (newId) => {
if (newId && props.isOpen) {
loadSocialData(newId)
checkRentalAccess()
fetchBackendZapStats(newId)
}
})
@@ -407,13 +434,23 @@ watch(() => props.isOpen, (open) => {
if (open && props.content?.id) {
loadSocialData(props.content.id)
checkRentalAccess()
fetchBackendZapStats(props.content.id)
} else if (!open) {
// Reset rental state when modal closes
hasActiveRental.value = false
rentalExpiresAt.value = null
backendZapStats.value = null
}
})
async function fetchBackendZapStats(contentId: string) {
try {
const data = await indeehubApiService.getZapStats([contentId])
backendZapStats.value = data[contentId] ?? null
} catch {
backendZapStats.value = null
}
}
function loadSocialData(contentId: string) {
nostr.cleanup()
nostr.subscribeToContent(contentId)
@@ -505,8 +542,8 @@ function handleZap() {
}
function handleZapped(_amount: number) {
// The zap was confirmed — the relay subscription will pick up
// the zap receipt automatically and update zapsList.
// In-app zaps go through BTCPay; refetch backend stats so modal updates.
if (props.content?.id) fetchBackendZapStats(props.content.id)
}
function getZapperName(pubkey: string): string {
@@ -522,6 +559,7 @@ function getZapperPicture(pubkey: string): string {
}
function formatZapAmount(sats: number): string {
if (sats <= 0) return '—'
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()

View File

@@ -45,21 +45,7 @@
<svg class="w-3 h-3" fill="none" stroke="currentColor" viewBox="0 0 24 24"><path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M8 12h.01M12 12h.01M16 12h.01M21 12c0 4.418-4.03 8-9 8a9.863 9.863 0 01-4.255-.949L3 20l1.395-3.72C3.512 15.042 3 13.574 3 12c0-4.418 4.03-8 9-8s9 3.582 9 8z"/></svg>
{{ getCommentCount(content.id) }}
</span>
<!-- Zaps: avatar stack + count (Primal/Yakihonne style) -->
<span
v-if="getZapCount(content.id) > 0"
class="social-badge zap-badge flex items-center gap-1"
:title="getZapCount(content.id) + ' zap(s)'"
>
<span class="flex -space-x-1.5">
<img
v-for="pk in getZapperPubkeys(content.id).slice(0, 3)"
:key="pk"
:src="zapperAvatarUrl(pk)"
:alt="''"
class="w-4 h-4 rounded-full border border-black/40 object-cover ring-1 ring-white/20"
/>
</span>
<span class="social-badge" v-if="getZapCount(content.id) > 0">
<svg class="w-3 h-3 text-[#F7931A]" viewBox="0 0 24 24" fill="currentColor">
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
</svg>
@@ -143,23 +129,6 @@ function getZapCount(contentId: string): number {
return discovery + backend
}
function getZapperPubkeys(contentId: string): string[] {
const discovery = getStats(contentId).recentZapperPubkeys ?? []
const backend = backendZapStats.value[contentId]?.recentZapperPubkeys ?? []
const seen = new Set<string>()
const merged: string[] = []
for (const pk of [...discovery, ...backend]) {
if (seen.has(pk) || merged.length >= 5) continue
seen.add(pk)
merged.push(pk)
}
return merged
}
function zapperAvatarUrl(pubkey: string): string {
return `https://robohash.org/${pubkey}.png`
}
const sliderRef = ref<HTMLElement | null>(null)
const canScrollLeft = ref(false)
const canScrollRight = ref(true)

View File

@@ -206,15 +206,48 @@ class IndeehubApiService {
/**
* Get zap stats for film cards (count, amount, recent zapper pubkeys) by project id.
* In dev, merges mock zap data for the first few projects so the UI is visible without real zaps.
*/
async getZapStats(projectIds: string[]): Promise<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>> {
if (projectIds.length === 0) return {}
const ids = projectIds.join(',')
const response = await this.client.get<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>(
'/zaps/stats',
{ params: { projectIds: ids } },
)
return response.data ?? {}
let data: Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }> = {}
try {
const response = await this.client.get<Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }>>(
'/zaps/stats',
{ params: { projectIds: ids } },
)
data = response.data ?? {}
} catch {
data = {}
}
if (import.meta.env.DEV) {
Object.assign(data, this.getMockZapStats(projectIds))
}
return data
}
/**
* Mock zap stats for dev so cards and modal show the zap UI.
* Uses fake pubkeys so robohash avatars display.
*/
private getMockZapStats(projectIds: string[]): Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }> {
const mockPubkeys = [
'aaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaaa',
'bbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbbb',
'c7937c1d0f8d0a2e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a0e8a',
]
const result: Record<string, { zapCount: number; zapAmountSats: number; recentZapperPubkeys: string[] }> = {}
const mockCounts = [3, 7, 1, 12, 2]
const mockSats = [2100, 50000, 1000, 100000, 420]
projectIds.slice(0, 5).forEach((id, i) => {
result[id] = {
zapCount: mockCounts[i % mockCounts.length],
zapAmountSats: mockSats[i % mockSats.length],
recentZapperPubkeys: mockPubkeys.slice(0, (i % 3) + 1),
}
})
return result
}
/**