diff --git a/app/admin/beta-invites/page.tsx b/app/admin/beta-invites/page.tsx
new file mode 100644
index 0000000..8797672
--- /dev/null
+++ b/app/admin/beta-invites/page.tsx
@@ -0,0 +1,92 @@
+"use client";
+
+import { useState } from "react";
+import { Button, Field, Input } from "@/components/ui/form";
+
+export default function AdminBetaInvitesPage() {
+ const [dryRun, setDryRun] = useState(true);
+ const [limit, setLimit] = useState("1");
+ const [tokenMinutes, setTokenMinutes] = useState("30");
+ const [loading, setLoading] = useState(false);
+ const [result, setResult] = useState(null);
+ const [error, setError] = useState(null);
+
+ async function runInvites() {
+ setLoading(true);
+ setError(null);
+ setResult(null);
+
+ try {
+ const qs = new URLSearchParams({
+ dryRun: String(dryRun),
+ limit: String(limit || "0"),
+ tokenMinutes: String(tokenMinutes || "30"),
+ });
+
+ const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
+ method: "POST",
+ });
+
+ const data = await res.json().catch(() => ({}));
+ if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
+
+ setResult(data);
+ } catch (e: any) {
+ setError(e?.message ?? "Failed");
+ } finally {
+ setLoading(false);
+ }
+ }
+
+ return (
+
+
+
Send Beta Invites
+
+ Runs the backend batch invite sender (uses your email templates + tracking).
+
+
+
+
+
+ );
+}
\ No newline at end of file
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}
+
+
+
setType(e.target.value as EnrichmentType)}
+ >
+ CALIBER
+ CALIBER_GROUP
+
+
+
{
+ setSelected({});
+ setLocalStatus({});
+ setStatus(e.target.value as EnrichmentStatus);
+ }}
+ >
+ PENDING_REVIEW
+ APPROVED
+ REJECTED
+ APPLIED
+
+
+
setLimit(parseInt(e.target.value, 10))}
+ >
+ 25
+ 50
+ 100
+ 200
+
+
+
+
+
+
+ Refresh
+
+
+
+
+ Run Rules
+
+
+
+
+ Run AI
+
+
+
+
+ {/* Bulk action bar */}
+ {selectedIds.length > 0 ? (
+
+
+ Selected{" "}
+ {selectedIds.length}
+
+ (Apply only works on APPROVED + product caliber blank)
+
+
+
+
+ bulk("approve")}
+ className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
+ disabled={loading}
+ >
+ Approve Selected
+
+
+ bulk("reject")}
+ className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
+ disabled={loading}
+ >
+ Reject Selected
+
+
+ bulk("apply")}
+ className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
+ disabled={loading}
+ title="Applies only APPROVED rows where product caliber is blank"
+ >
+ Apply Selected
+
+
+ setSelected({})}
+ className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
+ disabled={loading}
+ >
+ Clear
+
+
+
+ ) : 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.confidence ?? 0).toFixed(2)}
+
+
+
+ {suggested ?? "—"}
+
+
+
+ {st === "PENDING_REVIEW" && (
+ <>
+ act(it.id, "approve")}
+ disabled={isBusy || alreadySet}
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
+ title={
+ alreadySet
+ ? "Product caliber already set"
+ : "Approve"
+ }
+ aria-label="Approve"
+ >
+
+
+
+ act(it.id, "reject")}
+ disabled={isBusy}
+ className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
+ title="Reject"
+ aria-label="Reject"
+ >
+
+
+ >
+ )}
+
+ {st === "APPROVED" && (
+ act(it.id, "apply")}
+ disabled={isBusy || alreadySet}
+ className="inline-flex h-8 items-center justify-center rounded-md bg-amber-400 px-3 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
+ title={
+ alreadySet
+ ? `Already set on product: ${alreadySetValue}`
+ : "Apply (writes to product if blank)"
+ }
+ >
+ Apply
+
+ )}
+
+ {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..2b615f9 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,
@@ -11,110 +14,102 @@ import {
Users,
Settings,
LucideMail,
+ Wand2,
} 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: "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: },
+ { label: "Beta Invites", href: "/admin/beta-invites", 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 */}
-
);
}
\ No newline at end of file
diff --git a/app/api/admin/beta-invites/route.ts b/app/api/admin/beta-invites/route.ts
new file mode 100644
index 0000000..596d2ce
--- /dev/null
+++ b/app/api/admin/beta-invites/route.ts
@@ -0,0 +1,35 @@
+import { NextResponse } from "next/server";
+import { cookies } from "next/headers";
+
+const API_BASE_URL =
+ process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
+
+// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
+export async function POST(req: Request) {
+ const token = cookies().get("bb_access_token")?.value;
+
+ if (!token) {
+ return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
+ }
+
+ const url = new URL(req.url);
+ const dryRun = url.searchParams.get("dryRun") ?? "true";
+ const limit = url.searchParams.get("limit") ?? "0";
+ const tokenMinutes = url.searchParams.get("tokenMinutes") ?? "30";
+
+ const upstream = `${API_BASE_URL}/api/v1/admin/beta/invites/send?dryRun=${dryRun}&limit=${limit}&tokenMinutes=${tokenMinutes}`;
+
+ const res = await fetch(upstream, {
+ method: "POST",
+ headers: {
+ Authorization: `Bearer ${token}`,
+ },
+ cache: "no-store",
+ });
+
+ const text = await res.text();
+ return new NextResponse(text, {
+ status: res.status,
+ headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
+ });
+}
\ 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/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/builds/[buildId]/page.tsx b/app/builds/[buildId]/page.tsx
new file mode 100644
index 0000000..cfb9792
--- /dev/null
+++ b/app/builds/[buildId]/page.tsx
@@ -0,0 +1,407 @@
+// app/builds/[buildId]/page.tsx
+import Link from "next/link";
+import { notFound } from "next/navigation";
+
+const API_BASE_URL =
+ process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
+
+function formatMoney(n?: number | null) {
+ if (n == null) return "—";
+ return new Intl.NumberFormat("en-US", {
+ style: "currency",
+ currency: "USD",
+ maximumFractionDigits: 0,
+ }).format(n);
+}
+
+function timeAgo(iso?: string | null) {
+ if (!iso) return "";
+ const d = new Date(iso);
+ const diff = Math.max(0, Date.now() - d.getTime());
+ const mins = Math.floor(diff / 60000);
+ if (mins < 60) return `${mins}m ago`;
+ const hrs = Math.floor(mins / 60);
+ if (hrs < 24) return `${hrs}h ago`;
+ const days = Math.floor(hrs / 24);
+ return `${days}d ago`;
+}
+
+export default async function BuildBreakdownPage({
+ params,
+}: {
+ params: { buildId: string };
+}) {
+ // Public detail endpoint
+ const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
+ cache: "no-store",
+ });
+
+ if (res.status === 404) return notFound();
+
+ const build = await res.json().catch(() => null);
+ if (!build) return notFound();
+
+ const items: any[] = build.items ?? [];
+
+ const total = items.reduce((sum, it) => {
+ const qty = Number(it.quantity ?? 1);
+ const price = it.bestPrice == null ? 0 : Number(it.bestPrice);
+ return sum + qty * price;
+ }, 0);
+
+ const images: any[] = build.images ?? build.photos ?? build.media ?? [];
+
+ return (
+
+ {/* Header crumb */}
+
+
+ ← Back to Community Builds
+
+
+
+
+ Public Build
+
+ {build.updatedAt && (
+
+ updated {timeAgo(build.updatedAt)}
+
+ )}
+
+
+
+ {/* Layout */}
+
+ {/* Post column */}
+
+ {/* “Reddit-ish” Post Card */}
+
+
+ {/* Vote rail */}
+
+
+ {/* Post body */}
+
+ {/* Meta */}
+
+
+ r/BattlBuilders
+
+ •
+ posted by
+ u/anonymous
+ {build.createdAt && (
+ <>
+ •
+ {timeAgo(build.createdAt)}
+ >
+ )}
+
+
+ {/* Title */}
+
+ {build.title ?? "Untitled Build"}
+
+
+ {/* Description */}
+ {build.description ? (
+
+ {build.description}
+
+ ) : (
+
+ {/* placeholder copy */}
+ Field notes coming soon. This build breakdown will include
+ purpose, tradeoffs, and why each part made the cut.
+
+ )}
+
+ {/* Action bar */}
+
+
+ Comment
+
+
+ Share
+
+
+ Save
+
+
+
+ Est. Total
+
+ {formatMoney(total)}
+
+
+
+
+
+
+
+ {/* Parts card */}
+
+
+
Parts
+
{items.length} items
+
+
+
+ {items.map((it) => (
+
+ {/* Thumb */}
+
+ {it.productImageUrl ? (
+ // eslint-disable-next-line @next/next/no-img-element
+
+ ) : (
+
+ IMG
+
+ )}
+
+
+ {/* Info */}
+
+
+ {it.productName ?? "Part"}
+
+
+
+ {it.slot ?? "slot"}
+
+ {it.productBrand && {it.productBrand} }
+ •
+ x{it.quantity ?? 1}
+
+
+
+ {/* Price */}
+
+
+ {formatMoney(it.bestPrice)}
+
+
+ View part →
+
+
+
+ ))}
+
+
+
+ {/* Gallery */}
+
+
+
+ Gallery
+
+ user submitted
+
+ {images.length === 0 ? (
+
+
+ No photos yet. Be the first to add a build pic.
+
+
+ (Upload UI coming soon — we’ll store and moderate images
+ per build.)
+
+
+ {/* Mock thumbnails */}
+
+ {Array.from({ length: 8 }).map((_, i) => (
+
+ IMG
+
+ ))}
+
+
+
+ Add Photo (coming soon)
+
+
+ ) : (
+
+ {/* “Hero” preview */}
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+
+ {/* Thumbs */}
+
+ {images.slice(0, 12).map((img, idx) => (
+
+ {/* eslint-disable-next-line @next/next/no-img-element */}
+
+
+ ))}
+
+
+
+
+ View all
+
+
+ Add Photo
+
+
+ {images.length} photos
+
+
+
+ )}
+
+
+ {/* Comments (placeholder) */}
+
+
+
Comments
+ mocked
+
+
+
+ {[
+ {
+ user: "u/gearhead",
+ body: "Solid parts list. Any reason you went with that upper over a BCM?",
+ },
+ {
+ user: "u/OP",
+ body: "Mostly availability + price. I’ll probably swap once I track deals for a week.",
+ },
+ {
+ user: "u/boltcarrier",
+ body: "Would love to see this with a pinned/weld option for 14.5 builds.",
+ },
+ ].map((c, idx) => (
+
+
+ {c.user === "u/OP" ? "OP" : "u"}
+
+
+
+ {c.user} • 1h ago
+
+
+ {c.body}
+
+
+ Reply
+ Share
+ Save
+
+
+
+ ))}
+
+
+ Commenting is coming soon. This will become the “breakdown”
+ thread for the build (notes, tradeoffs, deal alerts, revisions).
+
+
+
+
+
+
+ {/* Sidebar */}
+
+
+
+ );
+}
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 }) {
{" "}
+
{children}
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"
/>
- {status === "loading" ? "Enlisting…" : "Get Beta Access"}
+ {status === "loading"
+ ? "Enlisting…"
+ : status === "success"
+ ? "On the List ✅"
+ : "Get Beta Access"}
{message && (
@@ -205,6 +213,19 @@ export default function HomePage() {
)}
+ {status === "success" && (
+ {
+ setStatus("idle");
+ setMessage(null);
+ }}
+ className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-900"
+ >
+ Submit another email
+
+ )}
+
Already invited?{" "}
diff --git a/components/AdminLeftNavigation.tsx b/components/AdminLeftNavigation.tsx
index c8df0cb..6952d79 100644
--- a/components/AdminLeftNavigation.tsx
+++ b/components/AdminLeftNavigation.tsx
@@ -1,6 +1,7 @@
"use client";
import type React from "react";
+import Link from "next/link";
import {
LayoutDashboard,
Download,
@@ -10,70 +11,41 @@ import {
Users,
Settings,
LucideMail,
+ Wand2,
} from "lucide-react";
-import Link from "next/link";
+
+export type AdminNavItem = {
+ label: string;
+ href: string;
+ icon: React.ReactNode;
+};
type AdminLeftNavigationProps = {
collapsed: boolean;
onToggleCollapsed: () => void;
+ items?: AdminNavItem[]; // ✅ NEW
};
-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: "Users",
- href: "/admin/users",
- icon: ,
- },
- {
- label: "Settings",
- href: "/admin/settings",
- icon: ,
- },
- {
- label: "Manage Emails",
- href: "/admin/email/manage",
- icon: ,
- },
- {
- label: "Send a Email",
- href: "/admin/email/send",
- icon: ,
+const defaultNavItems: AdminNavItem[] = [
+ { 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: "Manage Emails", href: "/admin/email/manage", icon: },
+ { label: "Send an Email", href: "/admin/email/send", icon: },
+ { label: "Beta Invites", href: "/admin/beta-invites", icon: ,
},
];
export default function AdminLeftNavigation({
collapsed,
onToggleCollapsed,
+ items = defaultNavItems, // ✅ NEW default
}: AdminLeftNavigationProps) {
return (
@@ -106,8 +80,8 @@ export default function AdminLeftNavigation({
- {navItems.map((item) => (
- (
+ {item.label}}
{!collapsed && › }
-
+
))}
{!collapsed && (
-
- Status
-
+
Status
Import engine: online
@@ -134,4 +106,4 @@ export default function AdminLeftNavigation({
)}
);
-}
+}
\ No newline at end of file
diff --git a/context/AuthContext.tsx b/context/AuthContext.tsx
index f048b81..96f4628 100644
--- a/context/AuthContext.tsx
+++ b/context/AuthContext.tsx
@@ -49,6 +49,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState
(null);
const [loading, setLoading] = useState(true);
+ /**
+ * Persist auth to localStorage
+ */
const persistAuth = useCallback(
(nextToken: string | null, nextUser: AuthUser) => {
if (typeof window === "undefined") return;
@@ -68,19 +71,34 @@ export function AuthProvider({ children }: { children: ReactNode }) {
[]
);
- // Hydrate from localStorage on first load
+ /**
+ * ✅ Hydrate from localStorage ONCE
+ * IMPORTANT: loading only flips false AFTER user/token are set (if present)
+ */
useEffect(() => {
if (typeof window === "undefined") return;
const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY);
- if (storedToken) setToken(storedToken);
-
- if (storedUser) {
+ if (storedToken && storedUser) {
try {
- setUser(JSON.parse(storedUser));
+ const parsedUser = JSON.parse(storedUser);
+
+ setToken(storedToken);
+ setUser(parsedUser);
+
+ // ✅ Re-sync cookie so middleware can allow /admin on hard refresh / direct nav
+ fetch("/api/auth/session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ token: storedToken,
+ role: parsedUser?.role,
+ }),
+ }).catch(() => {});
} catch {
+ window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
}
}
@@ -88,11 +106,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false);
}, []);
+ /**
+ * Used by login/register/magic-link
+ */
const setSession = useCallback(
(nextToken: string, nextUser: NonNullable) => {
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
+
+ // ✅ Make middleware / server aware of the session
+ fetch("/api/auth/session", {
+ method: "POST",
+ headers: { "Content-Type": "application/json" },
+ body: JSON.stringify({
+ token: nextToken,
+ role: nextUser.role,
+ }),
+ }).catch(() => {});
},
[persistAuth]
);
@@ -118,6 +149,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser =
data.user ??
({
+ uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? null,
role: data.role ?? "USER",
@@ -160,6 +192,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser =
data.user ??
({
+ uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? displayName ?? null,
role: data.role ?? "USER",
@@ -177,6 +210,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(null);
setUser(null);
persistAuth(null, null);
+
+ // Clear server session cookies
+ fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
}, [persistAuth]);
const getAuthHeaders = useCallback((): HeadersInit => {
@@ -191,7 +227,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register,
logout,
getAuthHeaders,
- setSession, // ✅ important
+ setSession,
};
return {children} ;
@@ -203,4 +239,4 @@ export function useAuth(): AuthContextValue {
throw new Error("useAuth must be used within an AuthProvider");
}
return ctx;
-}
\ No newline at end of file
+}
diff --git a/middleware.ts b/middleware.ts
index c1032d0..9034512 100644
--- a/middleware.ts
+++ b/middleware.ts
@@ -12,15 +12,40 @@ const ALWAYS_ALLOW = new Set([
"/sitemap.xml",
]);
-// Match common public/static file extensions so middleware never blocks them
-const PUBLIC_FILE = /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
+const PUBLIC_FILE =
+ /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
export function middleware(req: NextRequest) {
- if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
-
const { pathname } = req.nextUrl;
- // ✅ Always allow Next internals + static assets (public/ and _next/)
+ console.log("LAUNCH_ONLY_ROOT:", process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT);
+
+
+// =========================
+// 1) Admin gate (always on)
+// =========================
+if (pathname.startsWith("/admin")) {
+ const token = req.cookies.get("bb_access_token")?.value;
+ const role = req.cookies.get("bb_role")?.value; // optional
+
+ const ok = !!token && (!role || role === "ADMIN");
+
+ if (!ok) {
+ const url = req.nextUrl.clone();
+ url.pathname = "/login";
+ url.searchParams.set("next", pathname);
+ return NextResponse.redirect(url);
+ }
+
+ return NextResponse.next();
+}
+
+ // =====================================
+ // 2) Launch-only gate (your existing one)
+ // =====================================
+ if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
+
+ // ✅ Always allow Next internals + static assets
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
@@ -31,17 +56,17 @@ export function middleware(req: NextRequest) {
return NextResponse.next();
}
- // ✅ Allow the root + a few public files
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
- // 🚫 Everything else: hide it
+
return NextResponse.rewrite(new URL("/404", req.url));
}
+` 1`
export const config = {
- // Run middleware on everything *except* Next internals and obvious static files.
- // This keeps launch gating from ever breaking your logo/images/fonts/etc.
matcher: [
+ // run on everything except Next internals + obvious static files
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)).*)",
],
+
};
\ No newline at end of file