Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 777618f684 | |||
| b26dcb947e | |||
| 54c30b1d8a | |||
| 1c4fbed34a | |||
| 8b6d9a015e | |||
| 7c2206ffc3 | |||
| 6b0e0334b0 | |||
| 2cd871b529 | |||
| ce05593127 | |||
| be03424e26 | |||
| fa710d35a1 | |||
| 6863b3618c | |||
| b4177883f8 | |||
| 48e677bcfb |
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
@@ -0,0 +1,5 @@
|
||||
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
|
||||
BREVO_API_KEY=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
|
||||
BREVO_LIST_ID=9
|
||||
Generated
+8
@@ -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
|
||||
Generated
+17
@@ -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>
|
||||
Generated
+6
@@ -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
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Generated
Generated
+9
@@ -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>
|
||||
Generated
+8
@@ -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>
|
||||
Generated
+1
-1
@@ -3,4 +3,4 @@
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
Generated
-93
@@ -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">{
|
||||
"associatedIndex": 1
|
||||
}</component>
|
||||
<component name="ProjectId" id="361l08WqyxLtDro87xvcOWn3FhH" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"git-widget-placeholder": "main",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"js.debugger.nextJs.config.created.client": "true",
|
||||
"js.debugger.nextJs.config.created.server": "true",
|
||||
"kotlin-language-version-configured": "true",
|
||||
"last_opened_file_path": "//wsl.localhost/Arch/home/don/code/shadow-gunbuilder-ai-proto",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</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>
|
||||
@@ -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
-1
@@ -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) { ... }
|
||||
+19
-17
@@ -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");
|
||||
@@ -77,7 +79,7 @@ export default function PartDetailPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
fetchProduct();
|
||||
|
||||
return () => controller.abort();
|
||||
@@ -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 couldn’t 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
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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, data‑driven 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) {
|
||||
@@ -121,29 +135,28 @@ export default function GunbuilderPage() {
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
|
||||
const normalized: Part[] = data
|
||||
.map((p): Part | null => {
|
||||
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
|
||||
if (!categoryId) {
|
||||
// Skip any parts we don't know how to map yet
|
||||
return null;
|
||||
}
|
||||
.map((p): Part | null => {
|
||||
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
|
||||
if (!categoryId) {
|
||||
// Skip any parts we don't know how to map yet
|
||||
return null;
|
||||
}
|
||||
|
||||
const buyUrl = p.buyUrl ?? undefined;
|
||||
|
||||
return {
|
||||
id: String(p.id), // ALWAYS string
|
||||
categoryId,
|
||||
name: p.name,
|
||||
brand: p.brand,
|
||||
price: p.price ?? 0,
|
||||
imageUrl: p.mainImageUrl ?? undefined,
|
||||
affiliateUrl: buyUrl, // main link
|
||||
url: buyUrl, // 👈optional alias if you still reference .url. maybe pretty url?
|
||||
notes: undefined,
|
||||
};
|
||||
})
|
||||
.filter((p): p is Part => p !== null);
|
||||
const buyUrl = p.buyUrl ?? undefined;
|
||||
|
||||
return {
|
||||
id: String(p.id), // ALWAYS string
|
||||
categoryId,
|
||||
name: p.name,
|
||||
brand: p.brand,
|
||||
price: p.price ?? 0,
|
||||
imageUrl: p.mainImageUrl ?? undefined,
|
||||
affiliateUrl: buyUrl, // main link
|
||||
url: buyUrl, // 👈optional alias if you still reference .url. maybe pretty url?
|
||||
notes: undefined,
|
||||
};
|
||||
})
|
||||
.filter((p): p is Part => p !== null);
|
||||
|
||||
setParts(normalized);
|
||||
} catch (err: any) {
|
||||
@@ -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
|
||||
link—or a quick way to choose a part if you haven't picked
|
||||
one yet.
|
||||
current pick for that part type, with price and a direct buy link—or
|
||||
a quick way to choose a part if you haven'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">
|
||||
@@ -729,4 +796,4 @@ export default function GunbuilderPage() {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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"
|
||||
>
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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'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's next request. All new
|
||||
accounts are created as <span className="text-zinc-300">USER</span> by
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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
@@ -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, data‑driven 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
@@ -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();
|
||||
|
||||
+220
-26
@@ -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("You’re 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"
|
||||
>
|
||||
Launch The Builder
|
||||
</Link>
|
||||
</div>
|
||||
<defs>
|
||||
<pattern
|
||||
id="battlGrid"
|
||||
x="0"
|
||||
y="0"
|
||||
width="80"
|
||||
height="80"
|
||||
patternUnits="userSpaceOnUse"
|
||||
>
|
||||
<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'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>
|
||||
);
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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'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;
|
||||
@@ -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,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,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[];
|
||||
|
||||
+11
-38
@@ -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,24 +13,7 @@ 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'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">
|
||||
{/* Left: Brand / Home */}
|
||||
@@ -36,28 +21,16 @@ export function TopNav() {
|
||||
href="/"
|
||||
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
|
||||
>
|
||||
Battl 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>
|
||||
|
||||
{/* 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
|
||||
</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 */}
|
||||
|
||||
+65
-160
@@ -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 rifle’s 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 },
|
||||
];
|
||||
@@ -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}`);
|
||||
}
|
||||
}
|
||||
Generated
-9
@@ -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"
|
||||
|
||||
@@ -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 |
@@ -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 |
@@ -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",
|
||||
};
|
||||
@@ -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;
|
||||
}
|
||||
Reference in New Issue
Block a user