2026-01-14 00:34:36 +01:00

246 lines
7.4 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, Button } from './ui'
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
variant="secondary"
onClick={() => {
void onCopy()
}}
className="flex-1"
>
{copied ? t('payment.modal.copied') : t('payment.modal.copyInvoice')}
</Button>
<Button
variant="primary"
onClick={onOpenWallet}
className="flex-1"
>
{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>
)
}