14 Commits

42 changed files with 2416 additions and 1238 deletions
-1
View File
@@ -1 +0,0 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
+5
View File
@@ -0,0 +1,5 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
BREVO_API_KEY=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
BREVO_LIST_ID=9
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+17
View File
@@ -0,0 +1,17 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
<data-source source="LOCAL" name="postgres@r710.dev.gofwd.group" uuid="54ed6f82-1373-4766-8855-7dd5f698a5ec">
<driver-ref>postgresql</driver-ref>
<synchronize>true</synchronize>
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
<jdbc-url>jdbc:postgresql://r710.dev.gofwd.group:5433/postgres</jdbc-url>
<jdbc-additional-properties>
<property name="com.intellij.clouds.kubernetes.db.host.port" />
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
<property name="com.intellij.clouds.kubernetes.db.container.port" />
</jdbc-additional-properties>
<working-dir>$ProjectFileDir$</working-dir>
</data-source>
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="DataSourcePerFileMappings">
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" value="54ed6f82-1373-4766-8855-7dd5f698a5ec" />
</component>
</project>
+6
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/shadow-gunbuilder-ai-proto.iml" filepath="$PROJECT_DIR$/.idea/shadow-gunbuilder-ai-proto.iml" />
</modules>
</component>
</project>
View File
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="SqlDialectMappings">
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" dialect="PostgreSQL" />
<file url="file://$PROJECT_DIR$/schema-example.sql" dialect="PostgreSQL" />
<file url="PROJECT" dialect="PostgreSQL" />
</component>
</project>
-93
View File
@@ -1,93 +0,0 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AutoImportSettings">
<option name="autoReloadType" value="SELECTIVE" />
</component>
<component name="ChangeListManager">
<list default="true" id="261566a2-3672-4898-b754-916bbdb2063b" name="Changes" comment="">
<change beforePath="$PROJECT_DIR$/next-env.d.ts" beforeDir="false" afterPath="$PROJECT_DIR$/next-env.d.ts" afterDir="false" />
<change beforePath="$PROJECT_DIR$/tsconfig.json" beforeDir="false" afterPath="$PROJECT_DIR$/tsconfig.json" afterDir="false" />
</list>
<option name="SHOW_DIALOG" value="false" />
<option name="HIGHLIGHT_CONFLICTS" value="true" />
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
<option name="LAST_RESOLUTION" value="IGNORE" />
</component>
<component name="Git.Settings">
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
</component>
<component name="ProjectColorInfo">{
&quot;associatedIndex&quot;: 1
}</component>
<component name="ProjectId" id="361l08WqyxLtDro87xvcOWn3FhH" />
<component name="ProjectViewState">
<option name="hideEmptyMiddlePackages" value="true" />
<option name="showLibraryContents" value="true" />
</component>
<component name="PropertiesComponent">{
&quot;keyToString&quot;: {
&quot;ModuleVcsDetector.initialDetectionPerformed&quot;: &quot;true&quot;,
&quot;RunOnceActivity.ShowReadmeOnStart&quot;: &quot;true&quot;,
&quot;RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252&quot;: &quot;true&quot;,
&quot;RunOnceActivity.git.unshallow&quot;: &quot;true&quot;,
&quot;git-widget-placeholder&quot;: &quot;main&quot;,
&quot;ignore.virus.scanning.warn.message&quot;: &quot;true&quot;,
&quot;js.debugger.nextJs.config.created.client&quot;: &quot;true&quot;,
&quot;js.debugger.nextJs.config.created.server&quot;: &quot;true&quot;,
&quot;kotlin-language-version-configured&quot;: &quot;true&quot;,
&quot;last_opened_file_path&quot;: &quot;//wsl.localhost/Arch/home/don/code/shadow-gunbuilder-ai-proto&quot;,
&quot;node.js.detected.package.eslint&quot;: &quot;true&quot;,
&quot;node.js.detected.package.tslint&quot;: &quot;true&quot;,
&quot;node.js.selected.package.eslint&quot;: &quot;(autodetect)&quot;,
&quot;node.js.selected.package.tslint&quot;: &quot;(autodetect)&quot;,
&quot;nodejs_package_manager_path&quot;: &quot;npm&quot;,
&quot;vue.rearranger.settings.migration&quot;: &quot;true&quot;
}
}</component>
<component name="RunManager" selected="npm.Next.js: server-side">
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
<method v="2" />
</configuration>
<configuration name="Next.js: server-side" type="js.build_tools.npm">
<package-json value="$PROJECT_DIR$/package.json" />
<command value="run" />
<scripts>
<script value="dev" />
</scripts>
<node-interpreter value="project" />
<envs />
<method v="2" />
</configuration>
</component>
<component name="SharedIndexes">
<attachedChunks>
<set>
<option value="bundled-jdk-9823dce3aa75-bf35d07a577b-intellij.indexing.shared.core-IU-252.27397.103" />
<option value="bundled-js-predefined-d6986cc7102b-3aa1da707db6-JavaScript-IU-252.27397.103" />
</set>
</attachedChunks>
</component>
<component name="TaskManager">
<task active="true" id="Default" summary="Default task">
<changelist id="261566a2-3672-4898-b754-916bbdb2063b" name="Changes" comment="" />
<created>1764181733358</created>
<option name="number" value="Default" />
<option name="presentableId" value="Default" />
<updated>1764181733358</updated>
<workItem from="1764181734592" duration="78000" />
<workItem from="1764181824336" duration="9367000" />
</task>
<servers />
</component>
<component name="TypeScriptGeneratedFilesManager">
<option name="version" value="3" />
</component>
<component name="github-copilot-workspace">
<instructionFileLocations>
<option value=".github/instructions" />
</instructionFileLocations>
<promptFileLocations>
<option value=".github/prompts" />
</promptFileLocations>
</component>
</project>
+6 -6
View File
@@ -11,7 +11,7 @@ This is an internal tool built for speed, clarity, and capability.
# 🚀 What This Admin UI Does
## Merchant Feeds
Path: `/gunbuilder/admin/merchants`
Path: `//admin/merchants`
A clean dashboard for all merchants feeding the system.
@@ -26,7 +26,7 @@ Purpose: keep the catalog alive, current, and trustworthy.
---
## Category Mapping
Path: `/gunbuilder/admin/category-mapping`
Path: `/builder/admin/category-mapping`
This is where raw merchant chaos gets cleaned up.
@@ -42,7 +42,7 @@ Once mapped, future imports classify parts automatically.
## Builder Integration
The admin UI powers the consumer-facing Builder at `/gunbuilder`.
The admin UI powers the consumer-facing Builder at `/builder`.
Today:
- Load compatible parts
@@ -80,13 +80,13 @@ pnpm install
Create `.env.local`:
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
NEXT_PUBLIC_ADMIN_BASE_PATH=/gunbuilder/admin
NEXT_PUBLIC_ADMIN_BASE_PATH=/builder/admin
## Start Dev Server
npm run dev
Then visit:
http://localhost:3000/gunbuilder/admin
http://localhost:3000/builder/admin
---
@@ -103,7 +103,7 @@ If CORS fails, the admin cannot communicate with the API.
# 🧭 Project Structure
app/
gunbuilder/
builder/
admin/
merchants/
category-mapping/
@@ -1,4 +1,4 @@
// app/gunbuilder/[categoryId]/[partId]/data.ts
// app/builder/[categoryId]/[partId]/data.ts
// Simulated data functions - Replace these with API calls when ready
// Example: export async function getPricingHistory(partId: string) { ... }
@@ -17,7 +17,7 @@ type GunbuilderProductFromApi = {
platform: string;
partRole: string;
price: number | null;
mainImageUrl: string | null;
imageUrl: string | null;
buyUrl: string | null;
};
@@ -27,7 +27,10 @@ const API_BASE_URL =
export default function PartDetailPage() {
const params = useParams();
const categoryId = params.categoryId as CategoryId;
const partId = params.partId as string; // this is the UUID we passed in the link
// Support URLs like /builder/lower/25969-m5-complete-lower-receiver
const rawPartParam = params.partId as string;
const partId = rawPartParam.split("-")[0]; // "25969-m5-..." -> "25969"
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
const [loading, setLoading] = useState(true);
@@ -55,21 +58,20 @@ export default function PartDetailPage() {
// For now, pull the full AR-15 list and find by UUID.
// We can optimize with a dedicated /api/products/{uuid} later.
const url = `${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`;
const url = `${API_BASE_URL}/api/products/gunbuilder/products/${partId}`;
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();
const found = data.find((p) => p.id === partId) ?? null;
const data: GunbuilderProductFromApi = await res.json();
if (!found) {
if (!data) {
setError("Product not found");
}
setProduct(found);
setProduct(data);
} catch (err: any) {
if (err.name === "AbortError") return;
setError(err.message ?? "Failed to load product");
@@ -131,10 +133,10 @@ export default function PartDetailPage() {
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
<h1 className="text-2xl font-semibold mb-4">Category Not Found</h1>
<Link
href="/gunbuilder"
href="/builder"
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Gunbuilder
Return to Builder
</Link>
</div>
</main>
@@ -147,14 +149,14 @@ export default function PartDetailPage() {
{/* Breadcrumbs */}
<nav className="mb-4 text-xs text-zinc-500">
<Link
href="/gunbuilder"
href="/builder"
className="hover:text-zinc-300 transition-colors"
>
Gunbuilder
Builder
</Link>
<span className="mx-1">/</span>
<Link
href={`/gunbuilder/${categoryId}`}
href={`/builder/${categoryId}`}
className="hover:text-zinc-300 transition-colors"
>
{category.name}
@@ -176,7 +178,7 @@ export default function PartDetailPage() {
{error ?? "We couldnt find this product."}
</p>
<Link
href={`/gunbuilder/${categoryId}`}
href={`/builder/${categoryId}`}
className="text-amber-300 hover:text-amber-200 underline text-sm"
>
Back to {category.name} parts
@@ -187,11 +189,11 @@ export default function PartDetailPage() {
<div className="grid gap-6 md:grid-cols-[minmax(0,2fr)_minmax(0,1.3fr)] items-start">
{/* Left: image + meta */}
<div className="space-y-4">
{product.mainImageUrl && (
{product.imageUrl && (
<div className="overflow-hidden rounded-lg border border-zinc-800 bg-zinc-950">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={product.mainImageUrl}
src={product.imageUrl}
alt={product.name}
className="w-full object-contain max-h-80 bg-zinc-950"
/>
@@ -352,7 +354,7 @@ export default function PartDetailPage() {
<div className="space-y-2">
<Link
href={`/gunbuilder?select=${categoryId}:${product.id}`}
href={`/builder?select=${categoryId}:${product.id}`}
className="block w-full rounded-md bg-amber-400 text-black text-sm font-semibold text-center py-2.5 hover:bg-amber-300 transition-colors"
>
Add to Build
+855
View File
@@ -0,0 +1,855 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
type ViewMode = "card" | "list";
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
inStock?: boolean | null;
};
type UiPart = Part & {
// local-only stock flag for filtering
inStock?: boolean;
};
const PLATFORMS = ["AR-15", "AR-10"];
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// sort options
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
// build state type + storage key (matches /gunbuilder)
type BuildState = Partial<Record<CategoryId, string>>;
const STORAGE_KEY = "gunbuilder-build-state";
// support for url id-slug
const slugify = (str: string) =>
str
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
export default function CategoryPage() {
const params = useParams();
const categoryId = params.categoryId as CategoryId;
const router = useRouter();
const searchParams = useSearchParams();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<UiPart[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// filter + sort state
const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState<string>("");
const [priceRange, setPriceRange] = useState<{
min: number | null;
max: number | null;
}>({
min: null,
max: null,
});
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number>(1);
const PAGE_SIZE = 24;
// build state for this page, synced with localStorage
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
const initialPlatform = (searchParams.get("platform") as string) || "AR-15";
const [platform, setPlatform] = useState<string>(initialPlatform);
const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId),
[categoryId]
);
useEffect(() => {
if (!categoryId) return;
const controller = new AbortController();
async function fetchCategoryParts() {
try {
setLoading(true);
setError(null);
const search = new URLSearchParams();
search.set("platform", platform);
const url = `${API_BASE_URL}/api/products?${search.toString()}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: UiPart[] = data.map((p) => ({
id: String(p.id), // normalize to string for BuildState
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.imageUrl ?? undefined, // match backend field name
url: p.buyUrl ?? undefined,
notes: undefined,
inStock: p.inStock ?? true,
}));
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
setError(err.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchCategoryParts();
return () => controller.abort();
}, [categoryId, platform]);
// persist build to localStorage whenever it changes
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
} catch {
// ignore
}
}, [build]);
// handler to toggle Add / Remove for this category
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
const isSelected = build[categoryId] === partId;
if (isSelected) {
// Remove from build
setBuild((prev) => {
const updated: BuildState = { ...prev };
delete updated[categoryId];
return updated;
});
return;
}
// Add to build and navigate back to main builder page.
// Pass selection via URL so `/builder` can apply it.
setBuild((prev) => ({
...prev,
[categoryId]: partId,
}));
const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
qp.set("select", `${categoryId}:${partId}`);
router.push(`/builder?${qp.toString()}`);
};
// available brands for filter
const availableBrands = useMemo(
() =>
Array.from(new Set(parts.map((p) => p.brand)))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b)),
[parts]
);
const priceBounds = useMemo(() => {
if (parts.length === 0) {
return { min: null as number | null, max: null as number | null };
}
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const p of parts) {
const price = p.price ?? 0;
if (price < min) min = price;
if (price > max) max = price;
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return { min: null as number | null, max: null as number | null };
}
return { min, max };
}, [parts]);
// initialize / clamp priceRange whenever bounds change
useEffect(() => {
if (priceBounds.min == null || priceBounds.max == null) return;
setPriceRange((prev) => {
const min = prev.min ?? priceBounds.min!;
const max = prev.max ?? priceBounds.max!;
return {
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
};
});
}, [priceBounds.min, priceBounds.max]);
const filteredParts = useMemo(() => {
let result = [...parts];
// Price range filter
const effectiveMin =
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
const effectiveMax =
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
if (effectiveMin != null && effectiveMax != null) {
result = result.filter((p) => {
const price = p.price ?? 0;
return price >= effectiveMin && price <= effectiveMax;
});
}
// Brand filter
if (brandFilter.length > 0) {
result = result.filter((p) => brandFilter.includes(p.brand));
}
// In-stock filter
if (inStockOnly) {
result = result.filter((p) => p.inStock ?? true);
}
const normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery) {
result = result.filter((p) => {
const name = p.name.toLowerCase();
const brand = p.brand.toLowerCase();
return (
name.includes(normalizedQuery) || brand.includes(normalizedQuery)
);
});
}
switch (sortBy) {
case "price-asc":
result.sort((a, b) => a.price - b.price);
break;
case "price-desc":
result.sort((a, b) => b.price - a.price);
break;
case "brand-asc":
result.sort((a, b) => a.brand.localeCompare(b.brand));
break;
case "relevance":
default:
// leave in backend order
break;
}
// Pin the currently selected part (for this category) to the top, if any
const selectedId = build[categoryId];
if (selectedId) {
const selected = result.filter((p) => p.id === selectedId);
const others = result.filter((p) => p.id !== selectedId);
result = [...selected, ...others];
}
return result;
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery]);
const totalPages = useMemo(
() =>
filteredParts.length === 0
? 1
: Math.ceil(filteredParts.length / PAGE_SIZE),
[filteredParts, PAGE_SIZE]
);
const paginatedParts = useMemo(() => {
if (filteredParts.length === 0) return [];
const startIndex = (currentPage - 1) * PAGE_SIZE;
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
}, [filteredParts, currentPage, PAGE_SIZE]);
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [
categoryId,
brandFilter,
sortBy,
searchQuery,
priceRange,
inStockOnly,
platform,
]);
useEffect(() => {
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
setPlatform(nextPlatform);
}, [searchParams]);
const visibleRange = useMemo(() => {
if (filteredParts.length === 0) {
return { start: 0, end: 0 };
}
const start = (currentPage - 1) * PAGE_SIZE + 1;
const end = Math.min(
start + paginatedParts.length - 1,
filteredParts.length
);
return { start, end };
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
if (!category) {
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">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
Category Not Found
</h1>
<Link
href="/builder"
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Builder
</Link>
</div>
</div>
</main>
);
}
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">
<Link
href="/builder"
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
</Link>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
{category.name} <span className="text-amber-300">Parts</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Browse all available {category.name.toLowerCase()} options for
your build, pulled live from your Ballistic backend.
</p>
</div>
{!loading && !error && parts.length > 0 && (
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button
type="button"
onClick={() => setViewMode("card")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "card"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="Card view"
>
Card
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "list"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="List view"
>
List
</button>
</div>
)}
</div>
</header>
{/* Parts Grid/List */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<div className="flex flex-col gap-4 md:flex-row">
{/* Left sidebar: filters */}
{!loading && !error && parts.length > 0 && (
<aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Filters
</h2>
<p className="mt-1 text-[11px] text-zinc-500">
Narrow down the {category.name.toLowerCase()} list.
</p>
</div>
{/* Price range */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Price
</span>
{priceRange.min !== null || priceRange.max !== null ? (
<button
type="button"
onClick={() =>
setPriceRange({
min: priceBounds.min,
max: priceBounds.max,
})
}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Reset
</button>
) : null}
</div>
{priceBounds.min == null || priceBounds.max == null ? (
<p className="text-[11px] text-zinc-500">
No price data available.
</p>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-zinc-400">
<span>
Min:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.min ?? priceBounds.min).toFixed(0)}
</span>
</span>
<span>
Max:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.max ?? priceBounds.max).toFixed(0)}
</span>
</span>
</div>
<div className="space-y-2">
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.min ?? priceBounds.min ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: Math.min(
value,
prev.max ?? priceBounds.max ?? value
),
max: prev.max ?? priceBounds.max ?? value,
}));
}}
className="w-full"
/>
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.max ?? priceBounds.max ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: prev.min ?? priceBounds.min ?? value,
max: Math.max(
value,
prev.min ?? priceBounds.min ?? value
),
}));
}}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Brand multi-select */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Brand
</span>
{brandFilter.length > 0 && (
<button
type="button"
onClick={() => setBrandFilter([])}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Clear
</button>
)}
</div>
{availableBrands.length === 0 ? (
<p className="text-[11px] text-zinc-500">
No brands available yet.
</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{availableBrands.map((b) => {
const checked = brandFilter.includes(b);
return (
<label
key={b}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={checked}
onChange={(e) => {
setBrandFilter((prev) =>
e.target.checked
? [...prev, b]
: prev.filter((brand) => brand !== b)
);
}}
/>
<span className="truncate">{b}</span>
</label>
);
})}
</div>
)}
{brandFilter.length === 0 && availableBrands.length > 0 && (
<p className="mt-1 text-[10px] text-zinc-500">
No brands selected showing all.
</p>
)}
</div>
{/* In-stock toggle */}
<div className="border-t border-zinc-800 pt-3">
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
In stock only
</span>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={inStockOnly}
onChange={(e) => setInStockOnly(e.target.checked)}
/>
</label>
<p className="mt-1 text-[10px] text-zinc-500">
Hides items marked out of stock by the backend.
</p>
</div>
</aside>
)}
{/* Right side: toolbar + results */}
<div className="flex-1">
{/* Top toolbar: count + search + sort */}
{!loading && !error && parts.length > 0 && (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="font-medium text-zinc-200">
{visibleRange.start}-{visibleRange.end}
</span>{" "}
of{" "}
<span className="font-medium text-zinc-200">
{filteredParts.length}
</span>{" "}
matching parts
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label
htmlFor="part-search"
className="text-xs text-zinc-500"
>
Search
</label>
<div className="relative">
<input
id="part-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Search ${category.name.toLowerCase()}...`}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="platform-select"
className="text-xs text-zinc-500"
>
Platform
</label>
<select
id="platform-select"
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="sort-by"
className="text-xs text-zinc-500"
>
Sort
</label>
<select
id="sort-by"
value={sortBy}
onChange={(e) =>
setSortBy(e.target.value as SortOption)
}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low High</option>
<option value="price-desc">Price: High Low</option>
<option value="brand-asc">Brand: A Z</option>
</select>
</div>
</div>
</div>
)}
{/* Results / states */}
{loading ? (
<p className="py-8 text-center text-sm text-zinc-500">
Loading {category.name.toLowerCase()}
</p>
) : error ? (
<p className="py-8 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : filteredParts.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500">
No parts available for this category yet.
</p>
) : viewMode === "card" ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="mb-3 flex items-center gap-2 justify-between">
<div className="flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.brand}{" "}
<span className="font-normal"> {part.name}</span>
</div>
{part.notes && (
<p className="mt-1 line-clamp-2 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
</div>
<div className="mt-2 flex gap-2">
<Link
href={{
pathname: `/builder/${categoryId}/${
part.id
}-${slugify(part.name)}`,
query: { platform },
}}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() =>
handleTogglePart(categoryId, part.id)
}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
);
})}
</div>
) : (
<>
{/* Header row for list view */}
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
<span>Part</span>
<span>Brand</span>
<span className="text-right">Price</span>
<span className="pr-2 text-right">Actions</span>
</div>
<div className="space-y-2">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.name}
</div>
{part.notes && (
<p className="mt-1 line-clamp-1 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
{part.brand}
</div>
<div className="text-sm font-semibold text-amber-300 md:text-right">
${part.price.toFixed(2)}
</div>
<div className="flex justify-end gap-2">
<Link
href={{
pathname: `/builder/${categoryId}/${
part.id
}-${slugify(part.name)}`,
query: { platform },
}}
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() =>
handleTogglePart(categoryId, part.id)
}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected
? "Remove from Build"
: "Add to Build"}
</button>
</div>
</div>
</div>
);
})}
</div>
</>
)}
{/* Pagination controls */}
{filteredParts.length > 0 && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
<div>
Page{" "}
<span className="font-semibold text-zinc-200">
{currentPage}
</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">
{totalPages}
</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
disabled={currentPage === totalPages}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
</section>
</div>
</main>
);
}
+26
View File
@@ -0,0 +1,26 @@
// app/(builder)/layout.tsx
import type { ReactNode } from "react";
import { AuthProvider } from "@/context/AuthContext";
import { BuilderNav } from "@/components/BuilderNav";
export const metadata = {
title: {
default: "Battl Builder",
template: "%s | Battl Builder",
},
description:
"Battl Builder — the smarter, faster, datadriven way to build your next firearm.",
};
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-neutral-950 text-zinc-50">
<AuthProvider>
<BuilderNav />
<main className="min-h-screen">{children}</main>
</AuthProvider>
</div>
);
}
@@ -20,6 +20,8 @@ type GunbuilderProductFromApi = {
buyUrl: string | null;
};
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
@@ -85,6 +87,16 @@ export default function GunbuilderPage() {
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const isValidPlatform = (
value: string | null
): value is (typeof PLATFORMS)[number] =>
!!value && (PLATFORMS as readonly string[]).includes(value);
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>(() => {
if (typeof window === "undefined") return "AR-15";
const initial = new URLSearchParams(window.location.search).get("platform");
return isValidPlatform(initial) ? initial : "AR-15";
});
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY);
@@ -110,8 +122,10 @@ export default function GunbuilderPage() {
setError(null);
const res = await fetch(
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`,
{ signal: controller.signal },
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(
platform
)}`,
{ signal: controller.signal }
);
if (!res.ok) {
@@ -144,7 +158,6 @@ export default function GunbuilderPage() {
})
.filter((p): p is Part => p !== null);
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
@@ -157,7 +170,16 @@ export default function GunbuilderPage() {
fetchProducts();
return () => controller.abort();
}, []);
}, [platform]);
useEffect(() => {
// When the platform changes, reset the current build so we don't
// show stale part selections from a different platform.
setBuild({});
if (typeof window !== "undefined") {
localStorage.removeItem(STORAGE_KEY);
}
}, [platform]);
// Handle URL query parameter for part selection (?select=upper:165)
useEffect(() => {
@@ -176,11 +198,26 @@ export default function GunbuilderPage() {
return updated;
});
router.replace("/builder", { scroll: false });
const qp = searchParams.get("platform");
const nextPlatform = isValidPlatform(qp) ? qp : platform;
router.replace(
`/builder?platform=${encodeURIComponent(nextPlatform)}`,
{
scroll: false,
}
);
}
}
}, [searchParams, router]);
useEffect(() => {
const qp = searchParams.get("platform");
if (isValidPlatform(qp) && qp !== platform) {
setPlatform(qp);
}
}, [searchParams, platform]);
// Persist build state to localStorage whenever it changes
useEffect(() => {
if (typeof window !== "undefined") {
@@ -191,9 +228,7 @@ export default function GunbuilderPage() {
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
const grouped = {} as Record<CategoryId, Part[]>;
for (const category of CATEGORIES) {
grouped[category.id] = parts.filter(
(p) => p.categoryId === category.id,
);
grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
}
return grouped;
}, [parts]);
@@ -201,7 +236,7 @@ export default function GunbuilderPage() {
const selectedParts: Part[] = useMemo(() => {
return Object.entries(build)
.map(([categoryId, partId]) =>
parts.find((p) => p.id === partId && p.categoryId === categoryId),
parts.find((p) => p.id === partId && p.categoryId === categoryId)
)
.filter(Boolean) as Part[];
}, [build, parts]);
@@ -212,14 +247,14 @@ export default function GunbuilderPage() {
Object.entries(build).map(([categoryId, partId]) => [
categoryId as CategoryId,
partId ? true : false,
]),
])
) as Record<CategoryId, unknown>,
[build],
[build]
);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts],
[selectedParts]
);
useEffect(() => {
@@ -236,7 +271,7 @@ export default function GunbuilderPage() {
const encoded = window.btoa(payload);
const origin = window.location?.origin ?? "";
const url = `${origin}/builder/build?build=${encodeURIComponent(
encoded,
encoded
)}`;
setShareUrl(url);
} catch {
@@ -254,10 +289,10 @@ export default function GunbuilderPage() {
// Use group ordering for the summary as well
const summaryCategoryOrder: CategoryId[] = useMemo(
() =>
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter(
(id) => CATEGORIES.some((c) => c.id === id),
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
CATEGORIES.some((c) => c.id === id)
),
[],
[]
);
const handleShare = async () => {
@@ -278,14 +313,14 @@ export default function GunbuilderPage() {
const selectedPartId = build[cat.id];
const part = parts.find(
(p) => p.id === selectedPartId && p.categoryId === cat.id,
(p) => p.id === selectedPartId && p.categoryId === cat.id
);
if (part) {
lines.push(
`${cat.name}: ${part.brand}${part.name} ($${part.price.toFixed(
2,
)})`,
2
)})`
);
} else {
lines.push(`${cat.name}: [Not selected]`);
@@ -299,7 +334,7 @@ export default function GunbuilderPage() {
setShareStatus("Build summary copied to clipboard.");
} catch {
setShareStatus(
"Could not access clipboard. You can copy from the build summary page.",
"Could not access clipboard. You can copy from the build summary page."
);
}
};
@@ -334,7 +369,9 @@ export default function GunbuilderPage() {
setShareStatus("Share dialog opened.");
} catch {
// User canceled or share failed; keep it quiet but let them know
setShareStatus("Share canceled or unavailable. You can copy the link instead.");
setShareStatus(
"Share canceled or unavailable. You can copy the link instead."
);
}
} else {
// Fallback to copying the link
@@ -344,7 +381,6 @@ export default function GunbuilderPage() {
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">
@@ -361,10 +397,31 @@ export default function GunbuilderPage() {
update as you go. This early-access builder keeps your setup saved
locally so you can come back and refine it anytime.
</p>
<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">
Platform
<select
value={platform}
onChange={(e) =>
setPlatform(e.target.value as (typeof PLATFORMS)[number])
}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<p className="text-[0.7rem] text-zinc-500">
Parts list updates automatically when you change platforms.
</p>
</div>
</div>
</header>
{/* Build summary panel */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
{/* Left: title + totals + primary actions */}
@@ -416,7 +473,9 @@ export default function GunbuilderPage() {
}}
disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
selectedParts.length === 0
? "opacity-40 cursor-not-allowed"
: ""
}`}
>
Clear Build
@@ -432,8 +491,8 @@ export default function GunbuilderPage() {
Share Your Build
</h3>
<p className="mt-1 text-[0.7rem] text-zinc-500">
Share this link to let others view your build or bookmark it to come
back later.
Share this link to let others view your build or bookmark it
to come back later.
</p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1">
@@ -468,9 +527,7 @@ export default function GunbuilderPage() {
)}
{shareStatus && (
<p className="mt-1 text-[0.7rem] text-zinc-500">
{shareStatus}
</p>
<p className="mt-1 text-[0.7rem] text-zinc-500">{shareStatus}</p>
)}
</div>
</section>
@@ -511,15 +568,12 @@ export default function GunbuilderPage() {
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<p className="mb-4 text-xs text-zinc-500">
Work top-down through the major sections. Each row shows your
current pick for that part type, with price and a direct buy
linkor a quick way to choose a part if you haven&apos;t picked
one yet.
current pick for that part type, with price and a direct buy linkor
a quick way to choose a part if you haven&apos;t picked one yet.
</p>
{loading && (
<p className="text-sm text-zinc-500">
Loading parts from backend
</p>
<p className="text-sm text-zinc-500">Loading parts from backend</p>
)}
{error && !loading && (
<p className="text-sm text-red-400">
@@ -532,7 +586,7 @@ export default function GunbuilderPage() {
<div className="space-y-6">
{CATEGORY_GROUPS.map((group) => {
const groupCategories = CATEGORIES.filter((c) =>
group.categoryIds.includes(c.id as CategoryId),
group.categoryIds.includes(c.id as CategoryId)
);
if (groupCategories.length === 0) {
@@ -584,7 +638,7 @@ export default function GunbuilderPage() {
partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id];
const selectedPart = categoryParts.find(
(p) => p.id === selectedPartId,
(p) => p.id === selectedPartId
);
const hasParts = categoryParts.length > 0;
@@ -663,13 +717,26 @@ export default function GunbuilderPage() {
</>
) : hasParts ? (
<Link
href={`/builder/${category.id}`}
className="inline-flex rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1 text-[0.7rem] font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
onClick={() => {
// If you want to pre-select something later, you can handle here.
href={{
pathname: `/builder/${category.id}`,
query: platform ? { platform } : {},
}}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Choose a Part
<svg
className="w-3.5 h-3.5"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M12 4v16m8-8H4"
/>
</svg>
<span>Choose a Part</span>
</Link>
) : (
<span className="text-[0.7rem] text-zinc-600">
@@ -150,7 +150,7 @@ export default function BuildDetailsPage() {
useEffect(() => {
if (typeof window !== "undefined" && Object.keys(build).length > 0) {
const encoded = encodeBuildState(build);
const url = `${window.location.origin}/gunbuilder/build?build=${encoded}`;
const url = `${window.location.origin}/builder/build?build=${encoded}`;
setShareUrl(url);
}
}, [build]);
@@ -212,10 +212,10 @@ export default function BuildDetailsPage() {
You need to select at least one part to view your build.
</p>
<Link
href="/gunbuilder"
href="/builder"
className="inline-block rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
Go to Gunbuilder
Go to Builder
</Link>
</div>
</div>
@@ -229,7 +229,7 @@ export default function BuildDetailsPage() {
{/* Header */}
<header className="mb-6">
<Link
href="/gunbuilder"
href="/builder"
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
@@ -368,7 +368,7 @@ export default function BuildDetailsPage() {
</a>
)}
<Link
href={`/gunbuilder/${category.id}/${part.id}`}
href={`/builder/${category.id}/${part.id}`}
className="inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
View Details
@@ -398,7 +398,7 @@ export default function BuildDetailsPage() {
{/* Actions */}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
<Link
href="/gunbuilder"
href="/builder"
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
>
Edit Build
@@ -408,7 +408,7 @@ export default function BuildDetailsPage() {
type="button"
onClick={() => {
localStorage.removeItem(STORAGE_KEY);
router.push("/gunbuilder");
router.push("/builder");
}}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
+11
View File
@@ -0,0 +1,11 @@
import { TopNav } from "@/components/TopNav";
import type { ReactNode } from "react";
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-black text-zinc-50">
<TopNav />
{children}
</div>
);
}
+273
View File
@@ -0,0 +1,273 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import {
fetchEmailRequests,
createEmailRequest,
updateEmailRequest,
deleteEmailRequest,
type EmailRequest,
type CreateEmailRequestDto,
type UpdateEmailRequestDto,
} from "@/lib/api/emailRequests";
import { Pencil, Trash2, Plus, X } from "lucide-react";
export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [selectedEmail, setSelectedEmail] = useState<EmailRequest | null>(null);
const [formData, setFormData] = useState<CreateEmailRequestDto>({
email: "",
status: "",
});
const [submitting, setSubmitting] = useState(false);
const loadEmails = async () => {
if (!token) return;
try {
setLoading(true);
const data = await fetchEmailRequests(token);
setEmails(data);
setError(null);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to load email requests");
} finally {
setLoading(false);
}
};
useEffect(() => {
loadEmails();
}, [token]);
const handleCreate = () => {
setModalMode("create");
setFormData({ email: "", status: "" });
setSelectedEmail(null);
setShowModal(true);
};
const handleEdit = (email: EmailRequest) => {
setModalMode("edit");
setFormData({ email: email.email, status: email.status });
setSelectedEmail(email);
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return;
try {
await deleteEmailRequest(token, id);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to delete email request");
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
if (!token) return;
try {
setSubmitting(true);
if (modalMode === "create") {
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(token, selectedEmail.id, updateData);
}
setShowModal(false);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to save email request");
} finally {
setSubmitting(false);
}
};
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 */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">
Email Requests
</h1>
<p className="mt-1 text-xs text-zinc-400">
Manage email requests and their statuses.
</p>
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500"
>
<Plus size={14} />
Add Email Request
</button>
</div>
{/* Error */}
{error && (
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{/* Table */}
{loading ? (
<div className="text-sm text-zinc-400">Loading</div>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-900/80 text-zinc-400">
<tr>
<th className="px-3 py-2 font-medium">ID</th>
<th className="px-3 py-2 font-medium">Email</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Created At</th>
<th className="px-3 py-2 font-medium">Updated At</th>
<th className="px-3 py-2 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{emails.map((email) => (
<tr
key={email.id}
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
>
<td className="px-3 py-2 text-zinc-400">{email.id}</td>
<td className="px-3 py-2 text-zinc-100">{email.email}</td>
<td className="px-3 py-2">
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
{email.status}
</span>
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{new Date(email.createdAt).toLocaleString()}
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{new Date(email.updatedAt).toLocaleString()}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(email)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(email.id)}
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
{emails.length === 0 && (
<tr>
<td
colSpan={6}
className="px-3 py-4 text-center text-xs text-zinc-500"
>
No email requests found.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">
{modalMode === "create" ? "Create Email Request" : "Edit Email Request"}
</h2>
<button
onClick={() => setShowModal(false)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Status
</label>
<input
type="text"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="pending"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
disabled={submitting}
>
{submitting ? "Saving..." : modalMode === "create" ? "Create" : "Update"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+157
View File
@@ -0,0 +1,157 @@
"use client";
import type React from "react";
import { useState } from "react";
import {
LayoutDashboard,
Download,
Layers,
Boxes,
Store,
Users,
Settings,
} from "lucide-react";
const navItems = [
{
label: "Dashboard",
href: "/admin",
icon: <LayoutDashboard className="h-4 w-4" />,
},
{
label: "Imports",
href: "/admin/import-status",
icon: <Download className="h-4 w-4" />,
},
{
label: "Mappings",
href: "/admin/mapping",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Products (TO DO)",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Merchants",
href: "/admin/merchants",
icon: <Store className="h-4 w-4" />,
},
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{
label: "Settings (TO DO)",
href: "/admin/settings",
icon: <Settings className="h-4 w-4" />,
},
];
// ADMIN CHECK FOR LOGIN
// const { user, loading } = useAuth();
// if (!loading && user?.role !== "ADMIN") {
// redirect("/"); // or /login
// }
export default function AdminLayout({
children,
}: {
children: React.ReactNode;
}) {
const [collapsed, setCollapsed] = useState(false);
return (
<div className="flex min-h-screen bg-black text-zinc-50">
{/* Sidebar */}
<aside
className={`hidden border-r border-zinc-900 bg-zinc-950/80 px-3 py-6 md:flex md:flex-col transition-all duration-200 ${
collapsed ? "w-16" : "w-60"
}`}
>
<div className="mb-6 flex items-center gap-2">
<button
type="button"
onClick={() => setCollapsed((v) => !v)}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 text-zinc-400 transition hover:border-amber-400/60 hover:text-amber-300"
>
<span className="sr-only">Toggle sidebar</span>
<div className="space-y-0.5">
<span className="block h-[1px] w-3 bg-current" />
<span className="block h-[1px] w-3 bg-current" />
<span className="block h-[1px] w-3 bg-current" />
</div>
</button>
{!collapsed && (
<div>
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
Battl Builders
</p>
<p className="text-[10px] text-zinc-600">Admin Command</p>
</div>
)}
</div>
<nav className="flex-1 space-y-1 text-sm">
{navItems.map((item) => (
<a
key={item.href}
href={item.href}
className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300"
>
<div className="flex items-center gap-2">
{item.icon}
{!collapsed && <span>{item.label}</span>}
</div>
{!collapsed && (
<span className="text-[10px] text-zinc-600"></span>
)}
</a>
))}
</nav>
{!collapsed && (
<div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600">
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Status
</p>
<p className="mt-1">
Import engine: <span className="text-emerald-400">online</span>
</p>
<p className="text-zinc-500">Builder UI: in progress</p>
</div>
)}
</aside>
{/* Main column */}
<div className="flex min-h-screen flex-1 flex-col">
{/* Top bar */}
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
<div>
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Admin
</p>
<p className="text-sm text-zinc-200">
Battl Builders Control Panel
</p>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
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>
{/* Content */}
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
{children}
</main>
</div>
</div>
);
}
+227
View File
@@ -0,0 +1,227 @@
"use client";
import { useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext"; // adjust path if needed
import { Loader2 } from "lucide-react";
import type { AdminUser, UserRole } from "@/types/user";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export default function AdminUsersPage() {
const { getAuthHeaders } = useAuth();
const [users, setUsers] = useState<AdminUser[]>([]);
const [loading, setLoading] = useState(true);
const [savingId, setSavingId] = useState<string | null>(null);
const [error, setError] = useState<string | null>(null);
async function fetchUsers() {
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/admin/users`, {
headers: {
...getAuthHeaders(),
},
});
if (!res.ok) {
throw new Error(`Failed to load users (${res.status})`);
}
const data: AdminUser[] = await res.json();
setUsers(data);
} catch (err: any) {
console.error(err);
setError(err.message ?? "Failed to load users");
} finally {
setLoading(false);
}
}
useEffect(() => {
fetchUsers();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
async function handleRoleChange(userId: string, newRole: UserRole) {
setSavingId(userId);
setError(null);
const prev = users;
setUsers((cur) =>
cur.map((u) => (u.id === userId ? { ...u, role: newRole } : u))
);
try {
const res = await fetch(`${API_BASE_URL}/admin/users/${userId}/role`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
},
body: JSON.stringify({ role: newRole }),
});
if (!res.ok) {
throw new Error(`Failed to update role (${res.status})`);
}
} catch (err: any) {
console.error(err);
setError(err.message ?? "Failed to update role");
// rollback
setUsers(prev);
} finally {
setSavingId(null);
}
}
function handleRoleSelectChange(user: AdminUser, newRole: UserRole) {
if (newRole === user.role) {
return;
}
// Confirm promotions to ADMIN
if (
newRole === "ADMIN" &&
!window.confirm(
`Promote ${user.email} to ADMIN? This will give them access to the admin console.`
)
) {
return;
}
// Confirm demotions from ADMIN
if (
user.role === "ADMIN" &&
newRole === "USER" &&
!window.confirm(
`Remove ADMIN access from ${user.email}? They will lose access to the admin console.`
)
) {
return;
}
void handleRoleChange(user.id, newRole);
}
return (
<div className="flex flex-1 flex-col gap-4">
<div className="flex items-center justify-between gap-3">
<div>
<h1 className="text-base font-semibold text-zinc-100">
Users & Roles
</h1>
<p className="mt-1 text-xs text-zinc-500">
Promote trusted accounts to admins. All new signups start as{" "}
<span className="font-medium text-zinc-300">USER</span>.
</p>
</div>
<button
type="button"
onClick={fetchUsers}
className="inline-flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-amber-400/60 hover:text-amber-300"
disabled={loading}
>
{loading && (
<Loader2 className="h-3 w-3 animate-spin text-zinc-500" />
)}
<span>Refresh</span>
</button>
</div>
{error && (
<div className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{loading ? (
<div className="flex flex-1 items-center justify-center text-xs text-zinc-500">
<Loader2 className="mr-2 h-4 w-4 animate-spin text-zinc-500" />
Loading users
</div>
) : users.length === 0 ? (
<div className="rounded-md border border-zinc-900 bg-zinc-950 px-4 py-6 text-sm text-zinc-400">
No users found yet. Once people start signing up, they&apos;ll show up
here.
</div>
) : (
<div className="overflow-hidden rounded-md border border-zinc-900 bg-zinc-950">
<table className="min-w-full border-separate border-spacing-0 text-xs">
<thead className="bg-zinc-950/80">
<tr className="text-left text-[11px] uppercase tracking-[0.16em] text-zinc-500">
<th className="border-b border-zinc-900 px-3 py-2">Email</th>
<th className="border-b border-zinc-900 px-3 py-2">
Display Name
</th>
<th className="border-b border-zinc-900 px-3 py-2">Role</th>
<th className="border-b border-zinc-900 px-3 py-2">Created</th>
<th className="border-b border-zinc-900 px-3 py-2">
Last Login
</th>
</tr>
</thead>
<tbody>
{users.map((u, idx) => (
<tr
key={u.id}
className={idx % 2 === 0 ? "bg-zinc-950" : "bg-zinc-950/60"}
>
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-100">
{u.email}
</td>
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-300">
{u.displayName || "—"}
</td>
<td className="border-b border-zinc-900 px-3 py-2 align-middle">
<div className="inline-flex items-center gap-2">
<span
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
u.role === "ADMIN"
? "border border-amber-500/60 bg-amber-500/10 text-amber-200"
: "border border-zinc-700 bg-zinc-900 text-zinc-300"
}`}
>
{u.role}
</span>
<select
value={u.role}
onChange={(e) =>
handleRoleSelectChange(u, e.target.value as UserRole)
}
disabled={savingId === u.id}
className="rounded-md border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 outline-none transition hover:border-amber-400/60 focus:border-amber-400/80"
>
<option value="USER">USER</option>
<option value="ADMIN">ADMIN</option>
</select>
</div>
</td>
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-400">
{u.createdAt
? new Date(u.createdAt).toLocaleDateString()
: "—"}
</td>
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-400">
{u.lastLoginAt
? new Date(u.lastLoginAt).toLocaleDateString()
: "—"}
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
<p className="mt-2 text-[10px] text-zinc-600">
Note: Role changes take effect on the user&apos;s next request. All new
accounts are created as <span className="text-zinc-300">USER</span> by
default.
</p>
</div>
);
}
+61
View File
@@ -0,0 +1,61 @@
import { NextResponse } from "next/server";
const BREVO_API_KEY = process.env.BREVO_API_KEY;
const BREVO_LIST_ID = process.env.BREVO_LIST_ID; // optional
export async function POST(req: Request) {
if (!BREVO_API_KEY) {
console.error("BREVO_API_KEY is not set");
return NextResponse.json(
{ error: "Server not configured" },
{ status: 500 }
);
}
try {
const { email } = await req.json();
if (!email || typeof email !== "string") {
return NextResponse.json(
{ error: "Valid email is required" },
{ status: 400 }
);
}
// Brevo contacts API
const res = await fetch("https://api.brevo.com/v3/contacts", {
method: "POST",
headers: {
"Content-Type": "application/json",
"api-key": BREVO_API_KEY,
},
body: JSON.stringify({
email,
updateEnabled: true, // update if already exists
...(BREVO_LIST_ID
? { listIds: [Number(BREVO_LIST_ID)] }
: {}),
attributes: {
SOURCE: "Battl Builders Beta",
},
}),
});
if (!res.ok) {
const text = await res.text();
console.error("Brevo error:", res.status, text);
return NextResponse.json(
{ error: "Failed to subscribe" },
{ status: 500 }
);
}
return NextResponse.json({ ok: true });
} catch (err) {
console.error(err);
return NextResponse.json(
{ error: "Something went wrong" },
{ status: 500 }
);
}
}
-799
View File
@@ -1,799 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
type ViewMode = "card" | "list";
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
inStock?: boolean | null;
};
type UiPart = Part & {
// local-only stock flag for filtering
inStock?: boolean;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
// sort options
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
// build state type + storage key (matches /gunbuilder)
type BuildState = Partial<Record<CategoryId, string>>;
const STORAGE_KEY = "gunbuilder-build-state";
export default function CategoryPage() {
const params = useParams();
const categoryId = params.categoryId as CategoryId;
const router = useRouter();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<UiPart[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// filter + sort state
const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState<string>("");
const [priceRange, setPriceRange] = useState<{ min: number | null; max: number | null }>({
min: null,
max: null,
});
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number>(1);
const PAGE_SIZE = 24;
// build state for this page, synced with localStorage
const [build, setBuild] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId),
[categoryId],
);
useEffect(() => {
if (!categoryId) return;
const controller = new AbortController();
async function fetchCategoryParts() {
try {
setLoading(true);
setError(null);
const search = new URLSearchParams();
search.set("platform", "AR-15");
search.set("categorySlug", categoryId);
const url = `${API_BASE_URL}/api/gunbuilder/products/by-category?${search.toString()}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: UiPart[] = data.map((p) => ({
id: String(p.id), // normalize to string for BuildState
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.imageUrl ?? undefined, // match backend field name
url: p.buyUrl ?? undefined,
notes: undefined,
inStock: p.inStock ?? true,
}));
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
setError(err.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchCategoryParts();
return () => controller.abort();
}, [categoryId]);
// persist build to localStorage whenever it changes
useEffect(() => {
if (typeof window === "undefined") return;
try {
window.localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
} catch {
// ignore
}
}, [build]);
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery]);
// handler to toggle Add / Remove for this category
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
const isSelected = build[categoryId] === partId;
if (isSelected) {
// Remove from build
setBuild((prev) => {
const updated: BuildState = { ...prev };
delete updated[categoryId];
return updated;
});
} else {
// Add to build and navigate back to main gunbuilder page
setBuild((prev) => ({
...prev,
[categoryId]: partId,
}));
router.push("/builder");
}
};
// available brands for filter
const availableBrands = useMemo(
() =>
Array.from(new Set(parts.map((p) => p.brand)))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b)),
[parts],
);
const priceBounds = useMemo(() => {
if (parts.length === 0) {
return { min: null as number | null, max: null as number | null };
}
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const p of parts) {
const price = p.price ?? 0;
if (price < min) min = price;
if (price > max) max = price;
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return { min: null as number | null, max: null as number | null };
}
return { min, max };
}, [parts]);
// initialize / clamp priceRange whenever bounds change
useEffect(() => {
if (priceBounds.min == null || priceBounds.max == null) return;
setPriceRange((prev) => {
const min = prev.min ?? priceBounds.min!;
const max = prev.max ?? priceBounds.max!;
return {
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
};
});
}, [priceBounds.min, priceBounds.max]);
const filteredParts = useMemo(() => {
let result = [...parts];
// Price range filter
const effectiveMin =
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
const effectiveMax =
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
if (effectiveMin != null && effectiveMax != null) {
result = result.filter((p) => {
const price = p.price ?? 0;
return price >= effectiveMin && price <= effectiveMax;
});
}
// Brand filter
if (brandFilter.length > 0) {
result = result.filter((p) => brandFilter.includes(p.brand));
}
// In-stock filter
if (inStockOnly) {
result = result.filter((p) => p.inStock ?? true);
}
const normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery) {
result = result.filter((p) => {
const name = p.name.toLowerCase();
const brand = p.brand.toLowerCase();
return (
name.includes(normalizedQuery) ||
brand.includes(normalizedQuery)
);
});
}
switch (sortBy) {
case "price-asc":
result.sort((a, b) => a.price - b.price);
break;
case "price-desc":
result.sort((a, b) => b.price - a.price);
break;
case "brand-asc":
result.sort((a, b) => a.brand.localeCompare(b.brand));
break;
case "relevance":
default:
// leave in backend order
break;
}
// Pin the currently selected part (for this category) to the top, if any
const selectedId = build[categoryId];
if (selectedId) {
const selected = result.filter((p) => p.id === selectedId);
const others = result.filter((p) => p.id !== selectedId);
result = [...selected, ...others];
}
return result;
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery]);
const totalPages = useMemo(
() => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)),
[filteredParts, PAGE_SIZE]
);
const paginatedParts = useMemo(() => {
if (filteredParts.length === 0) return [];
const startIndex = (currentPage - 1) * PAGE_SIZE;
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
}, [filteredParts, currentPage, PAGE_SIZE]);
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
const visibleRange = useMemo(() => {
if (filteredParts.length === 0) {
return { start: 0, end: 0 };
}
const start = (currentPage - 1) * PAGE_SIZE + 1;
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
return { start, end };
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
if (!category) {
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">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
Category Not Found
</h1>
<Link
href="/builder"
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Gunbuilder
</Link>
</div>
</div>
</main>
);
}
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">
<Link
href="/builder"
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
</Link>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
{category.name} <span className="text-amber-300">Parts</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Browse all available {category.name.toLowerCase()} options for
your build, pulled live from your Ballistic backend.
</p>
</div>
{!loading && !error && parts.length > 0 && (
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button
type="button"
onClick={() => setViewMode("card")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "card"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="Card view"
>
Card
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "list"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="List view"
>
List
</button>
</div>
)}
</div>
</header>
{/* Parts Grid/List */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<div className="flex flex-col gap-4 md:flex-row">
{/* Left sidebar: filters */}
{!loading && !error && parts.length > 0 && (
<aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Filters
</h2>
<p className="mt-1 text-[11px] text-zinc-500">
Narrow down the {category.name.toLowerCase()} list.
</p>
</div>
{/* Price range */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Price
</span>
{priceRange.min !== null || priceRange.max !== null ? (
<button
type="button"
onClick={() =>
setPriceRange({
min: priceBounds.min,
max: priceBounds.max,
})
}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Reset
</button>
) : null}
</div>
{priceBounds.min == null || priceBounds.max == null ? (
<p className="text-[11px] text-zinc-500">
No price data available.
</p>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-zinc-400">
<span>
Min:{" "}
<span className="font-semibold text-zinc-200">
$
{(priceRange.min ?? priceBounds.min).toFixed(0)}
</span>
</span>
<span>
Max:{" "}
<span className="font-semibold text-zinc-200">
$
{(priceRange.max ?? priceBounds.max).toFixed(0)}
</span>
</span>
</div>
<div className="space-y-2">
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.min ?? priceBounds.min ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: Math.min(
value,
prev.max ?? priceBounds.max ?? value
),
max:
prev.max ??
priceBounds.max ??
value,
}));
}}
className="w-full"
/>
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.max ?? priceBounds.max ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min:
prev.min ??
priceBounds.min ??
value,
max: Math.max(
value,
prev.min ?? priceBounds.min ?? value
),
}));
}}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Brand multi-select */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Brand
</span>
{brandFilter.length > 0 && (
<button
type="button"
onClick={() => setBrandFilter([])}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Clear
</button>
)}
</div>
{availableBrands.length === 0 ? (
<p className="text-[11px] text-zinc-500">
No brands available yet.
</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{availableBrands.map((b) => {
const checked = brandFilter.includes(b);
return (
<label
key={b}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={checked}
onChange={(e) => {
setBrandFilter((prev) =>
e.target.checked
? [...prev, b]
: prev.filter((brand) => brand !== b)
);
}}
/>
<span className="truncate">{b}</span>
</label>
);
})}
</div>
)}
{brandFilter.length === 0 && availableBrands.length > 0 && (
<p className="mt-1 text-[10px] text-zinc-500">
No brands selected showing all.
</p>
)}
</div>
{/* In-stock toggle */}
<div className="border-t border-zinc-800 pt-3">
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
In stock only
</span>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={inStockOnly}
onChange={(e) => setInStockOnly(e.target.checked)}
/>
</label>
<p className="mt-1 text-[10px] text-zinc-500">
Hides items marked out of stock by the backend.
</p>
</div>
</aside>
)}
{/* Right side: toolbar + results */}
<div className="flex-1">
{/* Top toolbar: count + search + sort */}
{!loading && !error && parts.length > 0 && (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="font-medium text-zinc-200">
{visibleRange.start}-{visibleRange.end}
</span>{" "}
of{" "}
<span className="font-medium text-zinc-200">
{filteredParts.length}
</span>{" "}
matching parts
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label
htmlFor="part-search"
className="text-xs text-zinc-500"
>
Search
</label>
<div className="relative">
<input
id="part-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Search ${category.name.toLowerCase()}...`}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<label
htmlFor="sort-by"
className="text-xs text-zinc-500"
>
Sort
</label>
<select
id="sort-by"
value={sortBy}
onChange={(e) =>
setSortBy(e.target.value as SortOption)
}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low High</option>
<option value="price-desc">Price: High Low</option>
<option value="brand-asc">Brand: A Z</option>
</select>
</div>
</div>
</div>
)}
{/* Results / states */}
{loading ? (
<p className="py-8 text-center text-sm text-zinc-500">
Loading {category.name.toLowerCase()}
</p>
) : error ? (
<p className="py-8 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : filteredParts.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500">
No parts available for this category yet.
</p>
) : viewMode === "card" ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="mb-3 flex items-center gap-2 justify-between">
<div className="flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.brand}{" "}
<span className="font-normal">
{part.name}
</span>
</div>
{part.notes && (
<p className="mt-1 line-clamp-2 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
</div>
<div className="mt-2 flex gap-2">
<Link
href={`/builder/${categoryId}/${part.id}`}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() => handleTogglePart(categoryId, part.id)}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
);
})}
</div>
) : (
<>
{/* Header row for list view */}
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
<span>Part</span>
<span>Brand</span>
<span className="text-right">Price</span>
<span className="pr-2 text-right">Actions</span>
</div>
<div className="space-y-2">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.name}
</div>
{part.notes && (
<p className="mt-1 line-clamp-1 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
{part.brand}
</div>
<div className="text-sm font-semibold text-amber-300 md:text-right">
${part.price.toFixed(2)}
</div>
<div className="flex justify-end gap-2">
<Link
href={`/builder/${categoryId}/${part.id}`}
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() =>
handleTogglePart(categoryId, part.id)
}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
</div>
);
})}
</div>
</>
)}
{/* Pagination controls */}
{filteredParts.length > 0 && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
<div>
Page{" "}
<span className="font-semibold text-zinc-200">
{currentPage}
</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">
{totalPages}
</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() =>
setCurrentPage((p) => Math.max(1, p - 1))
}
disabled={currentPage === 1}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() =>
setCurrentPage((p) => Math.min(totalPages, p + 1))
}
disabled={currentPage === totalPages}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
</section>
</div>
</main>
);
}
+16
View File
@@ -0,0 +1,16 @@
export const metadata = {
title: 'Next.js',
description: 'Generated by Next.js',
}
export default function RootLayout({
children,
}: {
children: React.ReactNode
}) {
return (
<html lang="en">
<body>{children}</body>
</html>
)
}
+11 -10
View File
@@ -1,27 +1,28 @@
// app/layout.tsx
import "./globals.css";
import type { ReactNode } from "react";
import { AuthProvider } from "@/context/AuthContext";
import { TopNav } from "@/components/TopNav";
import { BuilderNav } from "@/components/BuilderNav";
import { Banner } from "@/components/Banner";
export const metadata = {
title: {
default: "Battl Builder",
template: "%s | Battl Builder",
default: "Battl Builders",
template: "%s Battl Builders",
},
description: "Battl Builder — the smarter, faster, datadriven way to build your next firearm.",
description: "Build rifles smarter, not harder.",
};
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en">
<body className="bg-black text-zinc-100">
<body className="bg-black text-zinc-50">
<AuthProvider>
<TopNav />
<BuilderNav />
<main className="min-h-screen">{children}</main>
<Banner />
{children}
</AuthProvider>
</body>
</html>
);
+1 -1
View File
@@ -15,7 +15,7 @@ export default function LoginPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const next = searchParams.get("next") || "/gunbuilder";
const next = searchParams.get("next") || "/builder";
async function handleSubmit(e: FormEvent) {
e.preventDefault();
+218 -24
View File
@@ -1,33 +1,227 @@
import Link from "next/link";
"use client";
import { FormEvent, useState } from "react";
import Image from "next/image";
export default function HomePage() {
const [email, setEmail] = useState("");
const [useCase, setUseCase] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [message, setMessage] = useState<string | null>(null);
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!email) {
setMessage("Drop an email in first, operator.");
setStatus("error");
return;
}
try {
setStatus("loading");
setMessage(null);
const res = await fetch("/api/beta-signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email, useCase }),
});
if (!res.ok) {
throw new Error("Failed to save your signup.");
}
setStatus("success");
setMessage("Youre locked in. Watch your inbox.");
setEmail("");
setUseCase("");
} catch (err) {
console.error(err);
setStatus("error");
setMessage("Something broke. Try again in a minute.");
}
}
return (
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
<div className="max-w-xl text-center">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
For Those Who Build With Intent.
</p>
<h1
className="mt-2 text-4xl md:text-5xl font-extrabold tracking-tight
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
text-transparent uppercase bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
<main className="min-h-screen bg-black text-zinc-50">
{/* Abstract Brand Background */}
<div className="pointer-events-none absolute inset-0 overflow-hidden opacity-[0.55]">
<svg
width="100%"
height="100%"
preserveAspectRatio="xMidYMid slice"
xmlns="http://www.w3.org/2000/svg"
>
Battl Builder
</h1>
<p className="mt-3 text-sm md:text-base text-zinc-400">
Battl Builder is your command center for rifle builds. We surface live
parts and hard-use brands, giving you a mission-ready workbench to
design smarter, track cost, swap components, and save setups. No
fluff. Just clean, trustworthy data.
</p>
<div className="mt-6">
<Link
href="/builder"
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15"
<defs>
<pattern
id="battlGrid"
x="0"
y="0"
width="80"
height="80"
patternUnits="userSpaceOnUse"
>
Launch The Builder
</Link>
<path
d="M 80 0 L 0 0 0 80"
fill="none"
stroke="#fbbf24"
strokeWidth="0.5"
strokeOpacity="0.35"
/>
</pattern>
<pattern
id="battlHex"
width="140"
height="140"
patternUnits="userSpaceOnUse"
>
<path
d="M70 4 L136 40 L136 110 L70 146 L4 110 L4 40 Z"
fill="none"
stroke="#fbbf24"
strokeWidth="0.4"
strokeOpacity="0.25"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#battlGrid)" />
<rect width="100%" height="100%" fill="url(#battlHex)" />
</svg>
</div>
<div className="mx-auto flex min-h-screen max-w-5xl flex-col px-4 py-10">
{/* Top Bar / Logo */}
<header className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<div className="flex items-center">
<Image
src="/battl/battl-logo-mark-2.svg"
alt="Battl Builders Logo"
width={260} // adjust to taste
height={48} // adjust to taste
className="block"
/>
</div>
</div>
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-300">
Private Beta Coming Soon
</span>
</header>
{/* Hero */}
<section className="mt-16 grid gap-10 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] md:items-center">
<div>
<h1 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl">
Stop Guessing.
<span className="block text-amber-300 py-0.16em">
Start Building.
</span>
</h1>
<p className="mt-4 max-w-xl text-sm text-zinc-400 sm:text-base">
From part comparisons to full-build matchups, Battl Builders helps
you find the right components, catch compatibility issues, and
score the best deals all without juggling tabs or spreadsheets.
</p>
<ul className="mt-6 space-y-2 text-sm text-zinc-300">
<li> Side-by-side part & build comparisons</li>
<li> Smart compatibility checks</li>
<li> Live pricing from vetted merchants</li>
<li> Save & share builds with your crew</li>
<li> Vote on the cleanest, meanest setups</li>
</ul>
</div>
{/* Beta Form */}
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-5 shadow-[0_0_0_1px_rgba(24,24,27,0.9)]">
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
Join the Beta List
</h2>
<p className="mt-2 text-xs text-zinc-400">
Drop your email and we&apos;ll send early access when the Builder
goes hot. No spam, just builds.
</p>
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<div>
<label
htmlFor="email"
className="text-xs font-medium text-zinc-300"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@gearjunkie.com"
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2"
required
/>
</div>
<div>
<label
htmlFor="useCase"
className="text-xs font-medium text-zinc-300"
>
What will you use Battl Builders for?
<span className="text-zinc-500"> (optional)</span>
</label>
<textarea
id="useCase"
value={useCase}
onChange={(e) => setUseCase(e.target.value)}
rows={3}
placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..."
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2"
/>
</div>
<button
type="submit"
disabled={status === "loading"}
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
>
{status === "loading" ? "Enlisting…" : "Get Beta Access"}
</button>
{message && (
<p
className={`text-xs ${
status === "success"
? "text-emerald-400"
: status === "error"
? "text-red-400"
: "text-zinc-400"
}`}
>
{message}
</p>
)}
<p className="pt-1 text-[10px] leading-relaxed text-zinc-500">
You can one-click unsubscribe any time with one-click. No data
games, no surprise newsletters.
</p>
</form>
</div>
</section>
{/* Footer strip */}
<footer className="mt-12 border-t border-zinc-900 pt-4 text-xs text-zinc-500">
<div className="flex flex-wrap items-center justify-between gap-3">
<span>© {new Date().getFullYear()} BATTL BUILDERS<span className="align-super text-[0.9em] ml-0.5"></span></span>
<span className="text-zinc-600">
v0.1 Import engine online Builder UI in progress
</span>
</div>
</footer>
</div>
</main>
);
+1 -1
View File
@@ -16,7 +16,7 @@ export default function RegisterPage() {
const [password, setPassword] = useState("");
const [error, setError] = useState<string | null>(null);
const next = searchParams.get("next") || "/gunbuilder";
const next = searchParams.get("next") || "/builder";
async function handleSubmit(e: FormEvent) {
e.preventDefault();
+27
View File
@@ -0,0 +1,27 @@
import React from "react";
export function Banner() {
return (
<div>
{/* Early access bar */}
<div className="border-b border-amber-500/20 bg-amber-500/5">
<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">
<div className="flex items-center gap-2">
<span className="rounded-sm bg-amber-500/20 px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-amber-300">
Early Access Beta
</span>
<span className="hidden text-amber-100/90 md:inline">
You&apos;re using an early-access prototype of The Armory. Data,
pricing, and available parts are still evolving.
</span>
</div>
<span className="text-amber-100/90 md:hidden">
Early-access prototype. Data and pricing may change.
</span>
</div>
</div>
</div>
);
}
export default Banner;
+11 -3
View File
@@ -9,6 +9,7 @@ interface CategoryColumnProps {
parts: Part[];
selectedPartId?: string;
onSelectPart: (partId: string) => void;
platform?: string;
}
export function CategoryColumn({
@@ -16,6 +17,7 @@ export function CategoryColumn({
parts,
selectedPartId,
onSelectPart,
platform,
}: CategoryColumnProps) {
// Show selected part if available, otherwise show placeholder
const displayedPart = selectedPartId
@@ -37,8 +39,11 @@ export function CategoryColumn({
<div className="w-full border border-zinc-700 rounded-md p-3 mb-2 flex items-center justify-between gap-3">
<p className="text-sm text-zinc-500">No part selected</p>
<Link
href={`/builder/${category.id}`}
className="inline-flex items-center gap-2 rounded-md border border-zinc-700 bg-zinc-900/60 px-3 py-1.5 text-xs font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-500 transition-colors"
href={{
pathname: `/builder/${category.id}`,
query: platform ? { platform } : {},
}}
className="inline-flex w-full items-center justify-center rounded-md border border-zinc-700 bg-zinc-900 px-4 py-2 text-xs font-semibold text-zinc-100 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
<svg
className="w-4 h-4"
@@ -68,7 +73,10 @@ export function CategoryColumn({
</div>
{parts.length >= 1 && (
<Link
href={`/builder/${category.id}`}
href={{
pathname: `/builder/${category.id}`,
query: platform ? { platform } : {},
}}
className="mt-2 w-full rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
>
View All ({parts.length})
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { PriceHistoryPoint } from "@/app/builder/[categoryId]/[partId]/data";
import type { PriceHistoryPoint } from "@/app/(builder)/builder/[categoryId]/[partId]/data";
interface PricingHistoryGraphProps {
data: PriceHistoryPoint[];
+1 -1
View File
@@ -1,6 +1,6 @@
"use client";
import type { RetailerOffer } from "@/app/builder/[categoryId]/[partId]/data";
import type { RetailerOffer } from "@/app/(builder)/builder/[categoryId]/[partId]/data";
interface RetailersListProps {
retailers: RetailerOffer[];
+9 -36
View File
@@ -2,6 +2,8 @@
"use client";
import Link from "next/link";
import Image from "next/image";
import { useAuth } from "@/context/AuthContext";
export function TopNav() {
@@ -11,23 +13,6 @@ export function TopNav() {
return (
<header className="border-b border-zinc-800 bg-black/95 backdrop-blur">
{/* Early access bar */}
<div className="border-b border-amber-500/20 bg-amber-500/5">
<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">
<div className="flex items-center gap-2">
<span className="rounded-sm bg-amber-500/20 px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-amber-300">
Early Access Beta
</span>
<span className="hidden text-amber-100/90 md:inline">
You&apos;re using an early-access prototype of The Armory. Data,
pricing, and available parts are still evolving.
</span>
</div>
<span className="text-amber-100/90 md:hidden">
Early-access prototype. Data and pricing may change.
</span>
</div>
</div>
{/* Main nav row */}
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
@@ -36,28 +21,16 @@ export function TopNav() {
href="/"
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
>
Battl Builder
</Link>
{/* Center: main nav (Builder / Admin) */}
<nav className="hidden items-center gap-4 md:flex">
<Link
href="/builder"
className="text-xs font-medium text-zinc-300 hover:text-zinc-50 transition-colors"
>
Builder
<Image
src="/battl/battl-logo-mark-2.svg"
alt="Battl Builders Logo"
width={260} // adjust to taste
height={48} // adjust to taste
className="block"
/>
</Link>
{isAuthenticated && (
<Link
href="/admin"
className="text-xs font-medium text-emerald-400 hover:text-emerald-300 transition-colors"
>
Admin
</Link>
)}
</nav>
{/* Right side actions */}
<div className="flex items-center gap-3">
{/* Search placeholder */}
+64 -159
View File
@@ -1,163 +1,68 @@
import type { Category, Part } from "@/types/gunbuilder";
import type { Category } from "@/types/gunbuilder";
export const CATEGORY_SLUGS = {
lower: "lower",
"complete-lower": "complete-lower",
"lower-parts": "lower-parts",
trigger: "trigger",
grip: "grip",
safety: "safety",
buffer: "buffer",
stock: "stock",
upper: "upper",
"complete-upper": "complete-upper",
barrel: "barrel",
handguard: "handguard",
"gas-block": "gas-block",
"gas-tube": "gas-tube",
"muzzle-device": "muzzle-device",
bcg: "bcg",
sights: "sights",
"charging-handle": "charging-handle",
suppressor: "suppressor",
optic: "optic",
magazine: "magazine",
"weapon-light": "weapon-light",
foregrip: "foregrip",
bipod: "bipod",
sling: "sling",
"rail-accessory": "rail-accessory",
tools: "tools",
} as const;
export const CATEGORIES: Category[] = [
// LOWER
{
id: "lower",
name: "Stripped Lowers",
description: "Serialized lower the foundation of the build.",
group: "lower",
},
{
id: "completeLower",
name: "Complete Lowers",
description: "Factory-built lower with stock, buffer system, and controls.",
group: "lower",
},
{
id: "lowerParts",
name: "Lower Parts Kits",
description: "Pins, springs, and controls for the lower.",
group: "lower",
},
{
id: "trigger",
name: "Trigger / Fire Control",
description: "Single-stage, two-stage, and match triggers.",
group: "lower",
},
{
id: "grip",
name: "Pistol Grip",
description: "Grips that shape how the rifle handles.",
group: "lower",
},
{
id: "safety",
name: "Safety / Selector",
description: "Ambi selectors and control upgrades.",
group: "lower",
},
{
id: "buffer",
name: "Buffer System",
description: "Buffer tube, buffer, spring, and related parts.",
group: "lower",
},
{
id: "stock",
name: "Stock / Brace",
description: "Adjustable, fixed, and minimalist stocks.",
group: "lower",
},
// Lower group
{ id: "lower", name: "Stripped Lowers", group: "lower", slug: CATEGORY_SLUGS.lower },
{ id: "complete-lower", name: "Complete Lower", group: "lower", slug: CATEGORY_SLUGS["complete-lower"] },
{ id: "lower-parts", name: "Lower Parts Kit", group: "lower", slug: CATEGORY_SLUGS["lower-parts"] },
{ id: "trigger", name: "Trigger / Fire Control", group: "lower", slug: CATEGORY_SLUGS.trigger },
{ id: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip },
{ id: "safety", name: "Safety / Selector", group: "lower", slug: CATEGORY_SLUGS.safety },
{ id: "buffer", name: "Buffer System", group: "lower", slug: CATEGORY_SLUGS.buffer },
{ id: "stock", name: "Stock / Brace", group: "lower", slug: CATEGORY_SLUGS.stock },
// UPPER
{
id: "upper",
name: "Stripped Uppers",
description: "The core of the top half of your build.",
group: "upper",
},
{
id: "completeUpper",
name: "Complete Uppers",
description: "Pre-assembled uppers ready to pin and shoot.",
group: "upper",
},
{
id: "bcg",
name: "Bolt Carriers",
description: "The heartbeat of the rifles cycling.",
group: "upper",
},
{
id: "barrel",
name: "Barrels",
description: "Different lengths, profiles, and calibers.",
group: "upper",
},
{
id: "gasBlock",
name: "Gas Blocks",
description: "Standard and adjustable gas blocks.",
group: "upper",
},
{
id: "gasTube",
name: "Gas Tubes",
description: "Carbine, mid, rifle, and more.",
group: "upper",
},
{
id: "muzzleDevice",
name: "Muzzle Devices",
description: "Brakes, comps, and flash hiders.",
group: "upper",
},
{
id: "suppressor",
name: "Suppressors",
description: "Hearing-safe setups and hosts.",
group: "upper",
},
{
id: "handguard",
name: "Handguards / Rails",
description: "M-LOK rails and front-end furniture.",
group: "upper",
},
{
id: "chargingHandle",
name: "Charging Handles",
description: "Standard and ambi charging handles.",
group: "upper",
},
{
id: "sights",
name: "Iron Sights",
description: "Backup and primary irons.",
group: "upper",
},
{
id: "optic",
name: "Optics",
description: "LPVOs, red dots, and magnifiers.",
group: "upper",
},
// ACCESSORIES GROUP
{
id: "magazine",
name: "Magazines",
group: "accessories",
},
{
id: "weaponLight",
name: "Weapon Lights & Lasers",
group: "accessories",
},
{
id: "foregrip",
name: "Vertical / Angled Grips",
group: "accessories",
},
{
id: "bipod",
name: "Bipods",
group: "accessories",
},
{
id: "sling",
name: "Slings & Mounts",
group: "accessories",
},
{
id: "railAccessory",
name: "Rail Sections & Covers",
group: "accessories",
},
{
id: "tools",
name: "Tools & Maintenance",
group: "accessories",
},
// Upper group
{ id: "upper", name: "Stripped Upper", group: "upper", slug: CATEGORY_SLUGS.upper },
{ id: "complete-upper", name: "Complete Upper", group: "upper", slug: CATEGORY_SLUGS["complete-upper"] },
{ id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel },
{ id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
{ id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] },
{ id: "gas-tube", name: "Gas Tube", group: "upper", slug: CATEGORY_SLUGS["gas-tube"] },
{ id: "muzzle-device", name: "Muzzle Device", group: "upper", slug: CATEGORY_SLUGS["muzzle-device"] },
{ id: "sights", name: "Iron Sights", group: "upper", slug: CATEGORY_SLUGS.sights },
{ id: "bcg", name: "Bolt Carrier Group", group: "upper", slug: CATEGORY_SLUGS.bcg },
{ id: "charging-handle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS["charging-handle"] },
{ id: "suppressor", name: "Suppressors", group: "upper", slug: CATEGORY_SLUGS.suppressor },
{ id: "optic", name: "Optics", group: "upper", slug: CATEGORY_SLUGS.optic },
// Accessories
{ id: "magazine", name: "Magazines", group: "accessories", slug: CATEGORY_SLUGS.magazine },
{ id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS["weapon-light"] },
{ id: "foregrip", name: "Vertical / Angled Grips", group: "accessories", slug: CATEGORY_SLUGS.foregrip },
{ id: "bipod", name: "Bipods", group: "accessories", slug: CATEGORY_SLUGS.bipod },
{ id: "sling", name: "Slings & Mounts", group: "accessories", slug: CATEGORY_SLUGS.sling },
{ id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] },
{ id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools },
];
+80
View File
@@ -0,0 +1,80 @@
export interface EmailRequest {
id: number;
email: string;
status: string;
createdAt: string;
updatedAt: string;
}
export interface CreateEmailRequestDto {
email: string;
status?: string;
}
export interface UpdateEmailRequestDto {
email?: string;
status?: string;
}
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
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}`);
}
return response.json();
}
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',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to create email request: ${response.statusText}`);
}
return response.json();
}
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',
Authorization: `Bearer ${token}`,
},
body: JSON.stringify(data),
});
if (!response.ok) {
throw new Error(`Failed to update email request: ${response.statusText}`);
}
return response.json();
}
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) {
throw new Error(`Failed to delete email request: ${response.statusText}`);
}
}
-9
View File
@@ -994,7 +994,6 @@
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
"dev": true,
"license": "MIT",
"peer": true,
"bin": {
"acorn": "bin/acorn"
},
@@ -1435,7 +1434,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"baseline-browser-mapping": "^2.8.25",
"caniuse-lite": "^1.0.30001754",
@@ -2098,7 +2096,6 @@
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@eslint-community/eslint-utils": "^4.2.0",
"@eslint-community/regexpp": "^4.6.1",
@@ -2267,7 +2264,6 @@
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
"dev": true,
"license": "MIT",
"peer": true,
"dependencies": {
"@rtsao/scc": "^1.1.0",
"array-includes": "^3.1.9",
@@ -4429,7 +4425,6 @@
}
],
"license": "MIT",
"peer": true,
"dependencies": {
"nanoid": "^3.3.7",
"picocolors": "^1.0.0",
@@ -4588,7 +4583,6 @@
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0"
},
@@ -4601,7 +4595,6 @@
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
"license": "MIT",
"peer": true,
"dependencies": {
"loose-envify": "^1.1.0",
"scheduler": "^0.23.2"
@@ -5548,7 +5541,6 @@
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
"dev": true,
"license": "MIT",
"peer": true,
"engines": {
"node": ">=12"
},
@@ -5718,7 +5710,6 @@
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
"dev": true,
"license": "Apache-2.0",
"peer": true,
"bin": {
"tsc": "bin/tsc",
"tsserver": "bin/tsserver"
+28
View File
@@ -0,0 +1,28 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 995.72 154.88">
<defs>
<style>
.cls-1 {
fill: #f0efef;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<g>
<path class="cls-1" d="M249.46,121.28h-41.14V18.23h40.87c5.93,0,9.17,3.37,9.17,9.44v26.57c0,4.86-1.35,7.15-4.99,9.31l-6.34,3.91,6.74,4.18c3.64,2.16,5.12,4.72,5.12,9.58v30.62c0,6.07-3.37,9.44-9.44,9.44ZM242.99,31.18h-19.29v30.21h11.87l7.42-5.26v-24.95ZM243.39,78.93l-7.55-4.99h-12.14v34.39h19.69v-29.4Z"/>
<path class="cls-1" d="M314.34,121.28l-3.1-19.56h-19.29l-3.1,19.56h-15.24v-.27l19.96-102.91h16.18l19.83,102.91v.27h-15.24ZM301.53,41.97l-7.55,46.53h15.11l-7.55-46.53Z"/>
<path class="cls-1" d="M366.94,31.86v89.42h-15.38V31.86h-16.86v-13.62h49.23v13.62h-16.99Z"/>
<path class="cls-1" d="M427.9,31.86v89.42h-15.38V31.86h-16.86v-13.62h49.23v13.62h-16.99Z"/>
<path class="cls-1" d="M459.46,121.28V18.23h15.38v89.29h25.09v13.76h-40.46Z"/>
<path class="cls-1" d="M556.98,121.28h-41.14V18.23h40.87c5.93,0,9.17,3.37,9.17,9.44v26.57c0,4.86-1.35,7.15-4.99,9.31l-6.34,3.91,6.74,4.18c3.64,2.16,5.12,4.72,5.12,9.58v30.62c0,6.07-3.37,9.44-9.44,9.44ZM550.5,31.18h-19.29v30.21h11.87l7.42-5.26v-24.95ZM550.91,78.93l-7.55-4.99h-12.14v34.39h19.69v-29.4Z"/>
<path class="cls-1" d="M627.38,121.28h-32.77c-6.07,0-9.31-3.37-9.31-9.44V18.23h15.38v89.83h20.77V18.23h15.38v93.6c0,6.07-3.37,9.44-9.44,9.44Z"/>
<path class="cls-1" d="M656.25,121.28V18.23h15.38v103.04h-15.38Z"/>
<path class="cls-1" d="M691.04,121.28V18.23h15.38v89.29h25.09v13.76h-40.46Z"/>
<path class="cls-1" d="M778.85,121.28h-31.43V18.23h31.43c12.54,0,19.96,7.69,19.96,19.56v64.06c0,11.87-7.55,19.42-19.96,19.42ZM783.3,40.08c0-5.53-3.51-8.63-9.04-8.63h-11.46v76.61h11.06c6.2,0,9.44-3.37,9.44-8.77v-59.21Z"/>
<path class="cls-1" d="M817.42,121.28V18.23h42.35v13.62h-26.97v30.08h23.2v13.35h-23.2v32.37h27.38v13.62h-42.75Z"/>
<path class="cls-1" d="M914.12,121.28l-16.99-47.88v-9.31h15.11V31.45h-19.42v89.83h-15.38V18.23h40.87c6.07,0,9.31,3.37,9.31,9.44v37.5c0,6.07-3.24,9.31-9.17,9.44h-5.26l16.99,46.13v.54h-16.05Z"/>
<path class="cls-1" d="M986.42,121.28h-31.02c-5.93,0-9.31-3.37-9.31-9.44v-24.55h15.38v21.04h18.88v-20.23l-30.89-29.27c-2.56-2.29-3.37-4.32-3.37-8.23v-22.93c0-6.07,3.37-9.44,9.31-9.44h31.02c5.93,0,9.31,3.37,9.31,9.31v23.06h-15.38v-19.42h-18.88v18.61l30.89,29.13c2.56,2.43,3.37,4.45,3.37,8.36v24.55c0,6.07-3.37,9.44-9.31,9.44Z"/>
</g>
<path class="cls-1" d="M155.87,0v108.05l-46.85,46.84H0V46.84L46.85,0h109.02ZM128.96,14.84H62.16v31.53h35.72l31.08-31.53ZM46.39,46.37v-24.11l-24.12,24.11h24.12ZM140.1,92.75V25.97l-31.55,31.07v35.71h31.55ZM46.39,61.21H15.77v66.78l30.62-30.14v-36.63ZM93.71,61.21h-31.55v31.53h31.55v-31.53ZM93.71,108.51h-36.65c-8.09,9.81-21.24,18.92-28.77,28.74-.72.93-1.79,1.17-1.39,2.79h65.41l1.39-1.39v-30.14ZM133.6,108.51h-25.05v24.11c1.62.4,1.85-.67,2.79-1.39,7.57-5.8,15.12-16.08,22.26-22.73Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 3.0 KiB

+36
View File
@@ -0,0 +1,36 @@
<?xml version="1.0" encoding="UTF-8"?>
<svg id="Layer_2" data-name="Layer 2" xmlns="http://www.w3.org/2000/svg" viewBox="0 0 1001.79 154.88">
<defs>
<style>
.cls-1 {
font-family: AgencyFB-Bold, AgencyFB;
font-size: 134.88px;
font-weight: 700;
}
.cls-1, .cls-2 {
fill: #f0efef;
}
.cls-3 {
letter-spacing: .06em;
}
.cls-4 {
letter-spacing: .05em;
}
.cls-5 {
letter-spacing: .05em;
}
.cls-6 {
letter-spacing: 0em;
}
</style>
</defs>
<g id="Layer_1-2" data-name="Layer 1">
<text class="cls-1" transform="translate(202.12 121.28)"><tspan class="cls-5" x="0" y="0">B</tspan><tspan class="cls-6" x="69.06" y="0">A</tspan><tspan class="cls-3" x="130.83" y="0">T</tspan><tspan class="cls-4" x="191.79" y="0">TLBUILDERS</tspan></text>
<path class="cls-2" d="M155.87,0v108.05l-46.85,46.84H0V46.84L46.85,0h109.02ZM128.96,14.84H62.16v31.53h35.72l31.08-31.53ZM46.39,46.37v-24.11l-24.12,24.11h24.12ZM140.1,92.75V25.97l-31.55,31.07v35.71h31.55ZM46.39,61.21H15.77v66.78l30.62-30.14v-36.63ZM93.71,61.21h-31.55v31.53h31.55v-31.53ZM93.71,108.51h-36.65c-8.09,9.81-21.24,18.92-28.77,28.74-.72.93-1.79,1.17-1.39,2.79h65.41l1.39-1.39v-30.14ZM133.6,108.51h-25.05v24.11c1.62.4,1.85-.67,2.79-1.39,7.57-5.8,15.12-16.08,22.26-22.73Z"/>
</g>
</svg>

After

Width:  |  Height:  |  Size: 1.4 KiB

+32
View File
@@ -50,3 +50,35 @@ export interface Part {
imageUrl?: string;
notes?: string;
}
export const CATEGORY_SLUGS: Record<CategoryId, string> = {
lower: "lower",
completeLower: "complete-lower",
lowerParts: "lower-parts",
trigger: "trigger",
grip: "grip",
safety: "safety",
buffer: "buffer",
stock: "stock",
upper: "upper",
completeUpper: "complete-upper",
barrel: "barrel",
handguard: "handguard",
gasBlock: "gas-block",
gasTube: "gas-tube",
muzzleDevice: "muzzle-device",
sights: "sights",
bcg: "bcg",
chargingHandle: "charging-handle",
suppressor: "suppressor",
optic: "optic",
magazine: "magazine",
weaponLight: "weapon-light",
foregrip: "foregrip",
bipod: "bipod",
sling: "sling",
railAccessory: "rail-accessory",
tools: "tools",
};
+34
View File
@@ -0,0 +1,34 @@
// /types/user.ts
// ---- Roles ----
export type UserRole = "USER" | "ADMIN";
// If you ever add more:
// export type UserRole = "USER" | "ADMIN" | "SUPERADMIN" | "SUSPENDED";
// ---- Core user shapes ----
export interface BaseUser {
id: string;
email: string;
displayName?: string | null;
role: UserRole;
createdAt: string; // ISO string from backend
updatedAt?: string | null; // optional if you expose it
lastLoginAt?: string | null;
}
// What the admin UI works with
export type AdminUser = BaseUser;
// What your auth context might use on the client
export type AuthUser = {
id: string;
email: string;
displayName?: string | null;
role: UserRole;
} | null;
// ---- Payloads / DTOs ----
export interface UpdateUserRolePayload {
role: UserRole;
}