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

118
backend/src/routes/admin.ts Normal file
View File

@@ -0,0 +1,118 @@
import { Router, Request, Response, NextFunction } from "express";
import { config } from "../config.js";
import { getDb } from "../db/index.js";
import { verifyJwt } from "../auth/jwt.js";
const router = Router();
function adminAuth(req: Request, res: Response, next: NextFunction): void {
if (config.adminPubkeys.length === 0) {
res.status(503).json({ code: "admin_disabled", message: "Admin API not configured" });
return;
}
const auth = req.headers.authorization;
const adminKey = req.headers["x-admin-key"];
let pubkey: string | null = null;
if (auth?.startsWith("Bearer ")) {
const token = auth.slice(7).trim();
const payload = verifyJwt(token);
if (payload?.pubkey) pubkey = payload.pubkey;
}
if (!pubkey && typeof adminKey === "string") {
pubkey = adminKey.trim();
}
if (!pubkey || !config.adminPubkeys.includes(pubkey)) {
res.status(403).json({ code: "forbidden", message: "Admin access required" });
return;
}
next();
}
router.use(adminAuth);
router.get("/sponsors", async (req: Request, res: Response) => {
const db = getDb();
const status = typeof req.query.status === "string" ? req.query.status : undefined;
const limit = Math.min(parseInt(String(req.query.limit || 100), 10) || 100, 500);
const sponsors = await db.getAllSponsors({ status, limit });
res.json(sponsors);
});
router.patch("/sponsors/:id", async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
return;
}
const action = typeof (req.body as { action?: string }).action === "string"
? (req.body as { action: string }).action
: "";
const durationDays = typeof (req.body as { duration_days?: number }).duration_days === "number"
? (req.body as { duration_days: number }).duration_days
: undefined;
const db = getDb();
const sponsor = await db.getSponsorById(id);
if (!sponsor) {
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
return;
}
switch (action) {
case "approve":
await db.updateSponsorStatus(id, "active");
if (!sponsor.activated_at || !sponsor.expires_at) {
const now = Math.floor(Date.now() / 1000);
const expiresAt = now + sponsor.duration_days * 86400;
await db.updateSponsorActivation(id, now, expiresAt);
}
break;
case "reject":
await db.updateSponsorStatus(id, "removed");
break;
case "pause":
await db.updateSponsorStatus(id, "pending_review");
break;
case "remove":
await db.updateSponsorStatus(id, "removed");
break;
case "extend":
if (durationDays && durationDays >= 1 && durationDays <= 365) {
const now = Math.floor(Date.now() / 1000);
const current = sponsor.expires_at ?? now;
const newExpires = Math.max(current, now) + durationDays * 86400;
await db.updateSponsorExpiresAt(id, newExpires);
} else {
res.status(400).json({ code: "invalid_duration", message: "duration_days 1-365 required" });
return;
}
break;
default:
res.status(400).json({ code: "invalid_action", message: "action must be approve, reject, pause, remove, or extend" });
return;
}
const updated = await db.getSponsorById(id);
res.json(updated);
});
router.delete("/sponsors/:id", async (req: Request, res: Response) => {
const id = parseInt(req.params.id, 10);
if (!Number.isFinite(id) || id < 1) {
res.status(400).json({ code: "invalid_id", message: "Invalid sponsor id" });
return;
}
const db = getDb();
const sponsor = await db.getSponsorById(id);
if (!sponsor) {
res.status(404).json({ code: "not_found", message: "Sponsor not found" });
return;
}
await db.updateSponsorStatus(id, "removed");
res.status(200).json({ ok: true });
});
export default router;