Fix Lightning payment UI not updating after LNbits invoice is paid.
Ensure the booking page receives payment confirmation via a streaming SSE proxy, parallel status polling, and more reliable backend event delivery. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -299,71 +299,75 @@ export default function BookingPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// Connect to SSE for real-time payment updates
|
||||
const connectPaymentStream = (ticketId: string) => {
|
||||
const apiUrl = process.env.NEXT_PUBLIC_API_URL || '';
|
||||
const eventSource = new EventSource(`${apiUrl}/api/lnbits/stream/${ticketId}`);
|
||||
|
||||
// Watch for Lightning payment confirmation while on the paying step.
|
||||
// SSE gives instant updates; a 3s poll runs in parallel as a safety net so a
|
||||
// buffered/stuck stream (e.g. a proxy that doesn't flush SSE) can't strand the UI.
|
||||
useEffect(() => {
|
||||
if (step !== 'paying' || !bookingResult?.ticketId) return;
|
||||
|
||||
const ticketId = bookingResult.ticketId;
|
||||
let settled = false;
|
||||
let pollTimer: ReturnType<typeof setTimeout> | null = null;
|
||||
|
||||
const confirmPaid = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
|
||||
setPaymentPending(false);
|
||||
setStep('success');
|
||||
};
|
||||
|
||||
const expire = () => {
|
||||
if (settled) return;
|
||||
settled = true;
|
||||
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
|
||||
setPaymentPending(false);
|
||||
};
|
||||
|
||||
// Always same-origin so the streaming proxy route handler is used (it
|
||||
// bypasses the rewrite, which buffers SSE).
|
||||
const eventSource = new EventSource(`/api/lnbits/stream/${ticketId}`);
|
||||
|
||||
eventSource.addEventListener('payment', (event) => {
|
||||
try {
|
||||
const data = JSON.parse(event.data);
|
||||
console.log('Payment event:', data);
|
||||
|
||||
if (data.type === 'paid') {
|
||||
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
|
||||
setPaymentPending(false);
|
||||
setStep('success');
|
||||
eventSource.close();
|
||||
const data = JSON.parse((event as MessageEvent).data);
|
||||
if (data.type === 'paid' || data.type === 'already_paid') {
|
||||
confirmPaid();
|
||||
} else if (data.type === 'expired') {
|
||||
toast.error(locale === 'es' ? 'La factura ha expirado' : 'Invoice has expired');
|
||||
setPaymentPending(false);
|
||||
eventSource.close();
|
||||
} else if (data.type === 'already_paid') {
|
||||
setPaymentPending(false);
|
||||
setStep('success');
|
||||
eventSource.close();
|
||||
expire();
|
||||
}
|
||||
} catch (e) {
|
||||
console.error('Error parsing payment event:', e);
|
||||
}
|
||||
});
|
||||
|
||||
eventSource.onerror = (error) => {
|
||||
console.error('SSE error:', error);
|
||||
// Fallback to polling if SSE fails
|
||||
|
||||
eventSource.onerror = () => {
|
||||
// SSE failed or was closed; the poll below remains the source of truth.
|
||||
eventSource.close();
|
||||
fallbackPoll(ticketId);
|
||||
};
|
||||
|
||||
return eventSource;
|
||||
};
|
||||
|
||||
// Fallback polling if SSE is not available
|
||||
const fallbackPoll = async (ticketId: string) => {
|
||||
const maxAttempts = 60;
|
||||
let attempts = 0;
|
||||
|
||||
|
||||
const poll = async () => {
|
||||
attempts++;
|
||||
try {
|
||||
const status = await ticketsApi.checkPaymentStatus(ticketId);
|
||||
if (status.isPaid) {
|
||||
toast.success(locale === 'es' ? '¡Pago confirmado!' : 'Payment confirmed!');
|
||||
setPaymentPending(false);
|
||||
setStep('success');
|
||||
confirmPaid();
|
||||
return;
|
||||
}
|
||||
} catch (error) {
|
||||
console.error('Error checking payment status:', error);
|
||||
}
|
||||
|
||||
if (attempts < maxAttempts && paymentPending) {
|
||||
setTimeout(poll, 5000);
|
||||
if (!settled) {
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
}
|
||||
};
|
||||
|
||||
poll();
|
||||
};
|
||||
pollTimer = setTimeout(poll, 3000);
|
||||
|
||||
return () => {
|
||||
settled = true;
|
||||
eventSource.close();
|
||||
if (pollTimer) clearTimeout(pollTimer);
|
||||
};
|
||||
}, [step, bookingResult?.ticketId, locale]);
|
||||
|
||||
// Copy invoice to clipboard
|
||||
const copyInvoiceToClipboard = (invoice: string) => {
|
||||
@@ -464,9 +468,7 @@ export default function BookingPage() {
|
||||
setBookingResult(result);
|
||||
setStep('paying');
|
||||
setPaymentPending(true);
|
||||
|
||||
// Connect to SSE for real-time payment updates
|
||||
connectPaymentStream(primaryTicket.id);
|
||||
// Payment confirmation is handled by the paying-step watcher effect.
|
||||
} else if (formData.paymentMethod === 'bank_transfer' || formData.paymentMethod === 'tpago') {
|
||||
// Manual payment methods - show payment details
|
||||
setBookingResult({
|
||||
|
||||
Reference in New Issue
Block a user