1 Commits

Author SHA1 Message Date
dstrawsb d9498f8aca upgrading nextjs to 15
CI / test (push) Successful in 6s
2026-01-24 23:32:58 -05:00
76 changed files with 2452 additions and 2854 deletions
+19 -72
View File
@@ -74,7 +74,8 @@ const isUuid = (v: string) =>
v
);
// API routes now handled by Next.js /api routes (server-side)
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const STORAGE_KEY = "gunbuilder-build-state";
@@ -295,7 +296,7 @@ function buildAssemblyRows(params: {
rows.push({
key: `${groupLabel}-${paths.complete}`,
kind: "category",
label: "Completed Lower (Fastest)",
label: "Complete Assembly (Fastest)",
categoryId: paths.complete,
indent: 0,
pill: mode === "complete" ? "Selected" : undefined,
@@ -336,8 +337,8 @@ function GunbuilderPageContent() {
const api = useApi();
// ✅ Auth: check if user is logged in
const { user, loading: authLoading } = useAuth();
// ✅ Auth: we need the token ready before calling /builds/me/*
const { token, loading: authLoading, getAuthHeaders } = useAuth();
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15");
@@ -615,7 +616,7 @@ function GunbuilderPageContent() {
try {
const res = await fetch(
`/api/catalog/products/by-ids`,
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
{
method: "POST",
signal: controller.signal,
@@ -727,7 +728,7 @@ function GunbuilderPageContent() {
if (authLoading) return;
// If not logged in, don't spam the backend; show a helpful message instead.
if (!user) {
if (!token) {
setShareStatus("Please log in to load builds from your Vault.");
window.setTimeout(() => setShareStatus(null), 4500);
return;
@@ -737,12 +738,14 @@ function GunbuilderPageContent() {
try {
setShareStatus("Loading build…");
// NOTE: using fetch here goes through our Next.js API routes with HTTP-only cookies
// NOTE: using fetch here avoids any “stale api instance” issues and lets us
// explicitly attach JWT headers.
const res = await fetch(
`/api/builds/${encodeURIComponent(uuidToLoad)}`,
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
{
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
},
}
);
@@ -784,7 +787,9 @@ function GunbuilderPageContent() {
searchParams,
router,
authLoading,
user,
token,
getAuthHeaders,
// API_BASE_URL is a module constant; no need to include
]);
// -----------------------------
@@ -1195,7 +1200,6 @@ function GunbuilderPageContent() {
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS
@@ -1209,17 +1213,6 @@ function GunbuilderPageContent() {
update as you go. This early-access builder keeps your setup saved
locally so you can come back and refine it anytime.
</p>
</div>
{user?.role === "ADMIN" ? (
<Link
href="/admin"
className="inline-flex items-center gap-2 rounded-md border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs font-semibold text-amber-200 hover:border-amber-300 hover:text-amber-100"
>
Admin Toolbar
</Link>
) : null}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3">
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
@@ -1253,6 +1246,7 @@ function GunbuilderPageContent() {
Parts list updates automatically when you change platforms.
</p>
</div>
</div>
</header>
{/* Build summary panel */}
@@ -1481,11 +1475,6 @@ function GunbuilderPageContent() {
if (groupCategories.length === 0) return null;
// Check if there are locked/conflicting parts in this group
const hasConflicts = tableRows.some(
(row) => row.kind === "category" && row.locked
);
return (
<div
id={`group-${group.id}`}
@@ -1505,23 +1494,6 @@ function GunbuilderPageContent() {
)}
</div>
</div>
{hasConflicts && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-start gap-2">
<span className="text-amber-400 text-sm"></span>
<div className="flex-1">
<p className="text-sm font-medium text-amber-300">
Part Conflict Detected
</p>
<p className="text-xs text-zinc-300 mt-1">
Some parts below are disabled because you've selected a complete assembly that already includes them. To customize individual parts, remove the complete assembly first.
</p>
</div>
</div>
</div>
)}
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
<table className="min-w-full text-xs md:text-sm">
<thead>
@@ -1636,15 +1608,9 @@ function GunbuilderPageContent() {
</div>
{selectedPart ? (
<Link
href={`/parts/p/${canonicalPlatform}/${category.id}/${selectedPart.id}-${selectedPart.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")}`}
className="text-[0.7rem] text-zinc-500 hover:text-amber-300 hover:underline line-clamp-1 transition-colors"
>
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
{selectedPart.name}
</Link>
</div>
) : (
<div className="text-[0.7rem] text-zinc-600">
No part selected
@@ -1674,27 +1640,8 @@ function GunbuilderPageContent() {
<td className="px-3 py-2 align-top">
<div className="flex justify-end gap-2">
{isLocked ? (
<div className="flex items-center gap-2">
<span className="text-[0.7rem] text-amber-400/70">
⚠ Disabled
</span>
<div className="group relative">
<button
type="button"
className="text-zinc-500 hover:text-zinc-300 text-xs"
title="Why is this disabled?"
>
</button>
<div className="invisible group-hover:visible absolute right-0 top-6 z-10 w-64 rounded-md border border-zinc-800 bg-zinc-950 p-3 text-xs text-zinc-300 shadow-xl">
<p className="font-semibold text-amber-300 mb-1">
Part Conflict
</p>
<p>
You've selected a complete assembly which already includes this part. Remove the complete assembly to select individual parts.
</p>
</div>
</div>
<div className="text-[0.7rem] text-zinc-500">
</div>
) : selectedPart ? (
<>
@@ -18,7 +18,6 @@ type OfferFromApi = {
price?: number | null;
originalPrice?: number | null;
buyUrl?: string | null;
buyShortUrl?: string | null;
inStock?: boolean | null;
lastUpdated?: string | null;
};
@@ -30,7 +29,6 @@ type GunbuilderProductFromApi = {
platform: string;
partRole: string;
price: number | null;
caliber?: string | null;
// Optional (legacy fallback label if product.offers is missing)
merchantName?: string | null;
@@ -39,7 +37,6 @@ type GunbuilderProductFromApi = {
mainImageUrl?: string | null;
buyUrl?: string | null;
buyShortUrl?: string | null;
inStock?: boolean | null;
shortDescription?: string | null;
@@ -241,23 +238,14 @@ export default function ProductDetailsPage() {
setLoading(true);
setError(null);
const url = `/api/catalog/products/${numericId}`;
const res = await fetch(url, {
signal: controller.signal,
credentials: 'include',
});
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load product (${res.status})`);
}
const data: GunbuilderProductFromApi = await res.json();
console.log('[DEBUG] Product from API:', {
id: data.id,
name: data.name,
buyUrl: data.buyUrl,
buyShortUrl: data.buyShortUrl
});
setProduct(data);
} catch (err: any) {
if (err?.name === "AbortError") return;
@@ -284,18 +272,14 @@ export default function ProductDetailsPage() {
try {
setOffersLoading(true);
const url = `/api/catalog/products/${numericId}/offers`;
const res = await fetch(url, {
signal: controller.signal,
credentials: 'include',
});
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load offers (${res.status})`);
}
const data: OfferFromApi[] = await res.json();
console.log('[DEBUG] Offers from API:', data);
setOffersFromApi(Array.isArray(data) ? data : []);
} catch (err: any) {
if (err?.name === "AbortError") return;
@@ -346,23 +330,14 @@ export default function ProductDetailsPage() {
* - Fallback to legacy single-offer derived from product if needed
*/
const offers = useMemo(() => {
if (offersFromApi.length > 0) {
console.log('[DEBUG] Using offers from API, first offer:', offersFromApi[0]);
const fallbackShortUrl = product?.buyShortUrl ?? null;
const normalized = offersFromApi.map((offer) => ({
...offer,
buyShortUrl: offer.buyShortUrl || fallbackShortUrl,
}));
return sortOffers(normalized);
}
if (offersFromApi.length > 0) return sortOffers(offersFromApi);
if (product?.buyUrl || product?.buyShortUrl) {
console.log('[DEBUG] Using fallback from product, buyShortUrl:', product.buyShortUrl, 'buyUrl:', product.buyUrl);
if (product?.buyUrl) {
return sortOffers([
{
merchantName: product.merchantName ?? "Retailer",
price: product.price,
buyUrl: product.buyShortUrl || product.buyUrl,
buyUrl: product.buyUrl,
inStock: product.inStock ?? true,
},
]);
@@ -584,14 +559,6 @@ export default function ProductDetailsPage() {
{product.platform || platform}
</span>
</span>
{product.caliber && (
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Caliber:{" "}
<span className="font-semibold text-zinc-100">
{product.caliber}
</span>
</span>
)}
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Role:{" "}
<span className="font-semibold text-zinc-100">
@@ -693,9 +660,9 @@ export default function ProductDetailsPage() {
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
</td>
<td className="px-3 py-2 text-right">
{(o.buyShortUrl || o.buyUrl) ? (
{o.buyUrl ? (
<a
href={o.buyShortUrl || o.buyUrl}
href={o.buyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
@@ -719,10 +686,10 @@ export default function ProductDetailsPage() {
)}
{/* Best Offer CTA */}
{(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
{bestOffer?.buyUrl ? (
<div className="mt-3 flex justify-end">
<a
href={bestOffer.buyShortUrl || bestOffer.buyUrl}
href={bestOffer.buyUrl}
target="_blank"
rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
@@ -1,5 +1,6 @@
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
export default function Page({ params }: { params: { platform: string; partRole: string } }) {
export default async function Page(props: { params: Promise<{ platform: string; partRole: string }> }) {
const params = await props.params;
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
}
+6 -10
View File
@@ -1,5 +1,6 @@
import React from "react";
import Link from "next/link";
import MailingAddressComponent from "@/components/MailingAddressComponent";
export const metadata = {
title: "Privacy Policy | Battl Builder",
@@ -7,6 +8,8 @@ export const metadata = {
"Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.",
};
export default function PrivacyPolicy() {
const lastUpdated = "January 9, 2026";
@@ -22,7 +25,8 @@ export default function PrivacyPolicy() {
<section>
<h2 className="mb-4 text-xl font-semibold">1. Introduction</h2>
<p>
Battl Builder (&quot;we,&quot; &quot;us,&quot; &quot;our,&quot; or &quot;Company&quot;) respects your privacy
Battl Builder (&quot;we,&quot; &quot;us,&quot; &quot;our,&quot; or &quot;Company&quot;) respects
your privacy
and is committed to protecting it through this Privacy Policy. This
policy explains our practices regarding the collection, use, and
protection of your personal information when you access our website
@@ -412,15 +416,7 @@ export default function PrivacyPolicy() {
<strong>Email:</strong>{" "}
<span className="text-amber-300">privacy@battlbuilder.com</span>
</p>
<p>
<strong>Mailing Address:</strong>
<br />
Battl Builder, LLC.
<br />
[Your Address]
<br />
[City, State, ZIP]
</p>
<MailingAddressComponent/>
</div>
<p className="mt-4 text-sm">
We will respond to all privacy inquiries within 30 days of receipt.
+9 -8
View File
@@ -18,7 +18,8 @@ type BuildDto = {
updatedAt?: string | null;
};
// API routes now handled by Next.js /api routes (server-side)
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// UUID validator (defensive)
const isUuid = (v: string) =>
@@ -39,14 +40,14 @@ export default function VaultPage() {
// NOTE:
// - `loading` here is AuthContext hydration, not network.
// - Auth is handled by HTTP-only cookies automatically.
const { user, loading: authLoading } = useAuth();
// - `token` is the real gate for /me endpoints.
const { user, token, loading: authLoading } = useAuth();
const [builds, setBuilds] = useState<BuildDto[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const authed = !!user;
const authed = !!user && !!token;
// Redirect if not logged in (after auth hydrates)
useEffect(() => {
@@ -55,12 +56,12 @@ export default function VaultPage() {
}
}, [authLoading, authed, router]);
// When auth identity changes, clear stale list to avoid "ghost builds"
// When auth identity changes, clear stale list to avoid ghost builds
useEffect(() => {
if (authLoading) return;
setBuilds([]);
setError(null);
}, [authLoading, user?.uuid]);
}, [authLoading, user?.uuid, token]);
// Fetch user builds
useEffect(() => {
@@ -74,8 +75,8 @@ export default function VaultPage() {
try {
// IMPORTANT:
// Use api wrapper which includes credentials (cookies) automatically.
const res = await api.get("/api/builds");
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
const res = await api.get("/api/v1/builds/me");
if (!res.ok) {
const text = await res.text().catch(() => "");
+41 -11
View File
@@ -1,7 +1,11 @@
"use client";
import { useEffect, useState } from "react";
import { useEffect, useMemo, useState } from "react";
import { Button, Field, Input } from "@/components/ui/form";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type AdminBetaRequestDto = {
id: number;
@@ -36,12 +40,17 @@ type AdminInviteResponse = {
token?: string;
};
async function fetchJson(path: string, init: RequestInit = {}) {
const res = await fetch(path, {
async function fetchJson(
path: string,
init: RequestInit = {},
authHeaders: HeadersInit = {}
) {
const res = await fetch(`${API_BASE_URL}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
...authHeaders,
},
cache: "no-store",
});
@@ -92,6 +101,9 @@ function Badge({
}
export default function AdminBetaInvitesPage() {
const { getAuthHeaders, user } = useAuth();
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
// ------------------------------
// Batch invites
// ------------------------------
@@ -114,9 +126,11 @@ export default function AdminBetaInvitesPage() {
tokenMinutes: String(tokenMinutes || "30"),
});
const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
method: "POST",
});
const data = await fetchJson(
`/api/v1/admin/beta/invites/send?${qs.toString()}`,
{ method: "POST" },
authHeaders
);
setBatchResult(data);
await loadRequests();
@@ -152,8 +166,9 @@ export default function AdminBetaInvitesPage() {
try {
const data = (await fetchJson(
`/api/admin/beta/requests?page=${page}&size=${size}`,
{ method: "GET" }
`/api/v1/admin/beta/requests?page=${page}&size=${size}`,
{ method: "GET" },
authHeaders
)) as PageResponse<AdminBetaRequestDto>;
setRequests(data);
@@ -166,9 +181,11 @@ export default function AdminBetaInvitesPage() {
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
method: "POST",
})) as AdminInviteResponse;
return (await fetchJson(
`/api/v1/admin/beta/requests/${userId}/invite`,
{ method: "POST" },
authHeaders
)) as AdminInviteResponse;
}
async function onSendInviteEmail(userId: number) {
@@ -224,6 +241,19 @@ export default function AdminBetaInvitesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
const isAdmin = (user?.role ?? "").toUpperCase() === "ADMIN";
if (!isAdmin) {
return (
<div className="space-y-2">
<h1 className="text-xl font-semibold">Admin</h1>
<p className="text-sm text-zinc-400">
You dont have access to this page.
</p>
</div>
);
}
return (
<div className="space-y-6">
<div>
+20 -5
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
fetchEmailRequests,
createEmailRequest,
@@ -16,6 +17,7 @@ import { formatDate } from "@/lib/dateFormattingUtility";
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -31,9 +33,11 @@ export default function AdminEmailPage() {
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
const loadEmails = useCallback(async () => {
if (!token) return;
try {
setLoading(true);
const data = await fetchEmailRequests();
const data = await fetchEmailRequests(token);
setEmails(data);
setError(null);
} catch (e: any) {
@@ -42,7 +46,7 @@ export default function AdminEmailPage() {
} finally {
setLoading(false);
}
}, []);
}, [token]);
useEffect(() => {
loadEmails();
@@ -104,10 +108,11 @@ export default function AdminEmailPage() {
};
const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return;
try {
await deleteEmailRequest(id);
await deleteEmailRequest(token, id);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to delete email request");
@@ -116,15 +121,17 @@ export default function AdminEmailPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
try {
setSubmitting(true);
if (modalMode === "create") {
await createEmailRequest(formData);
await createEmailRequest(token, formData);
} else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(selectedEmail.id, updateData);
await updateEmailRequest(token, selectedEmail.id, updateData);
}
setShowModal(false);
await loadEmails();
@@ -135,6 +142,14 @@ export default function AdminEmailPage() {
}
};
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return (
<div className="p-6 space-y-4">
{/* Header */}
+20 -5
View File
@@ -1,6 +1,7 @@
"use client";
import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
fetchEmailRequests,
createEmailRequest,
@@ -13,6 +14,7 @@ import {
import { Pencil, Trash2, Plus, X } from "lucide-react";
import { formatDate } from "@/lib/dateFormattingUtility";
export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
@@ -26,9 +28,11 @@ export default function AdminEmailPage() {
const [submitting, setSubmitting] = useState(false);
const loadEmails = useCallback(async () => {
if (!token) return;
try {
setLoading(true);
const data = await fetchEmailRequests();
const data = await fetchEmailRequests(token);
setEmails(data);
setError(null);
} catch (e: any) {
@@ -37,7 +41,7 @@ export default function AdminEmailPage() {
} finally {
setLoading(false);
}
}, []);
}, [token]);
useEffect(() => {
loadEmails();
@@ -58,10 +62,11 @@ export default function AdminEmailPage() {
};
const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return;
try {
await deleteEmailRequest(id);
await deleteEmailRequest(token, id);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to delete email request");
@@ -70,15 +75,17 @@ export default function AdminEmailPage() {
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
try {
setSubmitting(true);
if (modalMode === "create") {
await createEmailRequest(formData);
await createEmailRequest(token, formData);
} else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(selectedEmail.id, updateData);
await updateEmailRequest(token, selectedEmail.id, updateData);
}
setShowModal(false);
await loadEmails();
@@ -89,6 +96,14 @@ export default function AdminEmailPage() {
}
};
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return (
<div className="p-6 space-y-4">
{/* Header */}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {JSX, useEffect, useRef, useState} from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form";
+3 -4
View File
@@ -46,8 +46,8 @@ export default function ImportStatusPage() {
setError(null);
const [summaryRes, byMerchantRes] = await Promise.all([
fetch('/api/admin/import-status/summary', { credentials: 'include' }),
fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
fetch(`${API_BASE_URL}/api/admin/import-status/summary`),
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`),
]);
if (!summaryRes.ok || !byMerchantRes.ok) {
@@ -108,10 +108,9 @@ export default function ImportStatusPage() {
try {
setImportingId(merchantId);
const res = await fetch(
`/api/admin/merchants/${merchantId}/import`,
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`,
{
method: "POST",
credentials: 'include',
}
);
+6 -3
View File
@@ -149,7 +149,7 @@ export default function AdminLayout({
return (
// ✅ prevent browser-level horizontal scroll from any wide children
<div className="h-screen bg-black text-zinc-50 overflow-hidden">
<div className="min-h-screen bg-black text-zinc-50 overflow-x-hidden pt-[52px]">
<AdminLeftNavigation
collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)}
@@ -158,7 +158,7 @@ export default function AdminLayout({
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
<div
className="flex h-full flex-1 min-w-0 flex-col transition-all duration-300"
className="flex min-h-screen flex-1 min-w-0 flex-col transition-all duration-300"
style={{ marginLeft: collapsed ? '68px' : '280px' }}
>
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3 sticky top-0 z-30 backdrop-blur-sm">
@@ -173,11 +173,14 @@ export default function AdminLayout({
<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
</span>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
ADMIN
</div>
</div>
</header>
{/* ✅ min-w-0 here prevents table min-width from widening the page */}
<main className="flex w-full flex-1 min-w-0 flex-col overflow-y-auto px-4 py-6">
<main className="flex w-full flex-1 min-w-0 flex-col px-4 py-6">
{children}
</main>
</div>
+6 -10
View File
@@ -198,9 +198,8 @@ export default function MappingAdminPage() {
// EXPECTED backend endpoint (new):
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
const res = await fetch('/api/admin/mapping/options', {
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store",
});
@@ -244,9 +243,8 @@ export default function MappingAdminPage() {
if (tab === "roles") {
// Existing endpoint you already have:
// GET /api/admin/mapping/pending-buckets
const res = await fetch('/api/admin/mapping/pending-buckets', {
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store",
});
@@ -279,8 +277,8 @@ export default function MappingAdminPage() {
if (q?.trim()) params.set("q", q.trim());
const res = await fetch(
`/api/admin/mapping/raw-categories?${params.toString()}`,
{ headers: { Accept: "application/json" }, credentials: 'include', cache: "no-store" }
`${API_BASE_URL}/api/admin/mapping/raw-categories?${params.toString()}`,
{ headers: { Accept: "application/json" }, cache: "no-store" }
);
if (!res.ok) {
@@ -338,10 +336,9 @@ export default function MappingAdminPage() {
// Existing endpoint you already have:
// POST /api/admin/mapping/apply
const res = await fetch('/api/admin/mapping/apply', {
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({
merchantId: row.merchantId,
rawCategoryKey: row.rawCategoryKey,
@@ -393,10 +390,9 @@ export default function MappingAdminPage() {
// EXPECTED new endpoint:
// POST /api/admin/mapping/upsert
const res = await fetch('/api/admin/mapping/upsert', {
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({
merchantId: row.merchantId,
platform: row.platform ?? platform ?? null,
+7 -6
View File
@@ -2,7 +2,8 @@
import { useEffect, useState } from "react";
// API routes now handled by Next.js /api routes (server-side)
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type MerchantAdminDto = {
id: number;
@@ -90,7 +91,7 @@ export default function MerchantsAdminPage() {
setLoading(true);
setError(null);
const url = `/api/admin/merchants`;
const url = `${API_BASE_URL}/api/admin/merchants`;
console.log("Loading merchants from:", url);
const res = await fetch(url, {
@@ -149,7 +150,7 @@ export default function MerchantsAdminPage() {
setError(null);
setBanner(null);
const res = await fetch(`/api/admin/merchants/${id}`, {
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, {
method: "PUT",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
@@ -188,7 +189,7 @@ export default function MerchantsAdminPage() {
setError(null);
setBanner(null);
const res = await fetch(`/api/admin/merchants/${id}/import`, {
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, {
method: "POST",
});
@@ -217,7 +218,7 @@ export default function MerchantsAdminPage() {
setBanner(null);
const res = await fetch(
`/api/admin/merchants/${id}/offer-sync`,
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`,
{ method: "POST" }
);
@@ -246,7 +247,7 @@ export default function MerchantsAdminPage() {
setError(null);
setBanner(null);
const res = await fetch(`/api/admin/merchants`, {
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
+1 -3
View File
@@ -24,9 +24,7 @@ export default function AdminLandingPage() {
try {
setLoading(true);
setError(null);
const res = await fetch('/api/admin/dashboard/overview', {
credentials: 'include',
});
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`);
if (!res.ok) {
throw new Error(`Failed to load dashboard (${res.status})`);
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import {JSX, useCallback, useMemo, useState} from "react";
import type { CaliberDto, PlatformDto } from "../_lib/types";
import { RightDrawer } from "./RightDrawer";
+1 -1
View File
@@ -6,7 +6,7 @@ const API_BASE_URL =
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
export async function POST(req: Request) {
const token = cookies().get("session_token")?.value;
const token = (await cookies()).get("bb_access_token")?.value;
if (!token) {
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
@@ -1,39 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function POST(
_req: Request,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/beta/requests/${params.id}/invite`,
{
method: "POST",
headers,
cache: "no-store",
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
-39
View File
@@ -1,39 +0,0 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function GET(req: Request) {
try {
const headers = await getAuthHeader();
const url = new URL(req.url);
const search = url.searchParams.toString();
const upstream = `${API_BASE_URL}/api/v1/admin/beta/requests${
search ? `?${search}` : ""
}`;
const res = await fetch(upstream, {
headers,
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
-35
View File
@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
-63
View File
@@ -1,63 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
method: "DELETE",
headers,
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
-56
View File
@@ -1,56 +0,0 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/email`, {
headers,
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/email`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/summary`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
-40
View File
@@ -1,40 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
-35
View File
@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -1,35 +0,0 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -1,39 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const { searchParams } = new URL(request.url);
const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/raw-categories?${searchParams.toString()}`,
{
headers,
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
-40
View File
@@ -1,40 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/import`,
{ method: 'POST', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/offer-sync`,
{ method: 'POST', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
-90
View File
@@ -1,90 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
{
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
-54
View File
@@ -1,54 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
headers,
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
-46
View File
@@ -1,46 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Forward to backend API
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return NextResponse.json(
{ error: text || 'Login failed' },
{ status: res.status }
);
}
const data = await res.json();
// Set HTTP-only cookie instead of returning token
cookies().set('session_token', data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
// Return user data without token
return NextResponse.json({
user: data.user,
});
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Login failed' },
{ status: 500 }
);
}
}
-7
View File
@@ -1,7 +0,0 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST() {
cookies().delete('session_token');
return NextResponse.json({ success: true });
}
-30
View File
@@ -1,30 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
const token = cookies().get('session_token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
cookies().delete('session_token');
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch user' },
{ status: 500 }
);
}
}
-48
View File
@@ -1,48 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Forward to backend API
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return NextResponse.json(
{ error: text || 'Registration failed' },
{ status: res.status }
);
}
const data = await res.json();
// Set HTTP-only cookie on successful registration
if (data.token) {
cookies().set('session_token', data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
}
// Return user data without token
return NextResponse.json({
user: data.user,
});
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Registration failed' },
{ status: 500 }
);
}
}
-90
View File
@@ -1,90 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`,
{
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
-35
View File
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// Public build detail - no auth required
const res = await fetch(
`${API_BASE_URL}/api/v1/builds/${id}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch build' },
{ status: 500 }
);
}
}
-32
View File
@@ -1,32 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// Public builds endpoint - no auth required
const res = await fetch(
`${API_BASE_URL}/api/v1/builds?${searchParams}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch builds' },
{ status: 500 }
);
}
}
-57
View File
@@ -1,57 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const { searchParams } = new URL(request.url);
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds?${searchParams}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/gunbuilder/builds`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json(), { status: res.status });
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
-28
View File
@@ -1,28 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/options?${searchParams}`
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch catalog' },
{ status: 500 }
);
}
}
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products/${id}/offers`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch product offers' },
{ status: 500 }
);
}
}
-35
View File
@@ -1,35 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products/${id}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch product' },
{ status: 500 }
);
}
}
-33
View File
@@ -1,33 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch products' },
{ status: 500 }
);
}
}
-32
View File
@@ -1,32 +0,0 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products?${searchParams}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch products' },
{ status: 500 }
);
}
}
+16
View File
@@ -0,0 +1,16 @@
import { springProxy } from "@/lib/springProxy";
import type {
MerchantAdminResponse,
MerchantAdminUpdateRequest,
} from "@/types/merchant-admin";
export async function PUT(req: Request, props: { params: Promise<{ id: string }> }) {
const params = await props.params;
const body = (await req.json()) as MerchantAdminUpdateRequest;
return springProxy<MerchantAdminResponse, MerchantAdminUpdateRequest>(
req,
`/api/v1/admin/merchants/${params.id}`,
{ method: "PUT", body }
);
}
+19
View File
@@ -0,0 +1,19 @@
import { springProxy } from "@/lib/springProxy";
import type {
MerchantAdminCreateRequest,
MerchantAdminResponse,
} from "@/types/merchant-admin";
export async function GET(req: Request) {
return springProxy<MerchantAdminResponse[]>(req, "/api/v1/admin/merchants");
}
export async function POST(req: Request) {
const body = (await req.json()) as MerchantAdminCreateRequest;
return springProxy<MerchantAdminResponse, MerchantAdminCreateRequest>(
req,
"/api/v1/admin/merchants",
{ method: "POST", body }
);
}
+382
View File
@@ -0,0 +1,382 @@
"use client";
import {
useCallback,
useEffect,
useMemo,
useState,
} from "react";
import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA";
type Caliber = string;
type BuildCard = {
id: string;
title: string;
slug: string;
creator: string;
caliber: Caliber;
buildClass: BuildClass;
price: number;
votes: number;
tags: string[];
coverImageUrl?: string | null;
};
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
];
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
interface BuildsClientProps {
initialBuilds: BuildCard[];
initialError: string | null;
}
/**
* Client-side component that keeps all the previous interactivity:
* - filters
* - optimistic votes
*
* It receives the builds from the server but can optionally re-fetch on mount
* if you want truly live data (kept here as an example but can be removed).
*/
export default function BuildsClient({
initialBuilds,
initialError,
}: BuildsClientProps) {
// --- Remote data state (initialized from server) ---
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(initialError);
const [builds, setBuilds] = useState<BuildCard[]>(initialBuilds);
// --- UI state (filters/votes) ---
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
const [priceFilterId, setPriceFilterId] = useState<string>("all");
const [votes, setVotes] = useState<Record<string, number>>(() =>
Object.fromEntries(initialBuilds.map((b) => [b.id, b.votes])),
);
const activePriceFilter =
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
// Optional: client re-fetch to keep data fresh (can be removed if not needed)
useEffect(() => {
// If you don't want client refetching, just return early here.
if (!API_BASE_URL) return;
let cancelled = false;
async function run() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
method: "GET",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load builds (${res.status})`);
}
const data = (await res.json()) as BuildCard[];
if (cancelled) return;
setBuilds(data);
setVotes(Object.fromEntries(data.map((b) => [b.id, b.votes])));
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds feed");
} finally {
if (!cancelled) setLoading(false);
}
}
// Comment this out if you want *only* server-side fetching:
// run();
return () => {
cancelled = true;
};
}, []);
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
const uniq = new Set<string>();
for (const b of builds) {
const c = (b.caliber ?? "").trim();
if (c && c !== "—") uniq.add(c);
}
return ["all", ...Array.from(uniq).sort()];
}, [builds]);
const filteredBuilds = useMemo(() => {
return builds.filter((build) => {
const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter;
const matchesClass =
classFilter === "all" || build.buildClass === classFilter;
const matchesPrice =
build.price >= activePriceFilter.min &&
build.price < activePriceFilter.max;
return matchesCaliber && matchesClass && matchesPrice;
});
}, [builds, caliberFilter, classFilter, activePriceFilter]);
const handleVote = useCallback((id: string, delta: 1 | -1) => {
setVotes((prev) => ({
...prev,
[id]: (prev[id] ?? 0) + delta,
}));
}, []);
return (
<>
{/* Loading / Error (uses same UI as before) */}
{loading && (
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
Loading community builds
</div>
)}
{error && !loading && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
{error}
<div className="mt-2 text-xs text-red-200/80">
Dev tip: confirm backend route{" "}
<span className="font-mono">GET /api/v1/builds</span> is
implemented + CORS is configured.
</div>
</div>
)}
{/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
Filter Builds
</h2>
<p className="text-xs text-zinc-500 mt-1">
Filter by caliber, class, and price range. (Client-side for MVP.)
</p>
</div>
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredBuilds.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
builds
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{/* Caliber filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Caliber
</div>
<div className="flex flex-wrap gap-2">
{CALIBER_FILTERS.map((caliber) => (
<button
key={caliber}
type="button"
onClick={() =>
setCaliberFilter(caliber === "all" ? "all" : caliber)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
caliberFilter === caliber
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{caliber === "all" ? "All" : caliber}
</button>
))}
</div>
</div>
{/* Class filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Class
</div>
<div className="flex flex-wrap gap-2">
{CLASS_FILTERS.map((cls) => (
<button
key={cls}
type="button"
onClick={() =>
setClassFilter(cls === "all" ? "all" : cls)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{cls === "all" ? "All" : cls}
</button>
))}
</div>
</div>
{/* Price filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Price Range
</div>
<div className="flex flex-wrap gap-2">
{PRICE_FILTERS.map((range) => (
<button
key={range.id}
type="button"
onClick={() => setPriceFilterId(range.id)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
priceFilterId === range.id
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{range.label}
</button>
))}
</div>
</div>
</div>
</section>
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Upvote"
title="Upvote"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Downvote"
title="Downvote"
>
</button>
</div>
{/* Thumbnail */}
<div className="hidden sm:block">
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">b/{build.creator}</span>{" "}
· Build detail page will show the parts list +
comments.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
{build.price > 0 ? (
<span>
{`$${Math.floor(build.price / 100).toLocaleString()}`}
</span>
) : (
"—"
)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments + save coming soon.
</span>
</div>
</div>
</article>
))}
{!loading && !error && filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
No builds match those filters yet.
</div>
)}
</div>
</section>
</>
);
}
+1
View File
@@ -0,0 +1 @@
the page-use-client.tsx is the old client side page, this is a separation of the server side page and the client side
+10 -11
View File
@@ -26,14 +26,14 @@ function timeAgo(iso?: string | null) {
return `${days}d ago`;
}
export default async function BuildBreakdownPage({
params,
}: {
params: { buildId: string };
}) {
// Public detail endpoint - use Next.js API route
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/builds/public/${params.buildId}`, {
export default async function BuildBreakdownPage(
props: {
params: Promise<{ buildId: string }>;
}
) {
const params = await props.params;
// Public detail endpoint
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
cache: "no-store",
});
@@ -74,7 +74,6 @@ export default async function BuildBreakdownPage({
)}
</div>
</div>
{/* Layout */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
{/* Post column */}
@@ -169,11 +168,11 @@ export default async function BuildBreakdownPage({
<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
(<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
+481
View File
@@ -0,0 +1,481 @@
"use client";
/**
* app/builds/page.tsx
* Community Builds feed (public)
*
* Dev Notes:
* - We are moving to Option B:
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
*
* Backend (target contract):
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
*/
import { useEffect, useMemo, useState, useCallback } from "react";
import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA";
/**
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
* We can tighten once the controlled vocab is finalized.
*/
type Caliber = string;
/**
* This matches what the UI needs (card format).
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
*/
type BuildCard = {
id: string; // we use uuid here (public identifier)
title: string;
slug: string; // can be uuid for now; keep property so UI does not change
creator: string; // placeholder until user profiles are real
caliber: Caliber;
buildClass: BuildClass;
price: number; // optional server-side later; for now allow 0 fallback
votes: number;
tags: string[];
coverImageUrl?: string | null;
};
type BuildFeedCardDto = {
uuid: string;
title?: string | null;
slug?: string | null; // optional (we can generate from title later)
creator?: string | null; // optional
caliber?: string | null;
buildClass?: BuildClass | null;
price?: number | null;
votes?: number | null;
tags?: string[] | null;
coverImageUrl?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// --- UI Filter Sets (keep; well generate options from data too) ---
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
];
function safeArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
}
function fallbackSlug(dto: BuildFeedCardDto) {
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
return dto.slug?.trim() || dto.uuid;
}
function normalizeCard(dto: BuildFeedCardDto): BuildCard {
const title = (dto.title ?? "Untitled Build").trim() || "Untitled Build";
return {
id: dto.uuid,
title,
slug: fallbackSlug(dto),
// Until we wire real users: keep a placeholder creator string.
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
caliber: (dto.caliber ?? "—").trim() || "—",
// Default to Rifle for display if missing; backend should set this via profile.
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
// Price is optional (we can compute later from BuildItems + offers).
price: typeof dto.price === "number" ? dto.price : 0,
votes: typeof dto.votes === "number" ? dto.votes : 0,
tags: safeArray(dto.tags),
coverImageUrl: dto.coverImageUrl ?? null,
};
}
export default function BuildsPage() {
// --- Remote data state ---
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [builds, setBuilds] = useState<BuildCard[]>([]);
// --- UI state (filters/votes) ---
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
const [priceFilterId, setPriceFilterId] = useState<string>("all");
// Local vote state for optimistic UI
const [votes, setVotes] = useState<Record<string, number>>({});
const activePriceFilter =
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
// --- Fetch public builds feed ---
useEffect(() => {
let cancelled = false;
async function run() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
method: "GET",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load builds (${res.status})`);
}
const data = (await res.json()) as BuildFeedCardDto[];
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
if (cancelled) return;
setBuilds(cards);
// Initialize vote UI from payload totals
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds feed");
} finally {
if (!cancelled) setLoading(false);
}
}
run();
return () => {
cancelled = true;
};
}, []);
// Build the caliber filters dynamically from data, but keep "all" first.
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
const uniq = new Set<string>();
for (const b of builds) {
const c = (b.caliber ?? "").trim();
if (c && c !== "—") uniq.add(c);
}
return ["all", ...Array.from(uniq).sort()];
}, [builds]);
const filteredBuilds = useMemo(() => {
return builds.filter((build) => {
const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter;
const matchesClass =
classFilter === "all" || build.buildClass === classFilter;
const matchesPrice =
build.price >= activePriceFilter.min &&
build.price < activePriceFilter.max;
return matchesCaliber && matchesClass && matchesPrice;
});
}, [builds, caliberFilter, classFilter, activePriceFilter]);
const handleVote = useCallback((id: string, delta: 1 | -1) => {
// MVP: optimistic local vote only.
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
setVotes((prev) => ({
...prev,
[id]: (prev[id] ?? 0) + delta,
}));
}, []);
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 */}
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Battl Builder · Build Book
</p>
<h1
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
>
Community Builds
</h1>
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
Browse community builds, vote them up or down, and steal good
ideas without shame. This feed is backed by public builds
(isPublic=true).
</p>
</div>
<div className="flex gap-2 md:gap-3">
<Link
href="/builder"
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/60 px-4 py-2 text-xs md:text-sm font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Open Builder
</Link>
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
<Link
href="/vault"
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
+ Submit Build
</Link>
</div>
</header>
{/* Loading / Error */}
{loading && (
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
Loading community builds
</div>
)}
{error && !loading && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
{error}
<div className="mt-2 text-xs text-red-200/80">
Dev tip: confirm backend route{" "}
<span className="font-mono">GET /api/v1/builds</span> is
implemented + CORS is configured.
</div>
</div>
)}
{/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
Filter Builds
</h2>
<p className="text-xs text-zinc-500 mt-1">
Filter by caliber, class, and price range. (Client-side for
MVP.)
</p>
</div>
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredBuilds.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
builds
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{/* Caliber filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Caliber
</div>
<div className="flex flex-wrap gap-2">
{CALIBER_FILTERS.map((caliber) => (
<button
key={caliber}
type="button"
onClick={() =>
setCaliberFilter(caliber === "all" ? "all" : caliber)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
caliberFilter === caliber
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{caliber === "all" ? "All" : caliber}
</button>
))}
</div>
</div>
{/* Class filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Class
</div>
<div className="flex flex-wrap gap-2">
{CLASS_FILTERS.map((cls) => (
<button
key={cls}
type="button"
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{cls === "all" ? "All" : cls}
</button>
))}
</div>
</div>
{/* Price filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Price Range
</div>
<div className="flex flex-wrap gap-2">
{PRICE_FILTERS.map((range) => (
<button
key={range.id}
type="button"
onClick={() => setPriceFilterId(range.id)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
priceFilterId === range.id
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{range.label}
</button>
))}
</div>
</div>
</div>
</section>
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => {
const dollars = Math.floor((build.price ?? 0) / 100);
return (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Upvote"
title="Upvote"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Downvote"
title="Downvote"
>
</button>
</div>
{/* Thumbnail */}
<div className="hidden sm:block">
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Build detail page will show the parts list +
comments.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
{build.price > 0 ? (
<span>
${Math.floor(build.price / 100).toLocaleString()}
</span>
) : (
"—"
)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments + save coming soon.
</span>
</div>
</div>
</article>
);
})}
{!loading && !error && filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
No builds match those filters yet.
</div>
)}
</div>
</section>
</div>
</main>
);
}
+20 -371
View File
@@ -1,43 +1,17 @@
"use client";
/**
* app/builds/page.tsx
* Community Builds feed (public)
*
* Dev Notes:
* - We are moving to Option B:
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
*
* Backend (target contract):
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
*/
import { useEffect, useMemo, useState, useCallback } from "react";
// app/builds/page.tsx
import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA";
/**
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
* We can tighten once the controlled vocab is finalized.
*/
type Caliber = string;
/**
* This matches what the UI needs (card format).
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
*/
type BuildCard = {
id: string; // we use uuid here (public identifier)
id: string;
title: string;
slug: string; // can be uuid for now; keep property so UI does not change
creator: string; // placeholder until user profiles are real
slug: string;
creator: string;
caliber: Caliber;
buildClass: BuildClass;
price: number; // optional server-side later; for now allow 0 fallback
price: number;
votes: number;
tags: string[];
coverImageUrl?: string | null;
@@ -46,8 +20,8 @@ type BuildCard = {
type BuildFeedCardDto = {
uuid: string;
title?: string | null;
slug?: string | null; // optional (we can generate from title later)
creator?: string | null; // optional
slug?: string | null;
creator?: string | null;
caliber?: string | null;
buildClass?: BuildClass | null;
price?: number | null;
@@ -56,26 +30,13 @@ type BuildFeedCardDto = {
coverImageUrl?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// --- UI Filter Sets (keep; well generate options from data too) ---
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
];
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
function safeArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
}
function fallbackSlug(dto: BuildFeedCardDto) {
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
return dto.slug?.trim() || dto.uuid;
}
@@ -86,55 +47,28 @@ function normalizeCard(dto: BuildFeedCardDto): BuildCard {
id: dto.uuid,
title,
slug: fallbackSlug(dto),
// Until we wire real users: keep a placeholder creator string.
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
caliber: (dto.caliber ?? "—").trim() || "—",
// Default to Rifle for display if missing; backend should set this via profile.
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
// Price is optional (we can compute later from BuildItems + offers).
price: typeof dto.price === "number" ? dto.price : 0,
votes: typeof dto.votes === "number" ? dto.votes : 0,
tags: safeArray(dto.tags),
coverImageUrl: dto.coverImageUrl ?? null,
};
}
export default function BuildsPage() {
// --- Remote data state ---
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [builds, setBuilds] = useState<BuildCard[]>([]);
// Import the client component that will handle filters & voting
import BuildsClient from "./BuildsClient";
// --- UI state (filters/votes) ---
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
const [priceFilterId, setPriceFilterId] = useState<string>("all");
export default async function BuildsPage() {
let cards: BuildCard[] = [];
let error: string | null = null;
// Local vote state for optimistic UI
const [votes, setVotes] = useState<Record<string, number>>({});
const activePriceFilter =
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
// --- Fetch public builds feed ---
useEffect(() => {
let cancelled = false;
async function run() {
try {
setLoading(true);
setError(null);
const res = await fetch('/api/builds/public?limit=50', {
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
method: "GET",
credentials: "include",
// Disable full-page static caching; you can adjust this per your needs:
cache: "no-store",
headers: { "Content-Type": "application/json" },
});
@@ -144,61 +78,10 @@ export default function BuildsPage() {
}
const data = (await res.json()) as BuildFeedCardDto[];
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
if (cancelled) return;
setBuilds(cards);
// Initialize vote UI from payload totals
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
cards = (Array.isArray(data) ? data : []).map(normalizeCard);
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds feed");
} finally {
if (!cancelled) setLoading(false);
error = e?.message || "Failed to load builds feed";
}
}
run();
return () => {
cancelled = true;
};
}, []);
// Build the caliber filters dynamically from data, but keep "all" first.
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
const uniq = new Set<string>();
for (const b of builds) {
const c = (b.caliber ?? "").trim();
if (c && c !== "—") uniq.add(c);
}
return ["all", ...Array.from(uniq).sort()];
}, [builds]);
const filteredBuilds = useMemo(() => {
return builds.filter((build) => {
const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter;
const matchesClass =
classFilter === "all" || build.buildClass === classFilter;
const matchesPrice =
build.price >= activePriceFilter.min &&
build.price < activePriceFilter.max;
return matchesCaliber && matchesClass && matchesPrice;
});
}, [builds, caliberFilter, classFilter, activePriceFilter]);
const handleVote = useCallback((id: string, delta: 1 | -1) => {
// MVP: optimistic local vote only.
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
setVotes((prev) => ({
...prev,
[id]: (prev[id] ?? 0) + delta,
}));
}, []);
return (
<main className="min-h-screen bg-black text-zinc-50">
@@ -230,8 +113,6 @@ export default function BuildsPage() {
>
Open Builder
</Link>
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
<Link
href="/vault"
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
@@ -241,240 +122,8 @@ export default function BuildsPage() {
</div>
</header>
{/* Loading / Error */}
{loading && (
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
Loading community builds
</div>
)}
{error && !loading && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
{error}
<div className="mt-2 text-xs text-red-200/80">
Dev tip: confirm backend route{" "}
<span className="font-mono">GET /api/v1/builds</span> is
implemented + CORS is configured.
</div>
</div>
)}
{/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
Filter Builds
</h2>
<p className="text-xs text-zinc-500 mt-1">
Filter by caliber, class, and price range. (Client-side for
MVP.)
</p>
</div>
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredBuilds.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
builds
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{/* Caliber filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Caliber
</div>
<div className="flex flex-wrap gap-2">
{CALIBER_FILTERS.map((caliber) => (
<button
key={caliber}
type="button"
onClick={() =>
setCaliberFilter(caliber === "all" ? "all" : caliber)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
caliberFilter === caliber
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{caliber === "all" ? "All" : caliber}
</button>
))}
</div>
</div>
{/* Class filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Class
</div>
<div className="flex flex-wrap gap-2">
{CLASS_FILTERS.map((cls) => (
<button
key={cls}
type="button"
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{cls === "all" ? "All" : cls}
</button>
))}
</div>
</div>
{/* Price filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Price Range
</div>
<div className="flex flex-wrap gap-2">
{PRICE_FILTERS.map((range) => (
<button
key={range.id}
type="button"
onClick={() => setPriceFilterId(range.id)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
priceFilterId === range.id
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{range.label}
</button>
))}
</div>
</div>
</div>
</section>
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => {
const dollars = Math.floor((build.price ?? 0) / 100);
return (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Upvote"
title="Upvote"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Downvote"
title="Downvote"
>
</button>
</div>
{/* Thumbnail */}
<div className="hidden sm:block">
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Build detail page will show the parts list +
comments.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
{build.price > 0 ? (
<span>
${Math.floor(build.price / 100).toLocaleString()}
</span>
) : (
"—"
)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments + save coming soon.
</span>
</div>
</div>
</article>
);
})}
{!loading && !error && filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
No builds match those filters yet.
</div>
)}
</div>
</section>
{/* Pass initial data + any load error into the client component */}
<BuildsClient initialBuilds={cards} initialError={error} />
</div>
</main>
);
+6 -24
View File
@@ -5,7 +5,6 @@ import Link from "next/link";
import { usePathname } from "next/navigation";
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react";
import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
export type AdminNavItem = {
label: string;
@@ -43,21 +42,14 @@ export default function AdminLeftNavigation({
groups: AdminNavGroup[];
}) {
const pathname = usePathname() || "/admin";
const { user } = useAuth();
const identity =
user?.displayName?.trim() ||
user?.username?.trim() ||
user?.email?.trim() ||
user?.uuid ||
"Admin";
const secondary = user?.email && user.email !== identity ? user.email : null;
return (
<aside
className={cx(
"fixed inset-y-0 left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40",
"fixed left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40",
collapsed ? "w-[68px]" : "w-[280px]"
)}
style={{ top: '52px', height: 'calc(100vh - 52px)' }}
>
{/* Header / Brand */}
<div className={cx("flex items-center justify-between px-3 py-3", collapsed && "justify-center")}>
@@ -185,30 +177,20 @@ export default function AdminLeftNavigation({
{/* Footer */}
<div className={cx("border-t border-zinc-900 p-3", collapsed && "px-2")}>
<Link
href="/builder"
className={cx(
"mb-3 inline-flex w-full items-center justify-center rounded-md border border-zinc-800 bg-zinc-900/40 px-3 py-2 text-xs font-medium text-zinc-200 hover:bg-zinc-900/70",
collapsed && "px-2"
)}
title="Back to Builder"
>
{collapsed ? "↩" : "Back to Builder"}
</Link>
<div
className={cx(
"flex items-center gap-3 rounded-md bg-zinc-900/30 px-3 py-2 ring-1 ring-zinc-800",
collapsed && "justify-center px-2"
)}
title={secondary ?? identity}
title="Admin user"
>
<div className="h-8 w-8 rounded-full bg-zinc-900 ring-1 ring-zinc-800" />
{!collapsed && (
<div className="min-w-0">
<div className="truncate text-sm text-zinc-200">{identity}</div>
<div className="truncate text-xs text-zinc-500">
{secondary ?? "Authenticated user"}
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Internal
</div>
<div className="truncate text-sm text-zinc-200">ADMIN</div>
</div>
)}
</div>
+1 -7
View File
@@ -1,14 +1,8 @@
"use client";
import React from "react";
import { usePathname } from "next/navigation";
export function Banner() {
const pathname = usePathname();
if (pathname?.startsWith("/admin")) return null;
return (
<div className="w-full">
<div className="fixed top-0 left-0 right-0 z-50">
{/* Early access bar */}
<div className="border-b border-amber-500/20 bg-amber-500/5 backdrop-blur-sm">
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
+11
View File
@@ -0,0 +1,11 @@
export default function MailingAddressComponent() {
return <p>
<strong>Mailing Address:</strong>
<br/>
Battl Builder, LLC.
<br/>
[Your Address]
<br/>
[City, State, ZIP]
</p>;
}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {JSX, useEffect, useRef, useState} from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form";
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {JSX, useEffect, useRef, useState} from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input } from "@/components/ui/form";
import RichTextEditor from "@/components/ui/RichTextEditor";
+3 -2
View File
@@ -58,7 +58,8 @@ type GunbuilderProductFromApi = {
inStock?: boolean | null;
};
// API routes now handled by Next.js /api routes (server-side)
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const PAGE_SIZE = 24;
@@ -174,7 +175,7 @@ export default function PartsBrowseClient(props: {
}
const res = await fetch(
`/api/catalog/options?${search.toString()}`,
`${API_BASE_URL}/api/v1/catalog/options?${search.toString()}`,
{
signal: controller.signal,
}
+2 -5
View File
@@ -50,12 +50,9 @@ export default function PartsListPageClient(props: {
// Your current list endpoint:
// GET /api/v1/products?platform=AR-15&partRoles=upper-receiver
const url = `/api/catalog/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
const res = await fetch(url, {
headers: { Accept: "application/json" },
credentials: 'include',
});
const res = await fetch(url, { headers: { Accept: "application/json" } });
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(`Failed to load products (${res.status}): ${txt}`);
+3 -4
View File
@@ -41,9 +41,8 @@ async function fetchProductDetail(params: {
// ---- Preferred (if you add it): GET /api/v1/products/{id}
// If it 404s, we fall back.
try {
const res = await fetch(`/api/catalog/products/${encodeURIComponent(id)}`, {
const res = await fetch(`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}`, {
headers: { Accept: "application/json" },
credentials: 'include',
});
if (res.ok) {
@@ -55,8 +54,8 @@ async function fetchProductDetail(params: {
// ---- Fallback: use list endpoint + find by id (works right now with your current API)
const listRes = await fetch(
`/api/catalog/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
{ headers: { Accept: "application/json" }, credentials: 'include' }
`${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
{ headers: { Accept: "application/json" } }
);
if (!listRes.ok) {
+17 -16
View File
@@ -1,10 +1,16 @@
"use client";
import { useMemo } from "react";
import dynamic from "next/dynamic";
import type React from "react";
import "react-quill/dist/quill.snow.css";
// Import Quill modules after React Quill is loaded
if (typeof window !== 'undefined') {
const Quill = require('quill');
const QuillImageResize = require('quill-image-resize-module').default;
Quill.register("modules/imageResize", QuillImageResize);
}
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
type RichTextEditorProps = {
@@ -20,8 +26,15 @@ export default function RichTextEditor({
placeholder,
className,
}: RichTextEditorProps) {
const modules = useMemo(
() => ({
return (
<div className={className}>
<div className="bb-quill">
<ReactQuill
theme="snow"
value={value}
onChange={onChange}
placeholder={placeholder}
modules={{
toolbar: [
[{ header: [1, 2, 3, false] }],
["bold", "italic", "underline", "strike"],
@@ -31,19 +44,7 @@ export default function RichTextEditor({
["link"],
["clean"],
],
}),
[]
);
return (
<div className={className}>
<div className="bb-quill">
<ReactQuill
theme="snow"
value={value}
onChange={onChange}
placeholder={placeholder}
modules={modules}
}}
/>
</div>
</div>
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import { useEffect, useRef, useState } from "react";
import {JSX, useEffect, useRef, useState} from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input } from "@/components/ui/form";
import RichTextEditor from "@/components/ui/RichTextEditor";
+121 -79
View File
@@ -9,6 +9,9 @@ import {
ReactNode,
} from "react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type AuthUser = {
uuid: string;
email: string;
@@ -26,137 +29,174 @@ export type RegisterParams = {
tosVersion: string;
};
// 1) update the type
type AuthContextValue = {
user: AuthUser;
token: string | null;
loading: boolean;
login: (params: { email: string; password: string }) => Promise<void>;
register: (params: RegisterParams) => Promise<void>;
logout: () => void;
refreshUser: () => Promise<void>;
getAuthHeaders: () => HeadersInit;
setSession: (token: string, user: NonNullable<AuthUser>) => Promise<void>;
refreshMeAndSession: () => Promise<void>;
};
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
const TOKEN_KEY = "ballistic_auth_token";
const USER_KEY = "ballistic_auth_user";
export function AuthProvider({ children }: { children: ReactNode }) {
const [token, setToken] = useState<string | null>(null);
const [user, setUser] = useState<AuthUser>(null);
const [loading, setLoading] = useState<boolean>(true);
/**
* Persist user to localStorage (for quick initial render)
* Persist auth to localStorage
*/
const persistUser = useCallback((nextUser: AuthUser) => {
const persistAuth = useCallback(
(nextToken: string | null, nextUser: AuthUser) => {
if (typeof window === "undefined") return;
if (nextToken) {
window.localStorage.setItem(TOKEN_KEY, nextToken);
} else {
window.localStorage.removeItem(TOKEN_KEY);
}
if (nextUser) {
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
} else {
window.localStorage.removeItem(USER_KEY);
}
}, []);
},
[]
);
/**
* Hydrate from localStorage and refresh from server
* Hydrate from localStorage ONCE
* IMPORTANT: loading only flips false AFTER user/token are set (if present)
*/
useEffect(() => {
if (typeof window === "undefined") return;
const storedToken = window.localStorage.getItem(TOKEN_KEY);
const storedUser = window.localStorage.getItem(USER_KEY);
// Quick hydration from cached user
if (storedUser) {
if (storedToken && storedUser) {
try {
const parsedUser = JSON.parse(storedUser);
setToken(storedToken);
setUser(parsedUser);
// ✅ Re-sync cookie so middleware can allow /admin on hard refresh / direct nav
fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: storedToken,
role: parsedUser?.role,
}),
}).catch(() => {});
} catch {
window.localStorage.removeItem(TOKEN_KEY);
window.localStorage.removeItem(USER_KEY);
}
}
// Verify session with server
fetch("/api/auth/me", { credentials: 'include' })
.then((res) => {
if (res.ok) {
return res.json();
}
throw new Error("Not authenticated");
})
.then((data) => {
const nextUser: AuthUser = {
uuid: data.uuid,
email: data.email,
username: data.username || null,
displayName: data.displayName || null,
role: data.role || "USER",
passwordSetAt: data.passwordSetAt || null,
};
setUser(nextUser);
persistUser(nextUser);
})
.catch(() => {
setUser(null);
persistUser(null);
})
.finally(() => {
setLoading(false);
});
}, [persistUser]);
}, []);
const refreshUser = useCallback(async () => {
const res = await fetch("/api/auth/me", {
/**
* Used by login/register/magic-link
*/
const setSession = useCallback(
async (nextToken: string, nextUser: NonNullable<AuthUser>) => {
setToken(nextToken);
setUser(nextUser);
persistAuth(nextToken, nextUser);
// ✅ await so middleware sees cookie before navigation
try {
await fetch("/api/auth/session", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
token: nextToken,
role: nextUser.role,
}),
});
} catch {
// ignore
}
},
[persistAuth]
);
const refreshMeAndSession = useCallback(async () => {
if (!token) return;
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
cache: "no-store",
credentials: 'include',
});
if (!res.ok) {
const text = await res.text().catch(() => res.statusText);
throw new Error(text || "Failed to refresh user");
throw new Error(text || "Failed to refresh session");
}
const data = await res.json();
const me = await res.json();
const nextUser: AuthUser = {
uuid: data.uuid,
email: data.email,
username: data.username || null,
displayName: data.displayName || null,
role: data.role || "USER",
passwordSetAt: data.passwordSetAt || null,
};
const nextUser = {
uuid: me.uuid,
email: me.email,
username: me.username || null,
displayName: me.displayName || null,
role: me.role || "USER",
passwordSetAt: me.passwordSetAt || null,
} as NonNullable<AuthUser>;
setUser(nextUser);
persistUser(nextUser);
}, [persistUser]);
await setSession(token, nextUser);
}, [token, setSession]);
const login = useCallback(
async ({ email, password }: { email: string; password: string }) => {
setLoading(true);
try {
const res = await fetch("/api/auth/login", {
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({ email, password }),
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.error || "Login failed");
const text = await res.text().catch(() => res.statusText);
throw new Error(text || "Login failed");
}
const data = await res.json();
const nextUser: AuthUser = data.user;
const nextToken: string = data.token ?? data.accessToken;
const nextUser: AuthUser =
data.user ??
({
uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? null,
passwordSetAt: data.passwordSetAt ?? null,
role: data.role ?? "USER",
} as AuthUser);
setUser(nextUser);
persistUser(nextUser);
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
} finally {
setLoading(false);
}
},
[persistUser]
[setSession]
);
const register = useCallback(
@@ -169,10 +209,9 @@ export function AuthProvider({ children }: { children: ReactNode }) {
}: RegisterParams) => {
setLoading(true);
try {
const res = await fetch("/api/auth/register", {
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({
email,
password,
@@ -183,51 +222,54 @@ export function AuthProvider({ children }: { children: ReactNode }) {
});
if (!res.ok) {
const errorData = await res.json().catch(() => ({}));
throw new Error(errorData.error || "Registration failed");
const text = await res.text().catch(() => res.statusText);
throw new Error(text || "Registration failed");
}
const data = await res.json();
const nextUser: AuthUser = data.user;
const nextToken: string = data.token ?? data.accessToken;
const nextUser: AuthUser =
data.user ??
({
uuid: data.uuid,
email: data.email ?? email,
displayName: data.displayName ?? displayName ?? null,
passwordSetAt: data.passwordSetAt ?? null,
role: data.role ?? "USER",
} as AuthUser);
setUser(nextUser);
persistUser(nextUser);
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
} finally {
setLoading(false);
}
},
[persistUser]
[setSession]
);
const logout = useCallback(() => {
setToken(null);
setUser(null);
persistUser(null);
persistAuth(null, null);
// Clear server session cookies
fetch("/api/auth/logout", {
method: "POST",
credentials: 'include',
}).catch(() => {});
}, [persistUser]);
fetch("/api/auth/session", { method: "DELETE" }).catch(() => {});
}, [persistAuth]);
const getAuthHeaders = useCallback((): HeadersInit => {
if (typeof window === "undefined") return {};
const token =
localStorage.getItem("token") ||
localStorage.getItem("jwt") ||
localStorage.getItem("accessToken");
return token ? { Authorization: `Bearer ${token}` } : {};
}, []);
}, [token]);
const value: AuthContextValue = {
user,
token,
loading,
login,
register,
logout,
refreshUser,
getAuthHeaders,
setSession,
refreshMeAndSession
};
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from "eslint/config";
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
import path from "node:path";
import { fileURLToPath } from "node:url";
const __filename = fileURLToPath(import.meta.url);
const __dirname = path.dirname(__filename);
export default defineConfig([{
extends: [...nextCoreWebVitals],
}]);
+18 -8
View File
@@ -1,23 +1,33 @@
# ---------- deps ----------
FROM node:20-alpine AS deps
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
RUN npm ci
# ---------- build ----------
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY --from=deps /app/node_modules ./node_modules
COPY . .
ENV NODE_ENV=production
ENV NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
# Build-time public env (baked into client bundle). You can override by rebuilding.
ARG NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
RUN npm run build
# ---------- runner ----------
FROM node:20-alpine AS runner
WORKDIR /app
ENV NODE_ENV=production
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
# If you want slightly better security:
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
# Standalone output includes server + minimal node_modules
COPY --from=builder /app/.next/standalone ./
COPY --from=builder /app/.next/static ./.next/static
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
USER nextjs
EXPOSE 3000
CMD ["npm", "start"]
CMD ["node", "server.js"]
+24
View File
@@ -0,0 +1,24 @@
FROM node:20-alpine AS deps
RUN apk add curl
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci --only=production
FROM node:20-alpine AS builder
WORKDIR /app
COPY package.json package-lock.json ./
RUN npm ci
COPY . .
ENV NODE_ENV=production
ENV NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
RUN npm run build
FROM node:20-alpine AS runner
WORKDIR /app
COPY --from=deps /app/node_modules ./node_modules
COPY --from=builder /app/.next ./.next
COPY --from=builder /app/public ./public
COPY --from=builder /app/package.json ./package.json
EXPOSE 3000
CMD ["npm", "start"]
+23 -17
View File
@@ -1,57 +1,63 @@
"use client";
import { useMemo } from "react";
import { useAuth } from "@/context/AuthContext";
// API routes now handled by Next.js /api routes (server-side)
// Authentication happens via HTTP-only cookies automatically
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type JsonValue = any;
export function useApi() {
const { token } = useAuth();
return useMemo(() => {
function authHeaders(extra?: HeadersInit): HeadersInit {
const base: Record<string, string> = {};
if (token) base.Authorization = `Bearer ${token}`;
return { ...base, ...(extra as any) };
}
async function get(path: string, init?: RequestInit) {
// Path should now point to Next.js API routes (e.g., /api/builds)
return fetch(path, {
return fetch(`${API_BASE_URL}${path}`, {
...init,
credentials: 'include', // Include cookies in requests
headers: authHeaders(init?.headers),
});
}
async function post(path: string, body: JsonValue, init?: RequestInit) {
return fetch(path, {
return fetch(`${API_BASE_URL}${path}`, {
method: "POST",
...init,
credentials: 'include',
headers: {
headers: authHeaders({
"Content-Type": "application/json",
...(init?.headers as any),
},
}),
body: JSON.stringify(body),
});
}
async function put(path: string, body: JsonValue, init?: RequestInit) {
return fetch(path, {
return fetch(`${API_BASE_URL}${path}`, {
method: "PUT",
...init,
credentials: 'include',
headers: {
headers: authHeaders({
"Content-Type": "application/json",
...(init?.headers as any),
},
}),
body: JSON.stringify(body),
});
}
async function del(path: string, init?: RequestInit) {
return fetch(path, {
return fetch(`${API_BASE_URL}${path}`, {
method: "DELETE",
...init,
credentials: 'include',
headers: init?.headers,
headers: authHeaders(init?.headers),
});
}
// ✅ stable reference unless token changes
return { get, post, put, del };
}, []);
}, [token]);
}
+25 -25
View File
@@ -17,29 +17,28 @@ export interface UpdateEmailRequestDto {
status?: string;
}
export async function fetchEmailRequests(): Promise<EmailRequest[]> {
const response = await fetch("/api/admin/email", { cache: "no-store" });
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || '';
export async function fetchEmailRequests(token: string): Promise<EmailRequest[]> {
const response = await fetch(`${API_BASE}/api/email`, {
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
throw new Error(`Failed to fetch email requests: ${response.statusText}`);
}
const data = (await response.json()) as unknown;
if (Array.isArray(data)) return data as EmailRequest[];
const wrapped = data as { data?: unknown; content?: unknown };
if (Array.isArray(wrapped.data)) return wrapped.data as EmailRequest[];
if (Array.isArray(wrapped.content)) return wrapped.content as EmailRequest[];
return [];
return response.json();
}
export async function createEmailRequest(
data: CreateEmailRequestDto
): Promise<EmailRequest> {
const response = await fetch("/api/admin/email", {
method: "POST",
export async function createEmailRequest(token: string, data: CreateEmailRequestDto): Promise<EmailRequest> {
const response = await fetch(`${API_BASE}/api/email`, {
method: 'POST',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
@@ -51,14 +50,12 @@ export async function createEmailRequest(
return response.json();
}
export async function updateEmailRequest(
id: number,
data: UpdateEmailRequestDto
): Promise<EmailRequest> {
const response = await fetch(`/api/admin/email/${id}`, {
method: "PUT",
export async function updateEmailRequest(token: string, id: number, data: UpdateEmailRequestDto): Promise<EmailRequest> {
const response = await fetch(`${API_BASE}/api/email/${id}`, {
method: 'PUT',
headers: {
"Content-Type": "application/json",
'Content-Type': 'application/json',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
@@ -70,9 +67,12 @@ export async function updateEmailRequest(
return response.json();
}
export async function deleteEmailRequest(id: number): Promise<void> {
const response = await fetch(`/api/admin/email/${id}`, {
method: "DELETE",
export async function deleteEmailRequest(token: string, id: number): Promise<void> {
const response = await fetch(`${API_BASE}/api/email/${id}`, {
method: 'DELETE',
headers: {
Authorization: `Bearer ${token}`,
},
});
if (!response.ok) {
+4 -10
View File
@@ -58,12 +58,9 @@ export async function fetchProducts(params: {
if (sort) sp.set("sort", sort);
const url = `/api/catalog/products?${sp.toString()}`;
const url = `${API_BASE_URL}/api/v1/products?${sp.toString()}`;
const res = await fetch(url, {
cache: "no-store",
credentials: 'include',
});
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
const data = (await res.json()) as ProductListItem[];
@@ -86,13 +83,10 @@ export async function fetchProductById(params: {
const { id, platform } = params;
const url =
`/api/catalog/products/${encodeURIComponent(id)}` +
`${API_BASE_URL}/api/v1/products/${encodeURIComponent(id)}` +
(platform ? `?${new URLSearchParams({ platform })}` : "");
const res = await fetch(url, {
cache: "no-store",
credentials: 'include',
});
const res = await fetch(url, { cache: "no-store" });
if (!res.ok) throw new Error(`Failed to load product (${res.status})`);
return (await res.json()) as ProductDetail;
}
+49 -20
View File
@@ -2,42 +2,71 @@
import { NextResponse } from "next/server";
import type { NextRequest } from "next/server";
const LAUNCH_ONLY_ROOT = process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT === "true";
// ✅ Forever allow list (always reachable even during launch-only mode)
const ALWAYS_ALLOW = new Set<string>([
"/",
"/favicon.ico",
"/robots.txt",
"/sitemap.xml",
]);
const PUBLIC_FILE =
/\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
const token = req.cookies.get("session_token")?.value;
// Protect admin API routes
if (pathname.startsWith("/api/admin")) {
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
console.log("LAUNCH_ONLY_ROOT:", process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT);
// Protect builds API routes
if (pathname.startsWith("/api/builds")) {
if (!token) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
// Protect admin pages
// =========================
// 1) Admin gate (always on)
// =========================
if (pathname.startsWith("/admin")) {
if (!token) {
const token = req.cookies.get("bb_access_token")?.value;
const role = req.cookies.get("bb_role")?.value; // optional
const ok = !!token && (!role || role === "ADMIN");
if (!ok) {
const url = req.nextUrl.clone();
url.pathname = "/login";
url.searchParams.set("next", pathname);
return NextResponse.redirect(url);
}
}
return NextResponse.next();
}
// =====================================
// 2) Launch-only gate (your existing one)
// =====================================
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
// ✅ Always allow Next internals + static assets
if (
pathname.startsWith("/_next") ||
pathname.startsWith("/favicon") ||
pathname === "/robots.txt" ||
pathname === "/sitemap.xml" ||
PUBLIC_FILE.test(pathname)
) {
return NextResponse.next();
}
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
return NextResponse.rewrite(new URL("/404", req.url));
}
` 1`
export const config = {
matcher: [
"/api/admin/:path*",
"/api/builds",
"/api/builds/:path+",
"/admin/:path*",
// 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)).*)",
],
};
+2 -1
View File
@@ -1,5 +1,6 @@
/// <reference types="next" />
/// <reference types="next/image-types/global" />
/// <reference path="./.next/types/routes.d.ts" />
// NOTE: This file should not be edited
// see https://nextjs.org/docs/basic-features/typescript for more information.
// see https://nextjs.org/docs/app/api-reference/config/typescript for more information.
+10 -8
View File
@@ -3,6 +3,7 @@ const nextConfig = {
experimental: {
appDir: true,
},
output: "standalone",
typescript: {
ignoreBuildErrors: true,
},
@@ -10,14 +11,15 @@ const nextConfig = {
ignoreDuringBuilds: true,
},
/* async rewrites() {
return [
{
source: "/api/:path*",
destination: "http://battlbuilder-api:8080/api/:path*",
},
];
},*/
// If you want rewrites, uncomment this block:
// async rewrites() {
// return [
// {
// source: "/api/:path*",
// destination: "http://battlbuilder-api:8080/api/:path*",
// },
// ];
// },
};
export default nextConfig;
+863 -691
View File
File diff suppressed because it is too large Load Diff
+31 -11
View File
@@ -1,34 +1,54 @@
{
"name": "gunbuilder-prototype",
"version": "0.1.0",
"name": "battle-builder-webui",
"version": "0.9.0",
"private": true,
"author": {
"name": "Forward Group, LLC",
"email": "info@goforward.group",
"url": "https://goforward.group"
},
"contributors": [
{
"name": "Sean Strawsburg",
"email": "sean@goforward.group"
},
{
"name": "Don Strawsburg",
"email": "don@goforward.group"
}
],
"license": "UNLICENSED",
"scripts": {
"dev": "next dev",
"dev": "next dev --turbopack",
"build": "next build",
"start": "next start",
"lint": "next lint"
"lint": "eslint ."
},
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"clsx": "^2.1.1",
"lucide-react": "^0.555.0",
"next": "14.2.3",
"next": "15.5.9",
"quill": "^2.0.3",
"quill-image-resize-module": "^3.0.0",
"react": "18.3.1",
"react-dom": "18.3.1",
"react": "19.2.3",
"react-dom": "19.2.3",
"react-quill": "^2.0.0"
},
"devDependencies": {
"@types/node": "20.12.7",
"@types/react": "18.3.3",
"@types/react-dom": "18.3.0",
"@types/react": "19.2.9",
"@types/react-dom": "19.2.3",
"autoprefixer": "10.4.19",
"eslint": "8.57.0",
"eslint-config-next": "14.2.3",
"eslint": "^9",
"eslint-config-next": "15.5.9",
"postcss": "8.4.38",
"tailwindcss": "3.4.3",
"typescript": "5.4.5"
},
"overrides": {
"@types/react": "19.2.9",
"@types/react-dom": "19.2.3"
}
}
-24
View File
@@ -1,24 +0,0 @@
#!/usr/bin/env bash
set -e
REGISTRY="gitea.gofwd.group"
OWNER="sean/shadow-gunbuilder-ai-proto"
IMAGE="webui"
TAG=$(git rev-parse --short HEAD)
FULL_IMAGE="$REGISTRY/$OWNER/$IMAGE"
echo "Building $FULL_IMAGE:$TAG"
docker build -f frontend/Dockerfile --no-cache -t $FULL_IMAGE:$TAG .
echo "Tagging latest"
docker tag $FULL_IMAGE:$TAG $FULL_IMAGE:latest
echo "Pushing $TAG"
docker push $FULL_IMAGE:$TAG
echo "Pushing latest"
docker push $FULL_IMAGE:latest
echo "Done!"
Executable
+23
View File
@@ -0,0 +1,23 @@
#!/usr/bin/env bash
set -euo pipefail
REGISTRY="gitea.gofwd.group"
OWNER="sean/shadow-gunbuilder-ai-proto"
IMAGE="webui"
TAG="$(git rev-parse --short HEAD)"
FULL_IMAGE="$REGISTRY/$OWNER/$IMAGE"
EXTRA_ARGS=()
if [[ "${NO_CACHE:-0}" == "1" ]]; then
EXTRA_ARGS+=(--no-cache)
fi
docker buildx build \
-f frontend/Dockerfile \
--platform linux/amd64,linux/arm64 \
-t "$FULL_IMAGE:$TAG" \
-t "$FULL_IMAGE:latest" \
--push \
"${EXTRA_ARGS[@]}" \
.