Compare commits
27 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 463fd06a12 | |||
| 4c2d767d09 | |||
| 10c0bd953b | |||
| 607939c468 | |||
| 0f10ff4e09 | |||
| 978b9df96c | |||
| 3ddb48fe58 | |||
| e200667611 | |||
| 4e74a38287 | |||
| 7f59e8fb27 | |||
| e16271663e | |||
| e2d0fb8003 | |||
| 1757aba3e7 | |||
| 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
|
|
||||||
+10
@@ -0,0 +1,10 @@
|
|||||||
|
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||||
|
BACKEND_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# Middleware to limited site to only root page
|
||||||
|
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
|
||||||
|
|
||||||
|
# Brevo API key for beta sign up collection
|
||||||
|
BREVO_API_KEY=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
|
||||||
|
BREVO_LIST_ID=9
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
|
||||||
|
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||||
|
|
||||||
|
# Middleware to limited site to only root page
|
||||||
|
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
|
||||||
|
|
||||||
|
# Brevo API key for beta sign up collection
|
||||||
|
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">
|
<component name="VcsDirectoryMappings">
|
||||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||||
</component>
|
</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
|
# 🚀 What This Admin UI Does
|
||||||
|
|
||||||
## Merchant Feeds
|
## Merchant Feeds
|
||||||
Path: `/gunbuilder/admin/merchants`
|
Path: `//admin/merchants`
|
||||||
|
|
||||||
A clean dashboard for all merchants feeding the system.
|
A clean dashboard for all merchants feeding the system.
|
||||||
|
|
||||||
@@ -26,7 +26,7 @@ Purpose: keep the catalog alive, current, and trustworthy.
|
|||||||
---
|
---
|
||||||
|
|
||||||
## Category Mapping
|
## Category Mapping
|
||||||
Path: `/gunbuilder/admin/category-mapping`
|
Path: `/builder/admin/category-mapping`
|
||||||
|
|
||||||
This is where raw merchant chaos gets cleaned up.
|
This is where raw merchant chaos gets cleaned up.
|
||||||
|
|
||||||
@@ -42,7 +42,7 @@ Once mapped, future imports classify parts automatically.
|
|||||||
|
|
||||||
## Builder Integration
|
## Builder Integration
|
||||||
|
|
||||||
The admin UI powers the consumer-facing Builder at `/gunbuilder`.
|
The admin UI powers the consumer-facing Builder at `/builder`.
|
||||||
|
|
||||||
Today:
|
Today:
|
||||||
- Load compatible parts
|
- Load compatible parts
|
||||||
@@ -80,13 +80,13 @@ pnpm install
|
|||||||
Create `.env.local`:
|
Create `.env.local`:
|
||||||
|
|
||||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
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
|
## Start Dev Server
|
||||||
npm run dev
|
npm run dev
|
||||||
|
|
||||||
Then visit:
|
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
|
# 🧭 Project Structure
|
||||||
|
|
||||||
app/
|
app/
|
||||||
gunbuilder/
|
builder/
|
||||||
admin/
|
admin/
|
||||||
merchants/
|
merchants/
|
||||||
category-mapping/
|
category-mapping/
|
||||||
|
|||||||
@@ -0,0 +1,482 @@
|
|||||||
|
//
|
||||||
|
// This page is most likely temporary. We probably do not need this page
|
||||||
|
//
|
||||||
|
//
|
||||||
|
//
|
||||||
|
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
|
|
||||||
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
|
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||||
|
|
||||||
|
import {
|
||||||
|
Pencil,
|
||||||
|
Link2,
|
||||||
|
Square,
|
||||||
|
CheckSquare,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
|
type BuildState = Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
|
type GunbuilderProductFromApi = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||||
|
|
||||||
|
const UNIVERSAL_CATEGORIES = new Set<CategoryId>([
|
||||||
|
"magazine",
|
||||||
|
"weapon-light",
|
||||||
|
"foregrip",
|
||||||
|
"bipod",
|
||||||
|
"sling",
|
||||||
|
"rail-accessory",
|
||||||
|
"tools",
|
||||||
|
"optic",
|
||||||
|
"sights",
|
||||||
|
]);
|
||||||
|
|
||||||
|
const CATEGORY_GROUPS: {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
description?: string;
|
||||||
|
categoryIds: CategoryId[];
|
||||||
|
}[] = [
|
||||||
|
{
|
||||||
|
id: "lower-group",
|
||||||
|
label: "Lower Receiver Parts",
|
||||||
|
description:
|
||||||
|
"Everything from the serialized lower to small parts, fire control, and core controls.",
|
||||||
|
categoryIds: [
|
||||||
|
"lower-receiver",
|
||||||
|
"complete-lower",
|
||||||
|
"lower-parts",
|
||||||
|
"trigger",
|
||||||
|
"grip",
|
||||||
|
"safety",
|
||||||
|
"buffer",
|
||||||
|
"stock",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "upper-group",
|
||||||
|
label: "Upper Receiver Parts",
|
||||||
|
description:
|
||||||
|
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
|
||||||
|
categoryIds: [
|
||||||
|
"upper-receiver",
|
||||||
|
"complete-upper",
|
||||||
|
"bcg",
|
||||||
|
"barrel",
|
||||||
|
"gas-block",
|
||||||
|
"gas-tube",
|
||||||
|
"muzzle-device",
|
||||||
|
"suppressor",
|
||||||
|
"handguard",
|
||||||
|
"charging-handle",
|
||||||
|
"sights",
|
||||||
|
"optic",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "accessories-group",
|
||||||
|
label: "Accessories & Gear",
|
||||||
|
description:
|
||||||
|
"Universal add-ons that work across platforms — mags, lights, slings, tools, and more.",
|
||||||
|
categoryIds: [
|
||||||
|
"magazine",
|
||||||
|
"weapon-light",
|
||||||
|
"foregrip",
|
||||||
|
"bipod",
|
||||||
|
"sling",
|
||||||
|
"rail-accessory",
|
||||||
|
"tools",
|
||||||
|
],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
function isValidPlatform(
|
||||||
|
value: string | null
|
||||||
|
): value is (typeof PLATFORMS)[number] {
|
||||||
|
return !!value && (PLATFORMS as readonly string[]).includes(value);
|
||||||
|
}
|
||||||
|
|
||||||
|
function safeDecodeBuildParam(buildParam: string | null): BuildState | null {
|
||||||
|
if (!buildParam) return null;
|
||||||
|
|
||||||
|
try {
|
||||||
|
// build param is base64(json)
|
||||||
|
const json = window.atob(buildParam);
|
||||||
|
const parsed = JSON.parse(json) as BuildState;
|
||||||
|
return parsed && typeof parsed === "object" ? parsed : null;
|
||||||
|
} catch {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(n: number) {
|
||||||
|
return `$${n.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuildSummaryPage() {
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
const router = useRouter();
|
||||||
|
|
||||||
|
const qpPlatform = searchParams.get("platform");
|
||||||
|
const platform = isValidPlatform(qpPlatform) ? qpPlatform : "AR-15";
|
||||||
|
|
||||||
|
const [build, setBuild] = useState<BuildState>({});
|
||||||
|
const [parts, setParts] = useState<Part[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Load build from ?build=... OR localStorage
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
|
||||||
|
const buildParam = searchParams.get("build");
|
||||||
|
const decoded = safeDecodeBuildParam(buildParam);
|
||||||
|
|
||||||
|
if (decoded) {
|
||||||
|
setBuild(decoded);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
|
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
|
||||||
|
} catch {
|
||||||
|
setBuild({});
|
||||||
|
}
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Fetch product data so we can resolve ids -> Part info
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchProducts() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// scoped (platform)
|
||||||
|
const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
|
||||||
|
platform
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
// universal (optional)
|
||||||
|
const universalUrl = `${API_BASE_URL}/api/products`;
|
||||||
|
|
||||||
|
const [scopedRes, universalRes] = await Promise.all([
|
||||||
|
fetch(scopedUrl, { signal: controller.signal }),
|
||||||
|
fetch(universalUrl, { signal: controller.signal }).catch(
|
||||||
|
() => null as any
|
||||||
|
),
|
||||||
|
]);
|
||||||
|
|
||||||
|
if (!scopedRes || !scopedRes.ok) {
|
||||||
|
throw new Error(`Failed to load products (${scopedRes?.status ?? ""})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const scopedData: GunbuilderProductFromApi[] = await scopedRes.json();
|
||||||
|
|
||||||
|
let universalData: GunbuilderProductFromApi[] = [];
|
||||||
|
if (universalRes && universalRes.ok) {
|
||||||
|
universalData = await universalRes.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
// NOTE: We only need enough to render the summary.
|
||||||
|
// We will map “everything we can” and ignore unknown categories.
|
||||||
|
const toCategoryId = (rawRole: string): CategoryId | null => {
|
||||||
|
const role = (rawRole ?? "").trim().toLowerCase().replace(/_/g, "-");
|
||||||
|
|
||||||
|
// quick defensive mapping (mirrors your builder page strategy)
|
||||||
|
if (role.includes("complete-upper")) return "complete-upper";
|
||||||
|
if (role.includes("complete-lower")) return "complete-lower";
|
||||||
|
if (role === "upper-receiver" || role === "upper") return "upper-receiver";
|
||||||
|
if (role === "lower-receiver" || role === "lower") return "lower-receiver";
|
||||||
|
if (role.includes("charging")) return "charging-handle";
|
||||||
|
if (role.includes("handguard")) return "handguard";
|
||||||
|
if (role.includes("bcg") || role.includes("bolt-carrier")) return "bcg";
|
||||||
|
if (role.includes("barrel")) return "barrel";
|
||||||
|
if (role.includes("gas-block") || role.includes("gasblock")) return "gas-block";
|
||||||
|
if (role.includes("gas-tube") || role.includes("gastube")) return "gas-tube";
|
||||||
|
if (role.includes("muzzle") || role.includes("flash") || role.includes("brake") || role.includes("comp"))
|
||||||
|
return "muzzle-device";
|
||||||
|
if (role.includes("suppress")) return "suppressor";
|
||||||
|
if (role.includes("lower-parts") || role.includes("lpk")) return "lower-parts";
|
||||||
|
if (role.includes("trigger")) return "trigger";
|
||||||
|
if (role.includes("grip")) return "grip";
|
||||||
|
if (role.includes("safety")) return "safety";
|
||||||
|
if (role.includes("buffer")) return "buffer";
|
||||||
|
if (role.includes("stock")) return "stock";
|
||||||
|
if (role.includes("optic") || role.includes("scope")) return "optic";
|
||||||
|
if (role.includes("sight")) return "sights";
|
||||||
|
if (role.includes("mag")) return "magazine";
|
||||||
|
if (role.includes("weapon-light") || (role.includes("light") && !role.includes("flight")))
|
||||||
|
return "weapon-light";
|
||||||
|
if (role.includes("foregrip") || role.includes("grip-vertical")) return "foregrip";
|
||||||
|
if (role.includes("bipod")) return "bipod";
|
||||||
|
if (role.includes("sling")) return "sling";
|
||||||
|
if (role.includes("rail-accessory") || role.includes("rail-attachment")) return "rail-accessory";
|
||||||
|
if (role.includes("tool")) return "tools";
|
||||||
|
|
||||||
|
return null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const normalize = (data: GunbuilderProductFromApi[], isUniversal: boolean): Part[] =>
|
||||||
|
data
|
||||||
|
.map((p): Part | null => {
|
||||||
|
const categoryId = toCategoryId(p.partRole);
|
||||||
|
if (!categoryId) return null;
|
||||||
|
|
||||||
|
if (isUniversal && !UNIVERSAL_CATEGORIES.has(categoryId)) return null;
|
||||||
|
|
||||||
|
const buyUrl = p.buyUrl ?? undefined;
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: String(p.id),
|
||||||
|
categoryId,
|
||||||
|
name: p.name,
|
||||||
|
brand: p.brand,
|
||||||
|
price: p.price ?? 0,
|
||||||
|
imageUrl: (p.imageUrl as any) ?? (p as any).mainImageUrl ?? undefined,
|
||||||
|
affiliateUrl: buyUrl,
|
||||||
|
url: buyUrl,
|
||||||
|
notes: undefined,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
.filter((p): p is Part => p !== null);
|
||||||
|
|
||||||
|
const scopedParts = normalize(scopedData, false);
|
||||||
|
const universalParts = normalize(universalData, true);
|
||||||
|
|
||||||
|
const seen = new Set<string>();
|
||||||
|
const merged: Part[] = [];
|
||||||
|
for (const p of [...scopedParts, ...universalParts]) {
|
||||||
|
const key = `${p.categoryId}:${p.id}`;
|
||||||
|
if (seen.has(key)) continue;
|
||||||
|
seen.add(key);
|
||||||
|
merged.push(p);
|
||||||
|
}
|
||||||
|
|
||||||
|
setParts(merged);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
setError(err?.message ?? "Failed to load products");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProducts();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [platform]);
|
||||||
|
|
||||||
|
const summaryCategoryOrder: CategoryId[] = useMemo(
|
||||||
|
() =>
|
||||||
|
CATEGORY_GROUPS.flatMap((g) => g.categoryIds).filter((id) =>
|
||||||
|
CATEGORIES.some((c) => c.id === id)
|
||||||
|
),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
|
const selectedParts = useMemo(() => {
|
||||||
|
const out: { categoryId: CategoryId; part?: Part }[] = [];
|
||||||
|
for (const cid of summaryCategoryOrder) {
|
||||||
|
const partId = build[cid];
|
||||||
|
const part = partId ? parts.find((p) => p.id === partId && p.categoryId === cid) : undefined;
|
||||||
|
out.push({ categoryId: cid, part });
|
||||||
|
}
|
||||||
|
return out;
|
||||||
|
}, [build, parts, summaryCategoryOrder]);
|
||||||
|
|
||||||
|
const total = useMemo(
|
||||||
|
() => selectedParts.reduce((sum, row) => sum + (row.part?.price ?? 0), 0),
|
||||||
|
[selectedParts]
|
||||||
|
);
|
||||||
|
|
||||||
|
const filledCount = useMemo(
|
||||||
|
() => selectedParts.filter((r) => !!r.part).length,
|
||||||
|
[selectedParts]
|
||||||
|
);
|
||||||
|
|
||||||
|
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">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
BATTL BUILDERS
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Build <span className="text-amber-300">Summary</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||||
|
Snapshot of your current selections. (V1: data resolves from your local build state + live products.)
|
||||||
|
</p>
|
||||||
|
<div className="mt-3 text-xs text-zinc-500">
|
||||||
|
Platform: <span className="text-zinc-200 font-semibold">{platform}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-end gap-2">
|
||||||
|
<div className="text-right">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Total
|
||||||
|
</div>
|
||||||
|
<div className="text-2xl font-semibold text-amber-300">
|
||||||
|
{formatPrice(total)}
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">
|
||||||
|
{filledCount} / {summaryCategoryOrder.length} selected
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", platform);
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
}}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
Back to Builder
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
|
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Edit Build
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">Loading…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="py-10 text-center text-sm text-red-400">
|
||||||
|
{error} — check that the Ballistic API is running.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="space-y-4">
|
||||||
|
{CATEGORY_GROUPS.map((group) => {
|
||||||
|
const rows = group.categoryIds
|
||||||
|
.filter((cid) => summaryCategoryOrder.includes(cid))
|
||||||
|
.map((cid) => {
|
||||||
|
const cat = CATEGORIES.find((c) => c.id === cid);
|
||||||
|
const partId = build[cid];
|
||||||
|
const part = partId
|
||||||
|
? parts.find((p) => p.id === partId && p.categoryId === cid)
|
||||||
|
: undefined;
|
||||||
|
return { cid, cat, part };
|
||||||
|
});
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div key={group.id} className="rounded-md border border-zinc-800 bg-zinc-950/80">
|
||||||
|
<div className="border-b border-zinc-800 px-4 py-3">
|
||||||
|
<div className="text-sm font-semibold text-zinc-100">{group.label}</div>
|
||||||
|
{group.description && (
|
||||||
|
<div className="mt-1 text-xs text-zinc-500">{group.description}</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="divide-y divide-zinc-900">
|
||||||
|
{rows.map(({ cid, cat, part }) => (
|
||||||
|
<div key={cid} className="flex items-start justify-between gap-4 px-4 py-3">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs font-semibold text-zinc-300">
|
||||||
|
{cat?.name ?? cid}
|
||||||
|
</div>
|
||||||
|
{part ? (
|
||||||
|
<div className="mt-1 text-sm text-zinc-100 truncate">
|
||||||
|
<span className="text-zinc-400">{part.brand}</span>{" "}
|
||||||
|
{part.name}
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<div className="mt-1 text-sm text-zinc-600">
|
||||||
|
Not selected
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-xs text-zinc-500">Price</div>
|
||||||
|
<div className={`text-sm font-semibold ${part ? "text-amber-300" : "text-zinc-600"}`}>
|
||||||
|
{part ? formatPrice(part.price) : "—"}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 flex justify-end gap-2">
|
||||||
|
<Link
|
||||||
|
href={{
|
||||||
|
pathname: `/parts/p/${platform}/${cid}`,
|
||||||
|
query: platform ? { platform } : {},
|
||||||
|
}}
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1.5 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
title={part ? "Change part" : "Choose part"}
|
||||||
|
aria-label={part ? "Change part" : "Choose part"}
|
||||||
|
>
|
||||||
|
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{part?.url ? (
|
||||||
|
<a
|
||||||
|
href={part.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-2.5 py-1.5 hover:bg-amber-300 transition-colors"
|
||||||
|
title="Buy part"
|
||||||
|
aria-label="Buy part"
|
||||||
|
>
|
||||||
|
<Link2 className="h-3.5 w-3.5 text-black" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="inline-flex items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-1.5 text-[11px] text-zinc-600">
|
||||||
|
—
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="mt-4 text-center text-xs text-zinc-600">
|
||||||
|
Tip: If you landed here from a shared link, it uses <code>?build=</code> first; otherwise it reads your local build.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// app/(builder)/builder/layout.tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||||
|
return <>{children}</>;
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -1,10 +1,11 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useMemo, useState, useEffect } from "react";
|
import { useMemo, useStat, useEffect } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
import { useSearchParams, useRouter } from "next/navigation";
|
||||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||||
|
import { buildDetailHref } from "@/app/parts/_components/buildDetailHref";
|
||||||
|
|
||||||
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
||||||
const STORAGE_KEY = "gunbuilder-build-state";
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
@@ -23,7 +24,7 @@ type GunbuilderProductFromApi = {
|
|||||||
buyUrl: string | null;
|
buyUrl: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
// Map backend partRole -> CategoryId (same as /gunbuilder page)
|
// Map backend partRole -> CategoryId (align with builder categories)
|
||||||
const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
|
const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
|
||||||
"upper-receiver": "upper",
|
"upper-receiver": "upper",
|
||||||
barrel: "barrel",
|
barrel: "barrel",
|
||||||
@@ -34,6 +35,12 @@ const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
|
|||||||
sight: "sights",
|
sight: "sights",
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// Inverse: CategoryId -> backend partRole (for building detail URLs)
|
||||||
|
const CATEGORY_TO_PART_ROLE: Partial<Record<CategoryId, string>> =
|
||||||
|
Object.fromEntries(
|
||||||
|
Object.entries(PART_ROLE_TO_CATEGORY).map(([role, cat]) => [cat, role]),
|
||||||
|
) as Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
// Encode build state to URL-friendly string
|
// Encode build state to URL-friendly string
|
||||||
function encodeBuildState(build: BuildState): string {
|
function encodeBuildState(build: BuildState): string {
|
||||||
const entries = Object.entries(build)
|
const entries = Object.entries(build)
|
||||||
@@ -64,6 +71,9 @@ export default function BuildDetailsPage() {
|
|||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
|
|
||||||
|
// ✅ Option A: platform is a query param and is passed through to backend
|
||||||
|
const platform = searchParams.get("platform") ?? "AR-15";
|
||||||
|
|
||||||
const [build, setBuild] = useState<BuildState>({});
|
const [build, setBuild] = useState<BuildState>({});
|
||||||
const [shareUrl, setShareUrl] = useState<string>("");
|
const [shareUrl, setShareUrl] = useState<string>("");
|
||||||
const [copied, setCopied] = useState(false);
|
const [copied, setCopied] = useState(false);
|
||||||
@@ -78,23 +88,25 @@ export default function BuildDetailsPage() {
|
|||||||
if (buildParam) {
|
if (buildParam) {
|
||||||
const decoded = decodeBuildState(buildParam);
|
const decoded = decodeBuildState(buildParam);
|
||||||
setBuild(decoded);
|
setBuild(decoded);
|
||||||
// Also save to localStorage
|
try {
|
||||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
|
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
|
||||||
|
} catch {
|
||||||
|
// ignore storage failures (private mode, etc.)
|
||||||
|
}
|
||||||
} else if (typeof window !== "undefined") {
|
} else if (typeof window !== "undefined") {
|
||||||
// Load from localStorage
|
|
||||||
const stored = localStorage.getItem(STORAGE_KEY);
|
const stored = localStorage.getItem(STORAGE_KEY);
|
||||||
if (stored) {
|
if (stored) {
|
||||||
try {
|
try {
|
||||||
const parsed = JSON.parse(stored);
|
const parsed = JSON.parse(stored);
|
||||||
setBuild(parsed);
|
setBuild(parsed);
|
||||||
} catch {
|
} catch {
|
||||||
// Invalid stored data
|
// ignore invalid stored data
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}, [searchParams]);
|
}, [searchParams]);
|
||||||
|
|
||||||
// Fetch live parts from backend (same source as /gunbuilder)
|
// Fetch live parts from backend (uses selected platform)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const controller = new AbortController();
|
const controller = new AbortController();
|
||||||
|
|
||||||
@@ -104,7 +116,7 @@ export default function BuildDetailsPage() {
|
|||||||
setPartsError(null);
|
setPartsError(null);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/products/gunbuilder?platform=AR-15`,
|
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}`,
|
||||||
{ signal: controller.signal },
|
{ signal: controller.signal },
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -120,7 +132,7 @@ export default function BuildDetailsPage() {
|
|||||||
if (!categoryId) return null;
|
if (!categoryId) return null;
|
||||||
|
|
||||||
return {
|
return {
|
||||||
id: p.id, // already a string UUID
|
id: p.id,
|
||||||
categoryId,
|
categoryId,
|
||||||
name: p.name,
|
name: p.name,
|
||||||
brand: p.brand,
|
brand: p.brand,
|
||||||
@@ -134,26 +146,29 @@ export default function BuildDetailsPage() {
|
|||||||
|
|
||||||
setParts(normalized);
|
setParts(normalized);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err.name === "AbortError") return;
|
if (err?.name === "AbortError") return;
|
||||||
setPartsError(err.message ?? "Failed to load products");
|
setPartsError(err?.message ?? "Failed to load products");
|
||||||
} finally {
|
} finally {
|
||||||
setLoadingParts(false);
|
setLoadingParts(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fetchProducts();
|
fetchProducts();
|
||||||
|
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, []);
|
}, [platform]);
|
||||||
|
|
||||||
// Generate shareable URL
|
// Generate shareable URL (includes platform)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (typeof window !== "undefined" && Object.keys(build).length > 0) {
|
if (typeof window !== "undefined" && Object.keys(build).length > 0) {
|
||||||
const encoded = encodeBuildState(build);
|
const encoded = encodeBuildState(build);
|
||||||
const url = `${window.location.origin}/gunbuilder/build?build=${encoded}`;
|
const url = `${window.location.origin}/builder/build?platform=${encodeURIComponent(
|
||||||
|
platform,
|
||||||
|
)}&build=${encoded}`;
|
||||||
setShareUrl(url);
|
setShareUrl(url);
|
||||||
|
} else if (typeof window !== "undefined") {
|
||||||
|
setShareUrl("");
|
||||||
}
|
}
|
||||||
}, [build]);
|
}, [build, platform]);
|
||||||
|
|
||||||
const selectedParts: Part[] = useMemo(() => {
|
const selectedParts: Part[] = useMemo(() => {
|
||||||
if (parts.length === 0) return [];
|
if (parts.length === 0) return [];
|
||||||
@@ -173,31 +188,33 @@ export default function BuildDetailsPage() {
|
|||||||
);
|
);
|
||||||
|
|
||||||
const handleCopyLink = async () => {
|
const handleCopyLink = async () => {
|
||||||
if (shareUrl) {
|
if (!shareUrl) return;
|
||||||
try {
|
try {
|
||||||
await navigator.clipboard.writeText(shareUrl);
|
await navigator.clipboard.writeText(shareUrl);
|
||||||
setCopied(true);
|
setCopied(true);
|
||||||
setTimeout(() => setCopied(false), 2000);
|
setTimeout(() => setCopied(false), 2000);
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error("Failed to copy:", err);
|
console.error("Failed to copy:", err);
|
||||||
}
|
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleShare = async () => {
|
const handleShare = async () => {
|
||||||
if (navigator.share && shareUrl) {
|
if (!shareUrl) return;
|
||||||
|
|
||||||
|
if (navigator.share) {
|
||||||
try {
|
try {
|
||||||
await navigator.share({
|
await navigator.share({
|
||||||
title: "My AR-15 Build",
|
title: `My ${platform} Build`,
|
||||||
text: `Check out my AR-15 build: $${totalPrice.toFixed(2)}`,
|
text: `Check out my ${platform} build: $${totalPrice.toFixed(2)}`,
|
||||||
url: shareUrl,
|
url: shareUrl,
|
||||||
});
|
});
|
||||||
|
return;
|
||||||
} catch {
|
} catch {
|
||||||
handleCopyLink();
|
// fall back to copy
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
handleCopyLink();
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
await handleCopyLink();
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!loadingParts && selectedParts.length === 0) {
|
if (!loadingParts && selectedParts.length === 0) {
|
||||||
@@ -212,10 +229,10 @@ export default function BuildDetailsPage() {
|
|||||||
You need to select at least one part to view your build.
|
You need to select at least one part to view your build.
|
||||||
</p>
|
</p>
|
||||||
<Link
|
<Link
|
||||||
href="/gunbuilder"
|
href={`/builder?platform=${encodeURIComponent(platform)}`}
|
||||||
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"
|
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>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -229,11 +246,12 @@ export default function BuildDetailsPage() {
|
|||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="mb-6">
|
<header className="mb-6">
|
||||||
<Link
|
<Link
|
||||||
href="/gunbuilder"
|
href={`/builder?platform=${encodeURIComponent(platform)}`}
|
||||||
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
||||||
>
|
>
|
||||||
← Back to The Armory
|
← Back to The Armory
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
@@ -246,6 +264,9 @@ export default function BuildDetailsPage() {
|
|||||||
Review your build, purchase parts from our affiliate partners, or
|
Review your build, purchase parts from our affiliate partners, or
|
||||||
share your build with others.
|
share your build with others.
|
||||||
</p>
|
</p>
|
||||||
|
<p className="mt-2 text-xs text-zinc-500">
|
||||||
|
Platform: <span className="text-zinc-200">{platform}</span>
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 md:mt-0">
|
<div className="mt-3 md:mt-0">
|
||||||
@@ -293,6 +314,7 @@ export default function BuildDetailsPage() {
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{shareUrl && (
|
{shareUrl && (
|
||||||
<div className="mt-3 p-3 rounded-md bg-zinc-900/50 border border-zinc-800">
|
<div className="mt-3 p-3 rounded-md bg-zinc-900/50 border border-zinc-800">
|
||||||
<p className="text-xs text-zinc-500 mb-1">Shareable URL:</p>
|
<p className="text-xs text-zinc-500 mb-1">Shareable URL:</p>
|
||||||
@@ -327,6 +349,10 @@ export default function BuildDetailsPage() {
|
|||||||
p.categoryId === (category.id as CategoryId),
|
p.categoryId === (category.id as CategoryId),
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const partRole =
|
||||||
|
(CATEGORY_TO_PART_ROLE[category.id] as string | undefined) ??
|
||||||
|
"unknown";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
key={category.id}
|
key={category.id}
|
||||||
@@ -337,12 +363,14 @@ export default function BuildDetailsPage() {
|
|||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-2">
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-2">
|
||||||
{category.name}
|
{category.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{part ? (
|
{part ? (
|
||||||
<>
|
<>
|
||||||
<div className="text-lg font-semibold text-zinc-50 mb-1">
|
<div className="text-lg font-semibold text-zinc-50 mb-1">
|
||||||
<span className="text-zinc-400">{part.brand}</span>{" "}
|
<span className="text-zinc-400">{part.brand}</span>{" "}
|
||||||
{part.name}
|
{part.name}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="mt-3 flex gap-2 flex-wrap">
|
<div className="mt-3 flex gap-2 flex-wrap">
|
||||||
{part.url && (
|
{part.url && (
|
||||||
<a
|
<a
|
||||||
@@ -367,8 +395,14 @@ export default function BuildDetailsPage() {
|
|||||||
</svg>
|
</svg>
|
||||||
</a>
|
</a>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/gunbuilder/${category.id}/${part.id}`}
|
href={buildDetailHref({
|
||||||
|
id: part.id,
|
||||||
|
name: part.name,
|
||||||
|
platform,
|
||||||
|
partRole,
|
||||||
|
} as any)}
|
||||||
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"
|
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
|
View Details
|
||||||
@@ -376,11 +410,10 @@ export default function BuildDetailsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : (
|
) : (
|
||||||
<div className="text-sm text-zinc-600">
|
<div className="text-sm text-zinc-600">Not selected</div>
|
||||||
Not selected
|
|
||||||
</div>
|
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{part && (
|
{part && (
|
||||||
<div className="text-right">
|
<div className="text-right">
|
||||||
<div className="text-2xl font-semibold text-amber-300">
|
<div className="text-2xl font-semibold text-amber-300">
|
||||||
@@ -398,17 +431,22 @@ export default function BuildDetailsPage() {
|
|||||||
{/* Actions */}
|
{/* Actions */}
|
||||||
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
|
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
|
||||||
<Link
|
<Link
|
||||||
href="/gunbuilder"
|
href={`/builder?platform=${encodeURIComponent(platform)}`}
|
||||||
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"
|
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
|
Edit Build
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => {
|
onClick={() => {
|
||||||
localStorage.removeItem(STORAGE_KEY);
|
try {
|
||||||
router.push("/gunbuilder");
|
localStorage.removeItem(STORAGE_KEY);
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
router.push(`/builder?platform=${encodeURIComponent(platform)}`);
|
||||||
}}
|
}}
|
||||||
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"
|
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,18 @@
|
|||||||
|
// app/(builder)/layout.tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
import { BuilderNav } from "@/components/BuilderNav";
|
||||||
|
import { TopNav } from "@/components/TopNav";
|
||||||
|
|
||||||
|
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-950 text-zinc-50">
|
||||||
|
<AuthProvider>
|
||||||
|
<TopNav />
|
||||||
|
<BuilderNav />
|
||||||
|
<main className="min-h-screen">{children}</main>
|
||||||
|
</AuthProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,431 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
import {
|
||||||
|
PART_ROLE_TO_CATEGORY,
|
||||||
|
normalizePartRole,
|
||||||
|
} from "@/lib/catalogMappings";
|
||||||
|
|
||||||
|
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 BuildState = Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||||
|
|
||||||
|
function extractNumericId(productSlug: string): number | null {
|
||||||
|
if (!productSlug) return null;
|
||||||
|
const first = productSlug.split("-")[0];
|
||||||
|
const n = Number(first);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number | null | undefined): string {
|
||||||
|
if (price == null) return "—";
|
||||||
|
return `$${price.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetailsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// NEW ROUTE PARAMS:
|
||||||
|
const platformParam = String(params.platform ?? "");
|
||||||
|
const partRoleParam = String(params.partRole ?? "");
|
||||||
|
const productSlug = String(params.productSlug ?? "");
|
||||||
|
|
||||||
|
// Platform comes from path segment (fallback to ?platform just in case)
|
||||||
|
const platform =
|
||||||
|
platformParam ||
|
||||||
|
(searchParams.get("platform") as string) ||
|
||||||
|
"AR-15";
|
||||||
|
|
||||||
|
const normalizedRole = useMemo(
|
||||||
|
() => normalizePartRole(partRoleParam),
|
||||||
|
[partRoleParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryId = useMemo(() => {
|
||||||
|
return PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null;
|
||||||
|
}, [partRoleParam, normalizedRole]);
|
||||||
|
|
||||||
|
const numericId = useMemo(
|
||||||
|
() => extractNumericId(productSlug),
|
||||||
|
[productSlug]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Read-only build state (builder page owns writes)
|
||||||
|
const [build] = 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 selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||||
|
const isSelected =
|
||||||
|
!!categoryId &&
|
||||||
|
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||||
|
|
||||||
|
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// If platform segment is missing/empty, normalize into the new route (not /builder)
|
||||||
|
useEffect(() => {
|
||||||
|
if (platformParam) return;
|
||||||
|
|
||||||
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
|
router.replace(
|
||||||
|
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
|
||||||
|
|
||||||
|
// Fetch product
|
||||||
|
useEffect(() => {
|
||||||
|
if (!numericId) {
|
||||||
|
setError("Invalid product id.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchProduct() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `${API_BASE_URL}/api/products/${numericId}`;
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load product (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GunbuilderProductFromApi = await res.json();
|
||||||
|
setProduct(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
setError(err?.message ?? "Failed to load product");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProduct();
|
||||||
|
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [numericId]);
|
||||||
|
|
||||||
|
const handleTogglePart = () => {
|
||||||
|
if (!numericId) return;
|
||||||
|
|
||||||
|
// If mapping is missing, don’t send a bogus builder action
|
||||||
|
if (!categoryId) {
|
||||||
|
alert(
|
||||||
|
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
qp.set("remove", String(categoryId));
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qp.set("select", `${categoryId}:${numericId}`);
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
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">
|
||||||
|
Unknown Part Role
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-400 mb-6">
|
||||||
|
No mapping found for <span className="text-zinc-200">{partRoleParam}</span>.
|
||||||
|
Add it to <span className="text-zinc-200">catalogMappings</span>.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Back to Parts List
|
||||||
|
</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">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<header className="mb-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<Link
|
||||||
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
|
className="hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
The Armory
|
||||||
|
</Link>
|
||||||
|
<span className="text-zinc-700">/</span>
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
Parts
|
||||||
|
</Link>
|
||||||
|
<span className="text-zinc-700">/</span>
|
||||||
|
<span className="text-zinc-300">Details</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
BATTL BUILDERS
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Product <span className="text-amber-300">Details</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||||
|
Live product data pulled from your Ballistic backend. Add it to your build
|
||||||
|
or jump back to compare options.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform switch (updates NEW route) */}
|
||||||
|
<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) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
router.replace(
|
||||||
|
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}/${encodeURIComponent(productSlug)}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">Loading product…</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="py-10 text-center text-sm text-red-400">
|
||||||
|
{error} — check that the Ballistic API is running.
|
||||||
|
</p>
|
||||||
|
) : !product ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">Product not found.</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||||
|
{/* Left: image */}
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
|
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||||
|
{product.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={product.imageUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="h-full w-full object-contain p-2"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Stock
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold ${
|
||||||
|
product.inStock === false ? "text-red-300" : "text-emerald-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{product.inStock === false ? "Out of stock" : "In stock"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Back to List
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTogglePart}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* Right: details */}
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
{product.brand}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
|
||||||
|
{product.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
Platform:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.platform || platform}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
Role:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.partRole || partRoleParam}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
ID:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Current price
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
||||||
|
{formatPrice(product.price)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTogglePart}
|
||||||
|
className={`rounded-md px-4 py-2 text-sm 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>
|
||||||
|
|
||||||
|
{product.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={product.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-center text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Buy from Merchant →
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="rounded-md border border-zinc-800 bg-zinc-950 px-4 py-2 text-center text-sm text-zinc-500">
|
||||||
|
No buy link available
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 border-t border-zinc-800 pt-4">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Notes
|
||||||
|
</h3>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
|
This details page is intentionally “thin” for now — once we wire in
|
||||||
|
offers, price history, and richer product fields, this section becomes
|
||||||
|
the spec + comparison hub.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
|
Tip: use “Back to List” to compare multiple parts quickly.
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
|
Platform comes from the URL segment:{" "}
|
||||||
|
<span className="text-zinc-300">/parts/p/{platform}/...</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+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
|
// Simulated data functions - Replace these with API calls when ready
|
||||||
// Example: export async function getPricingHistory(partId: string) { ... }
|
// Example: export async function getPricingHistory(partId: string) { ... }
|
||||||
@@ -0,0 +1,846 @@
|
|||||||
|
"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";
|
||||||
|
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
|
||||||
|
|
||||||
|
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", "AR-9"];
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
|
// sort options
|
||||||
|
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||||
|
|
||||||
|
// support for url id-slug
|
||||||
|
const slugify = (str: string) =>
|
||||||
|
str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
|
||||||
|
const normalizeRole = (role: string) =>
|
||||||
|
role.trim().toLowerCase().replace(/_/g, "-");
|
||||||
|
|
||||||
|
function getNormalizedPartRolesForCategory(categoryId: string): string[] {
|
||||||
|
const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as
|
||||||
|
| string[]
|
||||||
|
| undefined;
|
||||||
|
if (!roles || roles.length === 0) return [];
|
||||||
|
return Array.from(new Set(roles.map(normalizeRole)));
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function CategoryPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const categoryId = params.categoryId as CategoryId;
|
||||||
|
const partRoles = useMemo(
|
||||||
|
() => getNormalizedPartRolesForCategory(String(categoryId)),
|
||||||
|
[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 (read-only), hydrated from localStorage
|
||||||
|
// NOTE: The main /builder page is the single source of truth for writing to STORAGE_KEY.
|
||||||
|
const [build] = 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);
|
||||||
|
|
||||||
|
// If a user lands here without ?platform=..., normalize the URL so downstream navigation keeps platform.
|
||||||
|
useEffect(() => {
|
||||||
|
const qpPlatform = searchParams.get("platform");
|
||||||
|
if (qpPlatform) return;
|
||||||
|
|
||||||
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
router.replace(`/builder/${categoryId}?${qp.toString()}`);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [categoryId, router, searchParams]);
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
// ✅ Scope results to this category's backend roles
|
||||||
|
for (const r of partRoles) {
|
||||||
|
search.append("partRoles", r);
|
||||||
|
}
|
||||||
|
|
||||||
|
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, partRoles]);
|
||||||
|
|
||||||
|
// handler to toggle Add / Remove for this category
|
||||||
|
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
|
||||||
|
const isSelected = build[categoryId] === partId;
|
||||||
|
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
// Tell the main builder to remove this category selection
|
||||||
|
qp.set("remove", String(categoryId));
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Tell the main builder to add/replace this category selection
|
||||||
|
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, priceBounds, priceRange, inStockOnly]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
// Keep selection highlight in sync if the build is changed elsewhere and user comes back.
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key !== STORAGE_KEY) return;
|
||||||
|
// Force a re-render by reading current storage (since build state is read-only)
|
||||||
|
// This keeps the selected row pinned/highlighted when navigating back.
|
||||||
|
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||||
|
window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
|
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={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
|
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) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
setPlatform(next);
|
||||||
|
|
||||||
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
|
qp.set("platform", next);
|
||||||
|
router.replace(`/builder/${categoryId}?${qp.toString()}`);
|
||||||
|
}}
|
||||||
|
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,14 @@
|
|||||||
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
|
export default async function PartsRoleRedirectPage({
|
||||||
|
params,
|
||||||
|
searchParams,
|
||||||
|
}: {
|
||||||
|
params: { partRole: string };
|
||||||
|
searchParams?: { platform?: string };
|
||||||
|
}) {
|
||||||
|
const partRole = params.partRole;
|
||||||
|
const platform = searchParams?.platform ?? "AR-15";
|
||||||
|
|
||||||
|
redirect(`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`);
|
||||||
|
}
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
// app/(builder)/parts/_components/buildDetailHref.ts
|
||||||
|
|
||||||
|
const toSlug = (str: string) =>
|
||||||
|
str
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
|
||||||
|
export type BuildDetailInput = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function buildDetailHref(
|
||||||
|
platform: string,
|
||||||
|
partRole: string,
|
||||||
|
p: BuildDetailInput
|
||||||
|
) {
|
||||||
|
const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`);
|
||||||
|
return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRole
|
||||||
|
)}/${p.id}-${slug}`;
|
||||||
|
}
|
||||||
@@ -0,0 +1,589 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
||||||
|
|
||||||
|
type OfferFromApi = {
|
||||||
|
merchantName?: string | null;
|
||||||
|
price?: number | null;
|
||||||
|
buyUrl?: string | null;
|
||||||
|
inStock?: boolean | null;
|
||||||
|
lastUpdated?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type GunbuilderProductFromApi = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number | null;
|
||||||
|
|
||||||
|
// image fields can vary depending on endpoint/version
|
||||||
|
imageUrl?: string | null;
|
||||||
|
mainImageUrl?: string | null;
|
||||||
|
|
||||||
|
// single best-link (legacy)
|
||||||
|
buyUrl?: string | null;
|
||||||
|
|
||||||
|
// stock info
|
||||||
|
inStock?: boolean | null;
|
||||||
|
|
||||||
|
// richer details (optional)
|
||||||
|
shortDescription?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
|
||||||
|
// offers (optional)
|
||||||
|
offers?: OfferFromApi[] | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildState = Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
const STORAGE_KEY = "gunbuilder-build-state";
|
||||||
|
|
||||||
|
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||||
|
|
||||||
|
function extractNumericId(productSlug: string): number | null {
|
||||||
|
if (!productSlug) return null;
|
||||||
|
const first = productSlug.split("-")[0];
|
||||||
|
const n = Number(first);
|
||||||
|
return Number.isFinite(n) && n > 0 ? n : null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatPrice(price: number | null | undefined): string {
|
||||||
|
if (price == null) return "—";
|
||||||
|
return `$${price.toFixed(2)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
||||||
|
return [...offers].sort((a, b) => {
|
||||||
|
const ap = a.price ?? Number.POSITIVE_INFINITY;
|
||||||
|
const bp = b.price ?? Number.POSITIVE_INFINITY;
|
||||||
|
if (ap !== bp) return ap - bp;
|
||||||
|
|
||||||
|
// in-stock first
|
||||||
|
const aStock = a.inStock === false ? 1 : 0;
|
||||||
|
const bStock = b.inStock === false ? 1 : 0;
|
||||||
|
if (aStock !== bStock) return aStock - bStock;
|
||||||
|
|
||||||
|
// merchant name
|
||||||
|
return (a.merchantName ?? "").localeCompare(b.merchantName ?? "");
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetailsPage() {
|
||||||
|
const params = useParams();
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
// Route params
|
||||||
|
const platformParam = String(params.platform ?? "");
|
||||||
|
const partRoleParam = String(params.partRole ?? "");
|
||||||
|
const productSlug = String(params.productSlug ?? "");
|
||||||
|
|
||||||
|
// Platform comes from path segment (fallback to ?platform just in case)
|
||||||
|
const platform =
|
||||||
|
platformParam || (searchParams.get("platform") as string) || "AR-15";
|
||||||
|
|
||||||
|
const normalizedRole = useMemo(
|
||||||
|
() => normalizePartRole(partRoleParam),
|
||||||
|
[partRoleParam]
|
||||||
|
);
|
||||||
|
|
||||||
|
const categoryId = useMemo(() => {
|
||||||
|
return (
|
||||||
|
PART_ROLE_TO_CATEGORY[partRoleParam] ??
|
||||||
|
PART_ROLE_TO_CATEGORY[normalizedRole] ??
|
||||||
|
null
|
||||||
|
);
|
||||||
|
}, [partRoleParam, normalizedRole]);
|
||||||
|
|
||||||
|
const numericId = useMemo(() => extractNumericId(productSlug), [productSlug]);
|
||||||
|
|
||||||
|
// Read-only build state (builder page owns writes)
|
||||||
|
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 {};
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Keep build in sync if user changes build elsewhere
|
||||||
|
useEffect(() => {
|
||||||
|
if (typeof window === "undefined") return;
|
||||||
|
const onStorage = (e: StorageEvent) => {
|
||||||
|
if (e.key !== STORAGE_KEY) return;
|
||||||
|
try {
|
||||||
|
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||||
|
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
};
|
||||||
|
window.addEventListener("storage", onStorage);
|
||||||
|
return () => window.removeEventListener("storage", onStorage);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||||
|
const isSelected =
|
||||||
|
!!categoryId && selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||||
|
|
||||||
|
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// If platform segment is missing/empty, normalize into the new route
|
||||||
|
useEffect(() => {
|
||||||
|
if (platformParam) return;
|
||||||
|
|
||||||
|
const qp = new URLSearchParams(searchParams.toString());
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
|
router.replace(
|
||||||
|
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||||
|
);
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
|
||||||
|
|
||||||
|
// Fetch product
|
||||||
|
useEffect(() => {
|
||||||
|
if (!numericId) {
|
||||||
|
setError("Invalid product id.");
|
||||||
|
setLoading(false);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchProduct() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const url = `${API_BASE_URL}/api/products/${numericId}`;
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load product (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: GunbuilderProductFromApi = await res.json();
|
||||||
|
setProduct(data);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
setError(err?.message ?? "Failed to load product");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchProduct();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [numericId]);
|
||||||
|
|
||||||
|
const handleTogglePart = () => {
|
||||||
|
if (!numericId) return;
|
||||||
|
|
||||||
|
// If mapping is missing, don’t send a bogus builder action
|
||||||
|
if (!categoryId) {
|
||||||
|
alert(
|
||||||
|
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", platform || "AR-15");
|
||||||
|
|
||||||
|
if (isSelected) {
|
||||||
|
qp.set("remove", String(categoryId));
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
qp.set("select", `${categoryId}:${numericId}`);
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null;
|
||||||
|
|
||||||
|
// Offers normalization:
|
||||||
|
// - If product.offers exists, use it.
|
||||||
|
// - Otherwise, fall back to a single “offer” from product.buyUrl/product.price.
|
||||||
|
const offers = useMemo(() => {
|
||||||
|
if (!product) return [];
|
||||||
|
|
||||||
|
const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[];
|
||||||
|
if (raw.length > 0) return sortOffers(raw);
|
||||||
|
|
||||||
|
if (product.buyUrl) {
|
||||||
|
return sortOffers([
|
||||||
|
{
|
||||||
|
merchantName: "Merchant",
|
||||||
|
price: product.price,
|
||||||
|
buyUrl: product.buyUrl,
|
||||||
|
inStock: product.inStock ?? true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
}
|
||||||
|
|
||||||
|
return [];
|
||||||
|
}, [product]);
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
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">
|
||||||
|
Unknown Part Role
|
||||||
|
</h1>
|
||||||
|
<p className="text-sm text-zinc-400 mb-6">
|
||||||
|
No mapping found for{" "}
|
||||||
|
<span className="text-zinc-200">{partRoleParam}</span>. Add it to{" "}
|
||||||
|
<span className="text-zinc-200">catalogMappings</span>.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Back to Parts List
|
||||||
|
</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">
|
||||||
|
{/* Breadcrumbs */}
|
||||||
|
<header className="mb-6">
|
||||||
|
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<Link
|
||||||
|
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||||
|
className="hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
The Armory
|
||||||
|
</Link>
|
||||||
|
<span className="text-zinc-700">/</span>
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="hover:text-zinc-300"
|
||||||
|
>
|
||||||
|
Parts
|
||||||
|
</Link>
|
||||||
|
<span className="text-zinc-700">/</span>
|
||||||
|
<span className="text-zinc-300">Details</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
BATTL BUILDERS
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Product <span className="text-amber-300">Details</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||||
|
Offers, pricing placeholders, and builder actions — all on the canonical
|
||||||
|
/parts/p route.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform switch (updates NEW route) */}
|
||||||
|
<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) => {
|
||||||
|
const next = e.target.value;
|
||||||
|
router.replace(
|
||||||
|
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}/${encodeURIComponent(productSlug)}`
|
||||||
|
);
|
||||||
|
}}
|
||||||
|
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>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Body */}
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">
|
||||||
|
Loading product…
|
||||||
|
</p>
|
||||||
|
) : error ? (
|
||||||
|
<p className="py-10 text-center text-sm text-red-400">
|
||||||
|
{error} — check that the Ballistic API is running.
|
||||||
|
</p>
|
||||||
|
) : !product ? (
|
||||||
|
<p className="py-10 text-center text-sm text-zinc-500">
|
||||||
|
Product not found.
|
||||||
|
</p>
|
||||||
|
) : (
|
||||||
|
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||||
|
{/* Left: image + core actions */}
|
||||||
|
<div className="space-y-3">
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
|
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||||
|
{imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={imageUrl}
|
||||||
|
alt={product.name}
|
||||||
|
className="h-full w-full object-contain p-2"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex items-center justify-between">
|
||||||
|
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Stock
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className={`text-xs font-semibold ${
|
||||||
|
product.inStock === false
|
||||||
|
? "text-red-300"
|
||||||
|
: "text-emerald-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{product.inStock === false ? "Out of stock" : "In stock"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex gap-2">
|
||||||
|
<Link
|
||||||
|
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||||
|
partRoleParam
|
||||||
|
)}`}
|
||||||
|
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Back to List
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleTogglePart}
|
||||||
|
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" : "Add"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Pricing history placeholder */}
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Price History
|
||||||
|
</h3>
|
||||||
|
<span className="text-[10px] text-zinc-600">placeholder</span>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600">
|
||||||
|
Pricing graph will go here
|
||||||
|
</div>
|
||||||
|
<p className="mt-2 text-[11px] text-zinc-500">
|
||||||
|
We’ll wire this to price snapshots once the offer model is stable.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: details + offers */}
|
||||||
|
<div className="min-w-0 space-y-4">
|
||||||
|
{/* Product meta */}
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div className="min-w-0">
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
{product.brand}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
|
||||||
|
{product.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
Platform:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.platform || platform}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
Role:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.partRole || partRoleParam}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
ID:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.id}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="shrink-0 text-right">
|
||||||
|
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Current price
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
||||||
|
{formatPrice(product.price)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{(product.shortDescription || product.description) && (
|
||||||
|
<div className="mt-4 border-t border-zinc-800 pt-4">
|
||||||
|
{product.shortDescription ? (
|
||||||
|
<p className="text-sm text-zinc-300">
|
||||||
|
{product.shortDescription}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
{product.description ? (
|
||||||
|
<p className="mt-2 whitespace-pre-wrap text-sm text-zinc-400">
|
||||||
|
{product.description}
|
||||||
|
</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Offers */}
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Merchant Offers
|
||||||
|
</h3>
|
||||||
|
<span className="text-[10px] text-zinc-600">
|
||||||
|
{offers.length ? `${offers.length} offers` : "no offers"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{offers.length ? (
|
||||||
|
<div className="mt-3 overflow-x-auto rounded border border-zinc-800">
|
||||||
|
<table className="min-w-full text-sm">
|
||||||
|
<thead className="bg-zinc-900/60">
|
||||||
|
<tr className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
<th className="px-3 py-2 text-left">Merchant</th>
|
||||||
|
<th className="px-3 py-2 text-left">Stock</th>
|
||||||
|
<th className="px-3 py-2 text-right">Price</th>
|
||||||
|
<th className="px-3 py-2 text-right">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody className="divide-y divide-zinc-800">
|
||||||
|
{offers.map((o, idx) => (
|
||||||
|
<tr key={idx} className="hover:bg-zinc-900/40">
|
||||||
|
<td className="px-3 py-2 text-zinc-200">
|
||||||
|
{o.merchantName ?? "Merchant"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-xs">
|
||||||
|
{o.inStock === false ? (
|
||||||
|
<span className="text-red-300">Out of stock</span>
|
||||||
|
) : (
|
||||||
|
<span className="text-emerald-300">In stock</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right font-semibold text-amber-300">
|
||||||
|
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
{o.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={o.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-xs text-zinc-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<p className="mt-3 text-sm text-zinc-500">
|
||||||
|
No offers attached yet. Once offers are available, this becomes the
|
||||||
|
“where to buy” table.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Keep a single "best" buy CTA if buyUrl exists */}
|
||||||
|
{product.buyUrl ? (
|
||||||
|
<div className="mt-3 flex justify-end">
|
||||||
|
<a
|
||||||
|
href={product.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||||
|
>
|
||||||
|
Buy from Merchant →
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Footer tips */}
|
||||||
|
<div className="flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
|
Tip: Add this part, then go back and compare alternates.
|
||||||
|
</span>
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||||
|
Canonical route:{" "}
|
||||||
|
<span className="text-zinc-300">
|
||||||
|
/parts/p/{platform}/{partRoleParam}/{productSlug}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
||||||
|
|
||||||
|
export default function Page({ params }: { params: { platform: string; partRole: string } }) {
|
||||||
|
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
"use server";
|
||||||
|
|
||||||
|
export type SendEmailPayload = {
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ApiResponse<T> = {
|
||||||
|
success?: boolean;
|
||||||
|
message?: string;
|
||||||
|
data?: T;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type EmailRequest = {
|
||||||
|
id: number;
|
||||||
|
recipient: string;
|
||||||
|
subject: string;
|
||||||
|
body: string;
|
||||||
|
sentAt: string | null;
|
||||||
|
status: "PENDING" | "SENT" | "FAILED" | string;
|
||||||
|
errorMessage: string | null;
|
||||||
|
createdAt: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export async function sendEmailAction(
|
||||||
|
payload: SendEmailPayload
|
||||||
|
): Promise<ApiResponse<EmailRequest>> {
|
||||||
|
const baseUrl = process.env.BACKEND_BASE_URL;
|
||||||
|
if (!baseUrl) {
|
||||||
|
throw new Error("BACKEND_BASE_URL is not set");
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${baseUrl}/api/email/send`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
// Authorization: `Bearer ${process.env.BACKEND_JWT}`, // if needed
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const data: ApiResponse<EmailRequest> | null = await res
|
||||||
|
.json()
|
||||||
|
.catch(() => null);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(data?.message ?? `Request failed with ${res.status}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!data) {
|
||||||
|
throw new Error("Empty response body");
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
@@ -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";
|
||||||
|
import { formatDate } from "@/lib/api/dateFormattingUtility";
|
||||||
|
export default function AdminEmailPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
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.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">
|
||||||
|
{formatDate(email.updatedAt)}
|
||||||
|
</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,116 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
|
||||||
|
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
||||||
|
|
||||||
|
export default function SendEmailForm(): JSX.Element {
|
||||||
|
const [result, setResult] = useState<
|
||||||
|
ApiResponse<EmailRequest> | { error: string } | null
|
||||||
|
>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
setShowSuccess(false);
|
||||||
|
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||||
|
|
||||||
|
const form = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
const recipient = String(form.get("recipient") ?? "");
|
||||||
|
const subject = String(form.get("subject") ?? "");
|
||||||
|
const body = String(form.get("body") ?? "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const data = await sendEmailAction({ recipient, subject, body });
|
||||||
|
setResult(data);
|
||||||
|
|
||||||
|
if (data?.success === true) {
|
||||||
|
setShowSuccess(true);
|
||||||
|
|
||||||
|
successTimerRef.current = setTimeout(() => {
|
||||||
|
setShowSuccess(false);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setResult({ error: message });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Send <span className="text-amber-300">Email</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
|
Compose a message and send it from the admin panel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
||||||
|
{result && "error" in result && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{result.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Recipient" htmlFor="recipient">
|
||||||
|
<Input
|
||||||
|
id="recipient"
|
||||||
|
name="recipient"
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
defaultValue="user@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Subject" htmlFor="subject">
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
placeholder="Test subject"
|
||||||
|
defaultValue="Test subject"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Body" htmlFor="body">
|
||||||
|
<Textarea
|
||||||
|
id="body"
|
||||||
|
name="body"
|
||||||
|
placeholder="Write your message…"
|
||||||
|
defaultValue="Test body"
|
||||||
|
rows={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
|
||||||
|
<div className="text-xs font-medium text-zinc-400">Response</div>
|
||||||
|
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
|
||||||
|
{result ? JSON.stringify(result, null, 2) : "No response yet."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,7 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import SendEmailForm from "@/components/SendEmail";
|
||||||
|
|
||||||
|
export default function SendEmailPage() {
|
||||||
|
return <SendEmailForm />;
|
||||||
|
}
|
||||||
@@ -0,0 +1,120 @@
|
|||||||
|
"use client";
|
||||||
|
import type React from "react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import AdminLeftNavigation from "@/components/AdminLeftNavigation";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Download,
|
||||||
|
Layers,
|
||||||
|
Boxes,
|
||||||
|
Store,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
LucideMail,
|
||||||
|
} 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",
|
||||||
|
href: "/admin/products",
|
||||||
|
icon: <Boxes className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Merchants",
|
||||||
|
href: "/admin/merchants",
|
||||||
|
icon: <Store className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Platforms",
|
||||||
|
href: "/admin/platforms",
|
||||||
|
icon: <Layers className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
href: "/admin/users",
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
href: "/admin/settings",
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "List Emails",
|
||||||
|
href: "/admin/email",
|
||||||
|
icon: <LucideMail className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Send a Email",
|
||||||
|
href: "/admin/email/send",
|
||||||
|
icon: <LucideMail className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
// ... existing code ...
|
||||||
|
// ADMIN CHECK FOR LOGIN
|
||||||
|
// const { user, loading } = useAuth();
|
||||||
|
|
||||||
|
// if (!loading && user?.role !== "ADMIN") {
|
||||||
|
// redirect("/"); // or /login
|
||||||
|
// }
|
||||||
|
|
||||||
|
export default function AdminLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
|
const [collapsed, setCollapsed] = useState(false);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex min-h-screen bg-black text-zinc-50">
|
||||||
|
<AdminLeftNavigation
|
||||||
|
collapsed={collapsed}
|
||||||
|
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||||
|
/>
|
||||||
|
|
||||||
|
{/* 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+102
-127
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
@@ -13,123 +13,58 @@ type PendingBucket = {
|
|||||||
productCount: number;
|
productCount: number;
|
||||||
};
|
};
|
||||||
|
|
||||||
const PART_ROLE_OPTIONS: string[] = [
|
/**
|
||||||
// LOWER – receivers
|
* Canonical part roles (kebab-case) — should match what the importer normalizes to.
|
||||||
"LOWER_RECEIVER_STRIPPED",
|
* Keep this list tight + intentional so mappings don't create junk roles in the DB.
|
||||||
"LOWER_RECEIVER_COMPLETE",
|
*/
|
||||||
"LOWER_RECEIVER_COMPLETE_PISTOL",
|
const PART_ROLE_OPTIONS = [
|
||||||
"LOWER_RECEIVER_COMPLETE_RIFLE",
|
// Assemblies / receivers
|
||||||
"LOWER_RECEIVER_80",
|
"upper-receiver",
|
||||||
"LOWER_RECEIVER_BILLET",
|
"complete-upper",
|
||||||
"LOWER_RECEIVER_FORGED",
|
"lower-receiver",
|
||||||
"LOWER_RECEIVER_POLYMER",
|
"complete-lower",
|
||||||
|
|
||||||
// LOWER – internals & controls
|
|
||||||
"LOWER_PARTS_KIT",
|
|
||||||
"LOWER_PARTS_KIT_ENHANCED",
|
|
||||||
|
|
||||||
"TRIGGER",
|
// Upper sub-parts
|
||||||
"TRIGGER_OTHER",
|
"bcg",
|
||||||
"TRIGGER_COMBAT",
|
"barrel",
|
||||||
"TRIGGER_MATCH",
|
"gas-block",
|
||||||
"TRIGGER_DROP_IN",
|
"gas-tube",
|
||||||
"TRIGGER_SINGLE_STAGE",
|
"muzzle-device",
|
||||||
"TRIGGER_TWO_STAGE",
|
"suppressor",
|
||||||
"TRIGGER_GUARD",
|
"handguard",
|
||||||
"PISTOL_GRIP",
|
"charging-handle",
|
||||||
"PISTOL_GRIP_ERGO",
|
|
||||||
"PISTOL_GRIP_VERTICAL",
|
|
||||||
"SAFETY_SELECTOR",
|
|
||||||
"SAFETY_SELECTOR_AMBI",
|
|
||||||
"SAFETY_SELECTOR_45_DEG",
|
|
||||||
"MAG_RELEASE",
|
|
||||||
"TAKEDOWN_PINS",
|
|
||||||
"SPRINGS_PINS_MISC",
|
|
||||||
|
|
||||||
// LOWER – buffer & stock
|
|
||||||
"BUFFER_KIT",
|
|
||||||
"BUFFER_TUBE",
|
|
||||||
"BUFFER_TUBE_MILSPEC",
|
|
||||||
"BUFFER_TUBE_COMMERCIAL",
|
|
||||||
"BUFFER_SPRING",
|
|
||||||
"BUFFER_WEIGHT",
|
|
||||||
"STOCK_ADJUSTABLE",
|
|
||||||
"STOCK_FIXED",
|
|
||||||
"STOCK_FOLDER",
|
|
||||||
"STOCK_PRECISION",
|
|
||||||
"BRACE_PISTOL",
|
|
||||||
|
|
||||||
// UPPER – receivers & BCG
|
|
||||||
"UPPER_RECEIVER_STRIPPED",
|
|
||||||
"UPPER_RECEIVER_BILLET",
|
|
||||||
"UPPER_RECEIVER_MONOLITHIC",
|
|
||||||
"UPPER_RECEIVER_COMPLETE",
|
|
||||||
"UPPER_RECEIVER_COMPLETE_PISTOL",
|
|
||||||
"UPPER_RECEIVER_COMPLETE_RIFLE",
|
|
||||||
"BOLT_CARRIER_GROUP",
|
|
||||||
"BCG_COMPLETE",
|
|
||||||
"BCG_LIGHTWEIGHT",
|
|
||||||
"BCG_NICKEL_BORON",
|
|
||||||
|
|
||||||
// UPPER – barrel & gas
|
|
||||||
"BARREL",
|
|
||||||
"BARREL_THREADED",
|
|
||||||
"BARREL_PENCIL",
|
|
||||||
"BARREL_MATCH",
|
|
||||||
"BARREL_HEAVY",
|
|
||||||
"BARREL_14_5_PINNED",
|
|
||||||
"GAS_BLOCK",
|
|
||||||
"GAS_BLOCK_ADJUSTABLE",
|
|
||||||
"GAS_BLOCK_LOW_PROFILE",
|
|
||||||
"GAS_TUBE_PISTOL",
|
|
||||||
"GAS_TUBE_CAR",
|
|
||||||
"GAS_TUBE_MID",
|
|
||||||
"GAS_TUBE_RIFLE",
|
|
||||||
|
|
||||||
// UPPER – front end / muzzle
|
|
||||||
"HANDGUARD_MLOK",
|
|
||||||
"HANDGUARD_KEYMOD",
|
|
||||||
"HANDGUARD_QUAD",
|
|
||||||
"HANDGUARD_SLICK",
|
|
||||||
"MUZZLE_BRAKE",
|
|
||||||
"MUZZLE_COMPENSATOR",
|
|
||||||
|
|
||||||
// 👇 generic / backup muzzle roles
|
// Lower sub-parts
|
||||||
"MUZZLE_DEVICE",
|
"lower-parts",
|
||||||
"MUZZLE_DEVICE_OTHER",
|
"trigger",
|
||||||
|
"grip",
|
||||||
"FLASH_HIDER",
|
"safety",
|
||||||
"MUZZLE_DEVICE_QD_MOUNT",
|
"buffer",
|
||||||
"SUPPRESSOR_DIRECT_THREAD",
|
"stock",
|
||||||
"SUPPRESSOR_QD",
|
|
||||||
"SUPPRESSOR_RIFLE",
|
// Sights / optics
|
||||||
"SUPPRESSOR_PISTOL",
|
"sights",
|
||||||
"CHARGING_HANDLE",
|
"optic",
|
||||||
"CHARGING_HANDLE_AMBI",
|
|
||||||
"CHARGING_HANDLE_GAS_BUSTER",
|
// Accessories / gear
|
||||||
|
"magazine",
|
||||||
// Sights / optics
|
"weapon-light",
|
||||||
"SIGHTS_BACKUP",
|
"foregrip",
|
||||||
"SIGHTS_IRON_FIXED",
|
"bipod",
|
||||||
"SIGHTS_IRON_FLIP",
|
"sling",
|
||||||
"SIGHTS_OFFSET",
|
"rail-accessory",
|
||||||
"OPTIC_REDDOT",
|
"tools",
|
||||||
"OPTIC_LPVO",
|
] as const;
|
||||||
"OPTIC_HOLOGRAPHIC",
|
|
||||||
"OPTIC_PRISM",
|
type PartRole = (typeof PART_ROLE_OPTIONS)[number];
|
||||||
"OPTIC_MAGNIFIER",
|
|
||||||
|
function toLabel(role: string) {
|
||||||
// Accessories
|
// "complete-upper" -> "Complete Upper"
|
||||||
"MAGAZINE",
|
return role
|
||||||
"SLING",
|
.split("-")
|
||||||
"BIPOD",
|
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||||
"MOUNT_SCOPE",
|
.join(" ");
|
||||||
"MOUNT_OFFSET",
|
}
|
||||||
"LIGHT_WEAPON",
|
|
||||||
"LASER_VISIBLE",
|
|
||||||
"LASER_IR",
|
|
||||||
"TOOL",
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function MappingAdminPage() {
|
export default function MappingAdminPage() {
|
||||||
const [rows, setRows] = useState<PendingBucket[]>([]);
|
const [rows, setRows] = useState<PendingBucket[]>([]);
|
||||||
@@ -137,17 +72,33 @@ export default function MappingAdminPage() {
|
|||||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const options = useMemo(
|
||||||
|
() =>
|
||||||
|
PART_ROLE_OPTIONS.map((value) => ({
|
||||||
|
value,
|
||||||
|
label: toLabel(value),
|
||||||
|
})),
|
||||||
|
[]
|
||||||
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function load() {
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/admin/mapping/pending-buckets`
|
`${API_BASE_URL}/api/admin/mapping/pending-buckets`,
|
||||||
|
{ headers: { Accept: "application/json" } }
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load pending buckets (${res.status})`);
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Failed to load pending buckets (${res.status})${text ? `: ${text}` : ""}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: PendingBucket[] = await res.json();
|
const data: PendingBucket[] = await res.json();
|
||||||
setRows(data);
|
setRows(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
@@ -162,9 +113,14 @@ export default function MappingAdminPage() {
|
|||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const handleChangeRole = (idx: number, value: string) => {
|
const handleChangeRole = (idx: number, value: string) => {
|
||||||
|
// Always store normalized kebab-case (defensive)
|
||||||
|
const normalized = value
|
||||||
|
? value.trim().toLowerCase().replace(/_/g, "-")
|
||||||
|
: "";
|
||||||
|
|
||||||
setRows((prev) => {
|
setRows((prev) => {
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[idx] = { ...next[idx], mappedPartRole: value || null };
|
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
@@ -172,6 +128,20 @@ export default function MappingAdminPage() {
|
|||||||
const handleSave = async (row: PendingBucket) => {
|
const handleSave = async (row: PendingBucket) => {
|
||||||
if (!row.mappedPartRole) return;
|
if (!row.mappedPartRole) return;
|
||||||
|
|
||||||
|
const mapped = row.mappedPartRole
|
||||||
|
.trim()
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/_/g, "-");
|
||||||
|
|
||||||
|
// Basic guard: prevent saving random roles from stale data
|
||||||
|
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
|
||||||
|
if (!allowed.has(mapped)) {
|
||||||
|
setError(
|
||||||
|
`Refusing to save unknown part role: "${row.mappedPartRole}". Pick a role from the dropdown list.`
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
const key = `${row.merchantId}-${row.rawCategoryKey}`;
|
const key = `${row.merchantId}-${row.rawCategoryKey}`;
|
||||||
try {
|
try {
|
||||||
setSavingKey(key);
|
setSavingKey(key);
|
||||||
@@ -183,12 +153,15 @@ export default function MappingAdminPage() {
|
|||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
merchantId: row.merchantId,
|
merchantId: row.merchantId,
|
||||||
rawCategoryKey: row.rawCategoryKey,
|
rawCategoryKey: row.rawCategoryKey,
|
||||||
mappedPartRole: row.mappedPartRole,
|
mappedPartRole: mapped,
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to save mapping (${res.status})`);
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Failed to save mapping (${res.status})${text ? `: ${text}` : ""}`
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After save, remove this bucket from the list
|
// After save, remove this bucket from the list
|
||||||
@@ -241,8 +214,8 @@ export default function MappingAdminPage() {
|
|||||||
Pending Buckets
|
Pending Buckets
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-xs text-zinc-400">
|
<span className="text-xs text-zinc-400">
|
||||||
{rows.length} buckets • {rows.reduce((s, r) => s + r.productCount, 0)}{" "}
|
{rows.length} buckets •{" "}
|
||||||
products
|
{rows.reduce((s, r) => s + r.productCount, 0)} products
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -283,7 +256,9 @@ export default function MappingAdminPage() {
|
|||||||
<td className="px-2 py-1 text-zinc-300">
|
<td className="px-2 py-1 text-zinc-300">
|
||||||
{row.rawCategoryKey}
|
{row.rawCategoryKey}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
|
<td className="px-2 py-1 text-zinc-200">
|
||||||
|
{row.productCount}
|
||||||
|
</td>
|
||||||
<td className="px-2 py-1 text-zinc-100">
|
<td className="px-2 py-1 text-zinc-100">
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||||
@@ -293,9 +268,9 @@ export default function MappingAdminPage() {
|
|||||||
}
|
}
|
||||||
>
|
>
|
||||||
<option value="">Select part role…</option>
|
<option value="">Select part role…</option>
|
||||||
{PART_ROLE_OPTIONS.map((pr) => (
|
{options.map((opt) => (
|
||||||
<option key={pr} value={pr}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{pr}
|
{opt.label} ({opt.value})
|
||||||
</option>
|
</option>
|
||||||
))}
|
))}
|
||||||
</select>
|
</select>
|
||||||
|
|||||||
@@ -34,52 +34,50 @@ export default function MerchantsAdminPage() {
|
|||||||
|
|
||||||
// --- load merchants ---
|
// --- load merchants ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
const controller = new AbortController();
|
||||||
|
|
||||||
async function loadMerchants() {
|
async function loadMerchants() {
|
||||||
try {
|
try {
|
||||||
console.log("Loading merchants from:", `${API_BASE_URL}/admin/merchants`);
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
const res = await fetch(`${API_BASE_URL}/admin/merchants`, {
|
|
||||||
|
const url = `${API_BASE_URL}/api/admin/merchants`;
|
||||||
|
console.log("Loading merchants from:", url);
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
method: "GET",
|
method: "GET",
|
||||||
headers: {
|
headers: { Accept: "application/json" },
|
||||||
Accept: "application/json",
|
signal: controller.signal,
|
||||||
},
|
|
||||||
});
|
});
|
||||||
|
|
||||||
console.log("Merchants response status:", res.status);
|
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
throw new Error(`HTTP ${res.status} ${res.statusText} – ${text}`);
|
throw new Error(`HTTP ${res.status} ${res.statusText} – ${text}`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: MerchantAdminDto[] = await res.json();
|
const data: MerchantAdminDto[] = await res.json();
|
||||||
setMerchants(data);
|
setMerchants(data);
|
||||||
setError(null);
|
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
console.error("Error loading merchants", err);
|
console.error("Error loading merchants", err);
|
||||||
setError(err.message ?? "Failed to load merchants");
|
setError(err?.message ?? "Failed to load merchants");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
loadMerchants();
|
|
||||||
|
|
||||||
return () => {
|
loadMerchants();
|
||||||
cancelled = true;
|
return () => controller.abort();
|
||||||
};
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
// --- local field editing ---
|
// --- local field editing ---
|
||||||
const updateMerchantField = <K extends keyof MerchantAdminDto>(
|
const updateMerchantField = <K extends keyof MerchantAdminDto>(
|
||||||
id: number,
|
id: number,
|
||||||
field: K,
|
field: K,
|
||||||
value: MerchantAdminDto[K],
|
value: MerchantAdminDto[K]
|
||||||
) => {
|
) => {
|
||||||
setMerchants((prev) =>
|
setMerchants((prev) =>
|
||||||
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)),
|
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m))
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -93,7 +91,7 @@ export default function MerchantsAdminPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/admin/merchants/${id}`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -108,20 +106,16 @@ export default function MerchantsAdminPage() {
|
|||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
console.error("Merchant save failed", res.status, text);
|
console.error("Merchant save failed", res.status, text);
|
||||||
throw new Error(
|
throw new Error(`Save failed (${res.status})${text ? `: ${text}` : ""}`);
|
||||||
`Save failed (${res.status})${text ? `: ${text}` : ""}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const updated: MerchantAdminDto = await res.json();
|
const updated: MerchantAdminDto = await res.json();
|
||||||
setMerchants((prev) =>
|
setMerchants((prev) => prev.map((m) => (m.id === id ? updated : m)));
|
||||||
prev.map((m) => (m.id === id ? updated : m)),
|
|
||||||
);
|
|
||||||
|
|
||||||
setBanner("Merchant saved successfully.");
|
setBanner("Merchant saved successfully.");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Error saving merchant", e);
|
console.error("Error saving merchant", e);
|
||||||
setError(e.message ?? "Failed to save merchant");
|
setError(e?.message ?? "Failed to save merchant");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingId(null);
|
setSavingId(null);
|
||||||
setTimeout(() => setBanner(null), 3000);
|
setTimeout(() => setBanner(null), 3000);
|
||||||
@@ -135,29 +129,27 @@ export default function MerchantsAdminPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/admin/imports/${id}`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
console.error("Full import failed", res.status, text);
|
console.error("Full import failed", res.status, text);
|
||||||
throw new Error(
|
throw new Error(`Import failed (${res.status})${text ? `: ${text}` : ""}`);
|
||||||
`Import failed (${res.status})${text ? `: ${text}` : ""}`,
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
setBanner("Full import started successfully.");
|
setBanner("Full import started successfully.");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Error starting full import", e);
|
console.error("Error starting full import", e);
|
||||||
setError(e.message ?? "Failed to start full import");
|
setError(e?.message ?? "Failed to start full import");
|
||||||
} finally {
|
} finally {
|
||||||
setImportingId(null);
|
setImportingId(null);
|
||||||
setTimeout(() => setBanner(null), 3000);
|
setTimeout(() => setBanner(null), 3000);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
// --- run offer sync (matches /{merchantId}/offers-only) ---
|
// --- run offer sync ---
|
||||||
const handleRunOfferSync = async (id: number) => {
|
const handleRunOfferSync = async (id: number) => {
|
||||||
try {
|
try {
|
||||||
setOfferSyncId(id);
|
setOfferSyncId(id);
|
||||||
@@ -165,24 +157,22 @@ export default function MerchantsAdminPage() {
|
|||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/admin/imports/${id}/offers-only`,
|
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`,
|
||||||
{
|
{ method: "POST" }
|
||||||
method: "POST",
|
|
||||||
},
|
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
console.error("Offer sync failed", res.status, text);
|
console.error("Offer sync failed", res.status, text);
|
||||||
throw new Error(
|
throw new Error(
|
||||||
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`,
|
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
setBanner("Offer sync started successfully.");
|
setBanner("Offer sync started successfully.");
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error("Error starting offer sync", e);
|
console.error("Error starting offer sync", e);
|
||||||
setError(e.message ?? "Failed to start offer sync");
|
setError(e?.message ?? "Failed to start offer sync");
|
||||||
} finally {
|
} finally {
|
||||||
setOfferSyncId(null);
|
setOfferSyncId(null);
|
||||||
setTimeout(() => setBanner(null), 3000);
|
setTimeout(() => setBanner(null), 3000);
|
||||||
@@ -202,9 +192,9 @@ export default function MerchantsAdminPage() {
|
|||||||
Merchant Feeds <span className="text-amber-300">Admin</span>
|
Merchant Feeds <span className="text-amber-300">Admin</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||||
Manage your AvantLink merchants, product feed URLs, and offer
|
Manage your AvantLink merchants, product feed URLs, and offer sync
|
||||||
sync settings. Use this to onboard new merchants and keep feeds
|
settings. Use this to onboard new merchants and keep feeds fresh
|
||||||
fresh without touching SQL.
|
without touching SQL.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
@@ -244,6 +234,7 @@ export default function MerchantsAdminPage() {
|
|||||||
<th className="py-2 pl-3 text-right">Actions</th>
|
<th className="py-2 pl-3 text-right">Actions</th>
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody className="divide-y divide-zinc-800">
|
<tbody className="divide-y divide-zinc-800">
|
||||||
{merchants.map((m) => (
|
{merchants.map((m) => (
|
||||||
<tr key={m.id} className="align-top">
|
<tr key={m.id} className="align-top">
|
||||||
@@ -257,71 +248,62 @@ export default function MerchantsAdminPage() {
|
|||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.avantlinkMid}
|
value={m.avantlinkMid}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(
|
updateMerchantField(m.id, "avantlinkMid", e.target.value)
|
||||||
m.id,
|
|
||||||
"avantlinkMid",
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.feedUrl ?? ""}
|
value={m.feedUrl ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(
|
updateMerchantField(m.id, "feedUrl", e.target.value)
|
||||||
m.id,
|
|
||||||
"feedUrl",
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.offerFeedUrl ?? ""}
|
value={m.offerFeedUrl ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(
|
updateMerchantField(m.id, "offerFeedUrl", e.target.value || null)
|
||||||
m.id,
|
|
||||||
"offerFeedUrl",
|
|
||||||
e.target.value || null,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
<td className="py-2 px-3">
|
||||||
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
|
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
checked={m.isActive}
|
checked={m.isActive}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(
|
updateMerchantField(m.id, "isActive", e.target.checked)
|
||||||
m.id,
|
|
||||||
"isActive",
|
|
||||||
e.target.checked,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||||
/>
|
/>
|
||||||
Active
|
Active
|
||||||
</label>
|
</label>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
||||||
{formatDate(m.lastFullImportAt)}
|
{formatDate(m.lastFullImportAt)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
||||||
{formatDate(m.lastOfferSyncAt)}
|
{formatDate(m.lastOfferSyncAt)}
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 pl-3">
|
<td className="py-2 pl-3">
|
||||||
<div className="flex flex-col items-end gap-1">
|
<div className="flex flex-col items-end gap-1">
|
||||||
<button
|
<button
|
||||||
@@ -336,33 +318,27 @@ export default function MerchantsAdminPage() {
|
|||||||
>
|
>
|
||||||
{savingId === m.id ? "Saving…" : "Save"}
|
{savingId === m.id ? "Saving…" : "Save"}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRunFullImport(m.id)}
|
onClick={() => handleRunFullImport(m.id)}
|
||||||
disabled={importingId === m.id}
|
disabled={importingId === m.id}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
||||||
importingId === m.id
|
importingId === m.id ? "opacity-60 cursor-wait" : ""
|
||||||
? "opacity-60 cursor-wait"
|
|
||||||
: ""
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{importingId === m.id
|
{importingId === m.id ? "Importing…" : "Run Full Import"}
|
||||||
? "Importing…"
|
|
||||||
: "Run Full Import"}
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRunOfferSync(m.id)}
|
onClick={() => handleRunOfferSync(m.id)}
|
||||||
disabled={offerSyncId === m.id}
|
disabled={offerSyncId === m.id}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
||||||
offerSyncId === m.id
|
offerSyncId === m.id ? "opacity-60 cursor-wait" : ""
|
||||||
? "opacity-60 cursor-wait"
|
|
||||||
: ""
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{offerSyncId === m.id
|
{offerSyncId === m.id ? "Syncing Offers…" : "Run Offer Sync"}
|
||||||
? "Syncing Offers…"
|
|
||||||
: "Run Offer Sync"}
|
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|||||||
@@ -0,0 +1,301 @@
|
|||||||
|
// app/admin/platforms/page.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { Plus, Pencil, Trash2, X } from "lucide-react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import {
|
||||||
|
createPlatform,
|
||||||
|
deletePlatform,
|
||||||
|
fetchPlatforms,
|
||||||
|
updatePlatform,
|
||||||
|
type Platform,
|
||||||
|
type CreatePlatformDto,
|
||||||
|
type UpdatePlatformDto,
|
||||||
|
} from "@/lib/api/platforms";
|
||||||
|
|
||||||
|
export default function AdminPlatformsPage() {
|
||||||
|
const { getAuthHeaders } = useAuth();
|
||||||
|
|
||||||
|
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const [showModal, setShowModal] = useState(false);
|
||||||
|
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||||
|
const [selected, setSelected] = useState<Platform | null>(null);
|
||||||
|
|
||||||
|
const [form, setForm] = useState<CreatePlatformDto>({
|
||||||
|
key: "",
|
||||||
|
label: "",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
const data = await fetchPlatforms(getAuthHeaders());
|
||||||
|
setPlatforms(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e?.message ?? "Failed to load platforms");
|
||||||
|
setPlatforms([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
void load();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const sorted = useMemo(() => {
|
||||||
|
return [...platforms].sort((a, b) => a.key.localeCompare(b.key));
|
||||||
|
}, [platforms]);
|
||||||
|
|
||||||
|
function openCreate() {
|
||||||
|
setModalMode("create");
|
||||||
|
setSelected(null);
|
||||||
|
setForm({ key: "", label: "", isActive: true });
|
||||||
|
setShowModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
function openEdit(p: Platform) {
|
||||||
|
setModalMode("edit");
|
||||||
|
setSelected(p);
|
||||||
|
setForm({ key: p.key, label: p.label, isActive: p.isActive });
|
||||||
|
setShowModal(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onDelete(p: Platform) {
|
||||||
|
if (!window.confirm(`Delete platform "${p.key}"? This cannot be undone.`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
await deletePlatform(getAuthHeaders(), p.id);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e?.message ?? "Failed to delete platform");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent) {
|
||||||
|
e.preventDefault();
|
||||||
|
|
||||||
|
const key = (form.key ?? "").trim();
|
||||||
|
const label = (form.label ?? "").trim();
|
||||||
|
|
||||||
|
if (!key || !label) {
|
||||||
|
setError("Key and Label are required.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
if (modalMode === "create") {
|
||||||
|
await createPlatform(getAuthHeaders(), { ...form, key, label });
|
||||||
|
} else if (selected) {
|
||||||
|
const update: UpdatePlatformDto = {};
|
||||||
|
if (key !== selected.key) update.key = key;
|
||||||
|
if (label !== selected.label) update.label = label;
|
||||||
|
if ((form.isActive ?? true) !== selected.isActive) update.isActive = form.isActive;
|
||||||
|
|
||||||
|
await updatePlatform(getAuthHeaders(), selected.id, update);
|
||||||
|
}
|
||||||
|
|
||||||
|
setShowModal(false);
|
||||||
|
await load();
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e?.message ?? "Failed to save platform");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
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">Platforms</h1>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
Manage canonical platforms used across products and filtering.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={openCreate}
|
||||||
|
className="inline-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 disabled:opacity-50"
|
||||||
|
disabled={loading || saving}
|
||||||
|
>
|
||||||
|
<Plus size={14} />
|
||||||
|
Add Platform
|
||||||
|
</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="text-sm text-zinc-400">Loading…</div>
|
||||||
|
) : sorted.length === 0 ? (
|
||||||
|
<div className="rounded-md border border-zinc-900 bg-zinc-950 px-4 py-6 text-sm text-zinc-400">
|
||||||
|
No platforms found.
|
||||||
|
</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">ID</th>
|
||||||
|
<th className="border-b border-zinc-900 px-3 py-2">Key</th>
|
||||||
|
<th className="border-b border-zinc-900 px-3 py-2">Label</th>
|
||||||
|
<th className="border-b border-zinc-900 px-3 py-2">Active</th>
|
||||||
|
<th className="border-b border-zinc-900 px-3 py-2 text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{sorted.map((p, idx) => (
|
||||||
|
<tr
|
||||||
|
key={p.id}
|
||||||
|
className={idx % 2 === 0 ? "bg-zinc-950" : "bg-zinc-950/60"}
|
||||||
|
>
|
||||||
|
<td className="border-b border-zinc-900 px-3 py-2 text-zinc-400">
|
||||||
|
{p.id}
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-zinc-900 px-3 py-2 text-zinc-100">
|
||||||
|
{p.key}
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-zinc-900 px-3 py-2 text-zinc-300">
|
||||||
|
{p.label}
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-zinc-900 px-3 py-2">
|
||||||
|
<span
|
||||||
|
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||||
|
p.isActive
|
||||||
|
? "border border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border border-zinc-700 bg-zinc-900 text-zinc-400"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{p.isActive ? "ACTIVE" : "INACTIVE"}
|
||||||
|
</span>
|
||||||
|
</td>
|
||||||
|
<td className="border-b border-zinc-900 px-3 py-2 text-right">
|
||||||
|
<div className="inline-flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => openEdit(p)}
|
||||||
|
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||||
|
title="Edit"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Pencil size={14} />
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => void onDelete(p)}
|
||||||
|
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
|
||||||
|
title="Delete"
|
||||||
|
disabled={saving}
|
||||||
|
>
|
||||||
|
<Trash2 size={14} />
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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 Platform" : "Edit Platform"}
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="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={onSubmit} className="space-y-4">
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||||
|
Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={form.key}
|
||||||
|
onChange={(e) => setForm((s) => ({ ...s, key: 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-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
placeholder="AR15"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||||
|
Label
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
value={form.label}
|
||||||
|
onChange={(e) => setForm((s) => ({ ...s, label: 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-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
placeholder="AR-15"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="flex items-center gap-2 text-xs text-zinc-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={!!form.isActive}
|
||||||
|
onChange={(e) => setForm((s) => ({ ...s, isActive: e.target.checked }))}
|
||||||
|
className="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-amber-400"
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<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={saving}
|
||||||
|
>
|
||||||
|
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={saving}
|
||||||
|
>
|
||||||
|
{saving ? "Saving…" : modalMode === "create" ? "Create" : "Update"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,411 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
|
||||||
|
type ProductSummary = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type PlatformOption = {
|
||||||
|
key: string; // e.g. AR15, AR9
|
||||||
|
label: string; // e.g. "Armalite Rifle model 15"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Backend currently filters products by canonical platform strings like "AR-15".
|
||||||
|
// DB platform keys are like "AR15" / "AR9". Translate when calling /api/products.
|
||||||
|
const platformKeyToApiPlatform = (key: string) => {
|
||||||
|
const k = (key ?? "").toUpperCase().trim();
|
||||||
|
if (!k) return "AR-15";
|
||||||
|
if (k === "AR15" || k === "AR-15") return "AR-15";
|
||||||
|
if (k === "AR10" || k === "AR-10") return "AR-10";
|
||||||
|
if (k === "AR9" || k === "AR-9") return "AR-9";
|
||||||
|
// Fallback: if it already contains a dash, assume it's canonical.
|
||||||
|
if (k.includes("-")) return k;
|
||||||
|
// Otherwise, best-effort: insert a dash after AR.
|
||||||
|
if (k.startsWith("AR") && k.length > 2) return `AR-${k.slice(2)}`;
|
||||||
|
return k;
|
||||||
|
};
|
||||||
|
|
||||||
|
const FALLBACK_PLATFORMS: PlatformOption[] = [
|
||||||
|
{ key: "AR15", label: "AR-15" },
|
||||||
|
{ key: "AR10", label: "AR-10" },
|
||||||
|
{ key: "AR9", label: "AR-9" },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminProductsPage() {
|
||||||
|
const [platforms, setPlatforms] = useState<PlatformOption[]>(FALLBACK_PLATFORMS);
|
||||||
|
const [platform, setPlatform] = useState<string>("AR15");
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [products, setProducts] = useState<ProductSummary[]>([]);
|
||||||
|
const [query, setQuery] = useState<string>("");
|
||||||
|
|
||||||
|
const [page, setPage] = useState<number>(1);
|
||||||
|
const [pageSize, setPageSize] = useState<number>(50);
|
||||||
|
|
||||||
|
// Load available platforms (dynamic if backend supports it; otherwise fallback)
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function loadPlatforms() {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/platforms`, {
|
||||||
|
signal: controller.signal,
|
||||||
|
});
|
||||||
|
if (!res.ok) return;
|
||||||
|
|
||||||
|
const data = (await res.json()) as unknown;
|
||||||
|
|
||||||
|
// Expected shape:
|
||||||
|
// [{ id, key, label, is_active, ... }]
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
|
||||||
|
const cleaned: PlatformOption[] = items
|
||||||
|
.map((item) => {
|
||||||
|
if (!item || typeof item !== "object") return null;
|
||||||
|
const obj = item as Record<string, unknown>;
|
||||||
|
|
||||||
|
const key = typeof obj.key === "string" ? obj.key.trim() : "";
|
||||||
|
const label = typeof obj.label === "string" ? obj.label.trim() : "";
|
||||||
|
|
||||||
|
// Some payloads might use isActive; yours currently uses is_active
|
||||||
|
const activeRaw = obj.is_active ?? obj.isActive;
|
||||||
|
const isActive =
|
||||||
|
typeof activeRaw === "boolean"
|
||||||
|
? activeRaw
|
||||||
|
: typeof activeRaw === "number"
|
||||||
|
? activeRaw === 1
|
||||||
|
: true;
|
||||||
|
|
||||||
|
if (!key || !label || !isActive) return null;
|
||||||
|
return { key, label } as PlatformOption;
|
||||||
|
})
|
||||||
|
.filter((v): v is PlatformOption => v !== null);
|
||||||
|
|
||||||
|
if (cleaned.length === 0) return;
|
||||||
|
|
||||||
|
// Keep them stable and unique by key
|
||||||
|
const byKey = new Map<string, PlatformOption>();
|
||||||
|
for (const p of cleaned) byKey.set(p.key, p);
|
||||||
|
const unique = Array.from(byKey.values());
|
||||||
|
|
||||||
|
setPlatforms(unique);
|
||||||
|
setPlatform((prev) => (unique.some((p) => p.key === prev) ? prev : unique[0].key));
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
// ignore (fallback list stays)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadPlatforms();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load products for selected platform
|
||||||
|
useEffect(() => {
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function loadProducts() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const apiPlatform = platformKeyToApiPlatform(platform);
|
||||||
|
|
||||||
|
// NOTE: backend endpoint expects `platform`, not `platformKey`.
|
||||||
|
const url = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
|
||||||
|
apiPlatform
|
||||||
|
)}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, { signal: controller.signal });
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load products (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as ProductSummary[];
|
||||||
|
setProducts(Array.isArray(data) ? data : []);
|
||||||
|
setPage(1);
|
||||||
|
} catch (err: any) {
|
||||||
|
if (err?.name === "AbortError") return;
|
||||||
|
setError(err?.message ?? "Failed to load products");
|
||||||
|
setProducts([]);
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
loadProducts();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [platform]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const q = query.trim().toLowerCase();
|
||||||
|
if (!q) return products;
|
||||||
|
|
||||||
|
return products.filter((p) => {
|
||||||
|
const name = (p.name ?? "").toLowerCase();
|
||||||
|
const brand = (p.brand ?? "").toLowerCase();
|
||||||
|
const role = (p.partRole ?? "").toLowerCase();
|
||||||
|
return name.includes(q) || brand.includes(q) || role.includes(q);
|
||||||
|
});
|
||||||
|
}, [products, query]);
|
||||||
|
|
||||||
|
// Reset to page 1 when the search query changes
|
||||||
|
useEffect(() => {
|
||||||
|
setPage(1);
|
||||||
|
}, [query, platform, pageSize]);
|
||||||
|
|
||||||
|
const totalPages = useMemo(() => {
|
||||||
|
const total = filtered.length;
|
||||||
|
return total === 0 ? 1 : Math.ceil(total / pageSize);
|
||||||
|
}, [filtered.length, pageSize]);
|
||||||
|
|
||||||
|
const clampedPage = useMemo(() => {
|
||||||
|
if (page < 1) return 1;
|
||||||
|
if (page > totalPages) return totalPages;
|
||||||
|
return page;
|
||||||
|
}, [page, totalPages]);
|
||||||
|
|
||||||
|
const pageStart = useMemo(() => (clampedPage - 1) * pageSize, [clampedPage, pageSize]);
|
||||||
|
const pageEndExclusive = useMemo(
|
||||||
|
() => Math.min(pageStart + pageSize, filtered.length),
|
||||||
|
[pageStart, pageSize, filtered.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const paginated = useMemo(() => {
|
||||||
|
return filtered.slice(pageStart, pageEndExclusive);
|
||||||
|
}, [filtered, pageStart, pageEndExclusive]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
<header className="mb-6">
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Admin
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Products <span className="text-amber-300">Catalog</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||||
|
Read-only view of products coming from the Ballistic backend. Filter by
|
||||||
|
platform, search, and spot-check pricing + buy links.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div className="flex flex-wrap items-center gap-3">
|
||||||
|
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
||||||
|
Platform
|
||||||
|
<select
|
||||||
|
value={platform}
|
||||||
|
onChange={(e) => setPlatform(e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
>
|
||||||
|
{platforms.map((p) => (
|
||||||
|
<option key={p.key} value={p.key}>
|
||||||
|
{p.label}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<label htmlFor="q" className="text-xs text-zinc-500">
|
||||||
|
Search
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="q"
|
||||||
|
value={query}
|
||||||
|
onChange={(e) => setQuery(e.target.value)}
|
||||||
|
placeholder="brand, name, part role…"
|
||||||
|
className="w-64 max-w-full rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
||||||
|
Page size
|
||||||
|
<select
|
||||||
|
value={pageSize}
|
||||||
|
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||||
|
>
|
||||||
|
{[25, 50, 100, 200].map((n) => (
|
||||||
|
<option key={n} value={n}>
|
||||||
|
{n}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{loading ? (
|
||||||
|
<span>Loading…</span>
|
||||||
|
) : error ? (
|
||||||
|
<span className="text-red-400">{error}</span>
|
||||||
|
) : (
|
||||||
|
<span>
|
||||||
|
Showing{" "}
|
||||||
|
<span className="font-medium text-zinc-200">
|
||||||
|
{filtered.length === 0 ? 0 : pageStart + 1}-{pageEndExclusive}
|
||||||
|
</span>
|
||||||
|
{" "}of{" "}
|
||||||
|
<span className="font-medium text-zinc-200">{filtered.length}</span>
|
||||||
|
{" "}{filtered.length === 1 ? "product" : "products"}
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !error && filtered.length > 0 && (
|
||||||
|
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||||
|
<div className="text-[0.7rem] text-zinc-500">
|
||||||
|
Page <span className="font-semibold text-zinc-200">{clampedPage}</span> of{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">{totalPages}</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={clampedPage === 1}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Previous
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={clampedPage === totalPages}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-4 overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
|
||||||
|
<table className="min-w-full text-xs md:text-sm">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800 bg-zinc-900/60">
|
||||||
|
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Product</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Brand</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Platform</th>
|
||||||
|
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Part Role</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Price</th>
|
||||||
|
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Link</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{loading ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
|
||||||
|
Loading products…
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : error ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-6 text-center text-sm text-red-400" colSpan={6}>
|
||||||
|
{error}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : paginated.length === 0 ? (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
|
||||||
|
No products found.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
) : (
|
||||||
|
paginated.map((p) => (
|
||||||
|
<tr
|
||||||
|
key={p.id}
|
||||||
|
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 align-top">
|
||||||
|
<div className="font-medium text-zinc-100 line-clamp-2">{p.name}</div>
|
||||||
|
<div className="mt-0.5 text-[0.7rem] text-zinc-600">ID: {p.id}</div>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 align-top text-zinc-300">{p.brand}</td>
|
||||||
|
<td className="px-3 py-2 align-top text-zinc-300">
|
||||||
|
{p.platform ?? platformKeyToApiPlatform(platform)}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 align-top text-zinc-300">{p.partRole}</td>
|
||||||
|
<td className="px-3 py-2 align-top text-right text-amber-300">
|
||||||
|
{p.price == null ? "—" : `$${Number(p.price).toFixed(2)}`}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 align-top text-right">
|
||||||
|
{p.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={p.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="inline-flex items-center justify-center whitespace-nowrap rounded-md bg-amber-400 px-2.5 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Open
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-zinc-600">—</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{!loading && !error && filtered.length > 0 && (
|
||||||
|
<div className="mt-3 flex items-center justify-center gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||||
|
disabled={clampedPage === 1}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
aria-label="Previous page"
|
||||||
|
>
|
||||||
|
<
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Page{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">
|
||||||
|
{clampedPage}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="font-semibold text-zinc-200">
|
||||||
|
{totalPages}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
disabled={clampedPage === totalPages}
|
||||||
|
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||||
|
aria-label="Next page"
|
||||||
|
>
|
||||||
|
>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="mt-3 text-[0.7rem] text-zinc-500">
|
||||||
|
Backend: <span className="font-mono text-zinc-400">{API_BASE_URL}</span> · Endpoint: <span className="font-mono text-zinc-400">/api/products?platform=...</span> · Page size: <span className="font-mono text-zinc-400">{pageSize}</span>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// app/admin/settings/page.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { Settings } from "lucide-react";
|
||||||
|
|
||||||
|
export default function Page() {
|
||||||
|
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">Settings</h1>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
Admin configuration and environment controls.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-900 bg-zinc-950 px-6 py-10">
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 text-zinc-300">
|
||||||
|
<Settings className="h-5 w-5" />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<p className="text-sm font-semibold text-zinc-100">Coming soon</p>
|
||||||
|
<p className="mt-1 text-xs text-zinc-500">
|
||||||
|
This section is under construction. Check back after the next deploy.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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,436 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
|
||||||
import Link from "next/link";
|
|
||||||
import { useParams } from "next/navigation";
|
|
||||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
|
||||||
import type { CategoryId } from "@/types/gunbuilder";
|
|
||||||
import {
|
|
||||||
getRetailerOffers,
|
|
||||||
type RetailerOffer,
|
|
||||||
} from "./data";
|
|
||||||
|
|
||||||
type GunbuilderProductFromApi = {
|
|
||||||
id: string; // backend UUID string
|
|
||||||
name: string;
|
|
||||||
brand: string;
|
|
||||||
platform: string;
|
|
||||||
partRole: string;
|
|
||||||
price: number | null;
|
|
||||||
mainImageUrl: string | null;
|
|
||||||
buyUrl: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
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
|
|
||||||
|
|
||||||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const [offers, setOffers] = useState<RetailerOffer[]>([]);
|
|
||||||
const [offersLoading, setOffersLoading] = useState(true);
|
|
||||||
const [offersError, setOffersError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
const category = useMemo(
|
|
||||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
|
||||||
[categoryId],
|
|
||||||
);
|
|
||||||
|
|
||||||
// 1) Load product (same as before)
|
|
||||||
useEffect(() => {
|
|
||||||
if (!partId) return;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
async function fetchProduct() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
// 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 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;
|
|
||||||
|
|
||||||
if (!found) {
|
|
||||||
setError("Product not found");
|
|
||||||
}
|
|
||||||
|
|
||||||
setProduct(found);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.name === "AbortError") return;
|
|
||||||
setError(err.message ?? "Failed to load product");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchProduct();
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [partId]);
|
|
||||||
|
|
||||||
// 2) Load offers for this product from Ballistic backend
|
|
||||||
useEffect(() => {
|
|
||||||
if (!partId) return;
|
|
||||||
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function fetchOffers() {
|
|
||||||
try {
|
|
||||||
setOffersLoading(true);
|
|
||||||
setOffersError(null);
|
|
||||||
|
|
||||||
const data = await getRetailerOffers(partId);
|
|
||||||
|
|
||||||
if (!cancelled) {
|
|
||||||
setOffers(data);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
if (!cancelled) {
|
|
||||||
setOffersError(err.message ?? "Failed to load retailer offers");
|
|
||||||
}
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) {
|
|
||||||
setOffersLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchOffers();
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, [partId]);
|
|
||||||
|
|
||||||
// Best price: prefer live offers, fall back to summary product.price
|
|
||||||
const bestPrice = useMemo(() => {
|
|
||||||
if (offers.length > 0) {
|
|
||||||
return offers[0].price;
|
|
||||||
}
|
|
||||||
return product?.price ?? null;
|
|
||||||
}, [offers, product]);
|
|
||||||
|
|
||||||
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">
|
|
||||||
<h1 className="text-2xl font-semibold mb-4">Category Not Found</h1>
|
|
||||||
<Link
|
|
||||||
href="/gunbuilder"
|
|
||||||
className="text-amber-300 hover:text-amber-200 underline"
|
|
||||||
>
|
|
||||||
Return to Gunbuilder
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
|
||||||
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
|
|
||||||
{/* Breadcrumbs */}
|
|
||||||
<nav className="mb-4 text-xs text-zinc-500">
|
|
||||||
<Link
|
|
||||||
href="/gunbuilder"
|
|
||||||
className="hover:text-zinc-300 transition-colors"
|
|
||||||
>
|
|
||||||
Gunbuilder
|
|
||||||
</Link>
|
|
||||||
<span className="mx-1">/</span>
|
|
||||||
<Link
|
|
||||||
href={`/gunbuilder/${categoryId}`}
|
|
||||||
className="hover:text-zinc-300 transition-colors"
|
|
||||||
>
|
|
||||||
{category.name}
|
|
||||||
</Link>
|
|
||||||
{product && (
|
|
||||||
<>
|
|
||||||
<span className="mx-1">/</span>
|
|
||||||
<span className="text-zinc-300">{product.name}</span>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</nav>
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-zinc-500">Loading product…</p>
|
|
||||||
) : error || !product ? (
|
|
||||||
<div>
|
|
||||||
<h1 className="text-xl font-semibold mb-2">Product Unavailable</h1>
|
|
||||||
<p className="text-sm text-zinc-500 mb-4">
|
|
||||||
{error ?? "We couldn’t find this product."}
|
|
||||||
</p>
|
|
||||||
<Link
|
|
||||||
href={`/gunbuilder/${categoryId}`}
|
|
||||||
className="text-amber-300 hover:text-amber-200 underline text-sm"
|
|
||||||
>
|
|
||||||
Back to {category.name} parts
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<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 && (
|
|
||||||
<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}
|
|
||||||
alt={product.name}
|
|
||||||
className="w-full object-contain max-h-80 bg-zinc-950"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
<div className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
{product.brand}
|
|
||||||
</div>
|
|
||||||
<h1 className="text-2xl md:text-3xl font-semibold tracking-tight">
|
|
||||||
{product.name}
|
|
||||||
</h1>
|
|
||||||
|
|
||||||
<div className="grid grid-cols-2 gap-3 text-xs">
|
|
||||||
<p className="text-zinc-500">
|
|
||||||
Platform:{" "}
|
|
||||||
<span className="text-zinc-300">{product.platform}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-zinc-500">
|
|
||||||
Role:{" "}
|
|
||||||
<span className="text-zinc-300">{product.partRole}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-zinc-500">
|
|
||||||
Category:{" "}
|
|
||||||
<span className="text-zinc-300">{category.name}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-zinc-500">
|
|
||||||
Offers:{" "}
|
|
||||||
<span className="text-zinc-300">
|
|
||||||
{offers.length > 0
|
|
||||||
? `${offers.length} live offer${
|
|
||||||
offers.length === 1 ? "" : "s"
|
|
||||||
}`
|
|
||||||
: "No live offers yet"}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Placeholder content until long-form fields are wired up */}
|
|
||||||
<section className="mt-4 space-y-3 text-sm text-zinc-300">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Product Overview
|
|
||||||
</h2>
|
|
||||||
<p className="text-zinc-400">
|
|
||||||
This is placeholder copy for the product overview. Once
|
|
||||||
the Ballistic backend exposes richer metadata (short
|
|
||||||
description, feature bullets, etc.), we'll swap this
|
|
||||||
text out and surface the real content here.
|
|
||||||
</p>
|
|
||||||
<p className="text-zinc-500 text-xs">
|
|
||||||
Use this section to highlight what makes this part worth
|
|
||||||
a slot in your build: materials, intended use
|
|
||||||
(duty/range/competition), and any standout features.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-3">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500 mb-2">
|
|
||||||
Quick Specs (Placeholder)
|
|
||||||
</h2>
|
|
||||||
<dl className="grid grid-cols-2 gap-x-4 gap-y-2 text-xs text-zinc-400">
|
|
||||||
<div>
|
|
||||||
<dt className="text-zinc-500">Configuration</dt>
|
|
||||||
<dd className="text-zinc-200">
|
|
||||||
TBD (Stripped / Complete / Kit)
|
|
||||||
</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-zinc-500">Caliber</dt>
|
|
||||||
<dd className="text-zinc-200">TBD from feed</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-zinc-500">Finish</dt>
|
|
||||||
<dd className="text-zinc-200">TBD from merchant data</dd>
|
|
||||||
</div>
|
|
||||||
<div>
|
|
||||||
<dt className="text-zinc-500">Weight</dt>
|
|
||||||
<dd className="text-zinc-200">TBD (oz)</dd>
|
|
||||||
</div>
|
|
||||||
</dl>
|
|
||||||
<p className="mt-2 text-[11px] text-zinc-500">
|
|
||||||
Specs are placeholders for now. As we normalize more
|
|
||||||
structured attributes in the importer, this block will
|
|
||||||
auto-populate per product.
|
|
||||||
</p>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Right: pricing + actions */}
|
|
||||||
<aside className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs uppercase tracking-[0.2em] text-zinc-500 mb-1">
|
|
||||||
Price
|
|
||||||
</p>
|
|
||||||
<p className="text-2xl font-semibold text-amber-300">
|
|
||||||
{bestPrice && bestPrice > 0
|
|
||||||
? `$${bestPrice.toFixed(2)}`
|
|
||||||
: "—"}
|
|
||||||
</p>
|
|
||||||
{!bestPrice && (
|
|
||||||
<p className="mt-1 text-xs text-zinc-500">
|
|
||||||
Pricing not available yet. Live pricing will appear once
|
|
||||||
offers are imported.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{offers.length > 0 && (
|
|
||||||
<p className="mt-1 text-[11px] text-zinc-500">
|
|
||||||
Showing the best available price across all live
|
|
||||||
offers for this part.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
{offers.length > 1 && (
|
|
||||||
<p className="mt-1 text-[11px] text-zinc-500">
|
|
||||||
Price range across retailers:{" "}
|
|
||||||
<span className="text-zinc-300">
|
|
||||||
${Math.min(...offers.map(o => o.price)).toFixed(2)} – ${Math.max(...offers.map(o => o.price)).toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Offers list */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
{offersLoading && (
|
|
||||||
<p className="text-xs text-zinc-500">Loading offers…</p>
|
|
||||||
)}
|
|
||||||
{offersError && (
|
|
||||||
<p className="text-xs text-red-400">{offersError}</p>
|
|
||||||
)}
|
|
||||||
{!offersLoading && !offersError && offers.length > 0 && (
|
|
||||||
<div className="space-y-1">
|
|
||||||
{offers.map((offer) => (
|
|
||||||
<a
|
|
||||||
key={offer.id}
|
|
||||||
href={offer.affiliateUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex items-center justify-between rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1.5 text-xs hover:bg-zinc-800 transition-colors"
|
|
||||||
>
|
|
||||||
<span className="text-zinc-200">
|
|
||||||
{offer.retailerName}
|
|
||||||
</span>
|
|
||||||
<span className="font-semibold text-amber-300">
|
|
||||||
${offer.price.toFixed(2)}
|
|
||||||
</span>
|
|
||||||
</a>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!offersLoading &&
|
|
||||||
!offersError &&
|
|
||||||
offers.length === 0 && (
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
No live retailer offers yet. As feeds are imported,
|
|
||||||
merchants and pricing will show up here.
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<Link
|
|
||||||
href={`/gunbuilder?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
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{product.buyUrl ? (
|
|
||||||
<a
|
|
||||||
href={product.buyUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="block w-full rounded-md border border-zinc-700 bg-zinc-900 text-xs font-medium text-zinc-200 text-center py-2 hover:bg-zinc-800 transition-colors"
|
|
||||||
>
|
|
||||||
View on Merchant Site
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
disabled
|
|
||||||
className="block w-full rounded-md border border-zinc-800 bg-zinc-900/60 text-xs font-medium text-zinc-500 text-center py-2 cursor-not-allowed"
|
|
||||||
>
|
|
||||||
External link coming soon
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</aside>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Lower content: builder-focused helper copy (placeholder) */}
|
|
||||||
<section className="mt-10 grid gap-6 md:grid-cols-3 text-sm md:grid-cols-4">
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Why We Like This Part
|
|
||||||
</h2>
|
|
||||||
<p className="text-zinc-400">
|
|
||||||
Drop in a short blurb here about what makes this part
|
|
||||||
worth picking over similar options — think reliability,
|
|
||||||
track record, and value for the money.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Best For
|
|
||||||
</h2>
|
|
||||||
<p className="text-zinc-400">
|
|
||||||
Use this block to call out ideal use cases:{" "}
|
|
||||||
<span className="text-zinc-200">
|
|
||||||
duty rifle, home defense, range toy, competition, night
|
|
||||||
work
|
|
||||||
</span>
|
|
||||||
, etc.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Builder Notes
|
|
||||||
</h2>
|
|
||||||
<p className="text-zinc-400">
|
|
||||||
Add any compatibility quirks or install tips here once
|
|
||||||
the compatibility engine is wired up — gas system length,
|
|
||||||
buffer recommendations, known fitment notes, and more.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
<div className="space-y-2">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Compatibility
|
|
||||||
</h2>
|
|
||||||
<div className="inline-flex items-center gap-2 rounded-md border border-amber-400/30 bg-amber-400/10 px-2 py-1 text-[11px] text-amber-300">
|
|
||||||
<span className="inline-block h-2 w-2 rounded-full bg-amber-300 animate-pulse"></span>
|
|
||||||
Compatibility engine coming online soon
|
|
||||||
</div>
|
|
||||||
<p className="text-zinc-500 text-xs">
|
|
||||||
Fitment checks, caliber validation, buffer/gas system logic, and platform rules will appear here once the engine is active.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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,18 @@
|
|||||||
|
// app/(builder)/layout.tsx
|
||||||
|
import type { ReactNode } from "react";
|
||||||
|
|
||||||
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
|
import { BuilderNav } from "@/components/BuilderNav";
|
||||||
|
import { TopNav } from "@/components/TopNav";
|
||||||
|
|
||||||
|
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||||
|
return (
|
||||||
|
<div className="min-h-screen bg-neutral-950 text-zinc-50">
|
||||||
|
<AuthProvider>
|
||||||
|
<TopNav />
|
||||||
|
<BuilderNav />
|
||||||
|
<main className="min-h-screen">{children}</main>
|
||||||
|
</AuthProvider>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-10
@@ -1,27 +1,28 @@
|
|||||||
|
// app/layout.tsx
|
||||||
import "./globals.css";
|
import "./globals.css";
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
import { AuthProvider } from "@/context/AuthContext";
|
import { AuthProvider } from "@/context/AuthContext";
|
||||||
import { TopNav } from "@/components/TopNav";
|
|
||||||
import { BuilderNav } from "@/components/BuilderNav";
|
import { Banner } from "@/components/Banner";
|
||||||
|
|
||||||
export const metadata = {
|
export const metadata = {
|
||||||
title: {
|
title: {
|
||||||
default: "Battl Builder",
|
default: "Battl Builders",
|
||||||
template: "%s | Battl Builder",
|
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 }) {
|
export default function RootLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<html lang="en">
|
<html lang="en">
|
||||||
<body className="bg-black text-zinc-100">
|
<body className="bg-black text-zinc-50">
|
||||||
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<TopNav />
|
<Banner />
|
||||||
<BuilderNav />
|
{children}
|
||||||
<main className="min-h-screen">{children}</main>
|
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
|
|
||||||
</body>
|
</body>
|
||||||
</html>
|
</html>
|
||||||
);
|
);
|
||||||
|
|||||||
+49
-66
@@ -5,6 +5,7 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
|
||||||
export default function LoginPage() {
|
export default function LoginPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -15,7 +16,7 @@ export default function LoginPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const next = searchParams.get("next") || "/gunbuilder";
|
const next = searchParams.get("next") || "/builder";
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -30,75 +31,57 @@ export default function LoginPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Log In to <span className="text-amber-300">The Armory</span>
|
Log In to <span className="text-amber-300">The Armory</span>
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-400">
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
Use your beta credentials to get back to your saved builds.
|
Use your beta credentials to get back to your saved builds.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
<Field label="Email" htmlFor="email">
|
||||||
<label className="text-xs font-medium text-zinc-400" htmlFor="email">
|
<Input
|
||||||
Email
|
id="email"
|
||||||
</label>
|
type="email"
|
||||||
<input
|
autoComplete="email"
|
||||||
id="email"
|
required
|
||||||
type="email"
|
value={email}
|
||||||
autoComplete="email"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
/>
|
||||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
</Field>
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<Field label="Password" htmlFor="password">
|
||||||
<label
|
<Input
|
||||||
className="text-xs font-medium text-zinc-400"
|
id="password"
|
||||||
htmlFor="password"
|
type="password"
|
||||||
>
|
autoComplete="current-password"
|
||||||
Password
|
required
|
||||||
</label>
|
value={password}
|
||||||
<input
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
id="password"
|
/>
|
||||||
type="password"
|
</Field>
|
||||||
autoComplete="current-password"
|
|
||||||
required
|
|
||||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={loading}>
|
||||||
type="submit"
|
{loading ? "Signing in…" : "Log In"}
|
||||||
disabled={loading}
|
</Button>
|
||||||
className="mt-2 w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 hover:bg-amber-400/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
</form>
|
||||||
>
|
|
||||||
{loading ? "Signing in…" : "Log In"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-zinc-500">
|
<p className="mt-4 text-xs text-zinc-500">
|
||||||
New here?{" "}
|
New here?{" "}
|
||||||
<Link
|
<Link href="/register" className="text-amber-300 hover:text-amber-200">
|
||||||
href="/register"
|
Join the beta
|
||||||
className="text-amber-300 hover:text-amber-200"
|
</Link>
|
||||||
>
|
.
|
||||||
Join the beta
|
</p>
|
||||||
</Link>
|
</div>
|
||||||
.
|
</main>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export default function NotFound() {
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
|
||||||
|
<div className="max-w-md text-center">
|
||||||
|
<p className="text-xs font-semibold tracking-[0.3em] uppercase text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 className="mt-4 text-4xl font-semibold tracking-tight">
|
||||||
|
Page Not Found
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-3 text-sm text-zinc-400">
|
||||||
|
This area of the site isn’t publicly accessible yet.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<div className="mt-6 flex justify-center gap-3">
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Go Home
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* <a
|
||||||
|
href="https://battl.builders"
|
||||||
|
className="rounded-md border border-zinc-700 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-900 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
Learn More
|
||||||
|
</a> */}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p className="mt-6 text-[11px] text-zinc-600">
|
||||||
|
Early access features are launching soon.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+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() {
|
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 (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
<div className="max-w-xl text-center">
|
{/* Abstract Brand Background */}
|
||||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
<div className="pointer-events-none absolute inset-0 overflow-hidden opacity-[0.55]">
|
||||||
For Those Who Build With Intent.
|
<svg
|
||||||
</p>
|
width="100%"
|
||||||
<h1
|
height="100%"
|
||||||
className="mt-2 text-4xl md:text-5xl font-extrabold tracking-tight
|
preserveAspectRatio="xMidYMid slice"
|
||||||
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
|
xmlns="http://www.w3.org/2000/svg"
|
||||||
text-transparent uppercase bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
|
|
||||||
>
|
>
|
||||||
Battl Builder
|
<defs>
|
||||||
</h1>
|
<pattern
|
||||||
<p className="mt-3 text-sm md:text-base text-zinc-400">
|
id="battlGrid"
|
||||||
Battl Builder is your command center for rifle builds. We surface live
|
x="0"
|
||||||
parts and hard-use brands, giving you a mission-ready workbench to
|
y="0"
|
||||||
design smarter, track cost, swap components, and save setups. No
|
width="80"
|
||||||
fluff. Just clean, trustworthy data.
|
height="80"
|
||||||
</p>
|
patternUnits="userSpaceOnUse"
|
||||||
<div className="mt-6">
|
>
|
||||||
<Link
|
<path
|
||||||
href="/builder"
|
d="M 80 0 L 0 0 0 80"
|
||||||
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"
|
fill="none"
|
||||||
>
|
stroke="#fbbf24"
|
||||||
Launch The Builder
|
strokeWidth="0.5"
|
||||||
</Link>
|
strokeOpacity="0.35"
|
||||||
</div>
|
/>
|
||||||
|
</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>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
+58
-79
@@ -5,6 +5,7 @@ import { FormEvent, useState } from "react";
|
|||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
export default function RegisterPage() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -16,7 +17,7 @@ export default function RegisterPage() {
|
|||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const next = searchParams.get("next") || "/gunbuilder";
|
const next = searchParams.get("next") || "/builder";
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -31,89 +32,67 @@ export default function RegisterPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
Join the <span className="text-amber-300">Shadow Standard</span> beta
|
Join the <span className="text-amber-300">Battl Builder</span> Beta
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-2 text-sm text-zinc-400">
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
Create an account so we can save your builds, watch price drops, and
|
Create an account so we can save your builds, watch price drops, and
|
||||||
roll out new features to you first.
|
roll out new features to you first.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
{error && (
|
{error && (
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
{error}
|
{error}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<div className="space-y-1">
|
<Field label="Display Name (optional)" htmlFor="displayName">
|
||||||
<label
|
<Input
|
||||||
className="text-xs font-medium text-zinc-400"
|
id="displayName"
|
||||||
htmlFor="displayName"
|
type="text"
|
||||||
>
|
value={displayName}
|
||||||
Display Name (optional)
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
</label>
|
/>
|
||||||
<input
|
</Field>
|
||||||
id="displayName"
|
|
||||||
type="text"
|
|
||||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<Field label="Email" htmlFor="email">
|
||||||
<label className="text-xs font-medium text-zinc-400" htmlFor="email">
|
<Input
|
||||||
Email
|
id="email"
|
||||||
</label>
|
type="email"
|
||||||
<input
|
autoComplete="email"
|
||||||
id="email"
|
required
|
||||||
type="email"
|
value={email}
|
||||||
autoComplete="email"
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
required
|
/>
|
||||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
</Field>
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-1">
|
<Field label="Password" htmlFor="password">
|
||||||
<label
|
<Input
|
||||||
className="text-xs font-medium text-zinc-400"
|
id="password"
|
||||||
htmlFor="password"
|
type="password"
|
||||||
>
|
autoComplete="new-password"
|
||||||
Password
|
required
|
||||||
</label>
|
value={password}
|
||||||
<input
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
id="password"
|
/>
|
||||||
type="password"
|
</Field>
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<button
|
<Button type="submit" disabled={loading}>
|
||||||
type="submit"
|
{loading ? "Creating account…" : "Join Beta"}
|
||||||
disabled={loading}
|
</Button>
|
||||||
className="mt-2 w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 hover:bg-amber-400/30 disabled:opacity-50 disabled:cursor-not-allowed transition-colors"
|
</form>
|
||||||
>
|
|
||||||
{loading ? "Creating account…" : "Join Beta"}
|
|
||||||
</button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-zinc-500">
|
<p className="mt-4 text-xs text-zinc-500">
|
||||||
Already have an account?{" "}
|
Already have an account?{" "}
|
||||||
<Link href="/login" className="text-amber-300 hover:text-amber-200">
|
<Link href="/login" className="text-amber-300 hover:text-amber-200">
|
||||||
Log in
|
Log in
|
||||||
</Link>
|
</Link>
|
||||||
.
|
.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,136 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import type React from "react";
|
||||||
|
import {
|
||||||
|
LayoutDashboard,
|
||||||
|
Download,
|
||||||
|
Layers,
|
||||||
|
Boxes,
|
||||||
|
Store,
|
||||||
|
Users,
|
||||||
|
Settings,
|
||||||
|
LucideMail,
|
||||||
|
} from "lucide-react";
|
||||||
|
|
||||||
|
type AdminLeftNavigationProps = {
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggleCollapsed: () => void;
|
||||||
|
};
|
||||||
|
|
||||||
|
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",
|
||||||
|
href: "/admin/products",
|
||||||
|
icon: <Boxes className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Merchants",
|
||||||
|
href: "/admin/merchants",
|
||||||
|
icon: <Store className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Platforms",
|
||||||
|
href: "/admin/platforms",
|
||||||
|
icon: <Layers className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Users",
|
||||||
|
href: "/admin/users",
|
||||||
|
icon: <Users className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
href: "/admin/settings",
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "List Emails",
|
||||||
|
href: "/admin/email",
|
||||||
|
icon: <LucideMail className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Send a Email",
|
||||||
|
href: "/admin/email/send",
|
||||||
|
icon: <LucideMail className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function AdminLeftNavigation({
|
||||||
|
collapsed,
|
||||||
|
onToggleCollapsed,
|
||||||
|
}: AdminLeftNavigationProps) {
|
||||||
|
return (
|
||||||
|
<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={onToggleCollapsed}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -24,7 +24,7 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
const currentCategory =
|
const currentCategory =
|
||||||
activeCategoryId ?? searchParams.get("category") ?? undefined;
|
activeCategoryId ?? searchParams.get("category") ?? undefined;
|
||||||
|
|
||||||
const baseHref = "/builder";
|
const baseHref = "/parts";
|
||||||
|
|
||||||
const renderDropdown = (label: string, items: Category[]) => {
|
const renderDropdown = (label: string, items: Category[]) => {
|
||||||
if (!items.length) return null;
|
if (!items.length) return null;
|
||||||
@@ -71,10 +71,10 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="max-w-6xl mx-auto w-full"> {/* ← aligns with topnav */}
|
<div className="max-w-6xl mx-auto w-full"> {/* ← aligns with topnav */}
|
||||||
<nav className="flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm bg-black/95 backdrop-blur-sm">
|
<nav className="flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm backdrop-blur-sm">
|
||||||
{/* Primary builder link */}
|
{/* Primary builder link */}
|
||||||
<Link
|
<Link
|
||||||
href={baseHref}
|
href={'/builder'}
|
||||||
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
|
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
|
||||||
>
|
>
|
||||||
Builder
|
Builder
|
||||||
|
|||||||
@@ -1,79 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import Link from "next/link";
|
|
||||||
import type { Category, Part } from "@/types/gunbuilder";
|
|
||||||
import { PartCard } from "@/components/PartCard";
|
|
||||||
|
|
||||||
interface CategoryColumnProps {
|
|
||||||
category: Category;
|
|
||||||
parts: Part[];
|
|
||||||
selectedPartId?: string;
|
|
||||||
onSelectPart: (partId: string) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryColumn({
|
|
||||||
category,
|
|
||||||
parts,
|
|
||||||
selectedPartId,
|
|
||||||
onSelectPart,
|
|
||||||
}: CategoryColumnProps) {
|
|
||||||
// Show selected part if available, otherwise show placeholder
|
|
||||||
const displayedPart = selectedPartId
|
|
||||||
? parts.find((p) => p.id === selectedPartId)
|
|
||||||
: null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex flex-col h-full">
|
|
||||||
<div className="mb-3">
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.15em] text-zinc-400 uppercase">
|
|
||||||
{category.name}
|
|
||||||
</h2>
|
|
||||||
</div>
|
|
||||||
<div className="flex-1 overflow-y-auto pr-1">
|
|
||||||
{parts.length === 0 && (
|
|
||||||
<p className="text-xs text-zinc-500">No parts available yet.</p>
|
|
||||||
)}
|
|
||||||
{!displayedPart && parts.length > 0 && (
|
|
||||||
<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"
|
|
||||||
>
|
|
||||||
<svg
|
|
||||||
className="w-4 h-4"
|
|
||||||
fill="none"
|
|
||||||
stroke="currentColor"
|
|
||||||
viewBox="0 0 24 24"
|
|
||||||
>
|
|
||||||
<path
|
|
||||||
strokeLinecap="round"
|
|
||||||
strokeLinejoin="round"
|
|
||||||
strokeWidth={2}
|
|
||||||
d="M12 4v16m8-8H4"
|
|
||||||
/>
|
|
||||||
</svg>
|
|
||||||
Choose a Part
|
|
||||||
</Link>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{displayedPart && (
|
|
||||||
<PartCard
|
|
||||||
key={displayedPart.id}
|
|
||||||
part={displayedPart}
|
|
||||||
selected={displayedPart.id === selectedPartId}
|
|
||||||
onSelect={() => onSelectPart(displayedPart.id)}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
{parts.length >= 1 && (
|
|
||||||
<Link
|
|
||||||
href={`/builder/${category.id}`}
|
|
||||||
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})
|
|
||||||
</Link>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PriceHistoryPoint } from "@/app/builder/[categoryId]/[partId]/data";
|
import type { PriceHistoryPoint } from "@/app/(builder)/parts/[partRole]/_old_prod_details/data";
|
||||||
|
|
||||||
interface PricingHistoryGraphProps {
|
interface PricingHistoryGraphProps {
|
||||||
data: PriceHistoryPoint[];
|
data: PriceHistoryPoint[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RetailerOffer } from "@/app/builder/[categoryId]/[partId]/data";
|
import type { RetailerOffer } from "@/app/(builder)/parts/[partRole]/_old_prod_details/data";
|
||||||
|
|
||||||
interface RetailersListProps {
|
interface RetailersListProps {
|
||||||
retailers: RetailerOffer[];
|
retailers: RetailerOffer[];
|
||||||
|
|||||||
@@ -0,0 +1,117 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
|
||||||
|
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
||||||
|
|
||||||
|
export default function SendEmailForm(): JSX.Element {
|
||||||
|
const [result, setResult] = useState<
|
||||||
|
ApiResponse<EmailRequest> | { error: string } | null
|
||||||
|
>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
setShowSuccess(false);
|
||||||
|
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||||
|
|
||||||
|
const form = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
const recipient = String(form.get("recipient") ?? "");
|
||||||
|
const subject = String(form.get("subject") ?? "");
|
||||||
|
const body = String(form.get("body") ?? "");
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const data = await sendEmailAction({ recipient, subject, body });
|
||||||
|
setResult(data);
|
||||||
|
|
||||||
|
if (data?.success === true) {
|
||||||
|
setShowSuccess(true);
|
||||||
|
|
||||||
|
successTimerRef.current = setTimeout(() => {
|
||||||
|
setShowSuccess(false);
|
||||||
|
setResult(null); // clear the response panel too
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setResult({ error: message });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Send <span className="text-amber-300">Email</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
|
Compose a message and send it from the admin panel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
||||||
|
{result && "error" in result && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{result.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Recipient" htmlFor="recipient">
|
||||||
|
<Input
|
||||||
|
id="recipient"
|
||||||
|
name="recipient"
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
defaultValue="user@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Subject" htmlFor="subject">
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
placeholder="Test subject"
|
||||||
|
defaultValue="Test subject"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Body" htmlFor="body">
|
||||||
|
<Textarea
|
||||||
|
id="body"
|
||||||
|
name="body"
|
||||||
|
placeholder="Write your message…"
|
||||||
|
defaultValue="Test body"
|
||||||
|
rows={6}
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
|
||||||
|
<div className="text-xs font-medium text-zinc-400">Response</div>
|
||||||
|
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
|
||||||
|
{result ? JSON.stringify(result, null, 2) : "No response yet."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
|
||||||
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
import RichTextEditor from "@/components/ui/RichTextEditor";
|
||||||
|
|
||||||
|
export default function SendEmailForm(): JSX.Element {
|
||||||
|
const [result, setResult] = useState<
|
||||||
|
ApiResponse<EmailRequest> | { error: string } | null
|
||||||
|
>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const [bodyHtml, setBodyHtml] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
setShowSuccess(false);
|
||||||
|
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||||
|
|
||||||
|
const form = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
const recipient = String(form.get("recipient") ?? "");
|
||||||
|
const subject = String(form.get("subject") ?? "");
|
||||||
|
const body = String(form.get("body") ?? ""); // HTML from hidden input
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const data = await sendEmailAction({ recipient, subject, body });
|
||||||
|
setResult(data);
|
||||||
|
|
||||||
|
if (data?.success === true) {
|
||||||
|
setShowSuccess(true);
|
||||||
|
|
||||||
|
successTimerRef.current = setTimeout(() => {
|
||||||
|
setShowSuccess(false);
|
||||||
|
setResult(null);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setResult({ error: message });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Send <span className="text-amber-300">Email</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
|
Compose a message and send it from the admin panel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
||||||
|
{result && "error" in result && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{result.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Recipient" htmlFor="recipient">
|
||||||
|
<Input
|
||||||
|
id="recipient"
|
||||||
|
name="recipient"
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
defaultValue="user@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Subject" htmlFor="subject">
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
placeholder="Subject…"
|
||||||
|
defaultValue="Test subject"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Body"
|
||||||
|
htmlFor="body"
|
||||||
|
hint="This will be sent as HTML."
|
||||||
|
>
|
||||||
|
{/* Hidden field so your existing FormData submit keeps working */}
|
||||||
|
<input type="hidden" id="body" name="body" value={bodyHtml} />
|
||||||
|
|
||||||
|
<RichTextEditor
|
||||||
|
value={bodyHtml}
|
||||||
|
onChange={setBodyHtml}
|
||||||
|
placeholder="Write your message…"
|
||||||
|
className="rounded-md border border-zinc-800 bg-zinc-950/60"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
|
||||||
|
<div className="text-xs font-medium text-zinc-400">Response</div>
|
||||||
|
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
|
||||||
|
{result ? JSON.stringify(result, null, 2) : "No response yet."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
+11
-38
@@ -2,6 +2,8 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
export function TopNav() {
|
export function TopNav() {
|
||||||
@@ -11,24 +13,7 @@ export function TopNav() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="border-b border-zinc-800 bg-black/95 backdrop-blur">
|
<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 */}
|
{/* Main nav row */}
|
||||||
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
||||||
{/* Left: Brand / Home */}
|
{/* Left: Brand / Home */}
|
||||||
@@ -36,28 +21,16 @@ export function TopNav() {
|
|||||||
href="/"
|
href="/"
|
||||||
className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-400"
|
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>
|
</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 */}
|
{/* Right side actions */}
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
{/* Search placeholder */}
|
{/* Search placeholder */}
|
||||||
|
|||||||
@@ -0,0 +1,34 @@
|
|||||||
|
// /components/builder/OverlapChip.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
|
export default function OverlapChip(props: {
|
||||||
|
tone?: "warning" | "info";
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
}) {
|
||||||
|
const { tone = "info", label, detail } = props;
|
||||||
|
|
||||||
|
const [ready, setReady] = useState(false);
|
||||||
|
useEffect(() => {
|
||||||
|
const id = window.setTimeout(() => setReady(true), 10);
|
||||||
|
return () => window.clearTimeout(id);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const base =
|
||||||
|
"inline-flex items-center gap-1 rounded-full border px-2 py-0.5 text-[0.65rem] leading-4 transition-all duration-200";
|
||||||
|
const pop = ready ? "opacity-100 scale-100" : "opacity-0 scale-95";
|
||||||
|
|
||||||
|
const toneCls =
|
||||||
|
tone === "warning"
|
||||||
|
? "border-amber-400/40 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-200";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`${base} ${toneCls} ${pop}`} title={detail ?? label}>
|
||||||
|
<span className="font-medium">{label}</span>
|
||||||
|
{detail ? <span className="text-zinc-400">· {detail}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,190 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import React from "react";
|
||||||
|
|
||||||
|
export default function Filters(props: {
|
||||||
|
partRoleLabel: string;
|
||||||
|
|
||||||
|
availableBrands: string[];
|
||||||
|
brandFilter: string[];
|
||||||
|
setBrandFilter: (v: string[]) => void;
|
||||||
|
|
||||||
|
priceBounds: { min: number | null; max: number | null };
|
||||||
|
priceRange: { min: number | null; max: number | null };
|
||||||
|
setPriceRange: (v: { min: number | null; max: number | null }) => void;
|
||||||
|
|
||||||
|
inStockOnly: boolean;
|
||||||
|
setInStockOnly: (v: boolean) => void;
|
||||||
|
}) {
|
||||||
|
const {
|
||||||
|
availableBrands,
|
||||||
|
brandFilter,
|
||||||
|
setBrandFilter,
|
||||||
|
priceBounds,
|
||||||
|
priceRange,
|
||||||
|
setPriceRange,
|
||||||
|
inStockOnly,
|
||||||
|
setInStockOnly,
|
||||||
|
} = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<aside className="w-full 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 results.
|
||||||
|
</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>
|
||||||
|
)}
|
||||||
|
</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({
|
||||||
|
min: Math.min(value, priceRange.max ?? priceBounds.max ?? value),
|
||||||
|
max: priceRange.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({
|
||||||
|
min: priceRange.min ?? priceBounds.min ?? value,
|
||||||
|
max: Math.max(value, priceRange.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(
|
||||||
|
e.target.checked
|
||||||
|
? [...brandFilter, b]
|
||||||
|
: brandFilter.filter((x) => x !== 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,62 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useId, useState } from "react";
|
||||||
|
|
||||||
|
export default function OverlapChip(props: {
|
||||||
|
label: string;
|
||||||
|
tooltip?: string;
|
||||||
|
/** stable key so chip doesn't re-animate on trivial rerenders */
|
||||||
|
persistKey?: string;
|
||||||
|
className?: string;
|
||||||
|
}) {
|
||||||
|
const { label, tooltip, persistKey, className } = props;
|
||||||
|
|
||||||
|
// Animate only once per persistKey per session
|
||||||
|
const storageKey = persistKey ? `bb_overlapchip_seen:${persistKey}` : null;
|
||||||
|
|
||||||
|
const [animateIn, setAnimateIn] = useState(false);
|
||||||
|
const tooltipId = useId();
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!storageKey) {
|
||||||
|
// No key provided: animate once on mount
|
||||||
|
setAnimateIn(true);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
const seen = sessionStorage.getItem(storageKey);
|
||||||
|
if (!seen) {
|
||||||
|
sessionStorage.setItem(storageKey, "1");
|
||||||
|
setAnimateIn(true);
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// sessionStorage blocked — still animate
|
||||||
|
setAnimateIn(true);
|
||||||
|
}
|
||||||
|
}, [storageKey]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`inline-flex items-center ${className ?? ""}`}>
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"inline-flex items-center gap-1.5 rounded-full border border-amber-400/50 bg-amber-400/10 px-2 py-1",
|
||||||
|
"text-[0.7rem] font-medium text-amber-200",
|
||||||
|
"transition-all duration-300 ease-out",
|
||||||
|
animateIn ? "opacity-100 translate-y-0" : "opacity-0 translate-y-1",
|
||||||
|
].join(" ")}
|
||||||
|
aria-describedby={tooltip ? tooltipId : undefined}
|
||||||
|
title={tooltip}
|
||||||
|
>
|
||||||
|
<span aria-hidden="true" className="text-amber-300">
|
||||||
|
⚠
|
||||||
|
</span>
|
||||||
|
<span>{label}</span>
|
||||||
|
</span>
|
||||||
|
|
||||||
|
{/* Optional: if you later want a real tooltip instead of title,
|
||||||
|
you can wire one here using tooltipId. */}
|
||||||
|
{tooltip ? <span id={tooltipId} className="sr-only">{tooltip}</span> : null}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
export default function Pagination(props: {
|
||||||
|
currentPage: number;
|
||||||
|
totalPages: number;
|
||||||
|
onPrev: () => void;
|
||||||
|
onNext: () => void;
|
||||||
|
}) {
|
||||||
|
const { currentPage, totalPages, onPrev, onNext } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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={onPrev}
|
||||||
|
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={onNext}
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,386 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PartsBrowseClient
|
||||||
|
* -----------------------------------------------------------------------------
|
||||||
|
* Browse shell for parts listing pages.
|
||||||
|
* Owns:
|
||||||
|
* - fetching parts from backend
|
||||||
|
* - filter + sort + pagination state
|
||||||
|
* - layout + list/card views
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
import Filters from "@/components/parts/Filters";
|
||||||
|
import SortBar from "@/components/parts/SortBar";
|
||||||
|
import PartsGrid from "@/components/parts/PartsGrid";
|
||||||
|
import Pagination from "@/components/parts/Pagination";
|
||||||
|
import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
|
||||||
|
|
||||||
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
||||||
|
|
||||||
|
type ViewMode = "card" | "list";
|
||||||
|
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||||
|
|
||||||
|
type GunbuilderProductFromApi = {
|
||||||
|
id: string | number;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number | null;
|
||||||
|
mainImageUrl: string | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
inStock?: boolean | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type UiPart = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number; // normalized
|
||||||
|
imageUrl?: string;
|
||||||
|
buyUrl?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
|
function normalizeId(id: string | number) {
|
||||||
|
return typeof id === "number" ? String(id) : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
function toSlug(s: string) {
|
||||||
|
return (s ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replaceAll(/[^a-z0-9]+/g, "-")
|
||||||
|
.replaceAll(/(^-|-$)/g, "")
|
||||||
|
.trim();
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Canonical product details route:
|
||||||
|
* /parts/p/[platform]/[partRole]/[productSlug]
|
||||||
|
*/
|
||||||
|
function buildDetailHref(platform: string, partRole: string, p: UiPart) {
|
||||||
|
const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`);
|
||||||
|
return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}/${encodeURIComponent(
|
||||||
|
`${p.id}-${slug}`
|
||||||
|
)}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PartsBrowseClient(props: {
|
||||||
|
partRole: string;
|
||||||
|
platform?: string | null; // if null, read from query param or default
|
||||||
|
title?: string;
|
||||||
|
subtitle?: string;
|
||||||
|
}) {
|
||||||
|
const router = useRouter();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const effectivePlatform = props.platform ?? searchParams.get("platform") ?? "AR-15";
|
||||||
|
const partRole = props.partRole;
|
||||||
|
|
||||||
|
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||||
|
const [parts, setParts] = useState<UiPart[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
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(false);
|
||||||
|
const [currentPage, setCurrentPage] = useState(1);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!partRole) return;
|
||||||
|
|
||||||
|
const controller = new AbortController();
|
||||||
|
|
||||||
|
async function fetchParts() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
search.set("platform", effectivePlatform);
|
||||||
|
search.append("partRoles", partRole);
|
||||||
|
|
||||||
|
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: normalizeId(p.id),
|
||||||
|
name: p.name,
|
||||||
|
brand: p.brand,
|
||||||
|
platform: p.platform,
|
||||||
|
partRole: p.partRole,
|
||||||
|
price: p.price ?? 0,
|
||||||
|
imageUrl: p.mainImageUrl ?? undefined,
|
||||||
|
buyUrl: p.buyUrl ?? 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);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fetchParts();
|
||||||
|
return () => controller.abort();
|
||||||
|
}, [partRole, effectivePlatform]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
setCurrentPage(1);
|
||||||
|
}, [partRole, effectivePlatform, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
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];
|
||||||
|
|
||||||
|
const effectiveMin = priceRange.min ?? priceBounds.min;
|
||||||
|
const effectiveMax = priceRange.max ?? priceBounds.max;
|
||||||
|
if (effectiveMin != null && effectiveMax != null) {
|
||||||
|
result = result.filter((p) => p.price >= effectiveMin && p.price <= effectiveMax);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (brandFilter.length > 0) {
|
||||||
|
result = result.filter((p) => brandFilter.includes(p.brand));
|
||||||
|
}
|
||||||
|
|
||||||
|
if (inStockOnly) {
|
||||||
|
result = result.filter((p) => p.inStock ?? true);
|
||||||
|
}
|
||||||
|
|
||||||
|
const q = searchQuery.trim().toLowerCase();
|
||||||
|
if (q) {
|
||||||
|
result = result.filter((p) => {
|
||||||
|
const name = (p.name ?? "").toLowerCase();
|
||||||
|
const brand = (p.brand ?? "").toLowerCase();
|
||||||
|
return name.includes(q) || brand.includes(q);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
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:
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
|
return result;
|
||||||
|
}, [parts, brandFilter, sortBy, searchQuery, priceRange, priceBounds.min, priceBounds.max, inStockOnly]);
|
||||||
|
|
||||||
|
const totalPages = useMemo(
|
||||||
|
() => (filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE)),
|
||||||
|
[filteredParts.length]
|
||||||
|
);
|
||||||
|
|
||||||
|
const paginatedParts = useMemo(() => {
|
||||||
|
if (filteredParts.length === 0) return [];
|
||||||
|
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
||||||
|
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
|
||||||
|
}, [filteredParts, currentPage]);
|
||||||
|
|
||||||
|
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]);
|
||||||
|
|
||||||
|
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`;
|
||||||
|
const headingSubtitle = props.subtitle ?? "Browse available parts pulled from your Ballistic backend.";
|
||||||
|
|
||||||
|
// ✅ Add-to-build handler: deep-links into builder’s existing ?select= logic
|
||||||
|
const handleAddToBuild = (p: UiPart) => {
|
||||||
|
const normalizedRole = normalizePartRole(partRole);
|
||||||
|
const categoryId: CategoryId | null =
|
||||||
|
PART_ROLE_TO_CATEGORY[partRole] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null;
|
||||||
|
|
||||||
|
if (!categoryId) {
|
||||||
|
alert(`No CategoryId mapping found for role "${partRole}". Add it to catalogMappings.`);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const qp = new URLSearchParams();
|
||||||
|
qp.set("platform", effectivePlatform);
|
||||||
|
qp.set("select", `${categoryId}:${p.id}`);
|
||||||
|
|
||||||
|
router.push(`/builder?${qp.toString()}`);
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
<header className="mb-6">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
{headingTitle} <span className="text-amber-300">{effectivePlatform}</span>
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<p className="mt-2 text-sm text-zinc-400 max-w-xl">{headingSubtitle}</p>
|
||||||
|
|
||||||
|
<div className="mt-3">
|
||||||
|
<PlatformSwitcher currentPlatform={effectivePlatform} partRole={partRole} preserveQuery />
|
||||||
|
</div>
|
||||||
|
</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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
List
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<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">
|
||||||
|
{!loading && !error && parts.length > 0 && (
|
||||||
|
<aside className="w-full md:w-64 shrink-0">
|
||||||
|
<Filters
|
||||||
|
partRoleLabel={partRole}
|
||||||
|
availableBrands={availableBrands}
|
||||||
|
brandFilter={brandFilter}
|
||||||
|
setBrandFilter={setBrandFilter}
|
||||||
|
priceBounds={priceBounds}
|
||||||
|
priceRange={priceRange}
|
||||||
|
setPriceRange={setPriceRange}
|
||||||
|
inStockOnly={inStockOnly}
|
||||||
|
setInStockOnly={setInStockOnly}
|
||||||
|
/>
|
||||||
|
</aside>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
{!loading && !error && parts.length > 0 && (
|
||||||
|
<SortBar
|
||||||
|
visibleRange={visibleRange}
|
||||||
|
totalCount={filteredParts.length}
|
||||||
|
searchQuery={searchQuery}
|
||||||
|
setSearchQuery={setSearchQuery}
|
||||||
|
sortBy={sortBy}
|
||||||
|
setSortBy={setSortBy}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{loading ? (
|
||||||
|
<p className="py-8 text-center text-sm text-zinc-500">Loading…</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 found for this role yet.</p>
|
||||||
|
) : (
|
||||||
|
<PartsGrid
|
||||||
|
viewMode={viewMode}
|
||||||
|
parts={paginatedParts}
|
||||||
|
buildDetailHref={(p) => buildDetailHref(effectivePlatform, partRole, p)}
|
||||||
|
onAddToBuild={handleAddToBuild}
|
||||||
|
addLabel="Add to Build"
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && filteredParts.length > 0 && totalPages > 1 && (
|
||||||
|
<Pagination
|
||||||
|
currentPage={currentPage}
|
||||||
|
totalPages={totalPages}
|
||||||
|
onPrev={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||||
|
onNext={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,162 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type UiPart = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number;
|
||||||
|
imageUrl?: string;
|
||||||
|
buyUrl?: string;
|
||||||
|
inStock?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PartsGrid(props: {
|
||||||
|
viewMode: "card" | "list";
|
||||||
|
parts: UiPart[];
|
||||||
|
buildDetailHref: (p: UiPart) => string;
|
||||||
|
|
||||||
|
// NEW: optional Add-to-Build behavior
|
||||||
|
onAddToBuild?: (p: UiPart) => void;
|
||||||
|
addLabel?: string;
|
||||||
|
}) {
|
||||||
|
const { viewMode, parts, buildDetailHref, onAddToBuild } = props;
|
||||||
|
const addLabel = props.addLabel ?? "Add to Build";
|
||||||
|
|
||||||
|
if (viewMode === "card") {
|
||||||
|
return (
|
||||||
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
|
{parts.map((part) => (
|
||||||
|
<div
|
||||||
|
key={part.id}
|
||||||
|
className="group relative flex flex-col rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all duration-200 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 min-w-0">
|
||||||
|
<div className="text-sm font-semibold text-zinc-50 truncate">
|
||||||
|
{part.brand} <span className="font-normal">— {part.name}</span>
|
||||||
|
</div>
|
||||||
|
</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={buildDetailHref(part)}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{/* NEW: Add to Build (preferred primary action if provided) */}
|
||||||
|
{onAddToBuild ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddToBuild(part)}
|
||||||
|
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
) : part.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={part.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="flex-1 rounded-md bg-zinc-700 px-3 py-2 text-center text-xs font-semibold text-zinc-200 opacity-50"
|
||||||
|
>
|
||||||
|
No Action
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// LIST view
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<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">
|
||||||
|
{parts.map((part) => (
|
||||||
|
<div
|
||||||
|
key={part.id}
|
||||||
|
className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all duration-200 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 truncate">
|
||||||
|
{part.name}
|
||||||
|
</div>
|
||||||
|
</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={buildDetailHref(part)}
|
||||||
|
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>
|
||||||
|
|
||||||
|
{onAddToBuild ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddToBuild(part)}
|
||||||
|
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
) : part.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={part.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="whitespace-nowrap rounded-md bg-zinc-700 px-3 py-1.5 text-xs font-semibold text-zinc-200 opacity-50"
|
||||||
|
>
|
||||||
|
No Action
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,178 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import PlatformSwitcher from "./PlatformSwitcher";
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type ProductListDto = {
|
||||||
|
id: string; // your API returns strings sometimes; keep it flexible
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
categoryKey: string | null;
|
||||||
|
price: number | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function safeSlugify(input: string) {
|
||||||
|
return (input ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "")
|
||||||
|
.slice(0, 80);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function PartsListPageClient(props: {
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
}) {
|
||||||
|
const { platform, partRole } = props;
|
||||||
|
|
||||||
|
const [items, setItems] = useState<ProductListDto[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
// Simple client-side search (fast + good enough for now)
|
||||||
|
const [q, setQ] = useState("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// Your current list endpoint:
|
||||||
|
// GET /api/products?platform=AR-15&partRoles=upper-receiver
|
||||||
|
const url = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`;
|
||||||
|
|
||||||
|
const res = await fetch(url, { headers: { Accept: "application/json" } });
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "");
|
||||||
|
throw new Error(`Failed to load products (${res.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: ProductListDto[] = await res.json();
|
||||||
|
if (!cancelled) setItems(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message ?? "Failed to load products");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [platform, partRole]);
|
||||||
|
|
||||||
|
const filtered = useMemo(() => {
|
||||||
|
const needle = q.trim().toLowerCase();
|
||||||
|
if (!needle) return items;
|
||||||
|
|
||||||
|
return items.filter((p) => {
|
||||||
|
const blob = `${p.brand ?? ""} ${p.name ?? ""} ${p.categoryKey ?? ""}`.toLowerCase();
|
||||||
|
return blob.includes(needle);
|
||||||
|
});
|
||||||
|
}, [items, q]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
<header className="mb-6 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Parts: <span className="text-amber-300">{partRole}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlatformSwitcher currentPlatform={platform} partRole={partRole} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<input
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => setQ(e.target.value)}
|
||||||
|
placeholder="Search brand, name, category…"
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-3 py-2 text-sm text-zinc-100 outline-none focus:border-amber-400"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-zinc-500">Loading parts…</p>}
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && (
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-3 md:p-4">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
Results
|
||||||
|
</p>
|
||||||
|
<p className="text-xs text-zinc-400">
|
||||||
|
{filtered.length} items
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 gap-3 md:grid-cols-2">
|
||||||
|
{filtered.map((p) => {
|
||||||
|
const id = String(p.id);
|
||||||
|
const slug = safeSlugify(p.name);
|
||||||
|
const href = `/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}/${id}-${slug}`;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={`${p.id}`}
|
||||||
|
href={href}
|
||||||
|
className="group rounded-lg border border-zinc-800 bg-black/30 p-3 hover:border-zinc-700"
|
||||||
|
>
|
||||||
|
<div className="flex gap-3">
|
||||||
|
<div className="h-16 w-16 overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{p.imageUrl ? (
|
||||||
|
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full grid place-items-center text-xs text-zinc-600">
|
||||||
|
—
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
{p.brand}
|
||||||
|
</p>
|
||||||
|
<p className="mt-1 text-sm font-medium text-zinc-100 group-hover:text-amber-200">
|
||||||
|
{p.name}
|
||||||
|
</p>
|
||||||
|
<div className="mt-2 flex items-center justify-between">
|
||||||
|
<p className="text-xs text-zinc-500 line-clamp-1">
|
||||||
|
{p.categoryKey ?? "—"}
|
||||||
|
</p>
|
||||||
|
<p className="text-xs font-semibold text-zinc-200">
|
||||||
|
{typeof p.price === "number" ? `$${p.price.toFixed(2)}` : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo } from "react";
|
||||||
|
import { usePathname, useRouter, useSearchParams } from "next/navigation";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* PlatformSwitcher
|
||||||
|
*
|
||||||
|
* Works on BOTH:
|
||||||
|
* - /parts/[partRole] (default browse route)
|
||||||
|
* - /parts/[platform]/[partRole] (canonical route)
|
||||||
|
*
|
||||||
|
* Behavior:
|
||||||
|
* - If you're on /parts/[partRole], switching platform navigates to /parts/[platform]/[partRole]
|
||||||
|
* (so your URL becomes explicit/shareable).
|
||||||
|
* - If you're already on /parts/[platform]/[partRole], it swaps the platform segment.
|
||||||
|
*/
|
||||||
|
export default function PlatformSwitcher(props: {
|
||||||
|
currentPlatform: string;
|
||||||
|
partRole: string;
|
||||||
|
// If true, we keep query params when switching (nice for sort/paging)
|
||||||
|
preserveQuery?: boolean;
|
||||||
|
}) {
|
||||||
|
const { currentPlatform, partRole, preserveQuery = true } = props;
|
||||||
|
|
||||||
|
const router = useRouter();
|
||||||
|
const pathname = usePathname();
|
||||||
|
const searchParams = useSearchParams();
|
||||||
|
|
||||||
|
const queryString = useMemo(() => {
|
||||||
|
if (!preserveQuery) return "";
|
||||||
|
const q = searchParams?.toString();
|
||||||
|
return q ? `?${q}` : "";
|
||||||
|
}, [searchParams, preserveQuery]);
|
||||||
|
|
||||||
|
function go(platform: string) {
|
||||||
|
// We always navigate to canonical to keep it simple & consistent
|
||||||
|
router.push(`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}${queryString}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-xs uppercase tracking-[0.14em] text-zinc-500">Platform</span>
|
||||||
|
<select
|
||||||
|
value={currentPlatform}
|
||||||
|
onChange={(e) => go(e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||||
|
>
|
||||||
|
<option value="AR-15">AR-15</option>
|
||||||
|
<option value="AR-10">AR-10</option>
|
||||||
|
<option value="AR-9">AR-9</option>
|
||||||
|
<option value="AK-47">AK-47</option>
|
||||||
|
</select>
|
||||||
|
|
||||||
|
<span className="ml-2 text-[0.7rem] text-zinc-500">
|
||||||
|
{pathname}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,204 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import PlatformSwitcher from "./PlatformSwitcher";
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
type ProductDetailDto = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
categoryKey: string | null;
|
||||||
|
price: number | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
imageUrl: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
function parseIdFromProductSlug(productSlug: string): string | null {
|
||||||
|
// Expected: "1217-some-slug"
|
||||||
|
const m = /^(\d+)(?:-|$)/.exec(productSlug ?? "");
|
||||||
|
return m?.[1] ?? null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Attempts to fetch a product detail.
|
||||||
|
* If you don't have a dedicated endpoint yet, it falls back to list + filter by id.
|
||||||
|
*/
|
||||||
|
async function fetchProductDetail(params: {
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
productSlug: string;
|
||||||
|
}): Promise<ProductDetailDto> {
|
||||||
|
const { platform, partRole, productSlug } = params;
|
||||||
|
const id = parseIdFromProductSlug(productSlug);
|
||||||
|
|
||||||
|
if (!id) throw new Error(`Invalid product URL slug: '${productSlug}' (could not parse id)`);
|
||||||
|
|
||||||
|
// ---- Preferred (if you add it): GET /api/products/{id}
|
||||||
|
// If it 404s, we fall back.
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/products/${encodeURIComponent(id)}`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (res.ok) {
|
||||||
|
return await res.json();
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// ignore and fall back
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Fallback: use list endpoint + find by id (works right now with your current API)
|
||||||
|
const listRes = await fetch(
|
||||||
|
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}&partRoles=${encodeURIComponent(partRole)}`,
|
||||||
|
{ headers: { Accept: "application/json" } }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!listRes.ok) {
|
||||||
|
const txt = await listRes.text().catch(() => "");
|
||||||
|
throw new Error(`Failed to load product (fallback) (${listRes.status}): ${txt}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const list: ProductDetailDto[] = await listRes.json();
|
||||||
|
const found = list.find((p) => String(p.id) === String(id));
|
||||||
|
|
||||||
|
if (!found) {
|
||||||
|
throw new Error(`Product id=${id} not found in ${platform}/${partRole} list`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return found;
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function ProductDetailPageClient(props: {
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
productSlug: string;
|
||||||
|
}) {
|
||||||
|
const { platform, partRole, productSlug } = props;
|
||||||
|
|
||||||
|
const [p, setP] = useState<ProductDetailDto | null>(null);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
const id = useMemo(() => parseIdFromProductSlug(productSlug), [productSlug]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const data = await fetchProductDetail({ platform, partRole, productSlug });
|
||||||
|
|
||||||
|
if (!cancelled) setP(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message ?? "Failed to load product");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
load();
|
||||||
|
return () => { cancelled = true; };
|
||||||
|
}, [platform, partRole, productSlug]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-5xl px-4 py-6 lg:py-10">
|
||||||
|
<header className="mb-6 flex flex-col gap-3">
|
||||||
|
<div className="flex items-center justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||||
|
Part: <span className="text-amber-300">{partRole}</span>
|
||||||
|
</h1>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<PlatformSwitcher currentPlatform={platform} partRole={partRole} />
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2 text-xs text-zinc-500">
|
||||||
|
<Link className="hover:text-amber-200" href={`/parts/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`}>
|
||||||
|
← Back to list
|
||||||
|
</Link>
|
||||||
|
<span>•</span>
|
||||||
|
<span>id: {id ?? "—"}</span>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{loading && <p className="text-sm text-zinc-500">Loading product…</p>}
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && !error && p && (
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 md:p-6">
|
||||||
|
<div className="flex flex-col gap-6 md:flex-row">
|
||||||
|
<div className="w-full md:w-72">
|
||||||
|
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-800 bg-black/30">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
{p.imageUrl ? (
|
||||||
|
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
|
||||||
|
) : (
|
||||||
|
<div className="h-full w-full grid place-items-center text-sm text-zinc-600">
|
||||||
|
No image
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex-1">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
{p.brand} • {p.platform}
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-2 text-xl md:text-2xl font-semibold text-zinc-100">
|
||||||
|
{p.name}
|
||||||
|
</h2>
|
||||||
|
|
||||||
|
<div className="mt-4 grid grid-cols-1 gap-3 text-sm">
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">Category</p>
|
||||||
|
<p className="mt-1 text-zinc-200">{p.categoryKey ?? "—"}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
|
||||||
|
<p className="text-xs uppercase tracking-[0.14em] text-zinc-500">Best price</p>
|
||||||
|
<p className="mt-1 text-zinc-200">
|
||||||
|
{typeof p.price === "number" ? `$${p.price.toFixed(2)}` : "—"}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-5 flex items-center gap-3">
|
||||||
|
{p.buyUrl ? (
|
||||||
|
<a
|
||||||
|
href={p.buyUrl}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
||||||
|
>
|
||||||
|
Buy / View offer
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<span className="text-sm text-zinc-500">No buy link</span>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,77 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||||
|
|
||||||
|
export default function SortBar(props: {
|
||||||
|
visibleRange: { start: number; end: number };
|
||||||
|
totalCount: number;
|
||||||
|
|
||||||
|
searchQuery: string;
|
||||||
|
setSearchQuery: (v: string) => void;
|
||||||
|
|
||||||
|
sortBy: SortOption;
|
||||||
|
setSortBy: (v: SortOption) => void;
|
||||||
|
}) {
|
||||||
|
const { visibleRange, totalCount, searchQuery, setSearchQuery, sortBy, setSortBy } = props;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<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">
|
||||||
|
{totalCount}
|
||||||
|
</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..."
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import dynamic from "next/dynamic";
|
||||||
|
import type React from "react";
|
||||||
|
|
||||||
|
import "react-quill/dist/quill.snow.css";
|
||||||
|
|
||||||
|
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
|
||||||
|
|
||||||
|
type RichTextEditorProps = {
|
||||||
|
value: string;
|
||||||
|
onChange: (html: string) => void;
|
||||||
|
placeholder?: string;
|
||||||
|
className?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function RichTextEditor({
|
||||||
|
value,
|
||||||
|
onChange,
|
||||||
|
placeholder,
|
||||||
|
className,
|
||||||
|
}: RichTextEditorProps) {
|
||||||
|
return (
|
||||||
|
<div className={className}>
|
||||||
|
<div className="bb-quill">
|
||||||
|
<ReactQuill
|
||||||
|
theme="snow"
|
||||||
|
value={value}
|
||||||
|
onChange={onChange}
|
||||||
|
placeholder={placeholder}
|
||||||
|
modules={{
|
||||||
|
toolbar: [
|
||||||
|
[{ header: [1, 2, 3, false] }],
|
||||||
|
["bold", "italic", "underline", "strike"],
|
||||||
|
[{ color: [] }, { background: [] }],
|
||||||
|
[{ list: "ordered" }, { list: "bullet" }],
|
||||||
|
["blockquote", "code-block"],
|
||||||
|
["link"],
|
||||||
|
["clean"],
|
||||||
|
],
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
// components/ui/form.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import * as React from "react";
|
||||||
|
import { cn } from "@/lib/cn";
|
||||||
|
|
||||||
|
export const labelClass = "text-xs font-medium text-zinc-400";
|
||||||
|
|
||||||
|
export const inputBaseClass =
|
||||||
|
"w-full rounded-md border border-zinc-800 bg-zinc-900/70 px-3 py-2 text-sm text-zinc-50 " +
|
||||||
|
"placeholder:text-zinc-500 focus:outline-none focus:ring-1 focus:ring-amber-400";
|
||||||
|
|
||||||
|
export const buttonBaseClass =
|
||||||
|
"w-full rounded-md border border-amber-400/70 bg-amber-400/20 px-3 py-2 text-sm font-semibold text-amber-100 " +
|
||||||
|
"hover:bg-amber-400/30 disabled:cursor-not-allowed disabled:opacity-50 transition-colors";
|
||||||
|
|
||||||
|
export const Input = React.forwardRef<
|
||||||
|
HTMLInputElement,
|
||||||
|
React.InputHTMLAttributes<HTMLInputElement>
|
||||||
|
>(function Input({ className, ...props }, ref) {
|
||||||
|
return <input ref={ref} className={cn(inputBaseClass, className)} {...props} />;
|
||||||
|
});
|
||||||
|
|
||||||
|
export const Textarea = React.forwardRef<
|
||||||
|
HTMLTextAreaElement,
|
||||||
|
React.TextareaHTMLAttributes<HTMLTextAreaElement>
|
||||||
|
>(function Textarea({ className, ...props }, ref) {
|
||||||
|
return (
|
||||||
|
<textarea ref={ref} className={cn(inputBaseClass, className)} {...props} />
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
export function Button({
|
||||||
|
className,
|
||||||
|
...props
|
||||||
|
}: React.ButtonHTMLAttributes<HTMLButtonElement>) {
|
||||||
|
return <button className={cn(buttonBaseClass, className)} {...props} />;
|
||||||
|
}
|
||||||
|
|
||||||
|
type FieldProps = {
|
||||||
|
label: string;
|
||||||
|
htmlFor: string;
|
||||||
|
hint?: React.ReactNode;
|
||||||
|
error?: React.ReactNode;
|
||||||
|
className?: string;
|
||||||
|
children: React.ReactNode;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function Field({
|
||||||
|
label,
|
||||||
|
htmlFor,
|
||||||
|
hint,
|
||||||
|
error,
|
||||||
|
className,
|
||||||
|
children,
|
||||||
|
}: FieldProps) {
|
||||||
|
return (
|
||||||
|
<div className={cn("space-y-1", className)}>
|
||||||
|
<label className={labelClass} htmlFor={htmlFor}>
|
||||||
|
{label}
|
||||||
|
</label>
|
||||||
|
|
||||||
|
{children}
|
||||||
|
|
||||||
|
{error ? (
|
||||||
|
<p className="text-xs text-red-300">{error}</p>
|
||||||
|
) : hint ? (
|
||||||
|
<p className="text-xs text-zinc-500">{hint}</p>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useRef, useState } from "react";
|
||||||
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
|
||||||
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
import RichTextEditor from "@/components/ui/RichTextEditor";
|
||||||
|
|
||||||
|
export default function SendEmailForm(): JSX.Element {
|
||||||
|
const [result, setResult] = useState<
|
||||||
|
ApiResponse<EmailRequest> | { error: string } | null
|
||||||
|
>(null);
|
||||||
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
|
const [showSuccess, setShowSuccess] = useState(false);
|
||||||
|
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
|
||||||
|
const [bodyHtml, setBodyHtml] = useState<string>("");
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
return () => {
|
||||||
|
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||||
|
e.preventDefault();
|
||||||
|
setResult(null);
|
||||||
|
|
||||||
|
setShowSuccess(false);
|
||||||
|
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||||
|
|
||||||
|
const form = new FormData(e.currentTarget);
|
||||||
|
|
||||||
|
const recipient = String(form.get("recipient") ?? "");
|
||||||
|
const subject = String(form.get("subject") ?? "");
|
||||||
|
const body = String(form.get("body") ?? ""); // HTML from hidden input
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSubmitting(true);
|
||||||
|
const data = await sendEmailAction({ recipient, subject, body });
|
||||||
|
setResult(data);
|
||||||
|
|
||||||
|
if (data?.success === true) {
|
||||||
|
setShowSuccess(true);
|
||||||
|
|
||||||
|
successTimerRef.current = setTimeout(() => {
|
||||||
|
setShowSuccess(false);
|
||||||
|
setResult(null);
|
||||||
|
}, 2000);
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
const message = err instanceof Error ? err.message : "Unknown error";
|
||||||
|
setResult({ error: message });
|
||||||
|
} finally {
|
||||||
|
setSubmitting(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Send <span className="text-amber-300">Email</span>
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
|
Compose a message and send it from the admin panel.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
||||||
|
{result && "error" in result && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{result.error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Recipient" htmlFor="recipient">
|
||||||
|
<Input
|
||||||
|
id="recipient"
|
||||||
|
name="recipient"
|
||||||
|
type="email"
|
||||||
|
placeholder="user@example.com"
|
||||||
|
defaultValue="user@example.com"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Subject" htmlFor="subject">
|
||||||
|
<Input
|
||||||
|
id="subject"
|
||||||
|
name="subject"
|
||||||
|
placeholder="Subject…"
|
||||||
|
defaultValue="Test subject"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field
|
||||||
|
label="Body"
|
||||||
|
htmlFor="body"
|
||||||
|
hint="This will be sent as HTML."
|
||||||
|
>
|
||||||
|
{/* Hidden field so your existing FormData submit keeps working */}
|
||||||
|
<input type="hidden" id="body" name="body" value={bodyHtml} />
|
||||||
|
|
||||||
|
<RichTextEditor
|
||||||
|
value={bodyHtml}
|
||||||
|
onChange={setBodyHtml}
|
||||||
|
placeholder="Write your message…"
|
||||||
|
className="rounded-md border border-zinc-800 bg-zinc-950/60"
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={submitting}>
|
||||||
|
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
|
||||||
|
</Button>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
|
||||||
|
<div className="text-xs font-medium text-zinc-400">Response</div>
|
||||||
|
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
|
||||||
|
{result ? JSON.stringify(result, null, 2) : "No response yet."}
|
||||||
|
</pre>
|
||||||
|
</div>
|
||||||
|
</form>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -85,7 +85,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
async ({ email, password }: { email: string; password: string }) => {
|
async ({ email, password }: { email: string; password: string }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/auth/login`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password }),
|
body: JSON.stringify({ email, password }),
|
||||||
@@ -130,7 +130,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
}) => {
|
}) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/auth/register`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password, displayName }),
|
body: JSON.stringify({ email, password, displayName }),
|
||||||
|
|||||||
+44
-145
@@ -1,163 +1,62 @@
|
|||||||
import type { Category, Part } from "@/types/gunbuilder";
|
import type { Category } from "@/types/gunbuilder";
|
||||||
|
import { CATEGORY_SLUGS } from "@/types/gunbuilder";
|
||||||
|
|
||||||
export const CATEGORIES: Category[] = [
|
export const CATEGORIES: Category[] = [
|
||||||
// LOWER
|
// Lower group
|
||||||
{
|
{
|
||||||
id: "lower",
|
id: "lower-receiver",
|
||||||
name: "Stripped Lowers",
|
name: "Stripped Lower",
|
||||||
description: "Serialized lower – the foundation of the build.",
|
|
||||||
group: "lower",
|
group: "lower",
|
||||||
|
slug: CATEGORY_SLUGS["lower-receiver"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "completeLower",
|
id: "complete-lower",
|
||||||
name: "Complete Lowers",
|
name: "Complete Lower",
|
||||||
description: "Factory-built lower with stock, buffer system, and controls.",
|
|
||||||
group: "lower",
|
group: "lower",
|
||||||
|
slug: CATEGORY_SLUGS["complete-lower"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "lowerParts",
|
id: "lower-parts",
|
||||||
name: "Lower Parts Kits",
|
name: "Lower Parts Kit",
|
||||||
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",
|
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
|
// Upper group
|
||||||
{
|
{
|
||||||
id: "upper",
|
id: "upper-receiver",
|
||||||
name: "Stripped Uppers",
|
name: "Stripped Upper",
|
||||||
description: "The core of the top half of your build.",
|
|
||||||
group: "upper",
|
group: "upper",
|
||||||
|
slug: CATEGORY_SLUGS["upper-receiver"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: "completeUpper",
|
id: "complete-upper",
|
||||||
name: "Complete Uppers",
|
name: "Complete Upper",
|
||||||
description: "Pre-assembled uppers ready to pin and shoot.",
|
|
||||||
group: "upper",
|
group: "upper",
|
||||||
|
slug: CATEGORY_SLUGS["complete-upper"],
|
||||||
},
|
},
|
||||||
{
|
{ id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel },
|
||||||
id: "bcg",
|
{ id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
|
||||||
name: "Bolt Carriers",
|
{ id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] },
|
||||||
description: "The heartbeat of the rifle’s cycling.",
|
{ id: "gas-tube", name: "Gas Tube", group: "upper", slug: CATEGORY_SLUGS["gas-tube"] },
|
||||||
group: "upper",
|
{ 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: "barrel",
|
{ id: "charging-handle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS["charging-handle"] },
|
||||||
name: "Barrels",
|
{ id: "suppressor", name: "Suppressors", group: "upper", slug: CATEGORY_SLUGS.suppressor },
|
||||||
description: "Different lengths, profiles, and calibers.",
|
{ id: "optic", name: "Optics", group: "upper", slug: CATEGORY_SLUGS.optic },
|
||||||
group: "upper",
|
|
||||||
},
|
// Accessories (platform-agnostic)
|
||||||
{
|
{ id: "magazine", name: "Magazines", group: "accessories", slug: CATEGORY_SLUGS.magazine },
|
||||||
id: "gasBlock",
|
{ id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS["weapon-light"] },
|
||||||
name: "Gas Blocks",
|
{ id: "foregrip", name: "Vertical / Angled Grips", group: "accessories", slug: CATEGORY_SLUGS.foregrip },
|
||||||
description: "Standard and adjustable gas blocks.",
|
{ id: "bipod", name: "Bipods", group: "accessories", slug: CATEGORY_SLUGS.bipod },
|
||||||
group: "upper",
|
{ 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 },
|
||||||
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",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
@@ -1,102 +0,0 @@
|
|||||||
import type { CategoryId } from "@/types/gunbuilder";
|
|
||||||
|
|
||||||
// CategoryId -> partRoles
|
|
||||||
export const CATEGORY_TO_PART_ROLES: Record<CategoryId, string[]> = {
|
|
||||||
// CORE / EXISTING
|
|
||||||
upper: ["upper-receiver"],
|
|
||||||
barrel: ["barrel"],
|
|
||||||
handguard: ["handguard"],
|
|
||||||
chargingHandle: ["charging-handle"],
|
|
||||||
buffer: ["buffer-kit"],
|
|
||||||
lowerParts: ["lower-parts-kit"],
|
|
||||||
sights: ["sight"],
|
|
||||||
lower: ["LOWER_RECEIVER_STRIPPED"],
|
|
||||||
optic: ["optic"],
|
|
||||||
stock: ["stock"],
|
|
||||||
|
|
||||||
// NEW LOWER PARTS
|
|
||||||
trigger: ["trigger", "trigger-kit"],
|
|
||||||
grip: ["pistol-grip", "grip"],
|
|
||||||
safety: ["safety", "safety-selector"],
|
|
||||||
completeLower: ["complete-lower"],
|
|
||||||
|
|
||||||
// NEW UPPER PARTS
|
|
||||||
completeUpper: ["complete-upper"],
|
|
||||||
bcg: ["bcg", "bolt-carrier-group"],
|
|
||||||
gasBlock: ["gas-block"],
|
|
||||||
gasTube: ["gas-tube"],
|
|
||||||
muzzleDevice: ["muzzle-device", "compensator", "brake"],
|
|
||||||
suppressor: ["suppressor"],
|
|
||||||
|
|
||||||
// ===== ACCESSORIES =====
|
|
||||||
|
|
||||||
// Magazines
|
|
||||||
magazine: [
|
|
||||||
"magazine",
|
|
||||||
"mag",
|
|
||||||
"magazine-ar15",
|
|
||||||
"magazine-308",
|
|
||||||
"drum-magazine",
|
|
||||||
],
|
|
||||||
|
|
||||||
// Lights / Lasers
|
|
||||||
weaponLight: [
|
|
||||||
"weapon-light",
|
|
||||||
"light",
|
|
||||||
"weapon-light-laser",
|
|
||||||
"light-laser-combo",
|
|
||||||
"laser",
|
|
||||||
],
|
|
||||||
|
|
||||||
// Foregrips
|
|
||||||
foregrip: [
|
|
||||||
"vertical-grip",
|
|
||||||
"angled-foregrip",
|
|
||||||
"foregrip",
|
|
||||||
"handstop",
|
|
||||||
],
|
|
||||||
|
|
||||||
// Bipods
|
|
||||||
bipod: [
|
|
||||||
"bipod",
|
|
||||||
],
|
|
||||||
|
|
||||||
// Slings & mounts
|
|
||||||
sling: [
|
|
||||||
"sling",
|
|
||||||
"sling-mount",
|
|
||||||
"sling-swivel",
|
|
||||||
"qd-sling-mount",
|
|
||||||
],
|
|
||||||
|
|
||||||
// Rail sections & covers
|
|
||||||
railAccessory: [
|
|
||||||
"rail-section",
|
|
||||||
"picatinny-rail-section",
|
|
||||||
"m-lok-rail-section",
|
|
||||||
"keymod-rail-section",
|
|
||||||
"rail-cover",
|
|
||||||
"rail-panel",
|
|
||||||
],
|
|
||||||
|
|
||||||
// Tools & maintenance
|
|
||||||
tools: [
|
|
||||||
"tool",
|
|
||||||
"armorer-tool",
|
|
||||||
"armorer-wrench",
|
|
||||||
"cleaning-kit",
|
|
||||||
"bore-snake",
|
|
||||||
"vise-block",
|
|
||||||
"torque-wrench",
|
|
||||||
],
|
|
||||||
};
|
|
||||||
|
|
||||||
// Invert to get partRole -> CategoryId
|
|
||||||
export const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = Object.entries(
|
|
||||||
CATEGORY_TO_PART_ROLES,
|
|
||||||
).reduce((acc, [categoryId, roles]) => {
|
|
||||||
for (const role of roles) {
|
|
||||||
acc[role] = categoryId as CategoryId;
|
|
||||||
}
|
|
||||||
return acc;
|
|
||||||
}, {} as Record<string, CategoryId>);
|
|
||||||
@@ -0,0 +1,5 @@
|
|||||||
|
export function formatDate(value: unknown) {
|
||||||
|
if (!value) return "—";
|
||||||
|
const d = new Date(value as any);
|
||||||
|
return Number.isNaN(d.getTime()) ? "—" : d.toLocaleString();
|
||||||
|
}
|
||||||
@@ -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}`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,103 @@
|
|||||||
|
// lib/api/platforms.ts
|
||||||
|
export type Platform = {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
isActive: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type CreatePlatformDto = {
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
isActive?: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
|
export type UpdatePlatformDto = Partial<CreatePlatformDto>;
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
function normalizePlatform(raw: any): Platform {
|
||||||
|
const id = Number(raw?.id);
|
||||||
|
const key = String(raw?.key ?? "").trim();
|
||||||
|
const label = String(raw?.label ?? "").trim();
|
||||||
|
|
||||||
|
const activeRaw = raw?.isActive ?? raw?.is_active ?? true;
|
||||||
|
const isActive =
|
||||||
|
typeof activeRaw === "boolean"
|
||||||
|
? activeRaw
|
||||||
|
: typeof activeRaw === "number"
|
||||||
|
? activeRaw === 1
|
||||||
|
: String(activeRaw).toLowerCase() === "true";
|
||||||
|
|
||||||
|
return { id, key, label, isActive };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchPlatforms(tokenHeaders: HeadersInit): Promise<Platform[]> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/platforms`, {
|
||||||
|
headers: { ...tokenHeaders },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(`Failed to load platforms (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as unknown;
|
||||||
|
const items = Array.isArray(data) ? data : [];
|
||||||
|
return items.map(normalizePlatform).filter((p) => Number.isFinite(p.id) && p.key && p.label);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function createPlatform(
|
||||||
|
tokenHeaders: HeadersInit,
|
||||||
|
dto: CreatePlatformDto
|
||||||
|
): Promise<Platform> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/platforms`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...tokenHeaders,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(dto),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(text || `Failed to create platform (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizePlatform(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updatePlatform(
|
||||||
|
tokenHeaders: HeadersInit,
|
||||||
|
id: number,
|
||||||
|
dto: UpdatePlatformDto
|
||||||
|
): Promise<Platform> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...tokenHeaders,
|
||||||
|
},
|
||||||
|
body: JSON.stringify(dto),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(text || `Failed to update platform (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return normalizePlatform(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deletePlatform(tokenHeaders: HeadersInit, id: number): Promise<void> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers: { ...tokenHeaders },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(text || `Failed to delete platform (${res.status})`);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
// /lib/buildOverlaps.ts
|
||||||
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
|
||||||
|
export type OverlapChipModel = {
|
||||||
|
key: string;
|
||||||
|
tone: "warning" | "info";
|
||||||
|
label: string;
|
||||||
|
detail?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
export const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper";
|
||||||
|
export const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower";
|
||||||
|
|
||||||
|
export const UPPER_OVERLAP_CATEGORIES = new Set<CategoryId>([
|
||||||
|
"upper-receiver",
|
||||||
|
"bcg",
|
||||||
|
"barrel",
|
||||||
|
"gas-block",
|
||||||
|
"gas-tube",
|
||||||
|
"muzzle-device",
|
||||||
|
"suppressor",
|
||||||
|
"handguard",
|
||||||
|
"charging-handle",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const LOWER_OVERLAP_CATEGORIES = new Set<CategoryId>([
|
||||||
|
"lower-receiver",
|
||||||
|
"lower-parts",
|
||||||
|
"trigger",
|
||||||
|
"grip",
|
||||||
|
"safety",
|
||||||
|
"buffer",
|
||||||
|
"stock",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export type BuildState = Partial<Record<CategoryId, string>>;
|
||||||
|
|
||||||
|
function selectedCount(build: BuildState, set: Set<CategoryId>) {
|
||||||
|
let count = 0;
|
||||||
|
for (const cid of set) if (build[cid]) count++;
|
||||||
|
return count;
|
||||||
|
}
|
||||||
|
|
||||||
|
function selectedList(build: BuildState, set: Set<CategoryId>) {
|
||||||
|
const list: CategoryId[] = [];
|
||||||
|
for (const cid of set) if (build[cid]) list.push(cid);
|
||||||
|
return list;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Chips to show on a given category row (education / “you may not need both”)
|
||||||
|
*/
|
||||||
|
export function getOverlapChipsForCategory(
|
||||||
|
categoryId: CategoryId,
|
||||||
|
build: BuildState
|
||||||
|
): OverlapChipModel[] {
|
||||||
|
const chips: OverlapChipModel[] = [];
|
||||||
|
|
||||||
|
const hasCompleteUpper = !!build[COMPLETE_UPPER_CATEGORY];
|
||||||
|
const hasCompleteLower = !!build[COMPLETE_LOWER_CATEGORY];
|
||||||
|
|
||||||
|
// ---- Upper overlap ----
|
||||||
|
if (categoryId === COMPLETE_UPPER_CATEGORY) {
|
||||||
|
const count = selectedCount(build, UPPER_OVERLAP_CATEGORIES);
|
||||||
|
if (count > 0) {
|
||||||
|
chips.push({
|
||||||
|
key: "upper-complete-overlaps-subparts",
|
||||||
|
tone: "warning",
|
||||||
|
label: "Overlaps selected upper parts",
|
||||||
|
detail: `${count} upper part${count === 1 ? "" : "s"} also selected`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UPPER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteUpper) {
|
||||||
|
chips.push({
|
||||||
|
key: "upper-subpart-in-complete",
|
||||||
|
tone: "info",
|
||||||
|
label: "Included in Complete Upper",
|
||||||
|
detail: "You have both selected (compare if you want)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- Lower overlap ----
|
||||||
|
if (categoryId === COMPLETE_LOWER_CATEGORY) {
|
||||||
|
const count = selectedCount(build, LOWER_OVERLAP_CATEGORIES);
|
||||||
|
if (count > 0) {
|
||||||
|
chips.push({
|
||||||
|
key: "lower-complete-overlaps-subparts",
|
||||||
|
tone: "warning",
|
||||||
|
label: "Overlaps selected lower parts",
|
||||||
|
detail: `${count} lower part${count === 1 ? "" : "s"} also selected`,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LOWER_OVERLAP_CATEGORIES.has(categoryId) && hasCompleteLower) {
|
||||||
|
chips.push({
|
||||||
|
key: "lower-subpart-in-complete",
|
||||||
|
tone: "info",
|
||||||
|
label: "Included in Complete Lower",
|
||||||
|
detail: "You have both selected (compare if you want)",
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
return chips;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Non-blocking “heads up” messages when selecting a part.
|
||||||
|
* (Use for toasts / shareStatus strip — no auto-removal)
|
||||||
|
*/
|
||||||
|
export function getSelectionHints(
|
||||||
|
nextCategoryId: CategoryId,
|
||||||
|
build: BuildState
|
||||||
|
): string[] {
|
||||||
|
const hints: string[] = [];
|
||||||
|
|
||||||
|
if (nextCategoryId === COMPLETE_UPPER_CATEGORY) {
|
||||||
|
const overlaps = selectedList(build, UPPER_OVERLAP_CATEGORIES);
|
||||||
|
if (overlaps.length > 0) {
|
||||||
|
hints.push(
|
||||||
|
"Heads up: Complete Upper overlaps with some selected upper parts. Nothing was removed — compare options and keep what you want."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (UPPER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_UPPER_CATEGORY]) {
|
||||||
|
hints.push(
|
||||||
|
"Heads up: You also have a Complete Upper selected. Nothing was removed — compare options and keep what you want."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextCategoryId === COMPLETE_LOWER_CATEGORY) {
|
||||||
|
const overlaps = selectedList(build, LOWER_OVERLAP_CATEGORIES);
|
||||||
|
if (overlaps.length > 0) {
|
||||||
|
hints.push(
|
||||||
|
"Heads up: Complete Lower overlaps with some selected lower parts. Nothing was removed — compare options and keep what you want."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
if (LOWER_OVERLAP_CATEGORIES.has(nextCategoryId) && build[COMPLETE_LOWER_CATEGORY]) {
|
||||||
|
hints.push(
|
||||||
|
"Heads up: You also have a Complete Lower selected. Nothing was removed — compare options and keep what you want."
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return hints;
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
// app/lib/catalog.ts
|
||||||
|
export const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
export type ProductListItem = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
categoryKey?: string | null;
|
||||||
|
price?: number | null;
|
||||||
|
buyUrl?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
slug?: string | null; // if your API returns it; otherwise we derive it client-side
|
||||||
|
};
|
||||||
|
|
||||||
|
export type ProductDetail = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
rawCategoryKey?: string | null;
|
||||||
|
description?: string | null;
|
||||||
|
shortDescription?: string | null;
|
||||||
|
imageUrl?: string | null;
|
||||||
|
offers?: Array<{
|
||||||
|
merchantName?: string;
|
||||||
|
price?: number | null;
|
||||||
|
buyUrl?: string | null;
|
||||||
|
inStock?: boolean | null;
|
||||||
|
}>;
|
||||||
|
};
|
||||||
|
|
||||||
|
export function slugify(input: string) {
|
||||||
|
return (input ?? "")
|
||||||
|
.toLowerCase()
|
||||||
|
.replace(/[^a-z0-9]+/g, "-")
|
||||||
|
.replace(/(^-|-$)/g, "");
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Your existing list endpoint:
|
||||||
|
* GET /api/products?platform=AR-15&partRoles=complete-upper
|
||||||
|
*/
|
||||||
|
export async function fetchProducts(params: {
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
}) {
|
||||||
|
const { platform, partRole } = params;
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`${API_BASE_URL}/api/products?` +
|
||||||
|
new URLSearchParams({
|
||||||
|
platform,
|
||||||
|
partRoles: partRole,
|
||||||
|
});
|
||||||
|
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
||||||
|
const data = (await res.json()) as ProductListItem[];
|
||||||
|
|
||||||
|
// Ensure each item has a "productSlug" of the form "{id}-{slugified-name}"
|
||||||
|
return data.map((p) => ({
|
||||||
|
...p,
|
||||||
|
productSlug: `${p.id}-${slugify(p.name)}`,
|
||||||
|
}));
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* You likely have (or should add) a detail endpoint like:
|
||||||
|
* GET /api/products/{id}?platform=AR-15 OR GET /api/products/{id}
|
||||||
|
*
|
||||||
|
* We'll call /api/products/{id} and optionally include platform as a query param.
|
||||||
|
*/
|
||||||
|
export async function fetchProductById(params: {
|
||||||
|
id: string;
|
||||||
|
platform?: string;
|
||||||
|
}) {
|
||||||
|
const { id, platform } = params;
|
||||||
|
|
||||||
|
const url =
|
||||||
|
`${API_BASE_URL}/api/products/${encodeURIComponent(id)}` +
|
||||||
|
(platform ? `?${new URLSearchParams({ platform })}` : "");
|
||||||
|
|
||||||
|
const res = await fetch(url, { cache: "no-store" });
|
||||||
|
if (!res.ok) throw new Error(`Failed to load product (${res.status})`);
|
||||||
|
return (await res.json()) as ProductDetail;
|
||||||
|
}
|
||||||
@@ -0,0 +1,100 @@
|
|||||||
|
import type { CategoryId } from "@/types/gunbuilder";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Normalize backend part roles into a canonical kebab-case form.
|
||||||
|
* - trims whitespace
|
||||||
|
* - lowercases
|
||||||
|
* - converts snake_case / ENUM_CASE to kebab-case
|
||||||
|
*/
|
||||||
|
export const normalizePartRole = (role: string) =>
|
||||||
|
role.trim().toLowerCase().replace(/_/g, "-");
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Maps canonical kebab-case CategoryIds to their associated part roles from the backend.
|
||||||
|
*
|
||||||
|
* Notes:
|
||||||
|
* - Keep the list here as the *source of truth*.
|
||||||
|
* - The derived `PART_ROLE_TO_CATEGORY` map below stores both the original and normalized keys.
|
||||||
|
*/
|
||||||
|
export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
|
||||||
|
// ===== UPPER =====
|
||||||
|
"upper-receiver": ["upper-receiver"],
|
||||||
|
// (optional) Back-compat if anything still uses the old ids:
|
||||||
|
// upper: ["upper-receiver", "upper"],
|
||||||
|
|
||||||
|
"complete-upper": ["complete-upper"],
|
||||||
|
barrel: ["barrel"],
|
||||||
|
"gas-block": ["gas-block"],
|
||||||
|
"gas-tube": ["gas-tube"],
|
||||||
|
"muzzle-device": ["muzzle-device", "compensator", "brake"],
|
||||||
|
suppressor: ["suppressor"],
|
||||||
|
handguard: ["handguard"],
|
||||||
|
"charging-handle": ["charging-handle"],
|
||||||
|
bcg: ["bcg", "bolt-carrier-group"],
|
||||||
|
|
||||||
|
// ===== LOWER =====
|
||||||
|
"lower-receiver": ["lower-receiver", "lower", "LOWER_RECEIVER_STRIPPED"],
|
||||||
|
// (optional) Back-compat if anything still uses the old ids:
|
||||||
|
|
||||||
|
"complete-lower": ["complete-lower"],
|
||||||
|
"lower-parts": ["lower-parts-kit", "lower-parts"],
|
||||||
|
trigger: ["trigger", "trigger-kit"],
|
||||||
|
grip: ["pistol-grip", "grip"],
|
||||||
|
safety: ["safety", "safety-selector"],
|
||||||
|
buffer: ["buffer-kit", "buffer"],
|
||||||
|
stock: ["stock"],
|
||||||
|
|
||||||
|
// ===== OPTICS =====
|
||||||
|
sights: ["sight", "sights", "iron-sights"],
|
||||||
|
optic: ["optic", "optics"],
|
||||||
|
|
||||||
|
// ===== ACCESSORIES =====
|
||||||
|
magazine: ["magazine", "mag", "magazine-ar15", "magazine-308", "drum-magazine"],
|
||||||
|
"weapon-light": [
|
||||||
|
"weapon-light",
|
||||||
|
"light",
|
||||||
|
"weapon-light-laser",
|
||||||
|
"light-laser-combo",
|
||||||
|
"laser",
|
||||||
|
],
|
||||||
|
foregrip: ["vertical-grip", "angled-foregrip", "foregrip", "handstop"],
|
||||||
|
bipod: ["bipod"],
|
||||||
|
sling: ["sling", "sling-mount", "sling-swivel", "qd-sling-mount"],
|
||||||
|
"rail-accessory": [
|
||||||
|
"rail-section",
|
||||||
|
"picatinny-rail-section",
|
||||||
|
"m-lok-rail-section",
|
||||||
|
"keymod-rail-section",
|
||||||
|
"rail-cover",
|
||||||
|
"rail-panel",
|
||||||
|
],
|
||||||
|
tools: [
|
||||||
|
"tool",
|
||||||
|
"armorer-tool",
|
||||||
|
"armorer-wrench",
|
||||||
|
"cleaning-kit",
|
||||||
|
"bore-snake",
|
||||||
|
"vise-block",
|
||||||
|
"torque-wrench",
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reverse lookup: backend partRole -> CategoryId.
|
||||||
|
*
|
||||||
|
* Implementation details:
|
||||||
|
* - Stores BOTH the original role and its normalized version as keys.
|
||||||
|
* - De-dupes role lists per category.
|
||||||
|
*/
|
||||||
|
export const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = Object.entries(
|
||||||
|
CATEGORY_TO_PART_ROLES
|
||||||
|
).reduce((acc, [categoryId, roles]) => {
|
||||||
|
const unique = Array.from(new Set(roles ?? []));
|
||||||
|
|
||||||
|
for (const role of unique) {
|
||||||
|
acc[role] = categoryId as CategoryId;
|
||||||
|
acc[normalizePartRole(role)] = categoryId as CategoryId;
|
||||||
|
}
|
||||||
|
|
||||||
|
return acc;
|
||||||
|
}, {} as Record<string, CategoryId>);
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
// lib/cn.ts
|
||||||
|
import clsx, { type ClassValue } from "clsx";
|
||||||
|
|
||||||
|
export function cn(...inputs: ClassValue[]) {
|
||||||
|
return clsx(inputs);
|
||||||
|
}
|
||||||
@@ -0,0 +1,47 @@
|
|||||||
|
// middleware.ts
|
||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import type { NextRequest } from "next/server";
|
||||||
|
|
||||||
|
const LAUNCH_ONLY_ROOT = process.env.NEXT_PUBLIC_LAUNCH_ONLY_ROOT === "true";
|
||||||
|
|
||||||
|
// ✅ Forever allow list (always reachable even during launch-only mode)
|
||||||
|
const ALWAYS_ALLOW = new Set<string>([
|
||||||
|
"/",
|
||||||
|
"/favicon.ico",
|
||||||
|
"/robots.txt",
|
||||||
|
"/sitemap.xml",
|
||||||
|
]);
|
||||||
|
|
||||||
|
// Match common public/static file extensions so middleware never blocks them
|
||||||
|
const PUBLIC_FILE = /\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)$/i;
|
||||||
|
|
||||||
|
export function middleware(req: NextRequest) {
|
||||||
|
if (!LAUNCH_ONLY_ROOT) return NextResponse.next();
|
||||||
|
|
||||||
|
const { pathname } = req.nextUrl;
|
||||||
|
|
||||||
|
// ✅ Always allow Next internals + static assets (public/ and _next/)
|
||||||
|
if (
|
||||||
|
pathname.startsWith("/_next") ||
|
||||||
|
pathname.startsWith("/favicon") ||
|
||||||
|
pathname === "/robots.txt" ||
|
||||||
|
pathname === "/sitemap.xml" ||
|
||||||
|
PUBLIC_FILE.test(pathname)
|
||||||
|
) {
|
||||||
|
return NextResponse.next();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ✅ Allow the root + a few public files
|
||||||
|
if (ALWAYS_ALLOW.has(pathname)) return NextResponse.next();
|
||||||
|
|
||||||
|
// 🚫 Everything else: hide it
|
||||||
|
return NextResponse.rewrite(new URL("/404", req.url));
|
||||||
|
}
|
||||||
|
|
||||||
|
export const config = {
|
||||||
|
// Run middleware on everything *except* Next internals and obvious static files.
|
||||||
|
// This keeps launch gating from ever breaking your logo/images/fonts/etc.
|
||||||
|
matcher: [
|
||||||
|
"/((?!_next/static|_next/image|favicon.ico|robots.txt|sitemap.xml|.*\\.(?:svg|png|jpg|jpeg|gif|webp|ico|css|js|map|txt|xml|json|woff|woff2|ttf|eot)).*)",
|
||||||
|
],
|
||||||
|
};
|
||||||
Generated
+218
-35
@@ -11,8 +11,10 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
|
"quill": "^2.0.3",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"react-quill": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "20.12.7",
|
"@types/node": "20.12.7",
|
||||||
@@ -557,6 +559,21 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/quill": {
|
||||||
|
"version": "1.3.10",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/quill/-/quill-1.3.10.tgz",
|
||||||
|
"integrity": "sha512-IhW3fPW+bkt9MLNlycw8u8fWb7oO7W5URC9MfZYHBlA24rex9rs23D5DETChu1zvgVdc5ka64ICjJOgQMr6Shw==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"parchment": "^1.1.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/@types/quill/node_modules/parchment": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/@types/react": {
|
"node_modules/@types/react": {
|
||||||
"version": "18.3.3",
|
"version": "18.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/@types/react/-/react-18.3.3.tgz",
|
||||||
@@ -994,7 +1011,6 @@
|
|||||||
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
"integrity": "sha512-NZyJarBfL7nWwIq+FDL6Zp/yHEhePMNnnJ0y3qfieCrmNvYct8uvtiV41UvlSe6apAfk0fY1FbWx+NwfmpvtTg==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"acorn": "bin/acorn"
|
"acorn": "bin/acorn"
|
||||||
},
|
},
|
||||||
@@ -1435,7 +1451,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"baseline-browser-mapping": "^2.8.25",
|
"baseline-browser-mapping": "^2.8.25",
|
||||||
"caniuse-lite": "^1.0.30001754",
|
"caniuse-lite": "^1.0.30001754",
|
||||||
@@ -1465,7 +1480,6 @@
|
|||||||
"version": "1.0.8",
|
"version": "1.0.8",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind/-/call-bind-1.0.8.tgz",
|
||||||
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
"integrity": "sha512-oKlSFMcMwpUg2ednkhQ454wfWiU/ul3CkJe/PEHcTKuiX6RpbehUiFMXu13HalGZxfUwCQzZG747YXBn1im9ww==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.0",
|
"call-bind-apply-helpers": "^1.0.0",
|
||||||
@@ -1484,7 +1498,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/call-bind-apply-helpers/-/call-bind-apply-helpers-1.0.2.tgz",
|
||||||
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
"integrity": "sha512-Sp1ablJ0ivDkSzjcaJdxEunN5/XvksFJ2sMBFfq6x0ryhQV/2b/KwFe21cMpmHtPOSij8K99/wSfoEuTObmuMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0",
|
"es-errors": "^1.3.0",
|
||||||
@@ -1498,7 +1511,6 @@
|
|||||||
"version": "1.0.4",
|
"version": "1.0.4",
|
||||||
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
"resolved": "https://registry.npmjs.org/call-bound/-/call-bound-1.0.4.tgz",
|
||||||
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
"integrity": "sha512-+ys997U96po4Kx/ABpBCqhA9EuxJaQWDQg7295H4hBphv3IZg0boBKuwYpt4YXp6MZ5AmZQnU/tyMTlRpaSejg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -1612,6 +1624,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clone": {
|
||||||
|
"version": "2.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/clone/-/clone-2.1.2.tgz",
|
||||||
|
"integrity": "sha512-3Pe/CF1Nn94hyhIYpjtiLhdCoEoz0DqQ+988E9gmeEdQZlojxnOb74wctFyuwWQHzqyf9X7C7MG8juUpqBJT8w==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.8"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/clsx": {
|
"node_modules/clsx": {
|
||||||
"version": "2.1.1",
|
"version": "2.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
@@ -1772,6 +1793,26 @@
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/deep-equal": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/deep-equal/-/deep-equal-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-5tdhKF6DbU7iIzrIOa1AOUt39ZRm13cmL1cGEh//aqR8x9+tNfbywRf0n5FD/18OKMdo7DNEtrX2t22ZAkI+eg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"is-arguments": "^1.1.1",
|
||||||
|
"is-date-object": "^1.0.5",
|
||||||
|
"is-regex": "^1.1.4",
|
||||||
|
"object-is": "^1.1.5",
|
||||||
|
"object-keys": "^1.1.1",
|
||||||
|
"regexp.prototype.flags": "^1.5.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/deep-is": {
|
"node_modules/deep-is": {
|
||||||
"version": "0.1.4",
|
"version": "0.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/deep-is/-/deep-is-0.1.4.tgz",
|
||||||
@@ -1783,7 +1824,6 @@
|
|||||||
"version": "1.1.4",
|
"version": "1.1.4",
|
||||||
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
"resolved": "https://registry.npmjs.org/define-data-property/-/define-data-property-1.1.4.tgz",
|
||||||
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
"integrity": "sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0",
|
"es-define-property": "^1.0.0",
|
||||||
@@ -1801,7 +1841,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/define-properties/-/define-properties-1.2.1.tgz",
|
||||||
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
"integrity": "sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"define-data-property": "^1.0.1",
|
"define-data-property": "^1.0.1",
|
||||||
@@ -1859,7 +1898,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/dunder-proto/-/dunder-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
"integrity": "sha512-KIN/nDJBQRcXw0MLVhZE9iQHmG68qAVIBg9CqmUYjmQIhgij9U5MFvrqkUL5FbtyyzZuOeOt0zdeRe4UY7ct+A==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.1",
|
"call-bind-apply-helpers": "^1.0.1",
|
||||||
@@ -1964,7 +2002,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-define-property/-/es-define-property-1.0.1.tgz",
|
||||||
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
"integrity": "sha512-e3nRfgfUZ4rNGL232gUgX06QNyyez04KdjFrF+LTRoOXmrOgFKDg4BCdsjW8EnT69eqdYGmRpJwiPVYNrCaW3g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -1974,7 +2011,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/es-errors/-/es-errors-1.3.0.tgz",
|
||||||
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
"integrity": "sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -2012,7 +2048,6 @@
|
|||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/es-object-atoms/-/es-object-atoms-1.1.1.tgz",
|
||||||
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
"integrity": "sha512-FGgH2h8zKNim9ljj7dankFPcICIK9Cp5bm+c2gQSYePhpaG5+esrLODihIorn+Pe6FGJzWhXQotPv73jTaldXA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-errors": "^1.3.0"
|
"es-errors": "^1.3.0"
|
||||||
@@ -2098,7 +2133,6 @@
|
|||||||
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
"deprecated": "This version is no longer supported. Please see https://eslint.org/version-support for other options.",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@eslint-community/eslint-utils": "^4.2.0",
|
"@eslint-community/eslint-utils": "^4.2.0",
|
||||||
"@eslint-community/regexpp": "^4.6.1",
|
"@eslint-community/regexpp": "^4.6.1",
|
||||||
@@ -2267,7 +2301,6 @@
|
|||||||
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
"integrity": "sha512-whOE1HFo/qJDyX4SnXzP4N6zOWn79WhnCUY/iDR0mPfQZO8wcYE4JClzI2oZrhBnnMUCBCHZhO6VQyoBU95mZA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"@rtsao/scc": "^1.1.0",
|
"@rtsao/scc": "^1.1.0",
|
||||||
"array-includes": "^3.1.9",
|
"array-includes": "^3.1.9",
|
||||||
@@ -2540,6 +2573,18 @@
|
|||||||
"node": ">=0.10.0"
|
"node": ">=0.10.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/eventemitter3": {
|
||||||
|
"version": "5.0.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-5.0.1.tgz",
|
||||||
|
"integrity": "sha512-GWkBvjiSZK87ELrYOSESUYeVIc9mvLLf/nXalMOS5dYrgZq9o5OVkbZAVM06CVxYsCwH9BDZFPlQTlPA1j4ahA==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/extend": {
|
||||||
|
"version": "3.0.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/extend/-/extend-3.0.2.tgz",
|
||||||
|
"integrity": "sha512-fjquC59cD7CyW6urNXK0FBufkZcoiGG80wTuPujX590cB5Ttln20E2UB4S/WARVqhXffZl2LNgS+gQdPIIim/g==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/fast-deep-equal": {
|
"node_modules/fast-deep-equal": {
|
||||||
"version": "3.1.3",
|
"version": "3.1.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
|
||||||
@@ -2547,6 +2592,12 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/fast-diff": {
|
||||||
|
"version": "1.3.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.3.0.tgz",
|
||||||
|
"integrity": "sha512-VxPP4NqbUjj6MaAOafWeUn2cXWLcCtljklUtZf0Ind4XQ+QPtmA0b18zZy0jIQx+ExRVCR/ZQpBmik5lXshNsw==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
"node_modules/fast-glob": {
|
"node_modules/fast-glob": {
|
||||||
"version": "3.3.3",
|
"version": "3.3.3",
|
||||||
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
"resolved": "https://registry.npmjs.org/fast-glob/-/fast-glob-3.3.3.tgz",
|
||||||
@@ -2739,7 +2790,6 @@
|
|||||||
"version": "1.1.2",
|
"version": "1.1.2",
|
||||||
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
"resolved": "https://registry.npmjs.org/function-bind/-/function-bind-1.1.2.tgz",
|
||||||
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
"integrity": "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -2770,7 +2820,6 @@
|
|||||||
"version": "1.2.3",
|
"version": "1.2.3",
|
||||||
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
"resolved": "https://registry.npmjs.org/functions-have-names/-/functions-have-names-1.2.3.tgz",
|
||||||
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
|
"integrity": "sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"funding": {
|
"funding": {
|
||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
@@ -2790,7 +2839,6 @@
|
|||||||
"version": "1.3.0",
|
"version": "1.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/get-intrinsic/-/get-intrinsic-1.3.0.tgz",
|
||||||
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
"integrity": "sha512-9fSjSaos/fRIVIp+xSJlE6lfwhES7LNtKaCBIamHsjr2na1BiABJPo0mOjjz8GJDURarmCPGqaiVg5mfjb98CQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind-apply-helpers": "^1.0.2",
|
"call-bind-apply-helpers": "^1.0.2",
|
||||||
@@ -2815,7 +2863,6 @@
|
|||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/get-proto/-/get-proto-1.0.1.tgz",
|
||||||
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
"integrity": "sha512-sTSfBjoXBp89JvIKIefqw7U2CCebsc74kiY6awiGogKtoSGbgjYE/G/+l9sF3MWFPNc9IcoOC4ODfKHfxFmp0g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"dunder-proto": "^1.0.1",
|
"dunder-proto": "^1.0.1",
|
||||||
@@ -2976,7 +3023,6 @@
|
|||||||
"version": "1.2.0",
|
"version": "1.2.0",
|
||||||
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
"resolved": "https://registry.npmjs.org/gopd/-/gopd-1.2.0.tgz",
|
||||||
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
"integrity": "sha512-ZUKRh6/kUFoAiTAtTYPZJ3hw9wNxx+BIBOijnlG9PnrJsCcSjs1wyyD6vJpaYtgnzDrKYRSqf3OO6Rfa93xsRg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3025,7 +3071,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-property-descriptors/-/has-property-descriptors-1.0.2.tgz",
|
||||||
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
"integrity": "sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"es-define-property": "^1.0.0"
|
"es-define-property": "^1.0.0"
|
||||||
@@ -3054,7 +3099,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/has-symbols/-/has-symbols-1.1.0.tgz",
|
||||||
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
"integrity": "sha512-1cDNdwJ2Jaohmb3sg4OmKaMBwuC48sYni5HUw2DvsC8LjGTLK9h+eb1X6RyuOHe4hT0ULCW68iomhjUoKUqlPQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -3067,7 +3111,6 @@
|
|||||||
"version": "1.0.2",
|
"version": "1.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/has-tostringtag/-/has-tostringtag-1.0.2.tgz",
|
||||||
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
"integrity": "sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"has-symbols": "^1.0.3"
|
"has-symbols": "^1.0.3"
|
||||||
@@ -3083,7 +3126,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/hasown/-/hasown-2.0.2.tgz",
|
||||||
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
"integrity": "sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"function-bind": "^1.1.2"
|
"function-bind": "^1.1.2"
|
||||||
@@ -3163,6 +3205,22 @@
|
|||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/is-arguments": {
|
||||||
|
"version": "1.2.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/is-arguments/-/is-arguments-1.2.0.tgz",
|
||||||
|
"integrity": "sha512-7bVbi0huj/wrIAOzb8U1aszg9kdi3KN/CyU19CTI7tAoZYEZoL9yCDXpbXN+uPsuWnP02cyug1gleqq+TU+YCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bound": "^1.0.2",
|
||||||
|
"has-tostringtag": "^1.0.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/is-array-buffer": {
|
"node_modules/is-array-buffer": {
|
||||||
"version": "3.0.5",
|
"version": "3.0.5",
|
||||||
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
"resolved": "https://registry.npmjs.org/is-array-buffer/-/is-array-buffer-3.0.5.tgz",
|
||||||
@@ -3308,7 +3366,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/is-date-object/-/is-date-object-1.1.0.tgz",
|
||||||
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
|
"integrity": "sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -3457,7 +3514,6 @@
|
|||||||
"version": "1.2.1",
|
"version": "1.2.1",
|
||||||
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
"resolved": "https://registry.npmjs.org/is-regex/-/is-regex-1.2.1.tgz",
|
||||||
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
"integrity": "sha512-MjYsKHO5O7mCsmRGxWcLWheFqN9DJ/2TmngvjKXihe6efViPqc274+Fx/4fYj/r03+ESvBdTXK0V6tA3rgez1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bound": "^1.0.2",
|
"call-bound": "^1.0.2",
|
||||||
@@ -3805,6 +3861,31 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash-es": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash-es/-/lodash-es-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.clonedeep": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.clonedeep/-/lodash.clonedeep-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-H5ZhCF25riFd9uB5UCkVKo61m3S/xZk1x4wA6yp/L3RFP6Z/eHH1ymQcGLo7J3GMPfm0V/7m1tryHuGVxpqEBQ==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/lodash.isequal": {
|
||||||
|
"version": "4.5.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash.isequal/-/lodash.isequal-4.5.0.tgz",
|
||||||
|
"integrity": "sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==",
|
||||||
|
"deprecated": "This package is deprecated. Use require('node:util').isDeepStrictEqual instead.",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.merge": {
|
"node_modules/lodash.merge": {
|
||||||
"version": "4.6.2",
|
"version": "4.6.2",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.merge/-/lodash.merge-4.6.2.tgz",
|
||||||
@@ -3844,7 +3925,6 @@
|
|||||||
"version": "1.1.0",
|
"version": "1.1.0",
|
||||||
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
"resolved": "https://registry.npmjs.org/math-intrinsics/-/math-intrinsics-1.1.0.tgz",
|
||||||
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
"integrity": "sha512-/IXtbwEk5HTPyEwyKX6hGkYXxM9nbj64B+ilVJnC/R6B0pH5G4V3b0pVbL7DBj4tkhBAppbQUlf6F6Xl9LHu1g==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4105,11 +4185,26 @@
|
|||||||
"url": "https://github.com/sponsors/ljharb"
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/object-is": {
|
||||||
|
"version": "1.1.6",
|
||||||
|
"resolved": "https://registry.npmjs.org/object-is/-/object-is-1.1.6.tgz",
|
||||||
|
"integrity": "sha512-F8cZ+KfGlSGi09lJT7/Nd6KJZ9ygtvYC0/UYYLI9nmQKLMnydpB9yvbv9K1uSkEu7FU9vYPmVwLg328tX+ot3Q==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"call-bind": "^1.0.7",
|
||||||
|
"define-properties": "^1.2.1"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 0.4"
|
||||||
|
},
|
||||||
|
"funding": {
|
||||||
|
"url": "https://github.com/sponsors/ljharb"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/object-keys": {
|
"node_modules/object-keys": {
|
||||||
"version": "1.1.1",
|
"version": "1.1.1",
|
||||||
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
"resolved": "https://registry.npmjs.org/object-keys/-/object-keys-1.1.1.tgz",
|
||||||
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
"integrity": "sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">= 0.4"
|
"node": ">= 0.4"
|
||||||
@@ -4283,6 +4378,12 @@
|
|||||||
"url": "https://github.com/sponsors/sindresorhus"
|
"url": "https://github.com/sponsors/sindresorhus"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/parchment": {
|
||||||
|
"version": "3.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-3.0.0.tgz",
|
||||||
|
"integrity": "sha512-HUrJFQ/StvgmXRcQ1ftY6VEZUq3jA2t9ncFN4F84J/vN0/FPpQF+8FKXb3l6fLces6q0uOHj6NJn+2xvZnxO6A==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
"node_modules/parent-module": {
|
"node_modules/parent-module": {
|
||||||
"version": "1.0.1",
|
"version": "1.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/parent-module/-/parent-module-1.0.1.tgz",
|
||||||
@@ -4429,7 +4530,6 @@
|
|||||||
}
|
}
|
||||||
],
|
],
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"nanoid": "^3.3.7",
|
"nanoid": "^3.3.7",
|
||||||
"picocolors": "^1.0.0",
|
"picocolors": "^1.0.0",
|
||||||
@@ -4583,12 +4683,40 @@
|
|||||||
],
|
],
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/quill": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill/-/quill-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-xEYQBqfYx/sfb33VJiKnSJp8ehloavImQ2A6564GAbqG55PGw1dAWUn1MUbQB62t0azawUS2CZZhWCjO8gRvTw==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"eventemitter3": "^5.0.1",
|
||||||
|
"lodash-es": "^4.17.21",
|
||||||
|
"parchment": "^3.0.0",
|
||||||
|
"quill-delta": "^5.1.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"npm": ">=8.2.3"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/quill-delta": {
|
||||||
|
"version": "5.1.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-5.1.0.tgz",
|
||||||
|
"integrity": "sha512-X74oCeRI4/p0ucjb5Ma8adTXd9Scumz367kkMK5V/IatcX6A0vlgLgKbzXWy5nZmCGeNJm2oQX0d2Eqj+ZIlCA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"fast-diff": "^1.3.0",
|
||||||
|
"lodash.clonedeep": "^4.5.0",
|
||||||
|
"lodash.isequal": "^4.5.0"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">= 12.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/react": {
|
"node_modules/react": {
|
||||||
"version": "18.3.1",
|
"version": "18.3.1",
|
||||||
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react/-/react-18.3.1.tgz",
|
||||||
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
"integrity": "sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0"
|
"loose-envify": "^1.1.0"
|
||||||
},
|
},
|
||||||
@@ -4601,7 +4729,6 @@
|
|||||||
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
"resolved": "https://registry.npmjs.org/react-dom/-/react-dom-18.3.1.tgz",
|
||||||
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
"integrity": "sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==",
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"loose-envify": "^1.1.0",
|
"loose-envify": "^1.1.0",
|
||||||
"scheduler": "^0.23.2"
|
"scheduler": "^0.23.2"
|
||||||
@@ -4617,6 +4744,67 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/react-quill": {
|
||||||
|
"version": "2.0.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/react-quill/-/react-quill-2.0.0.tgz",
|
||||||
|
"integrity": "sha512-4qQtv1FtCfLgoD3PXAur5RyxuUbPXQGOHgTlFie3jtxp43mXDtzCKaOgQ3mLyZfi1PUlyjycfivKelFhy13QUg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"@types/quill": "^1.3.10",
|
||||||
|
"lodash": "^4.17.4",
|
||||||
|
"quill": "^1.3.7"
|
||||||
|
},
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16 || ^17 || ^18",
|
||||||
|
"react-dom": "^16 || ^17 || ^18"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-quill/node_modules/eventemitter3": {
|
||||||
|
"version": "2.0.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/eventemitter3/-/eventemitter3-2.0.3.tgz",
|
||||||
|
"integrity": "sha512-jLN68Dx5kyFHaePoXWPsCGW5qdyZQtLYHkxkg02/Mz6g0kYpDx4FyP6XfArhQdlOC4b8Mv+EMxPo/8La7Tzghg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
|
"node_modules/react-quill/node_modules/fast-diff": {
|
||||||
|
"version": "1.1.2",
|
||||||
|
"resolved": "https://registry.npmjs.org/fast-diff/-/fast-diff-1.1.2.tgz",
|
||||||
|
"integrity": "sha512-KaJUt+M9t1qaIteSvjc6P3RbMdXsNhK61GRftR6SNxqmhthcd9MGIi4T+o0jD8LUSpSnSKXE20nLtJ3fOHxQig==",
|
||||||
|
"license": "Apache-2.0"
|
||||||
|
},
|
||||||
|
"node_modules/react-quill/node_modules/parchment": {
|
||||||
|
"version": "1.1.4",
|
||||||
|
"resolved": "https://registry.npmjs.org/parchment/-/parchment-1.1.4.tgz",
|
||||||
|
"integrity": "sha512-J5FBQt/pM2inLzg4hEWmzQx/8h8D0CiDxaG3vyp9rKrQRSDgBlhjdP5jQGgosEajXPSQouXGHOmVdgo7QmJuOg==",
|
||||||
|
"license": "BSD-3-Clause"
|
||||||
|
},
|
||||||
|
"node_modules/react-quill/node_modules/quill": {
|
||||||
|
"version": "1.3.7",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill/-/quill-1.3.7.tgz",
|
||||||
|
"integrity": "sha512-hG/DVzh/TiknWtE6QmWAF/pxoZKYxfe3J/d/+ShUWkDvvkZQVTPeVmUJVu1uE6DDooC4fWTiCLh84ul89oNz5g==",
|
||||||
|
"license": "BSD-3-Clause",
|
||||||
|
"dependencies": {
|
||||||
|
"clone": "^2.1.1",
|
||||||
|
"deep-equal": "^1.0.1",
|
||||||
|
"eventemitter3": "^2.0.3",
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"parchment": "^1.1.4",
|
||||||
|
"quill-delta": "^3.6.2"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"node_modules/react-quill/node_modules/quill-delta": {
|
||||||
|
"version": "3.6.3",
|
||||||
|
"resolved": "https://registry.npmjs.org/quill-delta/-/quill-delta-3.6.3.tgz",
|
||||||
|
"integrity": "sha512-wdIGBlcX13tCHOXGMVnnTVFtGRLoP0imqxM696fIPwIf5ODIYUHIvHbZcyvGlZFiFhK5XzDC2lpjbxRhnM05Tg==",
|
||||||
|
"license": "MIT",
|
||||||
|
"dependencies": {
|
||||||
|
"deep-equal": "^1.0.1",
|
||||||
|
"extend": "^3.0.2",
|
||||||
|
"fast-diff": "1.1.2"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.10"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/read-cache": {
|
"node_modules/read-cache": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
"resolved": "https://registry.npmjs.org/read-cache/-/read-cache-1.0.0.tgz",
|
||||||
@@ -4667,7 +4855,6 @@
|
|||||||
"version": "1.5.4",
|
"version": "1.5.4",
|
||||||
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
"resolved": "https://registry.npmjs.org/regexp.prototype.flags/-/regexp.prototype.flags-1.5.4.tgz",
|
||||||
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
|
"integrity": "sha512-dYqgNSZbDwkaJ2ceRd9ojCGjBq+mOm9LmtXnAnEGyHhN/5R7iDW2TRw3h+o/jCFxus3P2LfWIIiwowAjANm7IA==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"call-bind": "^1.0.8",
|
"call-bind": "^1.0.8",
|
||||||
@@ -4880,7 +5067,6 @@
|
|||||||
"version": "1.2.2",
|
"version": "1.2.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-length/-/set-function-length-1.2.2.tgz",
|
||||||
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
"integrity": "sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"define-data-property": "^1.1.4",
|
"define-data-property": "^1.1.4",
|
||||||
@@ -4898,7 +5084,6 @@
|
|||||||
"version": "2.0.2",
|
"version": "2.0.2",
|
||||||
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
|
"resolved": "https://registry.npmjs.org/set-function-name/-/set-function-name-2.0.2.tgz",
|
||||||
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
|
"integrity": "sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==",
|
||||||
"dev": true,
|
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
"define-data-property": "^1.1.4",
|
"define-data-property": "^1.1.4",
|
||||||
@@ -5548,7 +5733,6 @@
|
|||||||
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
"integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"peer": true,
|
|
||||||
"engines": {
|
"engines": {
|
||||||
"node": ">=12"
|
"node": ">=12"
|
||||||
},
|
},
|
||||||
@@ -5718,7 +5902,6 @@
|
|||||||
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
"integrity": "sha512-vcI4UpRgg81oIRUFwR0WSIHKt11nJ7SAVlYNIu+QpqeyXP+gpQJy/Z4+F0aGxSE4MqwjyXvW/TzgkLAx2AGHwQ==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "Apache-2.0",
|
"license": "Apache-2.0",
|
||||||
"peer": true,
|
|
||||||
"bin": {
|
"bin": {
|
||||||
"tsc": "bin/tsc",
|
"tsc": "bin/tsc",
|
||||||
"tsserver": "bin/tsserver"
|
"tsserver": "bin/tsserver"
|
||||||
|
|||||||
+3
-1
@@ -12,8 +12,10 @@
|
|||||||
"clsx": "^2.1.1",
|
"clsx": "^2.1.1",
|
||||||
"lucide-react": "^0.555.0",
|
"lucide-react": "^0.555.0",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
|
"quill": "^2.0.3",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1",
|
||||||
|
"react-quill": "^2.0.0"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
"@types/node": "20.12.7",
|
"@types/node": "20.12.7",
|
||||||
|
|||||||
@@ -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 |
+97
-20
@@ -1,35 +1,58 @@
|
|||||||
// Grouping for nav + layout
|
// Grouping for nav + layout
|
||||||
export type CategoryGroup = "lower" | "upper" | "accessories";
|
export type CategoryGroup = "lower" | "upper" | "accessories";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* CategoryId is the canonical identifier used throughout the UI + localStorage.
|
||||||
|
*
|
||||||
|
* ✅ Canonical IDs are kebab-case to match route segments and `gunbuilderParts.ts`.
|
||||||
|
*
|
||||||
|
* 🧯 Legacy camelCase IDs are temporarily supported to avoid breaking older
|
||||||
|
* localStorage/build-state and older code paths. Prefer kebab-case going forward.
|
||||||
|
*/
|
||||||
export type CategoryId =
|
export type CategoryId =
|
||||||
| "upper"
|
// ===== LOWER =====
|
||||||
| "completeUpper"
|
| "lower-receiver"
|
||||||
| "barrel"
|
| "complete-lower"
|
||||||
| "gasBlock"
|
| "lower-parts"
|
||||||
| "gasTube"
|
|
||||||
| "muzzleDevice"
|
|
||||||
| "suppressor"
|
|
||||||
| "handguard"
|
|
||||||
| "chargingHandle"
|
|
||||||
| "bcg"
|
|
||||||
| "buffer"
|
|
||||||
| "lower"
|
|
||||||
| "completeLower"
|
|
||||||
| "lowerParts"
|
|
||||||
| "trigger"
|
| "trigger"
|
||||||
| "grip"
|
| "grip"
|
||||||
| "safety"
|
| "safety"
|
||||||
|
| "buffer"
|
||||||
|
| "stock"
|
||||||
|
|
||||||
|
// ===== UPPER =====
|
||||||
|
| "upper-receiver"
|
||||||
|
| "complete-upper"
|
||||||
|
| "barrel"
|
||||||
|
| "handguard"
|
||||||
|
| "gas-block"
|
||||||
|
| "gas-tube"
|
||||||
|
| "muzzle-device"
|
||||||
|
| "bcg"
|
||||||
|
| "charging-handle"
|
||||||
|
| "suppressor"
|
||||||
| "sights"
|
| "sights"
|
||||||
| "optic"
|
| "optic"
|
||||||
| "stock"
|
|
||||||
// NEW ACCESSORY CATEGORIES
|
// ===== ACCESSORIES =====
|
||||||
| "magazine"
|
| "magazine"
|
||||||
| "weaponLight"
|
| "weapon-light"
|
||||||
| "foregrip"
|
| "foregrip"
|
||||||
| "bipod"
|
| "bipod"
|
||||||
| "sling"
|
| "sling"
|
||||||
| "railAccessory"
|
| "rail-accessory"
|
||||||
| "tools";
|
| "tools"
|
||||||
|
|
||||||
|
// ===== LEGACY (temporary) =====
|
||||||
|
| "completeUpper"
|
||||||
|
| "gasBlock"
|
||||||
|
| "gasTube"
|
||||||
|
| "muzzleDevice"
|
||||||
|
| "chargingHandle"
|
||||||
|
| "completeLower"
|
||||||
|
| "lowerParts"
|
||||||
|
| "weaponLight"
|
||||||
|
| "railAccessory";
|
||||||
|
|
||||||
export interface Category {
|
export interface Category {
|
||||||
id: CategoryId;
|
id: CategoryId;
|
||||||
@@ -37,6 +60,8 @@ export interface Category {
|
|||||||
description?: string;
|
description?: string;
|
||||||
/** used for nav & grouping */
|
/** used for nav & grouping */
|
||||||
group?: CategoryGroup;
|
group?: CategoryGroup;
|
||||||
|
/** optional slug override (mostly for legacy IDs) */
|
||||||
|
slug?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface Part {
|
export interface Part {
|
||||||
@@ -46,7 +71,59 @@ export interface Part {
|
|||||||
categoryId: CategoryId;
|
categoryId: CategoryId;
|
||||||
price: number;
|
price: number;
|
||||||
affiliateUrl?: string;
|
affiliateUrl?: string;
|
||||||
url?: string;
|
url?: string;
|
||||||
imageUrl?: string;
|
imageUrl?: string;
|
||||||
notes?: string;
|
notes?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Route / UI slug for each CategoryId.
|
||||||
|
*
|
||||||
|
* - Canonical kebab-case ids map 1:1.
|
||||||
|
* - Legacy camelCase ids map to the same slug.
|
||||||
|
*/
|
||||||
|
export const CATEGORY_SLUGS = {
|
||||||
|
// LOWER
|
||||||
|
"lower-receiver": "lower-receiver",
|
||||||
|
"complete-lower": "complete-lower",
|
||||||
|
"lower-parts": "lower-parts",
|
||||||
|
trigger: "trigger",
|
||||||
|
grip: "grip",
|
||||||
|
safety: "safety",
|
||||||
|
buffer: "buffer",
|
||||||
|
stock: "stock",
|
||||||
|
|
||||||
|
// UPPER
|
||||||
|
"upper-receiver": "upper-receiver",
|
||||||
|
"complete-upper": "complete-upper",
|
||||||
|
barrel: "barrel",
|
||||||
|
handguard: "handguard",
|
||||||
|
"gas-block": "gas-block",
|
||||||
|
"gas-tube": "gas-tube",
|
||||||
|
"muzzle-device": "muzzle-device",
|
||||||
|
sights: "sights",
|
||||||
|
bcg: "bcg",
|
||||||
|
"charging-handle": "charging-handle",
|
||||||
|
suppressor: "suppressor",
|
||||||
|
optic: "optic",
|
||||||
|
|
||||||
|
// ACCESSORIES
|
||||||
|
magazine: "magazine",
|
||||||
|
"weapon-light": "weapon-light",
|
||||||
|
foregrip: "foregrip",
|
||||||
|
bipod: "bipod",
|
||||||
|
sling: "sling",
|
||||||
|
"rail-accessory": "rail-accessory",
|
||||||
|
tools: "tools",
|
||||||
|
|
||||||
|
// LEGACY (temporary)
|
||||||
|
completeUpper: "complete-upper",
|
||||||
|
gasBlock: "gas-block",
|
||||||
|
gasTube: "gas-tube",
|
||||||
|
muzzleDevice: "muzzle-device",
|
||||||
|
chargingHandle: "charging-handle",
|
||||||
|
completeLower: "complete-lower",
|
||||||
|
lowerParts: "lower-parts",
|
||||||
|
weaponLight: "weapon-light",
|
||||||
|
railAccessory: "rail-accessory",
|
||||||
|
} as const satisfies Record<CategoryId, string>;
|
||||||
@@ -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