2026-01-15 02:45:27 +01:00

258 lines
8.2 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 { Card, Modal, Button } from './ui'
import { useToast } from './ui/ToastContainer'
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>
<Card variant="default" className="bg-cyber-darker border-neon-cyan/20 p-3 break-all text-sm font-mono mb-4 text-neon-cyan">
{invoiceText}
</Card>
<div className="flex justify-center mb-4">
<Card variant="default" className="bg-cyber-dark p-4 border-2 border-neon-cyan/30">
<QRCode
value={paymentUrl}
size={200}
style={{ height: 'auto', maxWidth: '100%', width: '100%' }}
viewBox="0 0 256 256"
/>
</Card>
</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 (
<Card variant="default" className="mt-4 p-3 bg-red-900/20 border-red-400/50">
<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>
</Card>
)
}
type PaymentModalState = {
copied: boolean
errorMessage: string | null
paymentUrl: string
timeRemaining: number | null
handleCopy: () => Promise<void>
handleOpenWallet: () => Promise<void>
}
function usePaymentModalState(invoice: AlbyInvoice, onPaymentComplete: () => void, showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined): 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, showToast: showToast ?? undefined }),
[invoice.invoice, showToast]
)
const handleOpenWallet = useCallback(
(): Promise<void> => openWalletForInvoice({ invoice: invoice.invoice, onPaymentComplete, setErrorMessage, showToast: showToast ?? undefined }),
[invoice.invoice, onPaymentComplete, showToast]
)
return { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet }
}
async function copyInvoiceToClipboard(params: {
invoice: string
setCopied: (value: boolean) => void
setErrorMessage: (value: string | null) => void
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> {
try {
await navigator.clipboard.writeText(params.invoice)
params.setCopied(true)
if (params.showToast !== undefined) {
params.showToast(t('payment.modal.copySuccess'), 'success', 2000)
}
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
showToast: ((message: string, variant?: 'success' | 'info' | 'warning' | 'error', duration?: number) => void) | undefined
}): Promise<void> {
try {
await payWithWebLN(params.invoice)
if (params.showToast !== undefined) {
params.showToast(t('payment.modal.paymentInitiated'), 'success')
}
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 { showToast } = useToast()
const { copied, errorMessage, paymentUrl, timeRemaining, handleCopy, handleOpenWallet } =
usePaymentModalState(invoice, onPaymentComplete, showToast)
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>
)
}