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). +

+
+ +
+ + +
+ + setLimit(e.target.value)} /> + + + + setTokenMinutes(e.target.value)} + /> + +
+ + + + {error && ( +
+ {error} +
+ )} + + {result && ( +
+            {JSON.stringify(result, null, 2)}
+          
+ )} +
+
+ ); +} \ 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} +
+ + + + + + +
+ +
+ + + + + +
+
+ + {/* 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..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 */} -
- {/* 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/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 */} +
+ + + + +
+ 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 + {it.productName + ) : ( +
+ IMG +
+ )} +
+ + {/* Info */} +
+
+ {it.productName ?? "Part"} +
+
+ + {it.slot ?? "slot"} + + {it.productBrand && {it.productBrand}} + + x{it.quantity ?? 1} +
+
+ + {/* Price */} +
+
+ {formatMoney(it.bestPrice)} +
+ +
+
+ ))} +
+
+ + {/* 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 +
+ ))} +
+ + +
+ ) : ( +
+ {/* “Hero” preview */} +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + Build photo +
+ + {/* Thumbs */} +
+ {images.slice(0, 12).map((img, idx) => ( +
+ {/* eslint-disable-next-line @next/next/no-img-element */} + {`Build +
+ ))} +
+ +
+ + + + {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} +
+
+ + + +
+
+
+ ))} + +
+ 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 }) { {" "} +