feat(ui): dual-ecash wallet settings, buy-peer-files, seed backup, assorted fixes
- Tabbed Wallet Settings modal (Cashu + Fedimint) and dual-balance wallet card - Buy a peer's paid file (ecash / node Lightning / on-chain / external QR) - Recovery-phrase reveal + backup section; onboarding seed retry resilience - NetBird HTTPS launch, remote-control two-finger scroll + external-open - Shared BackButton, single-v version label, mesh Bitcoin header toggles Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
co-authored by
Claude Opus 4.8
parent
bd567cd165
commit
87769cbfbf
@@ -1,26 +1,9 @@
|
||||
<template>
|
||||
<div class="pb-6">
|
||||
<!-- Header with back button -->
|
||||
<!-- Header with back button — shared component so peer files match local
|
||||
files (CloudFolder) on both desktop and mobile. -->
|
||||
<div class="shrink-0 mb-4">
|
||||
<button @click="goBack" class="hidden md:flex mb-4 items-center gap-2 text-white/70 hover:text-white transition-colors">
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
Back to Cloud
|
||||
</button>
|
||||
|
||||
<!-- Mobile Back Button -->
|
||||
<Teleport to="body">
|
||||
<button
|
||||
@click="goBack"
|
||||
class="md:hidden mobile-back-btn glass-button px-6 py-3 rounded-lg font-medium shadow-2xl flex items-center justify-center gap-2"
|
||||
>
|
||||
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M15 19l-7-7 7-7" />
|
||||
</svg>
|
||||
<span>Back to Cloud</span>
|
||||
</button>
|
||||
</Teleport>
|
||||
<BackButton label="Back to Cloud" @click="goBack" />
|
||||
|
||||
<!-- Peer Header -->
|
||||
<div class="flex items-center gap-4">
|
||||
@@ -276,14 +259,144 @@
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
|
||||
<!-- Payment method picker / Lightning invoice QR (#46) -->
|
||||
<Teleport to="body">
|
||||
<Transition name="fade">
|
||||
<div
|
||||
v-if="payItem"
|
||||
class="fixed inset-0 z-50 flex items-center justify-center bg-black/80 backdrop-blur-sm p-4"
|
||||
@click.self="closePayModal"
|
||||
>
|
||||
<div class="glass-card w-full max-w-md p-5 rounded-2xl relative">
|
||||
<button
|
||||
class="absolute top-3 right-3 text-white/50 hover:text-white transition-colors"
|
||||
@click="closePayModal"
|
||||
>
|
||||
<svg class="w-5 h-5" 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>
|
||||
|
||||
<h3 class="text-base font-semibold text-white mb-1">Buy this file</h3>
|
||||
<p class="text-xs text-white/50 mb-4 truncate">
|
||||
{{ payItem.filename.split('/').pop() }} · {{ getItemPrice(payItem.access) }} sats
|
||||
</p>
|
||||
|
||||
<!-- Step 1: choose a payment method -->
|
||||
<div v-if="payMode === 'choose'" class="space-y-3">
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="downloading === payItem.id"
|
||||
@click="payWithEcash"
|
||||
>
|
||||
<svg class="w-6 h-6 text-green-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M3 10h18M7 15h1m4 0h1m-7 4h12a3 3 0 003-3V8a3 3 0 00-3-3H6a3 3 0 00-3 3v8a3 3 0 003 3z" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">{{ downloading === payItem.id ? 'Paying…' : 'Pay from this node’s ecash wallet' }}</span>
|
||||
<span class="block text-sm text-white/50">Instant, using your ecash balance</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="lnPaying"
|
||||
@click="payWithLightning"
|
||||
>
|
||||
<svg class="w-6 h-6 text-yellow-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13 10V3L4 14h7v7l9-11h-7z" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">{{ lnPaying ? 'Paying…' : 'Pay with my Lightning node' }}</span>
|
||||
<span class="block text-sm text-white/50">Pays the seller’s invoice from your node’s Lightning wallet</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="lnPaying || onchainPaying"
|
||||
@click="payWithInvoice"
|
||||
>
|
||||
<svg class="w-6 h-6 text-amber-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M10.5 1.5H8.25A2.25 2.25 0 006 3.75v16.5a2.25 2.25 0 002.25 2.25h7.5A2.25 2.25 0 0018 20.25V3.75a2.25 2.25 0 00-2.25-2.25H13.5m-3 0V3h3V1.5m-3 0h3m-3 18.75h3" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">Pay from another wallet (QR)</span>
|
||||
<span class="block text-sm text-white/50">Scan a Lightning invoice with any wallet</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
class="w-full glass-button px-4 py-3 rounded-xl flex items-center justify-start gap-3 text-left"
|
||||
:disabled="lnPaying || onchainPaying"
|
||||
@click="payOnchain"
|
||||
>
|
||||
<svg class="w-6 h-6 text-orange-400 shrink-0" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M13.828 10.172a4 4 0 00-5.656 0l-4 4a4 4 0 105.656 5.656l1.102-1.101m-.758-4.899a4 4 0 005.656 0l4-4a4 4 0 00-5.656-5.656l-1.1 1.1" />
|
||||
</svg>
|
||||
<span>
|
||||
<span class="block text-base text-white">{{ onchainPaying ? 'Sending…' : 'Pay on-chain from my node' }}</span>
|
||||
<span class="block text-sm text-white/50">Sends Bitcoin on-chain from your node’s wallet (slower)</span>
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<p v-if="lnError" class="text-xs text-red-400 px-1">{{ lnError }}</p>
|
||||
</div>
|
||||
|
||||
<!-- Step 2: Lightning invoice -->
|
||||
<div v-else class="text-center">
|
||||
<div v-if="invoiceWaiting && !invoiceData" class="py-10 flex flex-col items-center gap-3">
|
||||
<svg class="w-7 h-7 animate-spin text-white/80" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
<span class="text-sm text-white/70">Requesting invoice from seller…</span>
|
||||
</div>
|
||||
|
||||
<div v-else-if="invoiceData">
|
||||
<div v-if="invoiceQr" class="bg-white rounded-xl p-3 inline-block mb-3">
|
||||
<img :src="invoiceQr" alt="Lightning invoice QR" class="w-48 h-48" />
|
||||
</div>
|
||||
<p class="text-sm text-white mb-1">{{ invoiceData.price_sats }} sats</p>
|
||||
<p class="text-xs text-white/50 mb-3 flex items-center justify-center gap-2">
|
||||
<svg class="w-3.5 h-3.5 animate-spin text-amber-400" fill="none" viewBox="0 0 24 24">
|
||||
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4" />
|
||||
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.4 0 0 5.4 0 12h4z" />
|
||||
</svg>
|
||||
Waiting for payment…
|
||||
</p>
|
||||
<div class="flex items-center gap-2 bg-black/40 rounded-lg px-2 py-1.5">
|
||||
<code class="text-[10px] text-white/60 truncate flex-1 text-left">{{ invoiceData.bolt11 }}</code>
|
||||
<button class="text-xs text-white/60 hover:text-white shrink-0" @click="copyInvoice">
|
||||
{{ invoiceCopied ? 'Copied!' : 'Copy' }}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<p v-if="invoiceError" class="text-sm text-red-400 mt-3">{{ invoiceError }}</p>
|
||||
<button
|
||||
v-if="invoiceError"
|
||||
class="glass-button px-4 py-2 rounded-lg text-sm mt-3"
|
||||
@click="payMode = 'choose'"
|
||||
>
|
||||
Back
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Transition>
|
||||
</Teleport>
|
||||
</div>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed, reactive, watch, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import QRCode from 'qrcode'
|
||||
import { rpcClient } from '@/api/rpc-client'
|
||||
import { useAudioPlayer } from '@/composables/useAudioPlayer'
|
||||
import BackButton from '@/components/BackButton.vue'
|
||||
|
||||
const props = defineProps<{
|
||||
peerId?: string
|
||||
@@ -335,6 +448,23 @@ const transportPill = computed(() => {
|
||||
const previewUrls = reactive<Record<string, string>>({})
|
||||
const audioPlayer = useAudioPlayer()
|
||||
|
||||
// ─── Payment picker (#46) ────────────────────────────────────────────────
|
||||
// When buying a paid item the user chooses how to pay: their local ecash
|
||||
// wallet (instant), or a Lightning invoice drawn on the SELLER's node that
|
||||
// they can pay from any external wallet by scanning a QR.
|
||||
const payItem = ref<CatalogItem | null>(null)
|
||||
const payMode = ref<'choose' | 'invoice'>('choose')
|
||||
const invoiceData = ref<{ bolt11: string; payment_hash: string; price_sats: number } | null>(null)
|
||||
const invoiceQr = ref('')
|
||||
const invoiceWaiting = ref(false)
|
||||
const invoiceError = ref('')
|
||||
const invoiceCopied = ref(false)
|
||||
const lnPaying = ref(false)
|
||||
const lnError = ref('')
|
||||
const onchainPaying = ref(false)
|
||||
let onchainPollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
let invoicePollTimer: ReturnType<typeof setTimeout> | null = null
|
||||
|
||||
// Video player modal state
|
||||
const videoPlayerItem = ref<CatalogItem | null>(null)
|
||||
const videoPlayerUrl = ref<string | null>(null)
|
||||
@@ -496,54 +626,30 @@ function getItemPrice(access: CatalogItem['access']): number {
|
||||
async function downloadFile(item: CatalogItem) {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!onion) return
|
||||
downloading.value = item.id
|
||||
purchaseError.value = null
|
||||
|
||||
const price = getItemPrice(item.access)
|
||||
if (price > 0) {
|
||||
// Let the buyer choose how to pay (local ecash vs external-wallet QR).
|
||||
openPayModal(item)
|
||||
return
|
||||
}
|
||||
|
||||
// Free / peers-only download: stream straight from the Range-capable proxy
|
||||
// (B3) to disk instead of pulling the whole file as a base64 blob over RPC.
|
||||
// The old base64-over-RPC path buffered the entire file in node memory AND
|
||||
// the browser, which failed outright for large files (#38). A tiny probe
|
||||
// first surfaces the real server error (peer unreachable on mesh and Tor)
|
||||
// instead of a generic failure (#30).
|
||||
downloading.value = item.id
|
||||
try {
|
||||
const price = getItemPrice(item.access)
|
||||
|
||||
if (price > 0) {
|
||||
// Check ecash balance first
|
||||
try {
|
||||
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
|
||||
method: 'wallet.ecash-balance',
|
||||
})
|
||||
const balance = balanceRes?.balance_sats ?? 0
|
||||
if (balance < price) {
|
||||
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet first.`
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Balance check failed — try the purchase anyway
|
||||
}
|
||||
|
||||
// Paid download: mint ecash + download atomically
|
||||
const result = await rpcClient.call<{ data?: string; error?: string; price_sats?: number }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion, content_id: item.id, price_sats: price },
|
||||
timeout: 120000,
|
||||
})
|
||||
|
||||
if (result?.data) {
|
||||
triggerDownload(result.data, item)
|
||||
} else if (result?.error) {
|
||||
purchaseError.value = `Payment failed: ${result.error}`
|
||||
}
|
||||
} else {
|
||||
// Free / peers-only download: stream straight from the Range-capable
|
||||
// proxy (B3) to disk instead of pulling the whole file as a base64 blob
|
||||
// over RPC. The old base64-over-RPC path buffered the entire file in node
|
||||
// memory AND the browser, which failed outright for large files (#38).
|
||||
// A tiny probe first surfaces the real server error (peer unreachable on
|
||||
// mesh and Tor) instead of a generic failure (#30).
|
||||
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
|
||||
const probe = await probePeerContent(streamUrl)
|
||||
if (probe !== true) {
|
||||
purchaseError.value = probe
|
||||
return
|
||||
}
|
||||
streamDownload(streamUrl, item)
|
||||
const streamUrl = `/api/peer-content/${encodeURIComponent(onion)}/${encodeURIComponent(item.id)}`
|
||||
const probe = await probePeerContent(streamUrl)
|
||||
if (probe !== true) {
|
||||
purchaseError.value = probe
|
||||
return
|
||||
}
|
||||
streamDownload(streamUrl, item)
|
||||
} catch (e: unknown) {
|
||||
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
||||
} finally {
|
||||
@@ -551,6 +657,280 @@ async function downloadFile(item: CatalogItem) {
|
||||
}
|
||||
}
|
||||
|
||||
function openPayModal(item: CatalogItem) {
|
||||
payItem.value = item
|
||||
payMode.value = 'choose'
|
||||
invoiceData.value = null
|
||||
invoiceQr.value = ''
|
||||
invoiceWaiting.value = false
|
||||
invoiceError.value = ''
|
||||
invoiceCopied.value = false
|
||||
lnPaying.value = false
|
||||
lnError.value = ''
|
||||
onchainPaying.value = false
|
||||
}
|
||||
|
||||
function closePayModal() {
|
||||
if (invoicePollTimer) { clearTimeout(invoicePollTimer); invoicePollTimer = null }
|
||||
if (onchainPollTimer) { clearTimeout(onchainPollTimer); onchainPollTimer = null }
|
||||
payItem.value = null
|
||||
invoiceWaiting.value = false
|
||||
onchainPaying.value = false
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay on-chain from THIS node's wallet: ask the seller for a fresh address +
|
||||
* amount, broadcast with lnd.sendcoins, then poll the seller until it detects
|
||||
* the payment and release the file (address is the gate token). Slower than LN
|
||||
* because the seller waits for the tx to appear/confirm.
|
||||
*/
|
||||
async function payOnchain() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion || onchainPaying.value) return
|
||||
|
||||
onchainPaying.value = true
|
||||
lnError.value = ''
|
||||
try {
|
||||
const req = await rpcClient.call<{ address?: string; amount_sats?: number; error?: string }>({
|
||||
method: 'content.request-onchain',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!req?.address || !req?.amount_sats) {
|
||||
lnError.value = req?.error || 'The seller could not provide an on-chain address.'
|
||||
onchainPaying.value = false
|
||||
return
|
||||
}
|
||||
const addr = req.address
|
||||
const send = await rpcClient.call<{ txid?: string; error?: string }>({
|
||||
method: 'lnd.sendcoins',
|
||||
params: { addr, amount: req.amount_sats },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!send?.txid) {
|
||||
lnError.value = send?.error || 'On-chain send failed (insufficient on-chain balance?).'
|
||||
onchainPaying.value = false
|
||||
return
|
||||
}
|
||||
// Broadcast — now wait for the seller to see it and release.
|
||||
pollOnchain(addr)
|
||||
} catch (e: unknown) {
|
||||
lnError.value = e instanceof Error ? e.message : 'Could not pay on-chain'
|
||||
onchainPaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
async function pollOnchain(address: string) {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) { onchainPaying.value = false; return }
|
||||
try {
|
||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
||||
method: 'content.onchain-status',
|
||||
params: { onion, content_id: item.id, address },
|
||||
timeout: 30000,
|
||||
})
|
||||
if (res?.paid) {
|
||||
const dl = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-onchain',
|
||||
params: { onion, content_id: item.id, address },
|
||||
timeout: 120000,
|
||||
})
|
||||
onchainPaying.value = false
|
||||
if (dl?.data) {
|
||||
triggerDownload(dl.data, item)
|
||||
closePayModal()
|
||||
} else {
|
||||
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// transient — keep polling
|
||||
}
|
||||
if (payItem.value && onchainPaying.value) {
|
||||
onchainPollTimer = setTimeout(() => pollOnchain(address), 5000)
|
||||
}
|
||||
}
|
||||
|
||||
/** Pay the open item from the local ecash wallet (mint token + download). */
|
||||
async function payWithEcash() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
const price = getItemPrice(item.access)
|
||||
|
||||
downloading.value = item.id
|
||||
purchaseError.value = null
|
||||
try {
|
||||
// Check ecash balance first
|
||||
try {
|
||||
const balanceRes = await rpcClient.call<{ balance_sats?: number }>({
|
||||
method: 'wallet.ecash-balance',
|
||||
})
|
||||
const balance = balanceRes?.balance_sats ?? 0
|
||||
if (balance < price) {
|
||||
purchaseError.value = `Insufficient ecash balance (${balance} sats). Need ${price} sats. Fund your wallet, or pay from another wallet via QR.`
|
||||
closePayModal()
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Balance check failed — try the purchase anyway
|
||||
}
|
||||
|
||||
const result = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-paid',
|
||||
params: { onion, content_id: item.id, price_sats: price },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (result?.data) {
|
||||
triggerDownload(result.data, item)
|
||||
closePayModal()
|
||||
} else if (result?.error) {
|
||||
purchaseError.value = `Payment failed: ${result.error}`
|
||||
closePayModal()
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
purchaseError.value = e instanceof Error ? e.message : 'Download failed'
|
||||
closePayModal()
|
||||
} finally {
|
||||
downloading.value = null
|
||||
}
|
||||
}
|
||||
|
||||
/** Ask the seller for a Lightning invoice, render a QR, and poll for payment. */
|
||||
async function payWithInvoice() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion) return
|
||||
|
||||
payMode.value = 'invoice'
|
||||
invoiceError.value = ''
|
||||
invoiceWaiting.value = true
|
||||
try {
|
||||
const res = await rpcClient.call<{ bolt11?: string; payment_hash?: string; price_sats?: number; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!res?.bolt11 || !res?.payment_hash) {
|
||||
invoiceError.value = res?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
invoiceWaiting.value = false
|
||||
return
|
||||
}
|
||||
invoiceData.value = { bolt11: res.bolt11, payment_hash: res.payment_hash, price_sats: res.price_sats ?? getItemPrice(item.access) }
|
||||
try {
|
||||
invoiceQr.value = await QRCode.toDataURL(res.bolt11.toUpperCase(), { margin: 1, width: 240 })
|
||||
} catch {
|
||||
invoiceQr.value = '' // fall back to showing the raw invoice string
|
||||
}
|
||||
scheduleInvoicePoll()
|
||||
} catch (e: unknown) {
|
||||
invoiceError.value = e instanceof Error ? e.message : 'Could not request an invoice'
|
||||
invoiceWaiting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Pay the seller's invoice straight from THIS node's Lightning wallet, then
|
||||
* release the file. No QR/polling: lnd.payinvoice only returns once the payment
|
||||
* settles, so the payment_hash is immediately valid as the download gate token.
|
||||
*/
|
||||
async function payWithLightning() {
|
||||
const item = payItem.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !onion || lnPaying.value) return
|
||||
|
||||
lnPaying.value = true
|
||||
lnError.value = ''
|
||||
try {
|
||||
// 1. Ask the seller to mint a bolt11 (also records the pending entitlement).
|
||||
const inv = await rpcClient.call<{ bolt11?: string; payment_hash?: string; error?: string }>({
|
||||
method: 'content.request-invoice',
|
||||
params: { onion, content_id: item.id },
|
||||
timeout: 60000,
|
||||
})
|
||||
if (!inv?.bolt11 || !inv?.payment_hash) {
|
||||
lnError.value = inv?.error || 'The seller could not create an invoice (is its Lightning node running?).'
|
||||
return
|
||||
}
|
||||
// 2. Pay it from our own node. Returns only after settlement.
|
||||
const pay = await rpcClient.call<{ payment_hash?: string; payment_error?: string }>({
|
||||
method: 'lnd.payinvoice',
|
||||
params: { payment_request: inv.bolt11 },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (pay?.payment_error) {
|
||||
lnError.value = `Payment failed: ${pay.payment_error}`
|
||||
return
|
||||
}
|
||||
// 3. Settled — pull the file using the payment hash as the gate token.
|
||||
const dl = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (dl?.data) {
|
||||
triggerDownload(dl.data, item)
|
||||
closePayModal()
|
||||
} else {
|
||||
lnError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
} catch (e: unknown) {
|
||||
lnError.value = e instanceof Error ? e.message : 'Could not pay from your Lightning node'
|
||||
} finally {
|
||||
lnPaying.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function scheduleInvoicePoll() {
|
||||
if (invoicePollTimer) clearTimeout(invoicePollTimer)
|
||||
invoicePollTimer = setTimeout(pollInvoice, 3000)
|
||||
}
|
||||
|
||||
async function pollInvoice() {
|
||||
const item = payItem.value
|
||||
const inv = invoiceData.value
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
if (!item || !inv || !onion) return
|
||||
try {
|
||||
const res = await rpcClient.call<{ paid?: boolean }>({
|
||||
method: 'content.invoice-status',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 30000,
|
||||
})
|
||||
if (res?.paid) {
|
||||
// Settled — pull the file using the payment hash as the gate token.
|
||||
invoiceWaiting.value = false
|
||||
const dl = await rpcClient.call<{ data?: string; error?: string }>({
|
||||
method: 'content.download-peer-invoice',
|
||||
params: { onion, content_id: item.id, payment_hash: inv.payment_hash },
|
||||
timeout: 120000,
|
||||
})
|
||||
if (dl?.data) {
|
||||
triggerDownload(dl.data, item)
|
||||
closePayModal()
|
||||
} else {
|
||||
invoiceError.value = dl?.error || 'Paid, but the download failed. Try again shortly.'
|
||||
}
|
||||
return
|
||||
}
|
||||
} catch {
|
||||
// Transient — keep polling.
|
||||
}
|
||||
if (payItem.value && invoiceData.value) scheduleInvoicePoll()
|
||||
}
|
||||
|
||||
async function copyInvoice() {
|
||||
if (!invoiceData.value) return
|
||||
try {
|
||||
await navigator.clipboard.writeText(invoiceData.value.bolt11)
|
||||
invoiceCopied.value = true
|
||||
setTimeout(() => { invoiceCopied.value = false }, 2000)
|
||||
} catch { /* clipboard blocked */ }
|
||||
}
|
||||
|
||||
/** Play audio/video inline. For free items, downloads full file; for paid, uses the preview. */
|
||||
async function playMedia(item: CatalogItem) {
|
||||
const onion = props.peerId || currentPeer.value?.onion
|
||||
|
||||
Reference in New Issue
Block a user