- Sponsors table, LNbits createInvoice, webhook handler - Sponsor routes: create, homepage, list, my-ads, click, extend, check-payment - Admin routes for sponsor management - Frontend: SponsorForm, SponsorTimeSlider, SponsorCard, SponsorsSection - Sponsors page, My Ads page, homepage sponsor block - Header login dropdown with My Ads, Create Sponsor - Transactions integration for sponsor payments - View/click tracking - OG meta fetch for sponsor images - Sponsor modal spacing, invoice polling fallback - Remove Lightning address and Category fields from sponsor form Made-with: Cursor
99 lines
2.9 KiB
TypeScript
99 lines
2.9 KiB
TypeScript
import { useState, useEffect, useRef } from "react";
|
|
import QRCode from "qrcode";
|
|
import { Modal } from "./Modal";
|
|
import { getSponsorCheckPayment } from "../api";
|
|
|
|
const POLL_INTERVAL_MS = 10_000;
|
|
const INVOICE_EXPIRY_MS = 60 * 60 * 1000; // 1 hour
|
|
|
|
export interface PendingInvoice {
|
|
payment_hash: string;
|
|
payment_request: string;
|
|
price_sats: number;
|
|
duration_days: number;
|
|
}
|
|
|
|
interface SponsorInvoiceModalProps {
|
|
open: boolean;
|
|
onClose: () => void;
|
|
result: PendingInvoice | null;
|
|
/** Called when payment is detected (e.g. to refresh sponsor list). */
|
|
onPaid?: () => void;
|
|
title?: string;
|
|
}
|
|
|
|
export function SponsorInvoiceModal({ open, onClose, result, onPaid, title }: SponsorInvoiceModalProps) {
|
|
const [qrDataUrl, setQrDataUrl] = useState<string | null>(null);
|
|
const onPaidRef = useRef(onPaid);
|
|
const onCloseRef = useRef(onClose);
|
|
onPaidRef.current = onPaid;
|
|
onCloseRef.current = onClose;
|
|
|
|
useEffect(() => {
|
|
if (!result?.payment_request) {
|
|
setQrDataUrl(null);
|
|
return;
|
|
}
|
|
QRCode.toDataURL(result.payment_request, { width: 200 }).then(setQrDataUrl).catch(() => setQrDataUrl(null));
|
|
}, [result?.payment_request]);
|
|
|
|
useEffect(() => {
|
|
if (!open || !result?.payment_hash) return;
|
|
const openedAt = Date.now();
|
|
let cancelled = false;
|
|
|
|
const check = async () => {
|
|
if (cancelled) return;
|
|
if (Date.now() - openedAt > INVOICE_EXPIRY_MS) return;
|
|
try {
|
|
const { paid: isPaid } = await getSponsorCheckPayment(result!.payment_hash);
|
|
if (cancelled) return;
|
|
if (isPaid) {
|
|
onPaidRef.current?.();
|
|
onCloseRef.current();
|
|
}
|
|
} catch {
|
|
// Ignore; will retry on next poll
|
|
}
|
|
};
|
|
|
|
check();
|
|
const interval = setInterval(check, POLL_INTERVAL_MS);
|
|
return () => {
|
|
cancelled = true;
|
|
clearInterval(interval);
|
|
};
|
|
}, [open, result?.payment_hash]);
|
|
|
|
const handleCopy = () => {
|
|
if (!result?.payment_request) return;
|
|
navigator.clipboard.writeText(result.payment_request).then(() => {
|
|
// Could show a toast
|
|
});
|
|
};
|
|
|
|
if (!result) return null;
|
|
|
|
return (
|
|
<Modal open={open} onClose={onClose} title={title ?? "Pay to activate sponsor"} variant="sponsor">
|
|
<div className="sponsor-invoice">
|
|
<p className="sponsor-invoice-desc">
|
|
Scan or copy the Lightning invoice to pay <strong>{result.price_sats.toLocaleString()} sats</strong>.
|
|
Your sponsor will activate after payment.
|
|
</p>
|
|
{qrDataUrl && (
|
|
<div className="sponsor-invoice-qr">
|
|
<img src={qrDataUrl} alt="Lightning invoice QR" />
|
|
</div>
|
|
)}
|
|
<button type="button" className="sponsor-invoice-copy" onClick={handleCopy}>
|
|
Copy invoice
|
|
</button>
|
|
<p className="sponsor-invoice-note">
|
|
Duration: {result.duration_days} days.
|
|
</p>
|
|
</div>
|
|
</Modal>
|
|
);
|
|
}
|