From 57034eefc364d3d11aa320cc4d8e517521cd42ca Mon Sep 17 00:00:00 2001 From: Sean Date: Tue, 23 Dec 2025 11:18:34 -0500 Subject: [PATCH 1/5] added enrichment ui and functionality --- .../enrichment/EnrichmentQueueClient.tsx | 585 ++++++++++++++++++ app/admin/enrichment/page.tsx | 18 + app/admin/layout.tsx | 7 + components/AdminLeftNavigation.tsx | 6 + 4 files changed, 616 insertions(+) create mode 100644 app/admin/enrichment/EnrichmentQueueClient.tsx create mode 100644 app/admin/enrichment/page.tsx diff --git a/app/admin/enrichment/EnrichmentQueueClient.tsx b/app/admin/enrichment/EnrichmentQueueClient.tsx new file mode 100644 index 0000000..c75f8ba --- /dev/null +++ b/app/admin/enrichment/EnrichmentQueueClient.tsx @@ -0,0 +1,585 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { Check, X, Wand2, RefreshCw } from "lucide-react"; + +type EnrichmentStatus = "PENDING_REVIEW" | "APPROVED" | "REJECTED" | "APPLIED"; +type EnrichmentType = "CALIBER" | "CALIBER_GROUP"; +type EnrichmentSource = "RULES" | "AI"; + +type QueueItem = { + id: number; + productId: number; + productName?: string; + productSlug?: string; + mainImageUrl?: string; + brandName?: string; + enrichmentType: EnrichmentType; + source: EnrichmentSource; + status: EnrichmentStatus; + attributes: Record; + confidence: number; + rationale?: string; + createdAt: string; + + // NEW: current value on products table (so we can prevent bad applies) + productCaliber?: string | null; + productCaliberGroup?: string | null; + +}; + +const API_BASE = + process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; + +function getAuthHeaders(): HeadersInit { + if (typeof window === "undefined") return {}; + const token = + localStorage.getItem("token") || + localStorage.getItem("jwt") || + localStorage.getItem("accessToken"); + + return token ? { Authorization: `Bearer ${token}` } : {}; +} + +async function apiFetch(path: string, init?: RequestInit) { + const res = await fetch(`${API_BASE}${path}`, { + ...init, + headers: { + "Content-Type": "application/json", + ...getAuthHeaders(), + ...(init?.headers ?? {}), + }, + // If you're using cookie auth instead of bearer, flip this on: + // credentials: "include", + }); + + if (!res.ok) { + const text = await res.text().catch(() => ""); + throw new Error(text || `Request failed (${res.status})`); + } + + const ct = res.headers.get("content-type") ?? ""; + if (ct.includes("application/json")) return res.json(); + return res.text(); +} + +function hasProductCaliber(it: QueueItem) { + const c = it.productCaliber; + return typeof c === "string" && c.trim().length > 0; +} + +function hasAlreadySet(it: QueueItem) { + if (it.enrichmentType === "CALIBER") { + return hasProductCaliber(it); + } + const g = it.productCaliberGroup; + return typeof g === "string" && g.trim().length > 0; + } + + // ✅ ADD THIS: text for the “Already set:” pill + function alreadySetLabel(it: QueueItem) { + return it.enrichmentType === "CALIBER" ? it.productCaliber : it.productCaliberGroup; + } + +export default function EnrichmentQueueClient() { + const [type, setType] = useState("CALIBER"); + const [status, setStatus] = useState("PENDING_REVIEW"); + const [limit, setLimit] = useState(50); + + const [items, setItems] = useState([]); + const [loading, setLoading] = useState(false); + const [busyId, setBusyId] = useState(null); + const [error, setError] = useState(null); + + // Bulk selection + const [selected, setSelected] = useState>({}); + + // “Fancy” per-row UI: treat an item as “locally approved/rejected” immediately + // so the row swaps buttons without waiting for a reload. + const [localStatus, setLocalStatus] = useState< + Record + >({}); + + const count = useMemo(() => items.length, [items]); + + const selectedIds = useMemo( + () => + Object.entries(selected) + .filter(([, v]) => v) + .map(([k]) => Number(k)), + [selected] + ); + + + const allOnPageSelected = useMemo(() => { + if (items.length === 0) return false; + return items.every((it) => selected[it.id]); + }, [items, selected]); + + function toggleAllOnPage() { + const next = { ...selected }; + const nextValue = !allOnPageSelected; + for (const it of items) next[it.id] = nextValue; + setSelected(next); + } + + function toggleOne(id: number) { + setSelected((prev) => ({ ...prev, [id]: !prev[id] })); + } + + function effectiveStatus(it: QueueItem): EnrichmentStatus { + return localStatus[it.id] ?? it.status; + } + + async function load() { + setLoading(true); + setError(null); + try { + const data = (await apiFetch( + `/api/admin/enrichment/queue2?type=${encodeURIComponent( + type + )}&status=${encodeURIComponent(status)}&limit=${limit}` + )) as QueueItem[]; + + setItems(data ?? []); + + // Keep selection only for items still visible + setSelected((prev) => { + const keep = new Set((data ?? []).map((x) => x.id)); + const next: Record = {}; + for (const [k, v] of Object.entries(prev)) { + const id = Number(k); + if (keep.has(id)) next[id] = v; + } + return next; + }); + + // Reset local status for items not in view + setLocalStatus((prev) => { + const keep = new Set((data ?? []).map((x) => x.id)); + const next: Record = {}; + for (const [k, v] of Object.entries(prev)) { + const id = Number(k); + if (keep.has(id)) next[id] = v; + } + return next; + }); + } catch (e: any) { + setError(e?.message ?? "Failed to load queue"); + } finally { + setLoading(false); + } + } + + async function runRules() { + setLoading(true); + setError(null); + try { + await apiFetch( + `/api/admin/enrichment/run?type=${encodeURIComponent(type)}&limit=200`, + { method: "POST" } + ); + setStatus("PENDING_REVIEW"); + setTimeout(load, 50); + } catch (e: any) { + setError(e?.message ?? "Failed to run rules"); + setLoading(false); + } + } + + async function runAi() { + setLoading(true); + setError(null); + try { + await apiFetch( + `/api/admin/enrichment/ai/run?type=${encodeURIComponent(type)}&limit=200`, + { method: "POST" } + ); + setStatus("PENDING_REVIEW"); + setTimeout(load, 50); + } catch (e: any) { + setError(e?.message ?? "Failed to run AI"); + setLoading(false); + } + } + + async function act(id: number, action: "approve" | "reject" | "apply") { + setBusyId(id); + setError(null); + + // Optimistic UI for approve/reject so the row instantly changes + if (action === "approve") + setLocalStatus((p) => ({ ...p, [id]: "APPROVED" })); + if (action === "reject") + setLocalStatus((p) => ({ ...p, [id]: "REJECTED" })); + + try { + await apiFetch(`/api/admin/enrichment/${id}/${action}`, { + method: "POST", + }); + await load(); + } catch (e: any) { + // rollback optimistic local status on error + setLocalStatus((p) => { + const next = { ...p }; + delete next[id]; + return next; + }); + setError(e?.message ?? `Failed to ${action}`); + } finally { + setBusyId(null); + } + } + + async function bulk(action: "approve" | "reject" | "apply") { + const ids = selectedIds; + if (ids.length === 0) return; + + setLoading(true); + setError(null); + + try { + // Apply should only run for items that are APPROVED (effective status) AND product caliber is blank + const byId = new Map(items.map((x) => [x.id, x])); + const filtered = + action === "apply" + ? ids.filter((id) => { + const it = byId.get(id); + if (!it) return false; + return ( + effectiveStatus(it) === "APPROVED" && !hasAlreadySet(it) + ); + }) + : ids; + + for (const id of filtered) { + // optimistic statuses for approve/reject in bulk + if (action === "approve") + setLocalStatus((p) => ({ ...p, [id]: "APPROVED" })); + if (action === "reject") + setLocalStatus((p) => ({ ...p, [id]: "REJECTED" })); + + await apiFetch(`/api/admin/enrichment/${id}/${action}`, { + method: "POST", + }); + } + + setSelected({}); + await load(); + } catch (e: any) { + setError(e?.message ?? `Failed to bulk ${action}`); + setLoading(false); + } finally { + setLoading(false); + } + } + + useEffect(() => { + load(); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [type, status, limit]); + + return ( +
+ {/* Controls */} +
+
+
+
+ Showing {count} +
+ + + + + + +
+ +
+ + + + + +
+
+ + {/* Bulk action bar */} + {selectedIds.length > 0 ? ( +
+
+ Selected{" "} + {selectedIds.length} + + (Apply only works on APPROVED + product caliber blank) + +
+ +
+ + + + + + + +
+
+ ) : null} +
+ + {error ? ( +
+ {error} +
+ ) : null} + + {/* Table */} +
+
+
+ +
+
Product
+
Confidence
+
Suggested
+
Actions
+
+ +
+ {items.length === 0 ? ( +
No items found.
+ ) : ( + items.map((it) => { + const suggested = + it.enrichmentType === "CALIBER" + ? it.attributes?.caliber + : it.attributes?.caliberGroup; const isBusy = busyId === it.id; + const st = effectiveStatus(it); + const alreadySet = hasAlreadySet(it); + const alreadySetValue = alreadySetLabel(it); + + return ( +
+
+ toggleOne(it.id)} + className="h-4 w-4 accent-amber-400" + aria-label={`Select enrichment ${it.id}`} + /> +
+ +
+
+ {it.mainImageUrl ? ( + // eslint-disable-next-line @next/next/no-img-element + + ) : ( +
+ )} + +
+ + {it.productName ?? `Product #${it.productId}`} + + +
+ {it.brandName ? `${it.brandName} • ` : ""} + {it.source} • {st} + {alreadySet ? ( + + Already set: {alreadySetValue} + + ) : null} +
+ + {it.rationale ? ( +
+ {it.rationale} +
+ ) : null} +
+
+
+ +
+ {(it.confidence ?? 0).toFixed(2)} +
+ +
+ {suggested ?? "—"} +
+ +
+ {st === "PENDING_REVIEW" && ( + <> + + + + + )} + + {st === "APPROVED" && ( + + )} + + {st === "APPLIED" && ( + + Applied + + )} + + {st === "REJECTED" && ( + + Rejected + + )} +
+
+ ); + }) + )} +
+
+
+ ); +} \ No newline at end of file diff --git a/app/admin/enrichment/page.tsx b/app/admin/enrichment/page.tsx new file mode 100644 index 0000000..65b290a --- /dev/null +++ b/app/admin/enrichment/page.tsx @@ -0,0 +1,18 @@ +import EnrichmentQueueClient from "./EnrichmentQueueClient"; + +export const dynamic = "force-dynamic"; + +export default function AdminEnrichmentPage() { + return ( +
+
+

Enrichment Queue

+

+ Review AI/rules suggestions, approve/reject, then apply to products. +

+
+ + +
+ ); +} \ No newline at end of file diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 331ab2b..804a4ff 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -11,6 +11,7 @@ import { Users, Settings, LucideMail, + Wand2, } from "lucide-react"; const navItems = [ @@ -44,6 +45,11 @@ const navItems = [ href: "/admin/platforms", icon: , }, + { + label: "Enrichment", + href: "/admin/enrichment", + icon: , + }, { label: "Users", href: "/admin/users", @@ -64,6 +70,7 @@ const navItems = [ href: "/admin/email/send", icon: , }, + ]; // ... existing code ... diff --git a/components/AdminLeftNavigation.tsx b/components/AdminLeftNavigation.tsx index c8df0cb..5a5d1bb 100644 --- a/components/AdminLeftNavigation.tsx +++ b/components/AdminLeftNavigation.tsx @@ -10,6 +10,7 @@ import { Users, Settings, LucideMail, + Wand2, } from "lucide-react"; import Link from "next/link"; @@ -49,6 +50,11 @@ const navItems = [ href: "/admin/platforms", icon: , }, + { + label: "Enrichment", + href: "/admin/enrichment", + icon: , + }, { label: "Users", href: "/admin/users", From 5401f6464d963d31b787ff86fef0ba350e939034 Mon Sep 17 00:00:00 2001 From: Sean Date: Wed, 24 Dec 2025 08:34:39 -0500 Subject: [PATCH 2/5] disable beta sign up email blasts --- app/api/beta-signup/route.ts | 1 + app/page.tsx | 33 +++++++++++++++++++++++++++------ 2 files changed, 28 insertions(+), 6 deletions(-) diff --git a/app/api/beta-signup/route.ts b/app/api/beta-signup/route.ts index fcf0a1b..cd940ea 100644 --- a/app/api/beta-signup/route.ts +++ b/app/api/beta-signup/route.ts @@ -11,6 +11,7 @@ export async function POST(req: Request) { method: "POST", headers: { "Content-Type": "application/json" }, body: JSON.stringify(body), + cache: "no-store", }); // Always return ok=true (matches your server behavior) diff --git a/app/page.tsx b/app/page.tsx index be03d45..0e71ffe 100644 --- a/app/page.tsx +++ b/app/page.tsx @@ -13,6 +13,7 @@ export default function HomePage() { async function handleSubmit(e: FormEvent) { e.preventDefault(); + if (!email) { setMessage("Drop an email in first, operator."); setStatus("error"); @@ -30,11 +31,12 @@ export default function HomePage() { }); if (!res.ok) { - throw new Error("Failed to save your signup."); + const txt = await res.text().catch(() => ""); + throw new Error(txt || "Failed to save your signup."); } setStatus("success"); - setMessage("You’re locked in. Watch your inbox."); + setMessage("✅ You’re on the list. Invites drop soon — we’ll email your access link when it’s go-time."); setEmail(""); setUseCase(""); } catch (err) { @@ -160,7 +162,8 @@ export default function HomePage() { value={email} onChange={(e) => setEmail(e.target.value)} placeholder="you@gearjunkie.com" - className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2" + disabled={status === "loading" || status === "success"} + className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed" required />
@@ -178,17 +181,22 @@ export default function HomePage() { value={useCase} onChange={(e) => setUseCase(e.target.value)} rows={3} + disabled={status === "loading" || status === "success"} placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..." - className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2" + className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed" /> {message && ( @@ -205,6 +213,19 @@ export default function HomePage() {

)} + {status === "success" && ( + + )} +
Already invited?{" "} From c8bf032f7a6cef2bdeeecb191ec03d7e2e589ad0 Mon Sep 17 00:00:00 2001 From: Sean Date: Fri, 26 Dec 2025 17:19:11 -0500 Subject: [PATCH 3/5] set admin behind a cookie and check role on profile --- app/admin/layout.tsx | 174 +++++++++++++---------------- app/api/auth/session/route.ts | 38 +++++++ app/layout.tsx | 5 + components/AdminLeftNavigation.tsx | 94 +++++----------- context/AuthContext.tsx | 50 +++++++-- middleware.ts | 40 +++++-- 6 files changed, 226 insertions(+), 175 deletions(-) create mode 100644 app/api/auth/session/route.ts diff --git a/app/admin/layout.tsx b/app/admin/layout.tsx index 804a4ff..f7db1ea 100644 --- a/app/admin/layout.tsx +++ b/app/admin/layout.tsx @@ -1,7 +1,10 @@ "use client"; + import type React from "react"; -import { useState } from "react"; +import { useEffect, useMemo, useState } from "react"; +import { usePathname, useRouter } from "next/navigation"; import AdminLeftNavigation from "@/components/AdminLeftNavigation"; +import { useAuth } from "@/context/AuthContext"; import { LayoutDashboard, Download, @@ -15,113 +18,96 @@ import { } from "lucide-react"; const navItems = [ - { - label: "Dashboard", - href: "/admin", - icon: , - }, - { - label: "Imports", - href: "/admin/import-status", - icon: , - }, - { - label: "Mappings", - href: "/admin/mapping", - icon: , - }, - { - label: "Products", - href: "/admin/products", - icon: , - }, - { - label: "Merchants", - href: "/admin/merchants", - icon: , - }, - { - label: "Platforms", - href: "/admin/platforms", - icon: , - }, - { - label: "Enrichment", - href: "/admin/enrichment", - icon: , - }, - { - label: "Users", - href: "/admin/users", - icon: , - }, - { - label: "Settings", - href: "/admin/settings", - icon: , - }, - { - label: "List Emails", - href: "/admin/email", - icon: , - }, - { - label: "Send a Email", - href: "/admin/email/send", - icon: , - }, - + { label: "Dashboard", href: "/admin", icon: }, + { label: "Imports", href: "/admin/import-status", icon: }, + { label: "Mappings", href: "/admin/mapping", icon: }, + { label: "Products", href: "/admin/products", icon: }, + { label: "Merchants", href: "/admin/merchants", icon: }, + { label: "Platforms", href: "/admin/platforms", icon: }, + { label: "Enrichment", href: "/admin/enrichment", icon: }, + { label: "Users", href: "/admin/users", icon: }, + { label: "Settings", href: "/admin/settings", icon: }, + { label: "List Emails", href: "/admin/email", icon: }, + { label: "Send an Email", href: "/admin/email/send", icon: }, ]; -// ... existing code ... -// ADMIN CHECK FOR LOGIN -// const { user, loading } = useAuth(); - -// if (!loading && user?.role !== "ADMIN") { -// redirect("/"); // or /login -// } - export default function AdminLayout({ - children, - }: { + children, +}: { children: React.ReactNode; }) { const [collapsed, setCollapsed] = useState(false); + const { user, loading } = useAuth(); + + const router = useRouter(); + const pathname = usePathname(); + + // Where to send people back after login + const next = useMemo( + () => encodeURIComponent(pathname || "/admin"), + [pathname] + ); + + /** + * ✅ AUTH GUARD + * Redirects happen in useEffect (NOT during render) + */ + useEffect(() => { + if (loading) return; + + // Not logged in + if (!user) { + router.replace(`/login?next=${next}`); + return; + } + + // Logged in but not admin + if (user.role !== "ADMIN") { + router.replace("/"); + } + }, [loading, user, router, next]); + + // While loading OR redirecting, render nothing + if (loading) return null; + if (!user) return null; + if (user.role !== "ADMIN") return null; return ( -
- setCollapsed((v) => !v)} - /> +
+ setCollapsed((v) => !v)} + items={navItems} + /> - {/* Main column */} -
- {/* Top bar */} -
-
-

