202 lines
6.6 KiB
TypeScript
202 lines
6.6 KiB
TypeScript
import { useEffect, useMemo, useState, useCallback } from 'react'
|
||
import QRCode from 'react-qr-code'
|
||
import type { AlbyInvoice } from '@/types/alby'
|
||
import { getAlbyService, isWebLNAvailable } from '@/lib/alby'
|
||
import { AlbyInstaller } from './AlbyInstaller'
|
||
|
||
interface PaymentModalProps {
|
||
invoice: AlbyInvoice
|
||
onClose: () => void
|
||
onPaymentComplete: () => void
|
||
}
|
||
|
||
function useInvoiceTimer(expiresAt?: number) {
|
||
const [timeRemaining, setTimeRemaining] = useState<number | null>(null)
|
||
|
||
useEffect(() => {
|
||
if (!expiresAt) {
|
||
return
|
||
}
|
||
const updateTimeRemaining = () => {
|
||
const now = Math.floor(Date.now() / 1000)
|
||
const remaining = expiresAt - now
|
||
setTimeRemaining(remaining > 0 ? remaining : 0)
|
||
}
|
||
updateTimeRemaining()
|
||
const interval = setInterval(updateTimeRemaining, 1000)
|
||
return () => clearInterval(interval)
|
||
}, [expiresAt])
|
||
|
||
return timeRemaining
|
||
}
|
||
|
||
function PaymentHeader({
|
||
amount,
|
||
timeRemaining,
|
||
onClose,
|
||
}: {
|
||
amount: number
|
||
timeRemaining: number | null
|
||
onClose: () => void
|
||
}) {
|
||
const timeLabel = useMemo(() => {
|
||
if (timeRemaining === null) {
|
||
return null
|
||
}
|
||
if (timeRemaining <= 0) {
|
||
return 'Expired'
|
||
}
|
||
const minutes = Math.floor(timeRemaining / 60)
|
||
const secs = timeRemaining % 60
|
||
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
||
}, [timeRemaining])
|
||
|
||
return (
|
||
<div className="flex justify-between items-center mb-4">
|
||
<div>
|
||
<h2 className="text-xl font-bold text-neon-cyan">Zap de {amount} sats</h2>
|
||
{timeLabel && (
|
||
<p className={`text-sm ${timeRemaining !== null && timeRemaining <= 60 ? 'text-red-400 font-semibold' : 'text-cyber-accent/70'}`}>
|
||
Time remaining: {timeLabel}
|
||
</p>
|
||
)}
|
||
</div>
|
||
<button onClick={onClose} className="text-cyber-accent hover:text-neon-cyan text-2xl transition-colors">
|
||
×
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function InvoiceDisplay({ invoiceText, paymentUrl }: { invoiceText: string; paymentUrl: string }) {
|
||
return (
|
||
<div className="mb-4">
|
||
<p className="text-sm text-cyber-accent mb-2">Lightning Invoice:</p>
|
||
<div className="bg-cyber-darker border border-neon-cyan/20 p-3 rounded break-all text-sm font-mono mb-4 text-neon-cyan">{invoiceText}</div>
|
||
<div className="flex justify-center mb-4">
|
||
<div className="bg-cyber-dark p-4 rounded-lg border-2 border-neon-cyan/30">
|
||
<QRCode
|
||
value={paymentUrl}
|
||
size={200}
|
||
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
|
||
viewBox="0 0 256 256"
|
||
/>
|
||
</div>
|
||
</div>
|
||
<p className="text-xs text-cyber-accent/70 text-center mb-2">Scan with your Lightning wallet to pay</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function PaymentActions({
|
||
copied,
|
||
onCopy,
|
||
onOpenWallet,
|
||
}: {
|
||
copied: boolean
|
||
onCopy: () => Promise<void>
|
||
onOpenWallet: () => void
|
||
}) {
|
||
return (
|
||
<div className="flex gap-2">
|
||
<button
|
||
onClick={() => {
|
||
void onCopy()
|
||
}}
|
||
className="flex-1 px-4 py-2 bg-cyber-light border border-neon-cyan/30 hover:border-neon-cyan/50 hover:bg-cyber-dark text-cyber-accent hover:text-neon-cyan rounded-lg font-medium transition-colors"
|
||
>
|
||
{copied ? 'Copied!' : 'Copy Invoice'}
|
||
</button>
|
||
<button
|
||
onClick={onOpenWallet}
|
||
className="flex-1 px-4 py-2 bg-neon-cyan/20 border border-neon-cyan/50 hover:bg-neon-cyan/30 hover:border-neon-cyan text-neon-cyan hover:text-neon-green rounded-lg font-medium transition-colors"
|
||
>
|
||
Pay with Alby
|
||
</button>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function ExpiredNotice({ show }: { show: boolean }) {
|
||
if (!show) {
|
||
return null
|
||
}
|
||
return (
|
||
<div className="mt-4 p-3 bg-red-900/20 border border-red-400/50 rounded-lg">
|
||
<p className="text-sm text-red-400 font-semibold mb-2">This invoice has expired</p>
|
||
<p className="text-xs text-red-400/80">Please close this modal and try again to generate a new invoice.</p>
|
||
</div>
|
||
)
|
||
}
|
||
|
||
function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void) {
|
||
const [copied, setCopied] = useState(false)
|
||
const [errorMessage, setErrorMessage] = useState<string | null>(null)
|
||
const paymentUrl = `lightning:${invoice.invoice}`
|
||
const timeRemaining = useInvoiceTimer(invoice.expiresAt)
|
||
|
||
const handleCopy = useCallback(async () => {
|
||
try {
|
||
await navigator.clipboard.writeText(invoice.invoice)
|
||
setCopied(true)
|
||
setTimeout(() => setCopied(false), 2000)
|
||
} catch (e) {
|
||
console.error('Failed to copy:', e)
|
||
setErrorMessage('Failed to copy the invoice')
|
||
}
|
||
}, [invoice.invoice])
|
||
|
||
const handleOpenWallet = useCallback(async () => {
|
||
try {
|
||
const alby = getAlbyService()
|
||
if (!isWebLNAvailable()) {
|
||
throw new Error('WebLN is not available. Please install Alby or another Lightning wallet extension.')
|
||
}
|
||
await alby.enable()
|
||
await alby.sendPayment(invoice.invoice)
|
||
onPaymentComplete()
|
||
} catch (e) {
|
||
const error = e instanceof Error ? e : new Error(String(e))
|
||
if (error.message.includes('user rejected') || error.message.includes('cancelled')) {
|
||
return
|
||
}
|
||
console.error('Payment failed:', error)
|
||
setErrorMessage(error.message)
|
||
}
|
||
}, [invoice.invoice, onPaymentComplete])
|
||
|
||
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
|
||
}
|
||
|
||
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps) {
|
||
const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } =
|
||
usePaymentModalState(invoice, onPaymentComplete)
|
||
const handleOpenWalletSync = () => {
|
||
void handleOpenWallet()
|
||
}
|
||
|
||
return (
|
||
<div className="fixed inset-0 bg-black bg-opacity-50 flex items-center justify-center z-50">
|
||
<div className="bg-cyber-dark border border-neon-cyan/30 rounded-lg p-6 max-w-md w-full mx-4 max-h-[90vh] overflow-y-auto shadow-glow-cyan">
|
||
<AlbyInstaller />
|
||
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} onClose={onClose} />
|
||
<InvoiceDisplay invoiceText={invoice.invoice} paymentUrl={paymentUrl} />
|
||
<PaymentActions
|
||
copied={copied}
|
||
onCopy={handleCopy}
|
||
onOpenWallet={handleOpenWalletSync}
|
||
/>
|
||
<ExpiredNotice show={timeRemaining !== null && timeRemaining <= 0} />
|
||
{errorMessage && (
|
||
<p className="text-xs text-red-400 mt-3 text-center" role="alert">
|
||
{errorMessage}
|
||
</p>
|
||
)}
|
||
<p className="text-xs text-cyber-accent/70 mt-4 text-center">
|
||
Payment will be automatically verified once completed
|
||
</p>
|
||
</div>
|
||
</div>
|
||
)
|
||
}
|