- Updated the BTCPay service to support internal Lightning invoices with private route hints, improving payment routing for users with private channels. - Added reconciliation methods for pending rents and subscriptions to ensure missed payments are processed on startup. - Enhanced the rental and subscription services to handle payments in satoshis, aligning with Lightning Network standards. - Improved the rental modal and content detail components to display rental status and pricing more clearly, including a countdown for rental expiration. - Refactored various components to streamline user experience and ensure accurate rental access checks.
516 lines
17 KiB
Vue
516 lines
17 KiB
Vue
<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>
|
|
|
|
<!-- ─── PLAN SELECTION STATE ─── -->
|
|
<template v-if="paymentState === 'select'">
|
|
<!-- Header -->
|
|
<div class="text-center mb-8">
|
|
<h2 class="text-3xl font-bold text-white mb-2">Choose Your Plan</h2>
|
|
<p class="text-white/60">Unlimited streaming. Pay with Lightning.</p>
|
|
</div>
|
|
|
|
<!-- Period Toggle -->
|
|
<div class="flex justify-center mb-8">
|
|
<div class="inline-flex rounded-xl bg-white/5 p-1">
|
|
<button
|
|
@click="period = 'monthly'"
|
|
:class="[
|
|
'px-6 py-2 rounded-lg font-medium transition-all',
|
|
period === 'monthly'
|
|
? 'bg-white text-black'
|
|
: 'text-white/60 hover:text-white'
|
|
]"
|
|
>
|
|
1 Month
|
|
</button>
|
|
<button
|
|
@click="period = 'yearly'"
|
|
:class="[
|
|
'px-6 py-2 rounded-lg font-medium transition-all flex items-center gap-2',
|
|
period === 'yearly'
|
|
? 'bg-white text-black'
|
|
: 'text-white/60 hover:text-white'
|
|
]"
|
|
>
|
|
1 Year
|
|
<span class="text-xs bg-orange-500 text-white px-2 py-0.5 rounded-full">Save 17%</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Error Message -->
|
|
<div v-if="errorMessage" class="mb-4 p-3 rounded-lg bg-red-500/20 border border-red-500/30 text-red-400 text-sm">
|
|
{{ errorMessage }}
|
|
</div>
|
|
|
|
<!-- Subscription Tiers -->
|
|
<div class="grid md:grid-cols-3 gap-4 mb-6">
|
|
<div
|
|
v-for="tier in tiers"
|
|
:key="tier.tier"
|
|
:class="[
|
|
'tier-card',
|
|
selectedTier === tier.tier && 'selected'
|
|
]"
|
|
@click="selectedTier = tier.tier"
|
|
>
|
|
<h3 class="text-xl font-bold text-white mb-2">{{ tier.name }}</h3>
|
|
<div class="mb-4 flex items-baseline gap-1">
|
|
<svg class="w-5 h-5 text-yellow-500 self-center" viewBox="0 0 24 24" fill="currentColor"><path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/></svg>
|
|
<span class="text-3xl font-bold text-white">
|
|
{{ (period === 'monthly' ? tier.monthlyPrice : tier.annualPrice).toLocaleString() }}
|
|
</span>
|
|
<span class="text-white/60 text-sm">
|
|
sats / {{ period === 'monthly' ? '1 month' : '1 year' }}
|
|
</span>
|
|
</div>
|
|
|
|
<ul class="space-y-2 text-sm text-white/80">
|
|
<li v-for="feature in tier.features" :key="feature" class="flex items-start gap-2">
|
|
<svg class="w-5 h-5 text-green-400 flex-shrink-0" fill="currentColor" viewBox="0 0 20 20">
|
|
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 10.586 7.707 9.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd" />
|
|
</svg>
|
|
{{ feature }}
|
|
</li>
|
|
</ul>
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Subscribe Button -->
|
|
<button
|
|
@click="handleSubscribe"
|
|
:disabled="isLoading || !selectedTier"
|
|
class="hero-play-button w-full flex items-center justify-center"
|
|
>
|
|
<svg v-if="!isLoading" class="w-5 h-5 mr-2" viewBox="0 0 24 24" fill="currentColor">
|
|
<path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/>
|
|
</svg>
|
|
<span v-if="!isLoading">Pay with Lightning — {{ Number(selectedPrice).toLocaleString() }} sats</span>
|
|
<span v-else>Creating invoice...</span>
|
|
</button>
|
|
|
|
<p class="text-center text-xs text-white/40 mt-4">
|
|
One-time payment. Renew manually when your plan expires.
|
|
</p>
|
|
</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/60 text-sm mb-1">
|
|
{{ selectedTierName }} — {{ period === 'monthly' ? '1 Month' : '1 Year' }}
|
|
</p>
|
|
<p class="text-white/40 text-xs mb-6">
|
|
Scan the QR code or copy the invoice to pay
|
|
</p>
|
|
|
|
<!-- QR Code -->
|
|
<div class="bg-white rounded-2xl p-4 inline-block mb-6">
|
|
<img v-if="qrCodeDataUrl" :src="qrCodeDataUrl" alt="Lightning Invoice QR Code" class="w-64 h-64" />
|
|
<div v-else class="w-64 h-64 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-yellow-500" viewBox="0 0 24 24" fill="currentColor"><path d="M13 3l-2 7h5l-6 11 2-7H7l6-11z"/></svg>
|
|
{{ displaySats }} sats
|
|
</div>
|
|
</div>
|
|
|
|
<!-- Expiration Countdown -->
|
|
<div class="mb-6 flex items-center justify-center gap-2 text-sm" :class="countdownSeconds <= 60 ? 'text-red-400' : 'text-white/60'">
|
|
<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="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
<span>Expires in {{ formatCountdown(countdownSeconds) }}</span>
|
|
</div>
|
|
|
|
<!-- Copy BOLT11 Button -->
|
|
<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-orange-500 hover:text-orange-400 text-sm font-medium transition-colors mb-4"
|
|
>
|
|
Open in wallet app
|
|
</a>
|
|
|
|
<p class="text-xs text-white/40">
|
|
Waiting for payment confirmation...
|
|
</p>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ─── SUCCESS STATE ─── -->
|
|
<template v-if="paymentState === 'success'">
|
|
<div class="text-center py-8">
|
|
<div class="w-20 h-20 bg-green-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
<svg class="w-10 h-10 text-green-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M5 13l4 4L19 7" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="text-2xl font-bold text-white mb-2">Subscription Active!</h2>
|
|
<p class="text-white/60 mb-6">
|
|
Welcome to {{ selectedTierName }}. Enjoy unlimited streaming!
|
|
</p>
|
|
<button
|
|
@click="handleSuccess"
|
|
class="hero-play-button w-full flex items-center justify-center"
|
|
>
|
|
Start Watching
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
<!-- ─── EXPIRED STATE ─── -->
|
|
<template v-if="paymentState === 'expired'">
|
|
<div class="text-center py-8">
|
|
<div class="w-20 h-20 bg-yellow-500/20 rounded-full flex items-center justify-center mx-auto mb-6">
|
|
<svg class="w-10 h-10 text-yellow-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
|
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 8v4l3 3m6-3a9 9 0 11-18 0 9 9 0 0118 0z" />
|
|
</svg>
|
|
</div>
|
|
<h2 class="text-2xl font-bold text-white mb-2">Invoice Expired</h2>
|
|
<p class="text-white/60 mb-6">The payment window has closed. Please try again.</p>
|
|
<button
|
|
@click="resetAndRetry"
|
|
class="hero-play-button w-full flex items-center justify-center"
|
|
>
|
|
Try Again
|
|
</button>
|
|
</div>
|
|
</template>
|
|
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</Transition>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed, watch, onMounted, onUnmounted } from 'vue'
|
|
import QRCode from 'qrcode'
|
|
import { subscriptionService } from '../services/subscription.service'
|
|
import { USE_MOCK } from '../utils/mock'
|
|
|
|
interface Props {
|
|
isOpen: boolean
|
|
}
|
|
|
|
interface Emits {
|
|
(e: 'close'): void
|
|
(e: 'success'): void
|
|
}
|
|
|
|
const props = defineProps<Props>()
|
|
const emit = defineEmits<Emits>()
|
|
|
|
type PaymentState = 'select' | 'invoice' | 'success' | 'expired'
|
|
|
|
const paymentState = ref<PaymentState>('select')
|
|
const period = ref<'monthly' | 'yearly'>('monthly')
|
|
const selectedTier = ref<string>('film-buff')
|
|
const tiers = ref<any[]>([])
|
|
const isLoading = ref(false)
|
|
const errorMessage = ref<string | null>(null)
|
|
|
|
// Invoice data
|
|
const invoiceData = ref<any>(null)
|
|
const bolt11Invoice = ref('')
|
|
const qrCodeDataUrl = ref('')
|
|
const copyButtonText = ref('Copy Invoice')
|
|
|
|
// Countdown
|
|
const countdownSeconds = ref(0)
|
|
let countdownInterval: ReturnType<typeof setInterval> | null = null
|
|
let pollInterval: ReturnType<typeof setInterval> | null = null
|
|
|
|
// USE_MOCK imported from utils/mock
|
|
|
|
const selectedTierName = computed(() => {
|
|
const tier = tiers.value.find((t) => t.tier === selectedTier.value)
|
|
return tier?.name || ''
|
|
})
|
|
|
|
const selectedPrice = computed(() => {
|
|
const tier = tiers.value.find((t) => t.tier === selectedTier.value)
|
|
if (!tier) return '0'
|
|
return period.value === 'monthly' ? tier.monthlyPrice : tier.annualPrice
|
|
})
|
|
|
|
/**
|
|
* Display sats — prefer invoice source amount (from BTCPay), fall back to tier price.
|
|
*/
|
|
const displaySats = computed(() => {
|
|
const sourceAmount = invoiceData.value?.sourceAmount?.amount
|
|
if (sourceAmount) {
|
|
return formatSats(sourceAmount)
|
|
}
|
|
return Number(selectedPrice.value).toLocaleString()
|
|
})
|
|
|
|
onMounted(async () => {
|
|
tiers.value = await subscriptionService.getSubscriptionTiers()
|
|
})
|
|
|
|
onUnmounted(cleanup)
|
|
|
|
watch(() => props.isOpen, (newVal) => {
|
|
if (!newVal) {
|
|
cleanup()
|
|
paymentState.value = 'select'
|
|
errorMessage.value = null
|
|
}
|
|
})
|
|
|
|
function cleanup() {
|
|
if (countdownInterval) {
|
|
clearInterval(countdownInterval)
|
|
countdownInterval = null
|
|
}
|
|
if (pollInterval) {
|
|
clearInterval(pollInterval)
|
|
pollInterval = null
|
|
}
|
|
}
|
|
|
|
function closeModal() {
|
|
cleanup()
|
|
paymentState.value = 'select'
|
|
errorMessage.value = null
|
|
invoiceData.value = null
|
|
bolt11Invoice.value = ''
|
|
qrCodeDataUrl.value = ''
|
|
copyButtonText.value = 'Copy Invoice'
|
|
emit('close')
|
|
}
|
|
|
|
function formatSats(amount: string | undefined): string {
|
|
if (!amount) return '---'
|
|
const btcAmount = parseFloat(amount)
|
|
if (isNaN(btcAmount)) return amount
|
|
const sats = Math.round(btcAmount * 100_000_000)
|
|
return sats.toLocaleString()
|
|
}
|
|
|
|
function formatCountdown(seconds: number): string {
|
|
const mins = Math.floor(seconds / 60)
|
|
const secs = seconds % 60
|
|
return `${mins}:${secs.toString().padStart(2, '0')}`
|
|
}
|
|
|
|
async function generateQRCode(bolt11: string) {
|
|
try {
|
|
qrCodeDataUrl.value = await QRCode.toDataURL(bolt11.toUpperCase(), {
|
|
width: 256,
|
|
margin: 2,
|
|
color: { dark: '#000000', light: '#FFFFFF' },
|
|
errorCorrectionLevel: 'M',
|
|
})
|
|
} catch (err) {
|
|
console.error('QR Code generation failed:', err)
|
|
}
|
|
}
|
|
|
|
function startCountdown(expirationDate: Date | string) {
|
|
const expiry = new Date(expirationDate).getTime()
|
|
const updateCountdown = () => {
|
|
const remaining = Math.max(0, Math.floor((expiry - Date.now()) / 1000))
|
|
countdownSeconds.value = remaining
|
|
if (remaining <= 0) {
|
|
cleanup()
|
|
paymentState.value = 'expired'
|
|
}
|
|
}
|
|
updateCountdown()
|
|
countdownInterval = setInterval(updateCountdown, 1000)
|
|
}
|
|
|
|
async function handleSubscribe() {
|
|
if (!selectedTier.value) return
|
|
|
|
isLoading.value = true
|
|
errorMessage.value = null
|
|
|
|
try {
|
|
if (USE_MOCK) {
|
|
console.log('Development mode: Simulating Lightning subscription invoice')
|
|
console.log(`Subscribed to: ${selectedTierName.value} (${period.value})`)
|
|
await new Promise(resolve => setTimeout(resolve, 800))
|
|
|
|
const mockBolt11 = 'lnbc200u1pjxyz123456789abcdefghijklmnopqrstuvwxyz0123456789abcdefghijklmnop'
|
|
bolt11Invoice.value = mockBolt11
|
|
invoiceData.value = {
|
|
sourceAmount: { amount: '0.00020000', currency: 'BTC' },
|
|
expiration: new Date(Date.now() + 15 * 60 * 1000).toISOString(),
|
|
}
|
|
|
|
await generateQRCode(mockBolt11)
|
|
paymentState.value = 'invoice'
|
|
startCountdown(invoiceData.value.expiration)
|
|
|
|
// Simulate payment after 5 seconds in mock mode
|
|
setTimeout(() => {
|
|
if (paymentState.value === 'invoice') {
|
|
cleanup()
|
|
paymentState.value = 'success'
|
|
}
|
|
}, 5000)
|
|
return
|
|
}
|
|
|
|
// Real API call — create Lightning subscription invoice
|
|
const result = await subscriptionService.createLightningSubscription({
|
|
type: selectedTier.value as any,
|
|
period: period.value as 'monthly' | 'yearly',
|
|
})
|
|
|
|
invoiceData.value = result
|
|
bolt11Invoice.value = result.lnInvoice
|
|
|
|
await generateQRCode(result.lnInvoice)
|
|
paymentState.value = 'invoice'
|
|
startCountdown(result.expiration)
|
|
|
|
// Poll for payment confirmation
|
|
pollInterval = setInterval(async () => {
|
|
try {
|
|
const active = await subscriptionService.getActiveSubscription()
|
|
if (active) {
|
|
cleanup()
|
|
paymentState.value = 'success'
|
|
}
|
|
} catch {
|
|
// Silently retry
|
|
}
|
|
}, 3000)
|
|
} catch (error: any) {
|
|
errorMessage.value = error.message || 'Failed to create invoice. Please try again.'
|
|
} finally {
|
|
isLoading.value = false
|
|
}
|
|
}
|
|
|
|
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 handleSuccess() {
|
|
emit('success')
|
|
closeModal()
|
|
}
|
|
|
|
function resetAndRetry() {
|
|
cleanup()
|
|
paymentState.value = 'select'
|
|
errorMessage.value = null
|
|
invoiceData.value = null
|
|
bolt11Invoice.value = ''
|
|
qrCodeDataUrl.value = ''
|
|
}
|
|
</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: 1024px;
|
|
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;
|
|
}
|
|
|
|
.tier-card {
|
|
background: rgba(255, 255, 255, 0.05);
|
|
border: 2px solid rgba(255, 255, 255, 0.1);
|
|
border-radius: 16px;
|
|
padding: 24px;
|
|
cursor: pointer;
|
|
transition: all 0.3s ease;
|
|
}
|
|
|
|
.tier-card:hover {
|
|
background: rgba(255, 255, 255, 0.08);
|
|
border-color: rgba(255, 255, 255, 0.15);
|
|
transform: translateY(-2px);
|
|
}
|
|
|
|
.tier-card.selected {
|
|
background: rgba(247, 147, 26, 0.1);
|
|
border-color: #F7931A;
|
|
box-shadow: 0 0 20px rgba(247, 147, 26, 0.3);
|
|
}
|
|
|
|
/* 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>
|