Add sponsors system with time slider, LNbits invoices, and UX improvements

- 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
This commit is contained in:
Michilis
2026-03-16 00:01:19 +00:00
parent ac9b8dc330
commit dc7007f708
30 changed files with 3123 additions and 68 deletions

View File

@@ -0,0 +1,98 @@
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>
);
}