fixed authcontext for sign up, added 404 and middleware for prod readiness

This commit is contained in:
2025-12-12 11:10:49 -05:00
parent 777618f684
commit 1757aba3e7
6 changed files with 504 additions and 6 deletions
+11 -3
View File
@@ -403,9 +403,17 @@ export default function GunbuilderPage() {
Platform
<select
value={platform}
onChange={(e) =>
setPlatform(e.target.value as (typeof PLATFORMS)[number])
}
onChange={(e) => {
const next = e.target.value as (typeof PLATFORMS)[number];
setPlatform(next);
// Keep URL in sync so navigation + refresh preserve platform
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", next);
qp.delete("select");
router.replace(`/builder?${qp.toString()}`, { scroll: false });
}}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
+411
View File
@@ -0,0 +1,411 @@
"use client";
import { useEffect, useMemo, useState } from "react";
type ProductSummary = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
buyUrl: string | null;
imageUrl: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type PlatformOption = {
key: string; // e.g. AR15, AR9
label: string; // e.g. "Armalite Rifle model 15"
};
// Backend currently filters products by canonical platform strings like "AR-15".
// DB platform keys are like "AR15" / "AR9". Translate when calling /api/products.
const platformKeyToApiPlatform = (key: string) => {
const k = (key ?? "").toUpperCase().trim();
if (!k) return "AR-15";
if (k === "AR15" || k === "AR-15") return "AR-15";
if (k === "AR10" || k === "AR-10") return "AR-10";
if (k === "AR9" || k === "AR-9") return "AR-9";
// Fallback: if it already contains a dash, assume it's canonical.
if (k.includes("-")) return k;
// Otherwise, best-effort: insert a dash after AR.
if (k.startsWith("AR") && k.length > 2) return `AR-${k.slice(2)}`;
return k;
};
const FALLBACK_PLATFORMS: PlatformOption[] = [
{ key: "AR15", label: "AR-15" },
{ key: "AR10", label: "AR-10" },
{ key: "AR9", label: "AR-9" },
];
export default function AdminProductsPage() {
const [platforms, setPlatforms] = useState<PlatformOption[]>(FALLBACK_PLATFORMS);
const [platform, setPlatform] = useState<string>("AR15");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [products, setProducts] = useState<ProductSummary[]>([]);
const [query, setQuery] = useState<string>("");
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(50);
// Load available platforms (dynamic if backend supports it; otherwise fallback)
useEffect(() => {
const controller = new AbortController();
async function loadPlatforms() {
try {
const res = await fetch(`${API_BASE_URL}/api/platforms`, {
signal: controller.signal,
});
if (!res.ok) return;
const data = (await res.json()) as unknown;
// Expected shape:
// [{ id, key, label, is_active, ... }]
const items = Array.isArray(data) ? data : [];
const cleaned: PlatformOption[] = items
.map((item) => {
if (!item || typeof item !== "object") return null;
const obj = item as Record<string, unknown>;
const key = typeof obj.key === "string" ? obj.key.trim() : "";
const label = typeof obj.label === "string" ? obj.label.trim() : "";
// Some payloads might use isActive; yours currently uses is_active
const activeRaw = obj.is_active ?? obj.isActive;
const isActive =
typeof activeRaw === "boolean"
? activeRaw
: typeof activeRaw === "number"
? activeRaw === 1
: true;
if (!key || !label || !isActive) return null;
return { key, label } as PlatformOption;
})
.filter((v): v is PlatformOption => v !== null);
if (cleaned.length === 0) return;
// Keep them stable and unique by key
const byKey = new Map<string, PlatformOption>();
for (const p of cleaned) byKey.set(p.key, p);
const unique = Array.from(byKey.values());
setPlatforms(unique);
setPlatform((prev) => (unique.some((p) => p.key === prev) ? prev : unique[0].key));
} catch (err: any) {
if (err?.name === "AbortError") return;
// ignore (fallback list stays)
}
}
loadPlatforms();
return () => controller.abort();
}, []);
// Load products for selected platform
useEffect(() => {
const controller = new AbortController();
async function loadProducts() {
try {
setLoading(true);
setError(null);
const apiPlatform = platformKeyToApiPlatform(platform);
// NOTE: backend endpoint expects `platform`, not `platformKey`.
const url = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
apiPlatform
)}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data = (await res.json()) as ProductSummary[];
setProducts(Array.isArray(data) ? data : []);
setPage(1);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
setProducts([]);
} finally {
setLoading(false);
}
}
loadProducts();
return () => controller.abort();
}, [platform]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return products;
return products.filter((p) => {
const name = (p.name ?? "").toLowerCase();
const brand = (p.brand ?? "").toLowerCase();
const role = (p.partRole ?? "").toLowerCase();
return name.includes(q) || brand.includes(q) || role.includes(q);
});
}, [products, query]);
// Reset to page 1 when the search query changes
useEffect(() => {
setPage(1);
}, [query, platform, pageSize]);
const totalPages = useMemo(() => {
const total = filtered.length;
return total === 0 ? 1 : Math.ceil(total / pageSize);
}, [filtered.length, pageSize]);
const clampedPage = useMemo(() => {
if (page < 1) return 1;
if (page > totalPages) return totalPages;
return page;
}, [page, totalPages]);
const pageStart = useMemo(() => (clampedPage - 1) * pageSize, [clampedPage, pageSize]);
const pageEndExclusive = useMemo(
() => Math.min(pageStart + pageSize, filtered.length),
[pageStart, pageSize, filtered.length]
);
const paginated = useMemo(() => {
return filtered.slice(pageStart, pageEndExclusive);
}, [filtered, pageStart, pageEndExclusive]);
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
<header className="mb-6">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Admin
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Products <span className="text-amber-300">Catalog</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Read-only view of products coming from the Ballistic backend. Filter by
platform, search, and spot-check pricing + buy links.
</p>
</header>
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md: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-3">
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
Platform
<select
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{platforms.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
</label>
<div className="flex items-center gap-2">
<label htmlFor="q" className="text-xs text-zinc-500">
Search
</label>
<input
id="q"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="brand, name, part role…"
className="w-64 max-w-full rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
Page size
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{[25, 50, 100, 200].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
<div className="text-xs text-zinc-500">
{loading ? (
<span>Loading</span>
) : error ? (
<span className="text-red-400">{error}</span>
) : (
<span>
Showing{" "}
<span className="font-medium text-zinc-200">
{filtered.length === 0 ? 0 : pageStart + 1}-{pageEndExclusive}
</span>
{" "}of{" "}
<span className="font-medium text-zinc-200">{filtered.length}</span>
{" "}{filtered.length === 1 ? "product" : "products"}
</span>
)}
</div>
</div>
{!loading && !error && filtered.length > 0 && (
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-[0.7rem] text-zinc-500">
Page <span className="font-semibold text-zinc-200">{clampedPage}</span> of{" "}
<span className="font-semibold text-zinc-200">{totalPages}</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={clampedPage === 1}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={clampedPage === totalPages}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
<div className="mt-4 overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
<table className="min-w-full text-xs md:text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Product</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Brand</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Platform</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Part Role</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Price</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Link</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
Loading products
</td>
</tr>
) : error ? (
<tr>
<td className="px-3 py-6 text-center text-sm text-red-400" colSpan={6}>
{error}
</td>
</tr>
) : paginated.length === 0 ? (
<tr>
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
No products found.
</td>
</tr>
) : (
paginated.map((p) => (
<tr
key={p.id}
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
>
<td className="px-3 py-2 align-top">
<div className="font-medium text-zinc-100 line-clamp-2">{p.name}</div>
<div className="mt-0.5 text-[0.7rem] text-zinc-600">ID: {p.id}</div>
</td>
<td className="px-3 py-2 align-top text-zinc-300">{p.brand}</td>
<td className="px-3 py-2 align-top text-zinc-300">
{p.platform ?? platformKeyToApiPlatform(platform)}
</td>
<td className="px-3 py-2 align-top text-zinc-300">{p.partRole}</td>
<td className="px-3 py-2 align-top text-right text-amber-300">
{p.price == null ? "—" : `$${Number(p.price).toFixed(2)}`}
</td>
<td className="px-3 py-2 align-top text-right">
{p.buyUrl ? (
<a
href={p.buyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center whitespace-nowrap rounded-md bg-amber-400 px-2.5 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
>
Open
</a>
) : (
<span className="text-zinc-600"></span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{!loading && !error && filtered.length > 0 && (
<div className="mt-3 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={clampedPage === 1}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
aria-label="Previous page"
>
&lt;
</button>
<span className="text-xs text-zinc-500">
Page{" "}
<span className="font-semibold text-zinc-200">
{clampedPage}
</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">
{totalPages}
</span>
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={clampedPage === totalPages}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
aria-label="Next page"
>
&gt;
</button>
</div>
)}
<div className="mt-3 text-[0.7rem] text-zinc-500">
Backend: <span className="font-mono text-zinc-400">{API_BASE_URL}</span> · Endpoint: <span className="font-mono text-zinc-400">/api/products?platform=...</span> · Page size: <span className="font-mono text-zinc-400">{pageSize}</span>
</div>
</section>
</div>
</main>
);
}
+41
View File
@@ -0,0 +1,41 @@
import Link from "next/link";
export default function NotFound() {
return (
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
<div className="max-w-md text-center">
<p className="text-xs font-semibold tracking-[0.3em] uppercase text-zinc-500">
Battl Builders
</p>
<h1 className="mt-4 text-4xl font-semibold tracking-tight">
Page Not Found
</h1>
<p className="mt-3 text-sm text-zinc-400">
This area of the site isnt publicly accessible yet.
</p>
<div className="mt-6 flex justify-center gap-3">
<Link
href="/"
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
>
Go Home
</Link>
<a
href="https://battl.builders"
className="rounded-md border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-900 hover:border-zinc-600 transition-colors"
>
Learn More
</a>
</div>
<p className="mt-6 text-[11px] text-zinc-600">
Early access features are launching soon.
</p>
</div>
</main>
);
}
+1 -1
View File
@@ -34,7 +34,7 @@ export default function RegisterPage() {
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
<h1 className="text-2xl font-semibold tracking-tight">
Join the <span className="text-amber-300">Shadow Standard</span> beta
Join the <span className="text-amber-300">Battl Builder</span> Beta
</h1>
<p className="mt-2 text-sm text-zinc-400">
Create an account so we can save your builds, watch price drops, and
+2 -2
View File
@@ -85,7 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
async ({ email, password }: { email: string; password: string }) => {
setLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/auth/login`, {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password }),
@@ -130,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}) => {
setLoading(true);
try {
const res = await fetch(`${API_BASE_URL}/auth/register`, {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, password, displayName }),
+38
View File
@@ -0,0 +1,38 @@
// middleware.ts
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const LAUNCH_ONLY_ROOT = process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT === "true";
// Allow only root + essential static files
const ALLOW = new Set([
"/",
"/favicon.ico",
"/robots.txt",
"/sitemap.xml",
]);
export function middleware(req: NextRequest) {
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
const { pathname } = req.nextUrl;
// Always allow Next internals + static assets
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/assets") ||
pathname.startsWith("/images")
) {
return NextResponse.next();
}
// Allow the root + a few public files
if (ALLOW.has(pathname)) return NextResponse.next();
// Everything else: hide it
return NextResponse.rewrite(new URL("/404", req.url)); // or redirect to "/"
}
export const config = {
matcher: ["/:path*"],
};