Add dynamic footer badge marquee with admin management.

Store badges in Postgres, expose public/admin APIs, render an infinite footer slider, and ship /admin for paste-embed CRUD.
This commit is contained in:
Atakan Doğan Özban
2026-08-01 19:58:24 +02:00
parent 89c49bcd33
commit 6bf2bffc55
21 changed files with 1027 additions and 20 deletions
+15
View File
@@ -0,0 +1,15 @@
import { NextRequest, NextResponse } from "next/server";
export function requireAdmin(req: NextRequest): NextResponse | null {
const adminKey = process.env.ADMIN_API_KEY;
if (!adminKey) {
return NextResponse.json({ error: "Admin API not configured" }, { status: 503 });
}
const auth = req.headers.get("authorization");
if (auth !== `Bearer ${adminKey}`) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
return null;
}
+67
View File
@@ -0,0 +1,67 @@
export type FooterBadgeInput = {
name?: string;
embedHtml?: string | null;
imageUrl?: string | null;
linkUrl?: string | null;
isActive?: boolean;
sortOrder?: number;
};
export type ParsedBadgeEmbed = {
imageUrl: string | null;
linkUrl: string | null;
};
/** Extract first <a href> + nested/nearby <img src> from pasted badge HTML. */
export function parseBadgeEmbedHtml(html: string): ParsedBadgeEmbed {
const trimmed = html.trim();
if (!trimmed) return { imageUrl: null, linkUrl: null };
const hrefMatch =
trimmed.match(/<a\b[^>]*\bhref\s*=\s*["']([^"']+)["'][^>]*>/i) ??
trimmed.match(/href\s*=\s*["']([^"']+)["']/i);
const srcMatch =
trimmed.match(/<img\b[^>]*\bsrc\s*=\s*["']([^"']+)["'][^>]*>/i) ??
trimmed.match(/src\s*=\s*["']([^"']+)["']/i);
return {
linkUrl: hrefMatch?.[1]?.trim() || null,
imageUrl: srcMatch?.[1]?.trim() || null,
};
}
export function normalizeBadgeFields(input: FooterBadgeInput) {
const name = (input.name ?? "").trim();
const embedHtml = input.embedHtml?.trim() || null;
let imageUrl = input.imageUrl?.trim() || null;
let linkUrl = input.linkUrl?.trim() || null;
if (embedHtml) {
const parsed = parseBadgeEmbedHtml(embedHtml);
if (!imageUrl && parsed.imageUrl) imageUrl = parsed.imageUrl;
if (!linkUrl && parsed.linkUrl) linkUrl = parsed.linkUrl;
}
return {
name,
embedHtml,
imageUrl,
linkUrl,
isActive: input.isActive ?? true,
sortOrder:
typeof input.sortOrder === "number" && Number.isFinite(input.sortOrder)
? Math.trunc(input.sortOrder)
: 0,
};
}
export function validateBadgeFields(fields: ReturnType<typeof normalizeBadgeFields>): string | null {
if (!fields.name) return "Name is required";
if (!fields.embedHtml && !fields.imageUrl) {
return "Provide embed HTML or an image URL";
}
if (fields.imageUrl && !fields.linkUrl && !fields.embedHtml) {
return "Link URL is required when using image URL only";
}
return null;
}
+1 -1
View File
@@ -1,7 +1,7 @@
import { SUPPORT_EMAIL, SALES_EMAIL, QUOTA_REQUEST_EMAIL } from "@/lib/plans";
import { BRAND_DOMAIN, BRAND_NAME } from "@/lib/branding";
export const LEGAL_LAST_UPDATED = "July 25, 2026";
export const LEGAL_LAST_UPDATED = "July 30, 2026";
export const LEGAL_OPERATOR = {
name: BRAND_NAME,