5 Commits

Author SHA1 Message Date
sean 8d7809ccf1 added stuff to builds/details 2025-12-26 21:04:33 -05:00
sean e202d811ed new builds/id page 2025-12-26 20:53:04 -05:00
sean c8bf032f7a set admin behind a cookie and check role on profile 2025-12-26 17:19:11 -05:00
sean 5401f6464d disable beta sign up email blasts 2025-12-24 08:34:39 -05:00
sean 57034eefc3 added enrichment ui and functionality 2025-12-23 11:18:34 -05:00
14 changed files with 1399 additions and 271 deletions
-102
View File
@@ -1,102 +0,0 @@
import React from "react";
import Link from "next/link";
export const metadata = {
title: "Terms of Service | BattlBuilder",
description: "Terms of Service and legal disclaimers for rifle assembly and part compatibility.",
};
export default function TermsOfService() {
const lastUpdated = "December 26, 2024";
return (
<div className="mx-auto max-w-4xl px-4 py-12">
<div className="mb-10">
<h1 className="text-4xl font-bold tracking-tight">Terms of Service</h1>
<p className="mt-2 text-sm opacity-60">Last Updated: {lastUpdated}</p>
</div>
<div className="space-y-10 text-sm leading-relaxed text-white/90">
{/* 1. Acceptance */}
<section>
<h2 className="mb-4 text-xl font-semibold text-white">1. Acceptance of Terms</h2>
<p>
By accessing or using Shadow GunBuilder AI (the "Site"), you agree to be bound by these Terms of Service.
This Site is intended for informational and entertainment purposes only, providing a platform to
visualize and plan AR-platform rifle builds using third-party components.
</p>
</section>
{/* 2. Legal Compliance */}
<section className="rounded-xl border border-orange-500/30 bg-orange-500/5 p-6">
<h2 className="mb-4 text-xl font-semibold text-orange-200">2. Legal Compliance & Firearms Safety</h2>
<div className="space-y-4">
<p className="font-medium text-orange-100">
IMPORTANT: It is your sole responsibility to ensure that any firearm configuration you assemble
complies with all applicable local, state, federal, and international laws.
</p>
<ul className="list-disc space-y-2 pl-5">
<li>Laws regarding "assault weapons," magazine capacity, barrel length (NFA regulations), and specific features vary significantly by jurisdiction.</li>
<li>The Site does not provide legal advice regarding the legality of any specific build in your area.</li>
<li>Always consult with a qualified gunsmith or legal professional before commencing a physical build.</li>
</ul>
</div>
</section>
{/* 3. No Guarantee of Compatibility */}
<section>
<h2 className="mb-4 text-xl font-semibold text-white">3. Compatibility Disclaimer</h2>
<p>
While we strive for accuracy, the Site is a digital simulation. Compatibility data between parts
(e.g., gas block diameters, handguard clearances, or buffer weights) is based on manufacturer
specifications which are subject to change.
</p>
<p className="mt-4">
Shadow GunBuilder AI does <strong>not</strong> guarantee that parts selected in the digital builder
will fit together physically or function safely. Mechanical tolerances and manufacturer variations
may exist.
</p>
</section>
{/* 4. User Generated Content */}
<section>
<h2 className="mb-4 text-xl font-semibold text-white">4. User Builds and the "Vault"</h2>
<p>
When you save a build to your Vault or publish it to the community, you grant us a non-exclusive
license to display and store that data. You are responsible for the content of your descriptions
and ensuring they do not violate any laws or third-party rights.
</p>
</section>
{/* 5. Affiliate and Pricing Disclosure */}
<section>
<h2 className="mb-4 text-xl font-semibold text-white">5. Affiliate & Pricing Data</h2>
<p>
Pricing and availability data are pulled from third-party retailers. We are not responsible
for pricing errors or out-of-stock items. We may receive commissions for purchases made
through links on the Site.
</p>
</section>
{/* 6. Limitation of Liability */}
<section className="border-t border-white/10 pt-10">
<h2 className="mb-4 text-xl font-semibold text-white">6. Limitation of Liability</h2>
<p className="uppercase text-xs opacity-70">
SHADOW GUNBUILDER AI AND ITS OPERATORS SHALL NOT BE LIABLE FOR ANY DAMAGES, INJURIES, OR LEGAL
CONSEQUENCES ARISING FROM THE USE OF THIS SITE OR THE PHYSICAL ASSEMBLY OF FIREARMS BASED ON
PLANS CREATED HEREIN. USE AT YOUR OWN RISK.
</p>
</section>
<div className="pt-10">
<Link
href="/public"
className="text-sm text-blue-400 hover:text-blue-300 transition-colors"
>
Return to Home
</Link>
</div>
</div>
</div>
);
}
+92
View File
@@ -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<any>(null);
const [error, setError] = useState<string | null>(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 (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
<p className="mt-1 text-sm text-zinc-400">
Runs the backend batch invite sender (uses your email templates + tracking).
</p>
</div>
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
/>
Dry run (do everything except actually send)
</label>
<div className="grid gap-4 md:grid-cols-3">
<Field label="Limit" htmlFor="limit">
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
</Field>
<Field label="Token minutes" htmlFor="tokenMinutes">
<Input
id="tokenMinutes"
value={tokenMinutes}
onChange={(e) => setTokenMinutes(e.target.value)}
/>
</Field>
</div>
<Button onClick={runInvites} disabled={loading}>
{loading ? "Running…" : "Send Beta Invites"}
</Button>
{error && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{error}
</div>
)}
{result && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(result, null, 2)}
</pre>
)}
</div>
</div>
);
}
@@ -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<string, any>;
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<EnrichmentType>("CALIBER");
const [status, setStatus] = useState<EnrichmentStatus>("PENDING_REVIEW");
const [limit, setLimit] = useState<number>(50);
const [items, setItems] = useState<QueueItem[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Bulk selection
const [selected, setSelected] = useState<Record<number, boolean>>({});
// “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<number, EnrichmentStatus>
>({});
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<number, boolean> = {};
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<number, EnrichmentStatus> = {};
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 (
<div className="space-y-4">
{/* Controls */}
<div className="flex flex-col gap-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm text-zinc-400">
Showing <span className="text-zinc-200 font-medium">{count}</span>
</div>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={type}
onChange={(e) => setType(e.target.value as EnrichmentType)}
>
<option value="CALIBER">CALIBER</option>
<option value="CALIBER_GROUP">CALIBER_GROUP</option>
</select>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={status}
onChange={(e) => {
setSelected({});
setLocalStatus({});
setStatus(e.target.value as EnrichmentStatus);
}}
>
<option value="PENDING_REVIEW">PENDING_REVIEW</option>
<option value="APPROVED">APPROVED</option>
<option value="REJECTED">REJECTED</option>
<option value="APPLIED">APPLIED</option>
</select>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={limit}
onChange={(e) => setLimit(parseInt(e.target.value, 10))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={load}
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</button>
<button
type="button"
onClick={runRules}
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
disabled={loading}
title="Run enrichment rules to create new suggestions"
>
<Wand2 className="h-4 w-4" />
Run Rules
</button>
<button
type="button"
onClick={runAi}
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800"
disabled={loading}
title="Run AI enrichment to create new suggestions"
>
<Wand2 className="h-4 w-4" />
Run AI
</button>
</div>
</div>
{/* Bulk action bar */}
{selectedIds.length > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
<div className="text-sm text-zinc-300">
Selected{" "}
<span className="font-semibold">{selectedIds.length}</span>
<span className="ml-2 text-xs text-zinc-500">
(Apply only works on APPROVED + product caliber blank)
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
<button
type="button"
onClick={() => 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
</button>
</div>
</div>
) : null}
</div>
{error ? (
<div className="rounded-md border border-red-900/50 bg-red-950/30 p-3 text-sm text-red-200">
{error}
</div>
) : null}
{/* Table */}
<div className="overflow-hidden rounded-md border border-zinc-800">
<div className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 border-b border-zinc-800 bg-zinc-950 px-4 py-3 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={toggleAllOnPage}
className="h-4 w-4 accent-amber-400"
aria-label="Select all"
/>
</div>
<div>Product</div>
<div className="text-right">Confidence</div>
<div className="text-right">Suggested</div>
<div className="text-right">Actions</div>
</div>
<div className="divide-y divide-zinc-800 bg-zinc-950/40">
{items.length === 0 ? (
<div className="p-6 text-sm text-zinc-400">No items found.</div>
) : (
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 (
<div
key={it.id}
className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 px-4 py-3"
>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={!!selected[it.id]}
onChange={() => toggleOne(it.id)}
className="h-4 w-4 accent-amber-400"
aria-label={`Select enrichment ${it.id}`}
/>
</div>
<div className="min-w-0">
<div className="flex items-start gap-3">
{it.mainImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.mainImageUrl}
alt=""
className="h-10 w-10 rounded border border-zinc-800 object-cover"
/>
) : (
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-900" />
)}
<div className="min-w-0">
<a
href={`/admin/products/${it.productId}`}
className="text-sm font-semibold text-zinc-100 hover:underline line-clamp-2"
>
{it.productName ?? `Product #${it.productId}`}
</a>
<div className="mt-0.5 text-xs text-zinc-500">
{it.brandName ? `${it.brandName}` : ""}
{it.source} {st}
{alreadySet ? (
<span className="ml-2 inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-zinc-300">
Already set: {alreadySetValue}
</span>
) : null}
</div>
{it.rationale ? (
<div className="mt-1 text-xs text-zinc-400 line-clamp-2">
{it.rationale}
</div>
) : null}
</div>
</div>
</div>
<div className="text-right text-sm font-semibold text-zinc-200">
{(it.confidence ?? 0).toFixed(2)}
</div>
<div className="text-right text-sm text-amber-300">
{suggested ?? "—"}
</div>
<div className="flex justify-end gap-2">
{st === "PENDING_REVIEW" && (
<>
<button
type="button"
onClick={() => 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"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => 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"
>
<X className="h-4 w-4" />
</button>
</>
)}
{st === "APPROVED" && (
<button
type="button"
onClick={() => 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
</button>
)}
{st === "APPLIED" && (
<span className="inline-flex h-8 items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-200">
Applied
</span>
)}
{st === "REJECTED" && (
<span className="inline-flex h-8 items-center rounded-md border border-red-500/30 bg-red-500/10 px-3 text-xs font-semibold text-red-200">
Rejected
</span>
)}
</div>
</div>
);
})
)}
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import EnrichmentQueueClient from "./EnrichmentQueueClient";
export const dynamic = "force-dynamic";
export default function AdminEnrichmentPage() {
return (
<div className="mx-auto w-full max-w-6xl px-6 py-8">
<div className="mb-6">
<h1 className="text-xl font-semibold text-zinc-50">Enrichment Queue</h1>
<p className="mt-1 text-sm text-zinc-400">
Review AI/rules suggestions, approve/reject, then apply to products.
</p>
</div>
<EnrichmentQueueClient />
</div>
);
}
+54 -59
View File
@@ -1,7 +1,10 @@
"use client"; "use client";
import type React from "react"; 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 AdminLeftNavigation from "@/components/AdminLeftNavigation";
import { useAuth } from "@/context/AuthContext";
import { import {
LayoutDashboard, LayoutDashboard,
Download, Download,
@@ -11,81 +14,72 @@ import {
Users, Users,
Settings, Settings,
LucideMail, LucideMail,
Wand2,
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navItems = [
{ { label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
label: "Dashboard", { label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
href: "/admin", { label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
icon: <LayoutDashboard className="h-4 w-4" />, { label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
}, { label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
{ { label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
label: "Imports", { label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
href: "/admin/import-status", { label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
icon: <Download className="h-4 w-4" />, { label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
}, { label: "List Emails", href: "/admin/email", icon: <LucideMail className="h-4 w-4" /> },
{ { label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
label: "Mappings", { label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" /> },
href: "/admin/mapping",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Products",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Merchants",
href: "/admin/merchants",
icon: <Store className="h-4 w-4" />,
},
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{
label: "Settings",
href: "/admin/settings",
icon: <Settings className="h-4 w-4" />,
},
{
label: "List Emails",
href: "/admin/email",
icon: <LucideMail className="h-4 w-4" />,
},
{
label: "Send a Email",
href: "/admin/email/send",
icon: <LucideMail className="h-4 w-4" />,
},
]; ];
// ... existing code ...
// ADMIN CHECK FOR LOGIN
// const { user, loading } = useAuth();
// if (!loading && user?.role !== "ADMIN") {
// redirect("/"); // or /login
// }
export default function AdminLayout({ export default function AdminLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [collapsed, setCollapsed] = useState(false); 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 ( return (
<div className="flex min-h-screen bg-black text-zinc-50"> <div className="flex min-h-screen bg-black text-zinc-50">
<AdminLeftNavigation <AdminLeftNavigation
collapsed={collapsed} collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)} onToggleCollapsed={() => setCollapsed((v) => !v)}
items={navItems}
/> />
{/* Main column */} {/* Main column */}
@@ -100,6 +94,7 @@ export default function AdminLayout({
Battl Builders Control Panel Battl Builders Control Panel
</p> </p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300"> <span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
Internal v0.1 Internal v0.1
+35
View File
@@ -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" },
});
}
+38
View File
@@ -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;
}
+1
View File
@@ -11,6 +11,7 @@ export async function POST(req: Request) {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(body), body: JSON.stringify(body),
cache: "no-store",
}); });
// Always return ok=true (matches your server behavior) // Always return ok=true (matches your server behavior)
+407
View File
@@ -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 (
<main className="mx-auto max-w-6xl px-4 py-8">
{/* Header crumb */}
<div className="flex items-center justify-between">
<Link
href="/builds"
className="text-sm text-zinc-400 hover:text-zinc-200"
>
Back to Community Builds
</Link>
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<span className="rounded-full border border-zinc-800 bg-zinc-950 px-2 py-0.5">
Public Build
</span>
{build.updatedAt && (
<span className="hidden md:inline">
updated {timeAgo(build.updatedAt)}
</span>
)}
</div>
</div>
{/* Layout */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
{/* Post column */}
<div className="space-y-4">
{/* “Reddit-ish” Post Card */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60">
<div className="flex">
{/* Vote rail */}
<div className="flex w-12 flex-col items-center gap-2 border-r border-zinc-900 bg-black/20 py-4 text-zinc-500">
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
</button>
<div className="text-xs font-semibold text-zinc-400"></div>
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
</button>
</div>
{/* Post body */}
<div className="flex-1 p-5">
{/* Meta */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-500">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-amber-300">
r/BattlBuilders
</span>
<span></span>
<span>posted by</span>
<span className="text-zinc-300">u/anonymous</span>
{build.createdAt && (
<>
<span></span>
<span>{timeAgo(build.createdAt)}</span>
</>
)}
</div>
{/* Title */}
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-zinc-50">
{build.title ?? "Untitled Build"}
</h1>
{/* Description */}
{build.description ? (
<p className="mt-2 text-sm text-zinc-300/90">
{build.description}
</p>
) : (
<p className="mt-2 text-sm text-zinc-500">
{/* placeholder copy */}
Field notes coming soon. This build breakdown will include
purpose, tradeoffs, and why each part made the cut.
</p>
)}
{/* Action bar */}
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Comment
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Share
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Save
</button>
<div className="ml-auto flex items-center gap-2 text-zinc-500">
<span className="hidden sm:inline">Est. Total</span>
<span className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 text-zinc-200">
{formatMoney(total)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Parts card */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">Parts</h2>
<div className="text-xs text-zinc-500">{items.length} items</div>
</div>
<div className="mt-4 space-y-2">
{items.map((it) => (
<div
key={it.uuid ?? it.id}
className="flex items-center gap-3 rounded-lg border border-zinc-900 bg-black/30 px-3 py-3"
>
{/* Thumb */}
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
{it.productImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.productImageUrl}
alt={it.productName ?? "Part image"}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
IMG
</div>
)}
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-zinc-100">
{it.productName ?? "Part"}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-zinc-500">
<span className="rounded-md border border-zinc-800 bg-black/20 px-1.5 py-0.5">
{it.slot ?? "slot"}
</span>
{it.productBrand && <span>{it.productBrand}</span>}
<span className="text-zinc-600"></span>
<span>x{it.quantity ?? 1}</span>
</div>
</div>
{/* Price */}
<div className="flex flex-col items-end gap-1">
<div className="text-sm font-medium text-zinc-200">
{formatMoney(it.bestPrice)}
</div>
<button className="text-[11px] text-amber-300/90 hover:text-amber-200">
View part
</button>
</div>
</div>
))}
</div>
</div>
{/* Gallery */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">
Gallery
</h2>
<span className="text-xs text-zinc-500">user submitted</span>
</div>
{images.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4">
<p className="text-sm text-zinc-400">
No photos yet. Be the first to add a build pic.
</p>
<p className="mt-1 text-[11px] text-zinc-600">
(Upload UI coming soon well store and moderate images
per build.)
</p>
{/* Mock thumbnails */}
<div className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-md border border-zinc-800 bg-zinc-950 flex items-center justify-center text-[10px] text-zinc-600"
>
IMG
</div>
))}
</div>
<button
className="mt-4 rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
disabled
title="Upload UI coming soon"
>
Add Photo (coming soon)
</button>
</div>
) : (
<div className="mt-4">
{/* “Hero” preview */}
<div className="aspect-[16/9] overflow-hidden rounded-lg border border-zinc-900 bg-black/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images[0].url ?? images[0]}
alt="Build photo"
className="h-full w-full object-cover"
/>
</div>
{/* Thumbs */}
<div className="mt-3 grid grid-cols-4 gap-2 sm:grid-cols-6">
{images.slice(0, 12).map((img, idx) => (
<div
key={img.uuid ?? img.id ?? idx}
className="aspect-square overflow-hidden rounded-md border border-zinc-900 bg-black/30"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.thumbUrl ?? img.url ?? img}
alt={`Build photo ${idx + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
View all
</button>
<button
className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
disabled
title="Upload UI coming soon"
>
Add Photo
</button>
<span className="ml-auto text-[11px] text-zinc-600">
{images.length} photos
</span>
</div>
</div>
)}
</div>
{/* Comments (placeholder) */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">Comments</h2>
<span className="text-xs text-zinc-500">mocked</span>
</div>
<div className="mt-4 space-y-4">
{[
{
user: "u/gearhead",
body: "Solid parts list. Any reason you went with that upper over a BCM?",
},
{
user: "u/OP",
body: "Mostly availability + price. Ill 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) => (
<div key={idx} className="flex gap-3">
<div className="mt-1 h-6 w-6 rounded-full bg-zinc-900 text-[10px] text-zinc-300 flex items-center justify-center">
{c.user === "u/OP" ? "OP" : "u"}
</div>
<div className="flex-1">
<div className="text-[11px] text-zinc-500">
<span className="text-zinc-300">{c.user}</span> 1h ago
</div>
<div className="mt-1 text-sm text-zinc-300/90">
{c.body}
</div>
<div className="mt-2 flex items-center gap-3 text-[11px] text-zinc-500">
<button className="hover:text-amber-300">Reply</button>
<button className="hover:text-amber-300">Share</button>
<button className="hover:text-amber-300">Save</button>
</div>
</div>
</div>
))}
<div className="rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4 text-sm text-zinc-500">
Commenting is coming soon. This will become the breakdown
thread for the build (notes, tradeoffs, deal alerts, revisions).
</div>
</div>
</div>
</div>
{/* Sidebar */}
<aside className="space-y-4">
{/* Build stats */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
Build Summary
</h3>
<div className="mt-3 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-zinc-500">Build ID</span>
<span className="text-zinc-300">
{(build.uuid ?? params.buildId).slice(0, 8)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Visibility</span>
<span className="text-zinc-300">
{build.isPublic ? "Public" : "Private"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Items</span>
<span className="text-zinc-300">{items.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Est. Total</span>
<span className="text-zinc-50 font-medium">
{formatMoney(total)}
</span>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<button className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition">
Open in Builder
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
Copy share link
</button>
</div>
</div>
{/* Placeholder “OP notes” */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
OP Notes
</h3>
<ul className="mt-3 space-y-2 text-sm text-zinc-400">
<li> Purpose: home defense / range</li>
<li> Priority: reliability first</li>
<li> Next upgrades: optic, sling, light</li>
</ul>
</div>
</aside>
</div>
</main>
);
}
+5
View File
@@ -1,6 +1,8 @@
// app/layout.tsx // app/layout.tsx
import "./globals.css"; import "./globals.css";
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import Script from "next/script";
import { AuthProvider } from "@/context/AuthContext"; import { AuthProvider } from "@/context/AuthContext";
import { Banner } from "@/components/Banner"; import { Banner } from "@/components/Banner";
@@ -38,6 +40,9 @@ export default function RootLayout({ children }: { children: ReactNode }) {
<body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50"> <body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
{" "} {" "}
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="ad310b4a-aa5e-471a-938b-47a6c0f4108d"
strategy="lazyOnload"
/>
<AuthProvider> <AuthProvider>
<Banner /> <Banner />
{children} {children}
+27 -6
View File
@@ -13,6 +13,7 @@ export default function HomePage() {
async function handleSubmit(e: FormEvent) { async function handleSubmit(e: FormEvent) {
e.preventDefault(); e.preventDefault();
if (!email) { if (!email) {
setMessage("Drop an email in first, operator."); setMessage("Drop an email in first, operator.");
setStatus("error"); setStatus("error");
@@ -30,11 +31,12 @@ export default function HomePage() {
}); });
if (!res.ok) { 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"); setStatus("success");
setMessage("Youre locked in. Watch your inbox."); setMessage("Youre on the list. Invites drop soon — well email your access link when its go-time.");
setEmail(""); setEmail("");
setUseCase(""); setUseCase("");
} catch (err) { } catch (err) {
@@ -160,7 +162,8 @@ export default function HomePage() {
value={email} value={email}
onChange={(e) => setEmail(e.target.value)} onChange={(e) => setEmail(e.target.value)}
placeholder="you@gearjunkie.com" 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 required
/> />
</div> </div>
@@ -178,17 +181,22 @@ export default function HomePage() {
value={useCase} value={useCase}
onChange={(e) => setUseCase(e.target.value)} onChange={(e) => setUseCase(e.target.value)}
rows={3} rows={3}
disabled={status === "loading" || status === "success"}
placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..." 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"
/> />
</div> </div>
<button <button
type="submit" type="submit"
disabled={status === "loading"} disabled={status === "loading" || status === "success"}
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70" className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
> >
{status === "loading" ? "Enlisting…" : "Get Beta Access"} {status === "loading"
? "Enlisting…"
: status === "success"
? "On the List ✅"
: "Get Beta Access"}
</button> </button>
{message && ( {message && (
@@ -205,6 +213,19 @@ export default function HomePage() {
</p> </p>
)} )}
{status === "success" && (
<button
type="button"
onClick={() => {
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
</button>
)}
<div className="mt-6 text-center text-[11px] text-zinc-500"> <div className="mt-6 text-center text-[11px] text-zinc-500">
Already invited?{" "} Already invited?{" "}
<a href="/login" className="underline hover:text-zinc-300"> <a href="/login" className="underline hover:text-zinc-300">
+30 -58
View File
@@ -1,6 +1,7 @@
"use client"; "use client";
import type React from "react"; import type React from "react";
import Link from "next/link";
import { import {
LayoutDashboard, LayoutDashboard,
Download, Download,
@@ -10,70 +11,41 @@ import {
Users, Users,
Settings, Settings,
LucideMail, LucideMail,
Wand2,
} from "lucide-react"; } from "lucide-react";
import Link from "next/link";
export type AdminNavItem = {
label: string;
href: string;
icon: React.ReactNode;
};
type AdminLeftNavigationProps = { type AdminLeftNavigationProps = {
collapsed: boolean; collapsed: boolean;
onToggleCollapsed: () => void; onToggleCollapsed: () => void;
items?: AdminNavItem[]; // ✅ NEW
}; };
const navItems = [ const defaultNavItems: AdminNavItem[] = [
{ { label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
label: "Dashboard", { label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
href: "/admin", { label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
icon: <LayoutDashboard className="h-4 w-4" />, { label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
}, { label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
{ { label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
label: "Imports", { label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
href: "/admin/import-status", { label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
icon: <Download className="h-4 w-4" />, { label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
}, { label: "Manage Emails", href: "/admin/email/manage", icon: <LucideMail className="h-4 w-4" /> },
{ { label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
label: "Mappings", { label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" />,
href: "/admin/mapping",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Products",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Merchants",
href: "/admin/merchants",
icon: <Store className="h-4 w-4" />,
},
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{
label: "Settings",
href: "/admin/settings",
icon: <Settings className="h-4 w-4" />,
},
{
label: "Manage Emails",
href: "/admin/email/manage",
icon: <LucideMail className="h-4 w-4" />,
},
{
label: "Send a Email",
href: "/admin/email/send",
icon: <LucideMail className="h-4 w-4" />,
}, },
]; ];
export default function AdminLeftNavigation({ export default function AdminLeftNavigation({
collapsed, collapsed,
onToggleCollapsed, onToggleCollapsed,
items = defaultNavItems, // ✅ NEW default
}: AdminLeftNavigationProps) { }: AdminLeftNavigationProps) {
return ( return (
<aside <aside
@@ -98,7 +70,9 @@ export default function AdminLeftNavigation({
{!collapsed && ( {!collapsed && (
<div> <div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500"> <p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
<Link href={"/"} title={"Back to Battl Dashboard"} >Battl Builders</Link> <Link href="/" title="Back to Battl Dashboard">
Battl Builders
</Link>
</p> </p>
<p className="text-[10px] text-zinc-600">Admin Command</p> <p className="text-[10px] text-zinc-600">Admin Command</p>
</div> </div>
@@ -106,8 +80,8 @@ export default function AdminLeftNavigation({
</div> </div>
<nav className="flex-1 space-y-1 text-sm"> <nav className="flex-1 space-y-1 text-sm">
{navItems.map((item) => ( {items.map((item) => (
<a <Link
key={item.href} key={item.href}
href={item.href} href={item.href}
className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300" className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300"
@@ -117,15 +91,13 @@ export default function AdminLeftNavigation({
{!collapsed && <span>{item.label}</span>} {!collapsed && <span>{item.label}</span>}
</div> </div>
{!collapsed && <span className="text-[10px] text-zinc-600"></span>} {!collapsed && <span className="text-[10px] text-zinc-600"></span>}
</a> </Link>
))} ))}
</nav> </nav>
{!collapsed && ( {!collapsed && (
<div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600"> <div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600">
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500"> <p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">Status</p>
Status
</p>
<p className="mt-1"> <p className="mt-1">
Import engine: <span className="text-emerald-400">online</span> Import engine: <span className="text-emerald-400">online</span>
</p> </p>
+42 -6
View File
@@ -49,6 +49,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<AuthUser>(null); const [user, setUser] = useState<AuthUser>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
/**
* Persist auth to localStorage
*/
const persistAuth = useCallback( const persistAuth = useCallback(
(nextToken: string | null, nextUser: AuthUser) => { (nextToken: string | null, nextUser: AuthUser) => {
if (typeof window === "undefined") return; 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(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
const storedToken = window.localStorage.getItem(TOKEN_KEY); const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY); const storedUser = window.localStorage.getItem(USER_KEY);
if (storedToken) setToken(storedToken); if (storedToken && storedUser) {
if (storedUser) {
try { 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 { } catch {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY); window.localStorage.removeItem(USER_KEY);
} }
} }
@@ -88,11 +106,24 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setLoading(false); setLoading(false);
}, []); }, []);
/**
* Used by login/register/magic-link
*/
const setSession = useCallback( const setSession = useCallback(
(nextToken: string, nextUser: NonNullable<AuthUser>) => { (nextToken: string, nextUser: NonNullable<AuthUser>) => {
setToken(nextToken); setToken(nextToken);
setUser(nextUser); setUser(nextUser);
persistAuth(nextToken, 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] [persistAuth]
); );
@@ -118,6 +149,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser = const nextUser: AuthUser =
data.user ?? data.user ??
({ ({
uuid: data.uuid,
email: data.email ?? email, email: data.email ?? email,
displayName: data.displayName ?? null, displayName: data.displayName ?? null,
role: data.role ?? "USER", role: data.role ?? "USER",
@@ -160,6 +192,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
const nextUser: AuthUser = const nextUser: AuthUser =
data.user ?? data.user ??
({ ({
uuid: data.uuid,
email: data.email ?? email, email: data.email ?? email,
displayName: data.displayName ?? displayName ?? null, displayName: data.displayName ?? displayName ?? null,
role: data.role ?? "USER", role: data.role ?? "USER",
@@ -177,6 +210,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
setToken(null); setToken(null);
setUser(null); setUser(null);
persistAuth(null, null); persistAuth(null, null);
// Clear server session cookies
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
}, [persistAuth]); }, [persistAuth]);
const getAuthHeaders = useCallback((): HeadersInit => { const getAuthHeaders = useCallback((): HeadersInit => {
@@ -191,7 +227,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
register, register,
logout, logout,
getAuthHeaders, getAuthHeaders,
setSession, // ✅ important setSession,
}; };
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>; return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+34 -9
View File
@@ -12,15 +12,40 @@ const ALWAYS_ALLOW = new Set<string>([
"/sitemap.xml", "/sitemap.xml",
]); ]);
// Match common public/static file extensions so middleware never blocks them const PUBLIC_FILE =
const PUBLIC_FILE = /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i; /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
export function middleware(req: NextRequest) { export function middleware(req: NextRequest) {
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
const { pathname } = req.nextUrl; 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 ( if (
pathname.startsWith("/_next") || pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") || pathname.startsWith("/favicon") ||
@@ -31,17 +56,17 @@ export function middleware(req: NextRequest) {
return NextResponse.next(); return NextResponse.next();
} }
// ✅ Allow the root + a few public files
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next(); if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
// 🚫 Everything else: hide it
return NextResponse.rewrite(new URL("/404", req.url)); return NextResponse.rewrite(new URL("/404", req.url));
} }
` 1`
export const config = { 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: [ 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)).*)", "/((?!_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)).*)",
], ],
}; };