- Admin -

-

- Battl Builders Control Panel -

-
-
+ {/* Main column */} +
+ {/* Top bar */} +
+
+

+ Admin +

+

+ Battl Builders Control Panel +

+
+ +
Internal • v0.1 -
- ADMIN -
+
+ ADMIN
-
+
+
- {/* Content */} -
- {children} -
-
+ {/* Content */} +
+ {children} +
+
); } \ No newline at end of file diff --git a/app/api/auth/session/route.ts b/app/api/auth/session/route.ts new file mode 100644 index 0000000..ec6b25a --- /dev/null +++ b/app/api/auth/session/route.ts @@ -0,0 +1,38 @@ +import { NextResponse } from "next/server"; + +export async function POST(req: Request) { + const { token, role } = await req.json(); + + if (!token) { + return NextResponse.json({ ok: false }, { status: 400 }); + } + + const res = NextResponse.json({ ok: true }); + + // Server-readable, JS-unreadable + res.cookies.set("bb_access_token", token, { + httpOnly: true, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); + + // Optional: for quick middleware role checks (not “secure” by itself) + if (role) { + res.cookies.set("bb_role", role, { + httpOnly: false, + secure: process.env.NODE_ENV === "production", + sameSite: "lax", + path: "/", + }); + } + + return res; +} + +export async function DELETE() { + const res = NextResponse.json({ ok: true }); + res.cookies.set("bb_access_token", "", { path: "/", maxAge: 0 }); + res.cookies.set("bb_role", "", { path: "/", maxAge: 0 }); + return res; +} \ No newline at end of file diff --git a/app/layout.tsx b/app/layout.tsx index 623c225..d9f8f51 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -1,6 +1,8 @@ // app/layout.tsx import "./globals.css"; import type { ReactNode } from "react"; +import Script from "next/script"; + import { AuthProvider } from "@/context/AuthContext"; import { Banner } from "@/components/Banner"; @@ -38,6 +40,9 @@ export default function RootLayout({ children }: { children: ReactNode }) { {" "} +