244 lines
7.7 KiB
TypeScript
244 lines
7.7 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'
|
|
import { Modal } from './ui/Modal'
|
|
import { t } from '@/lib/i18n'
|
|
|
|
interface PaymentModalProps {
|
|
invoice: AlbyInvoice
|
|
onClose: () => void
|
|
onPaymentComplete: () => void
|
|
}
|
|
|
|
function useInvoiceTimer(expiresAt?: number): number | null {
|
|
const [timeRemaining, setTimeRemaining] = useState<number | null>(null)
|
|
|
|
useEffect(() => {
|
|
if (!expiresAt) {
|
|
return
|
|
}
|
|
const updateTimeRemaining = (): void => {
|
|
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,
|
|
}: {
|
|
amount: number
|
|
timeRemaining: number | null
|
|
}): React.ReactElement {
|
|
const timeLabel = useMemo((): string | null => {
|
|
if (timeRemaining === null) {
|
|
return null
|
|
}
|
|
if (timeRemaining <= 0) {
|
|
return t('payment.expired')
|
|
}
|
|
const minutes = Math.floor(timeRemaining / 60)
|
|
const secs = timeRemaining % 60
|
|
return `${minutes}:${secs.toString().padStart(2, '0')}`
|
|
}, [timeRemaining])
|
|
|
|
return (
|
|
<div className="mb-4">
|
|
<h2 className="text-xl font-bold text-neon-cyan">{t('payment.modal.zapAmount', { amount })}</h2>
|
|
{timeLabel && (
|
|
<p className={`text-sm ${timeRemaining !== null && timeRemaining <= 60 ? 'text-red-400 font-semibold' : 'text-cyber-accent/70'}`}>
|
|
{t('payment.modal.timeRemaining', { time: timeLabel })}
|
|
</p>
|
|
)}
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function InvoiceDisplay({ invoiceText, paymentUrl }: { invoiceText: string; paymentUrl: string }): React.ReactElement {
|
|
return (
|
|
<div className="mb-4">
|
|
<p className="text-sm text-cyber-accent mb-2">{t('payment.modal.lightningInvoice')}</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">{t('payment.modal.scanQr')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function PaymentActions({
|
|
copied,
|
|
onCopy,
|
|
onOpenWallet,
|
|
}: {
|
|
copied: boolean
|
|
onCopy: () => Promise<void>
|
|
onOpenWallet: () => void
|
|
}): React.ReactElement {
|
|
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 ? t('payment.modal.copied') : t('payment.modal.copyInvoice')}
|
|
</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"
|
|
>
|
|
{t('payment.modal.payWithAlby')}
|
|
</button>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
function ExpiredNotice({ show }: { show: boolean }): React.ReactElement | null {
|
|
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">{t('payment.modal.invoiceExpired')}</p>
|
|
<p className="text-xs text-red-400/80">{t('payment.modal.invoiceExpiredHelp')}</p>
|
|
</div>
|
|
)
|
|
}
|
|
|
|
type PaymentModalState = {
|
|
copied: boolean
|
|
errorMessage: string | null
|
|
paymentUrl: string
|
|
timeRemaining: number | null
|
|
handleCopy: () => Promise<void>
|
|
handleOpenWallet: () => Promise<void>
|
|
}
|
|
|
|
function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void): PaymentModalState {
|
|
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(
|
|
(): Promise<void> => copyInvoiceToClipboard({ invoice: invoice.invoice, setCopied, setErrorMessage }),
|
|
[invoice.invoice]
|
|
)
|
|
|
|
const handleOpenWallet = useCallback(
|
|
(): Promise<void> => openWalletForInvoice({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage }),
|
|
[invoice.invoice, onPaymentComplete]
|
|
)
|
|
|
|
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
|
|
}
|
|
|
|
async function copyInvoiceToClipboard(params: {
|
|
invoice: string
|
|
setCopied: (value: boolean) => void
|
|
setErrorMessage: (value: string | null) => void
|
|
}): Promise<void> {
|
|
try {
|
|
await navigator.clipboard.writeText(params.invoice)
|
|
params.setCopied(true)
|
|
scheduleCopiedReset(params.setCopied)
|
|
} catch (e) {
|
|
console.error('Failed to copy:', e)
|
|
params.setErrorMessage(t('payment.modal.copyFailed'))
|
|
}
|
|
}
|
|
|
|
async function openWalletForInvoice(params: {
|
|
invoice: string
|
|
onPaymentComplete: () => void
|
|
setErrorMessage: (value: string | null) => void
|
|
}): Promise<void> {
|
|
try {
|
|
await payWithWebLN(params.invoice)
|
|
params.onPaymentComplete()
|
|
} catch (e) {
|
|
const error = normalizePaymentError(e)
|
|
if (isUserCancellationError(error)) {
|
|
return
|
|
}
|
|
console.error('Payment failed:', error)
|
|
params.setErrorMessage(error.message)
|
|
}
|
|
}
|
|
|
|
function normalizePaymentError(error: unknown): Error {
|
|
return error instanceof Error ? error : new Error(String(error))
|
|
}
|
|
|
|
function scheduleCopiedReset(setCopied: (value: boolean) => void): void {
|
|
setTimeout(() => setCopied(false), 2000)
|
|
}
|
|
|
|
function isUserCancellationError(error: Error): boolean {
|
|
return error.message.includes('user rejected') || error.message.includes('cancelled')
|
|
}
|
|
|
|
async function payWithWebLN(invoice: string): Promise<void> {
|
|
const alby = getAlbyService()
|
|
if (!isWebLNAvailable()) {
|
|
throw new Error(t('payment.modal.weblnNotAvailable'))
|
|
}
|
|
await alby.enable()
|
|
await alby.sendPayment(invoice)
|
|
}
|
|
|
|
export function PaymentModal({ invoice, onClose, onPaymentComplete }: PaymentModalProps): React.ReactElement {
|
|
const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } =
|
|
usePaymentModalState(invoice, onPaymentComplete)
|
|
const handleOpenWalletSync = (): void => {
|
|
void handleOpenWallet()
|
|
}
|
|
|
|
return (
|
|
<Modal
|
|
isOpen
|
|
onClose={onClose}
|
|
title={t('payment.modal.zapAmount', { amount: invoice.amount })}
|
|
size="small"
|
|
aria-label={t('payment.modal.zapAmount', { amount: invoice.amount })}
|
|
>
|
|
<AlbyInstaller />
|
|
<PaymentHeader amount={invoice.amount} timeRemaining={timeRemaining} />
|
|
<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">
|
|
{t('payment.modal.autoVerify')}
|
|
</p>
|
|
</Modal>
|
|
)
|
|
}
|