fixed merge config
This commit is contained in:
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
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="AskMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="Ask2AgentMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="EditMigrationStateService">
|
||||||
|
<option name="migrationStatus" value="COMPLETED" />
|
||||||
|
</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
+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
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="SqlDialectMappings">
|
||||||
|
<file url="file://$PROJECT_DIR$/schema-example.sql" dialect="PostgreSQL" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="VcsDirectoryMappings">
|
||||||
|
<mapping directory="" vcs="Git" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -0,0 +1,125 @@
|
|||||||
|
// app/admin/categories/page.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import {
|
||||||
|
fetchAdminCategories,
|
||||||
|
type AdminCategory,
|
||||||
|
} from "@/lib/api/adminCategoryMappings";
|
||||||
|
|
||||||
|
export default function AdminCategoriesPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [categories, setCategories] = useState<AdminCategory[]>([]);
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const cats = await fetchAdminCategories(token);
|
||||||
|
setCategories(cats);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e?.message ?? "Failed to load categories");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, [token]);
|
||||||
|
|
||||||
|
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">
|
||||||
|
Canonical categories
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-xs text-zinc-400">
|
||||||
|
These are your standardized builder categories and their groupings.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
{/* Placeholder for future actions (add/edit) */}
|
||||||
|
{/* <button className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-500">
|
||||||
|
+ Add category
|
||||||
|
</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">Group</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Name</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Slug</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Sort order</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Description</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<tr
|
||||||
|
key={cat.id}
|
||||||
|
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
|
||||||
|
>
|
||||||
|
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||||
|
{cat.groupName ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-zinc-100">{cat.name}</td>
|
||||||
|
<td className="px-3 py-2 text-[11px] text-zinc-500">
|
||||||
|
<code>{cat.slug}</code>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||||
|
{cat.sortOrder ?? "—"}
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-zinc-300">
|
||||||
|
{cat.description || (
|
||||||
|
<span className="text-zinc-500">No description</span>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{categories.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={5}
|
||||||
|
className="px-3 py-4 text-center text-xs text-zinc-500"
|
||||||
|
>
|
||||||
|
No categories found. Seed the <code>part_categories</code>{" "}
|
||||||
|
table to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,360 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useApi } from "@/lib/useApi";
|
||||||
|
|
||||||
|
type Merchant = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type CategoryMapping = {
|
||||||
|
id: number;
|
||||||
|
merchantId: number;
|
||||||
|
merchantName: string;
|
||||||
|
rawCategoryPath: string;
|
||||||
|
partCategoryId: number | null;
|
||||||
|
partCategoryName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Matches the Spring MerchantCategoryMappingDto record
|
||||||
|
type MerchantCategoryMappingDto = {
|
||||||
|
id: number;
|
||||||
|
merchantId: number;
|
||||||
|
merchantName: string;
|
||||||
|
rawCategoryPath: string;
|
||||||
|
partCategoryId: number | null;
|
||||||
|
partCategoryName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type AdminCategory = {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
groupName?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function MerchantCategoryMappingPage() {
|
||||||
|
const { get, post } = useApi();
|
||||||
|
|
||||||
|
const [merchants, setMerchants] = useState<Merchant[]>([]);
|
||||||
|
const [selectedMerchantId, setSelectedMerchantId] = useState<number | null>(
|
||||||
|
null
|
||||||
|
);
|
||||||
|
const [mappings, setMappings] = useState<CategoryMapping[]>([]);
|
||||||
|
const [loadingMerchants, setLoadingMerchants] = useState(false);
|
||||||
|
const [loadingMappings, setLoadingMappings] = useState(false);
|
||||||
|
const [savingId, setSavingId] = useState<number | null>(null);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [categories, setCategories] = useState<AdminCategory[]>([]);
|
||||||
|
const [loadingCategories, setLoadingCategories] = useState(false);
|
||||||
|
const [dirty, setDirty] = useState<Record<number, CategoryMapping>>({});
|
||||||
|
|
||||||
|
// 1) Load merchants for the dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingMerchants(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
get<Merchant[]>("/api/admin/category-mappings/merchants")
|
||||||
|
.then((data) => {
|
||||||
|
setMerchants(data);
|
||||||
|
if (data.length > 0 && selectedMerchantId == null) {
|
||||||
|
setSelectedMerchantId(data[0].id);
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
console.error("Failed to load merchants", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load merchants"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingMerchants(false));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Load global (canonical) part categories for the dropdown
|
||||||
|
// Load global (canonical) part categories for the dropdown
|
||||||
|
useEffect(() => {
|
||||||
|
setLoadingCategories(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
get<AdminCategory[]>("/api/admin/categories")
|
||||||
|
.then((data) => {
|
||||||
|
setCategories(data);
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
console.error("Failed to load categories", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error ? err.message : "Failed to load categories"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingCategories(false));
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, []);
|
||||||
|
// 2) Load mappings when merchant changes (THIS sends merchantId)
|
||||||
|
useEffect(() => {
|
||||||
|
if (selectedMerchantId == null) return;
|
||||||
|
|
||||||
|
setLoadingMappings(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
get<CategoryMapping[]>(
|
||||||
|
`/api/admin/category-mappings?merchantId=${selectedMerchantId}`
|
||||||
|
)
|
||||||
|
.then((data) => {
|
||||||
|
setMappings(data);
|
||||||
|
})
|
||||||
|
.catch((err: any) => {
|
||||||
|
console.error("Failed to load category mappings", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to load category mappings"
|
||||||
|
);
|
||||||
|
})
|
||||||
|
.finally(() => setLoadingMappings(false));
|
||||||
|
}, [selectedMerchantId]);
|
||||||
|
|
||||||
|
// 3) Save a single mapping row (adjust endpoint if yours differs)
|
||||||
|
const handleSaveMapping = async (
|
||||||
|
mappingId: number,
|
||||||
|
updated: { partCategoryId: number | null }
|
||||||
|
) => {
|
||||||
|
setSavingId(mappingId);
|
||||||
|
setError(null);
|
||||||
|
try {
|
||||||
|
const updatedMapping = await post<MerchantCategoryMappingDto>(
|
||||||
|
`/api/admin/category-mappings/${mappingId}`,
|
||||||
|
{
|
||||||
|
partCategoryId: updated.partCategoryId,
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
setMappings((prev) =>
|
||||||
|
prev.map((m) =>
|
||||||
|
m.id === mappingId
|
||||||
|
? {
|
||||||
|
...m,
|
||||||
|
// map Java DTO fields back into your UI model
|
||||||
|
merchantId: updatedMapping.merchantId,
|
||||||
|
merchantName: updatedMapping.merchantName,
|
||||||
|
rawCategoryPath: updatedMapping.rawCategoryPath,
|
||||||
|
partCategoryId: updatedMapping.partCategoryId,
|
||||||
|
partCategoryName: updatedMapping.partCategoryName,
|
||||||
|
}
|
||||||
|
: m
|
||||||
|
)
|
||||||
|
);
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to save mapping", err);
|
||||||
|
setError(err instanceof Error ? err.message : "Failed to save mapping");
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveAll = async () => {
|
||||||
|
if (Object.keys(dirty).length === 0) return;
|
||||||
|
|
||||||
|
// special flag to indicate a global save is in progress
|
||||||
|
setSavingId(-1);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
const dirtyRows = Object.values(dirty);
|
||||||
|
|
||||||
|
const updatedMappings = await Promise.all(
|
||||||
|
dirtyRows.map((row) =>
|
||||||
|
post<MerchantCategoryMappingDto>(
|
||||||
|
`/api/admin/category-mappings/${row.id}`,
|
||||||
|
{
|
||||||
|
partCategoryId: row.partCategoryId ?? null,
|
||||||
|
}
|
||||||
|
)
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
setMappings((prev) =>
|
||||||
|
prev.map((m) => {
|
||||||
|
const updated = updatedMappings.find((u) => u.id === m.id);
|
||||||
|
if (!updated) return m;
|
||||||
|
|
||||||
|
return {
|
||||||
|
...m,
|
||||||
|
merchantId: updated.merchantId,
|
||||||
|
merchantName: updated.merchantName,
|
||||||
|
rawCategoryPath: updated.rawCategoryPath,
|
||||||
|
partCategoryId: updated.partCategoryId,
|
||||||
|
partCategoryName: updated.partCategoryName,
|
||||||
|
};
|
||||||
|
})
|
||||||
|
);
|
||||||
|
|
||||||
|
// Clear dirty state after successful batch save
|
||||||
|
setDirty({});
|
||||||
|
} catch (err: any) {
|
||||||
|
console.error("Failed to save all mappings", err);
|
||||||
|
setError(
|
||||||
|
err instanceof Error
|
||||||
|
? err.message
|
||||||
|
: "Failed to save all mappings"
|
||||||
|
);
|
||||||
|
} finally {
|
||||||
|
setSavingId(null);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="p-6 space-y-6">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-2xl font-semibold text-zinc-50">
|
||||||
|
Merchant Category Mapping
|
||||||
|
</h1>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<span className="text-sm text-zinc-400">Merchant:</span>
|
||||||
|
<select
|
||||||
|
className="bg-zinc-900 border border-zinc-700 rounded px-2 py-1 text-sm"
|
||||||
|
value={selectedMerchantId ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
setSelectedMerchantId(
|
||||||
|
e.target.value ? Number(e.target.value) : null
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{loadingMerchants && <option value="">Loading merchants…</option>}
|
||||||
|
{!loadingMerchants && merchants.length === 0 && (
|
||||||
|
<option value="">No merchants</option>
|
||||||
|
)}
|
||||||
|
{merchants.map((m) => (
|
||||||
|
<option key={m.id} value={m.id}>
|
||||||
|
{m.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded bg-red-900/40 border border-red-700 px-3 py-2 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{Object.keys(dirty).length > 0 && (
|
||||||
|
<div className="flex justify-end">
|
||||||
|
<button
|
||||||
|
onClick={handleSaveAll}
|
||||||
|
disabled={savingId !== null}
|
||||||
|
className="mb-3 rounded bg-emerald-600 px-4 py-2 text-sm font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
Save All Changes ({Object.keys(dirty).length})
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="border border-zinc-800 rounded-lg overflow-hidden">
|
||||||
|
<div className="bg-zinc-900 border-b border-zinc-800 px-4 py-2 text-xs uppercase tracking-wide text-zinc-400">
|
||||||
|
Category Mappings
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{loadingMappings ? (
|
||||||
|
<div className="p-4 text-sm text-zinc-400">Loading mappings…</div>
|
||||||
|
) : mappings.length === 0 ? (
|
||||||
|
<div className="p-4 text-sm text-zinc-400">
|
||||||
|
No mappings found for this merchant.
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<table className="w-full text-sm">
|
||||||
|
<thead className="bg-zinc-950/60 border-b border-zinc-800">
|
||||||
|
<tr>
|
||||||
|
<th className="text-left px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
|
||||||
|
Merchant Category
|
||||||
|
</th>
|
||||||
|
<th className="text-left px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
|
||||||
|
Global Category
|
||||||
|
</th>
|
||||||
|
<th className="px-4 py-2 text-xs uppercase tracking-wide text-zinc-500">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{mappings.map((m) => (
|
||||||
|
<tr key={m.id} className="border-t border-zinc-800">
|
||||||
|
<td className="px-4 py-2 text-zinc-100">
|
||||||
|
{m.rawCategoryPath}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2">
|
||||||
|
{loadingCategories ? (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
Loading categories…
|
||||||
|
</span>
|
||||||
|
) : categories.length === 0 ? (
|
||||||
|
<span className="text-xs text-zinc-500">
|
||||||
|
No categories
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
<select
|
||||||
|
className="bg-zinc-950 border border-zinc-700 rounded px-2 py-1 text-sm w-64"
|
||||||
|
value={m.partCategoryId ?? ""}
|
||||||
|
onChange={(e) => {
|
||||||
|
const value = e.target.value
|
||||||
|
? Number(e.target.value)
|
||||||
|
: null;
|
||||||
|
|
||||||
|
const updatedRow: CategoryMapping = {
|
||||||
|
...m,
|
||||||
|
partCategoryId: value,
|
||||||
|
};
|
||||||
|
|
||||||
|
setMappings((prev) =>
|
||||||
|
prev.map((row) =>
|
||||||
|
row.id === m.id ? updatedRow : row
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// mark this row as dirty so it can be batch-saved
|
||||||
|
setDirty((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[m.id]: updatedRow,
|
||||||
|
}));
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">— Unmapped —</option>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.id}>
|
||||||
|
{cat.groupName ? `[${cat.groupName}] ` : ""}
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{m.partCategoryName && (
|
||||||
|
<div className="text-xs text-zinc-500 mt-1">
|
||||||
|
Current: {m.partCategoryName}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
<td className="px-4 py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() =>
|
||||||
|
handleSaveMapping(m.id, {
|
||||||
|
partCategoryId: m.partCategoryId ?? null,
|
||||||
|
})
|
||||||
|
}
|
||||||
|
disabled={savingId === m.id}
|
||||||
|
className="text-xs px-3 py-1 rounded border border-zinc-600 hover:bg-zinc-800 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{savingId === m.id ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,67 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import Link from "next/link";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
export default function AdminHomePage() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return (
|
||||||
|
<div className="p-6 text-sm text-red-500">
|
||||||
|
You must be logged in to view this page.
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const cards = [
|
||||||
|
{
|
||||||
|
title: "Canonical categories",
|
||||||
|
href: "admin/categories",
|
||||||
|
description:
|
||||||
|
"Manage the core builder categories and their group/grouping + sort order.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Part role mappings",
|
||||||
|
href: "/admin/part-role-mappings",
|
||||||
|
description:
|
||||||
|
"Advanced: map logical builder part roles to canonical categories per platform.",
|
||||||
|
},
|
||||||
|
{
|
||||||
|
title: "Merchant category mappings",
|
||||||
|
href: "/admin/merchant-category-mappings",
|
||||||
|
description:
|
||||||
|
"Map raw merchant feed categories to your canonical categories.",
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-5xl p-6 space-y-6">
|
||||||
|
<div>
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight text-zinc-100">
|
||||||
|
Admin
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-xs text-zinc-400">
|
||||||
|
Internal tools to keep your builder data clean and consistent.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-4 sm:grid-cols-3">
|
||||||
|
{cards.map((card) => (
|
||||||
|
<Link
|
||||||
|
key={card.href}
|
||||||
|
href={card.href}
|
||||||
|
className="group rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-left hover:border-emerald-500/70 hover:bg-zinc-900/80"
|
||||||
|
>
|
||||||
|
<h2 className="text-sm font-medium text-zinc-100 group-hover:text-white">
|
||||||
|
{card.title}
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-[11px] leading-snug text-zinc-400">
|
||||||
|
{card.description}
|
||||||
|
</p>
|
||||||
|
</Link>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,228 @@
|
|||||||
|
// app/admin/category-mappings/page.tsx
|
||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import {
|
||||||
|
fetchAdminCategories,
|
||||||
|
fetchPartRoleMappings,
|
||||||
|
createPartRoleMapping,
|
||||||
|
updatePartRoleMapping,
|
||||||
|
deletePartRoleMapping,
|
||||||
|
type AdminCategory,
|
||||||
|
type AdminPartRoleMapping,
|
||||||
|
} from "@/lib/api/adminCategoryMappings";
|
||||||
|
|
||||||
|
export default function AdminCategoryMappingsPage() {
|
||||||
|
const { token } = useAuth();
|
||||||
|
const [categories, setCategories] = useState<AdminCategory[]>([]);
|
||||||
|
const [mappings, setMappings] = useState<AdminPartRoleMapping[]>([]);
|
||||||
|
const [platform, setPlatform] = useState("AR-15");
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [saving, setSaving] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const load = async () => {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
const [cats, maps] = await Promise.all([
|
||||||
|
fetchAdminCategories(token),
|
||||||
|
fetchPartRoleMappings(token, platform),
|
||||||
|
]);
|
||||||
|
setCategories(cats);
|
||||||
|
setMappings(maps);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e?.message ?? "Failed to load mappings");
|
||||||
|
} finally {
|
||||||
|
setLoading(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
load();
|
||||||
|
}, [token, platform]);
|
||||||
|
|
||||||
|
const handleCreate = async () => {
|
||||||
|
if (!token) return;
|
||||||
|
const defaultCategory = categories[0];
|
||||||
|
if (!defaultCategory) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const created = await createPartRoleMapping(token, {
|
||||||
|
platform,
|
||||||
|
partRole: "NEW_PART_ROLE",
|
||||||
|
categorySlug: defaultCategory.slug,
|
||||||
|
});
|
||||||
|
setMappings((prev) => [...prev, created]);
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e?.message ?? "Failed to create mapping");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleUpdate = async (row: AdminPartRoleMapping, updates: Partial<AdminPartRoleMapping>) => {
|
||||||
|
if (!token) return;
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
const updated = await updatePartRoleMapping(token, row.id, {
|
||||||
|
platform: updates.platform ?? row.platform,
|
||||||
|
partRole: updates.partRole ?? row.partRole,
|
||||||
|
categorySlug: updates.categorySlug ?? row.categorySlug,
|
||||||
|
notes: updates.notes ?? row.notes ?? undefined,
|
||||||
|
});
|
||||||
|
setMappings((prev) => prev.map((m) => (m.id === row.id ? updated : m)));
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e?.message ?? "Failed to update mapping");
|
||||||
|
} finally {
|
||||||
|
setSaving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleDelete = async (row: AdminPartRoleMapping) => {
|
||||||
|
if (!token) return;
|
||||||
|
if (!confirm(`Delete mapping for ${row.partRole}?`)) return;
|
||||||
|
|
||||||
|
try {
|
||||||
|
setSaving(true);
|
||||||
|
await deletePartRoleMapping(token, row.id);
|
||||||
|
setMappings((prev) => prev.filter((m) => m.id !== row.id));
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e?.message ?? "Failed to delete mapping");
|
||||||
|
} finally {
|
||||||
|
setSaving(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">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<h1 className="text-lg font-semibold tracking-tight">
|
||||||
|
Part role → category mappings
|
||||||
|
</h1>
|
||||||
|
<p className="mt-1 text-xs text-zinc-400">
|
||||||
|
FOR LATER! Advanced mapping: defines which canonical category each builder part role
|
||||||
|
uses for a given platform.
|
||||||
|
</p>
|
||||||
|
<div className="flex items-center gap-3">
|
||||||
|
<select
|
||||||
|
value={platform}
|
||||||
|
onChange={(e) => setPlatform(e.target.value)}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-1 text-sm"
|
||||||
|
>
|
||||||
|
<option value="AR-15">AR-15</option>
|
||||||
|
{/* add other platforms later */}
|
||||||
|
</select>
|
||||||
|
<button
|
||||||
|
onClick={handleCreate}
|
||||||
|
disabled={saving || loading || categories.length === 0}
|
||||||
|
className="rounded-md bg-emerald-600 px-3 py-1 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||||
|
>
|
||||||
|
+ Add mapping
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{error && (
|
||||||
|
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{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">Platform</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Part role</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Category</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Group</th>
|
||||||
|
<th className="px-3 py-2 font-medium">Notes</th>
|
||||||
|
<th className="px-3 py-2 text-right font-medium">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
{mappings.map((row) => (
|
||||||
|
<tr key={row.id} className="border-t border-zinc-800/70 hover:bg-zinc-900/60">
|
||||||
|
<td className="px-3 py-2 text-zinc-300">{row.platform}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
|
||||||
|
defaultValue={row.partRole}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.target.value !== row.partRole) {
|
||||||
|
handleUpdate(row, { partRole: e.target.value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<select
|
||||||
|
className="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
|
||||||
|
value={row.categorySlug}
|
||||||
|
onChange={(e) => {
|
||||||
|
handleUpdate(row, { categorySlug: e.target.value });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{categories.map((cat) => (
|
||||||
|
<option key={cat.id} value={cat.slug}>
|
||||||
|
{cat.groupName ? `[${cat.groupName}] ` : ""}
|
||||||
|
{cat.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-zinc-400 text-[11px]">{row.groupName}</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<input
|
||||||
|
className="w-full rounded border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
|
||||||
|
defaultValue={row.notes ?? ""}
|
||||||
|
onBlur={(e) => {
|
||||||
|
if (e.target.value !== (row.notes ?? "")) {
|
||||||
|
handleUpdate(row, { notes: e.target.value });
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleDelete(row)}
|
||||||
|
className="rounded border border-red-700/70 px-2 py-1 text-[11px] text-red-300 hover:bg-red-900/40"
|
||||||
|
>
|
||||||
|
Delete
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{mappings.length === 0 && (
|
||||||
|
<tr>
|
||||||
|
<td
|
||||||
|
colSpan={6}
|
||||||
|
className="px-3 py-4 text-center text-xs text-zinc-500"
|
||||||
|
>
|
||||||
|
No mappings yet for {platform}. Click “Add mapping” to get started.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,436 @@
|
|||||||
|
"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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,806 @@
|
|||||||
|
"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 { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
|
||||||
|
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||||
|
|
||||||
|
type ViewMode = "card" | "list";
|
||||||
|
|
||||||
|
type GunbuilderProductFromApi = {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
brand: string;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
price: number | null;
|
||||||
|
mainImageUrl: string | null;
|
||||||
|
buyUrl: string | null;
|
||||||
|
// optional stock flag if/when backend sends it
|
||||||
|
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);
|
||||||
|
|
||||||
|
// FIX: determine which backend partRoles map to this category
|
||||||
|
const roles = CATEGORY_TO_PART_ROLES[categoryId] ?? [];
|
||||||
|
|
||||||
|
const search = new URLSearchParams();
|
||||||
|
search.set("platform", "AR-15");
|
||||||
|
roles.forEach((r) => search.append("partRoles", r));
|
||||||
|
|
||||||
|
const url = `${API_BASE_URL}/api/products/gunbuilder${
|
||||||
|
roles.length ? `?${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: p.id,
|
||||||
|
categoryId,
|
||||||
|
name: p.name,
|
||||||
|
brand: p.brand,
|
||||||
|
price: p.price ?? 0,
|
||||||
|
imageUrl: p.mainImageUrl ?? undefined,
|
||||||
|
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("/gunbuilder");
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
// 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="/gunbuilder"
|
||||||
|
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="/gunbuilder"
|
||||||
|
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={`/gunbuilder/${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={`/gunbuilder/${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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -6,6 +6,7 @@ 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 { PART_ROLE_TO_CATEGORY } from "@/data/partRoleMappings";
|
import { PART_ROLE_TO_CATEGORY } from "@/data/partRoleMappings";
|
||||||
|
import { Pencil, Link2 } from "lucide-react";
|
||||||
|
|
||||||
type GunbuilderProductFromApi = {
|
type GunbuilderProductFromApi = {
|
||||||
id: number; // backend numeric id
|
id: number; // backend numeric id
|
||||||
@@ -598,17 +599,21 @@ export default function GunbuilderPage() {
|
|||||||
<>
|
<>
|
||||||
<Link
|
<Link
|
||||||
href={`/gunbuilder/${category.id}`}
|
href={`/gunbuilder/${category.id}`}
|
||||||
className="hidden md:inline-flex rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1 text-[0.7rem] font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
className="hidden md:inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
aria-label="Change part"
|
||||||
|
title="Change part"
|
||||||
>
|
>
|
||||||
Change Part
|
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
|
||||||
</Link>
|
</Link>
|
||||||
<a
|
<a
|
||||||
href={selectedPart.url}
|
href={selectedPart.url}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex rounded-md bg-amber-400 px-2.5 py-1 text-[0.7rem] font-semibold text-black hover:bg-amber-300 transition-colors"
|
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-2 py-1 hover:bg-amber-300 transition-colors"
|
||||||
|
aria-label="Buy part"
|
||||||
|
title="Buy part"
|
||||||
>
|
>
|
||||||
Buy
|
<Link2 className="h-3.5 w-3.5 text-black" />
|
||||||
</a>
|
</a>
|
||||||
</>
|
</>
|
||||||
) : hasParts ? (
|
) : hasParts ? (
|
||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||||
|
type Caliber = "5.56" | "7.62" | "9mm" | ".300 BLK" | "12ga";
|
||||||
|
|
||||||
|
type BuildCard = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
creator: string;
|
||||||
|
caliber: Caliber;
|
||||||
|
buildClass: BuildClass;
|
||||||
|
price: number;
|
||||||
|
votes: number;
|
||||||
|
tags: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
const DUMMY_BUILDS: BuildCard[] = [
|
||||||
|
{
|
||||||
|
id: "1",
|
||||||
|
title: "Duty-Grade 12.5\" AR-15",
|
||||||
|
slug: "duty-12-5-ar15",
|
||||||
|
creator: "quietpro_01",
|
||||||
|
caliber: "5.56",
|
||||||
|
buildClass: "Rifle",
|
||||||
|
price: 2450,
|
||||||
|
votes: 128,
|
||||||
|
tags: ["Duty", "NV-Ready", "LPVO"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "2",
|
||||||
|
title: "Home Defense PCC",
|
||||||
|
slug: "home-defense-pcc",
|
||||||
|
creator: "nerdgunner",
|
||||||
|
caliber: "9mm",
|
||||||
|
buildClass: "Pistol",
|
||||||
|
price: 1650,
|
||||||
|
votes: 74,
|
||||||
|
tags: ["Home Defense", "Red Dot", "Suppressor-Ready"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "3",
|
||||||
|
title: "Short King .300 BLK SBR",
|
||||||
|
slug: "short-king-300blk-sbr",
|
||||||
|
creator: "subsonic_six",
|
||||||
|
caliber: ".300 BLK",
|
||||||
|
buildClass: "NFA",
|
||||||
|
price: 3125,
|
||||||
|
votes: 201,
|
||||||
|
tags: ["SBR", "Suppressed", "Night Work"],
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: "4",
|
||||||
|
title: "Budget 16\" Training Rifle",
|
||||||
|
slug: "budget-16-training-rifle",
|
||||||
|
creator: "range_rat",
|
||||||
|
caliber: "5.56",
|
||||||
|
buildClass: "Rifle",
|
||||||
|
price: 975,
|
||||||
|
votes: 53,
|
||||||
|
tags: ["Budget", "Trainer", "Recce-ish"],
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
|
const CALIBER_FILTERS: (Caliber | "all")[] = [
|
||||||
|
"all",
|
||||||
|
"5.56",
|
||||||
|
"7.62",
|
||||||
|
"9mm",
|
||||||
|
".300 BLK",
|
||||||
|
"12ga",
|
||||||
|
];
|
||||||
|
|
||||||
|
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
|
||||||
|
|
||||||
|
const PRICE_FILTERS = [
|
||||||
|
{ id: "all", label: "All", min: 0, max: Infinity },
|
||||||
|
{ id: "sub1k", label: "< $1k", min: 0, max: 1000 },
|
||||||
|
{ id: "1to2k", label: "$1k–$2k", min: 1000, max: 2000 },
|
||||||
|
{ id: "2to3k", label: "$2k–$3k", min: 2000, max: 3000 },
|
||||||
|
{ id: "3kplus", label: "$3k+", min: 3000, max: Infinity },
|
||||||
|
];
|
||||||
|
|
||||||
|
export default function BuildsPage() {
|
||||||
|
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
||||||
|
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
||||||
|
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
||||||
|
const [votes, setVotes] = useState<Record<string, number>>(
|
||||||
|
Object.fromEntries(DUMMY_BUILDS.map((b) => [b.id, b.votes])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const activePriceFilter = PRICE_FILTERS.find(
|
||||||
|
(p) => p.id === priceFilterId,
|
||||||
|
) ?? PRICE_FILTERS[0];
|
||||||
|
|
||||||
|
const filteredBuilds = useMemo(() => {
|
||||||
|
return DUMMY_BUILDS.filter((build) => {
|
||||||
|
const matchesCaliber =
|
||||||
|
caliberFilter === "all" || build.caliber === caliberFilter;
|
||||||
|
|
||||||
|
const matchesClass =
|
||||||
|
classFilter === "all" || build.buildClass === classFilter;
|
||||||
|
|
||||||
|
const matchesPrice =
|
||||||
|
build.price >= activePriceFilter.min &&
|
||||||
|
build.price < activePriceFilter.max;
|
||||||
|
|
||||||
|
return matchesCaliber && matchesClass && matchesPrice;
|
||||||
|
});
|
||||||
|
}, [caliberFilter, classFilter, activePriceFilter]);
|
||||||
|
|
||||||
|
const handleVote = (id: string, delta: 1 | -1) => {
|
||||||
|
setVotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: (prev[id] ?? 0) + delta,
|
||||||
|
}));
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||||
|
{/* Header */}
|
||||||
|
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
|
||||||
|
<div>
|
||||||
|
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||||
|
Battl Builder · Build Book
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
|
||||||
|
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
|
||||||
|
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
|
||||||
|
>
|
||||||
|
Community Builds
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
|
||||||
|
Browse community rifle builds, vote them up or down, and steal
|
||||||
|
good ideas without shame. This is placeholder content for now —
|
||||||
|
real accounts, comments, and saved builds will wire in later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2 md:gap-3">
|
||||||
|
<Link
|
||||||
|
href="/builder"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/60 px-4 py-2 text-xs md:text-sm font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
Open Builder
|
||||||
|
</Link>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
||||||
|
>
|
||||||
|
+ Submit Build (Coming Soon)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
||||||
|
Filter Builds
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Filter by caliber, platform class, and ballpark price range.
|
||||||
|
All logic is client-side placeholder until the real feed is wired
|
||||||
|
into the backend.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">
|
||||||
|
{filteredBuilds.length}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">
|
||||||
|
{DUMMY_BUILDS.length}
|
||||||
|
</span>{" "}
|
||||||
|
demo builds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{/* Caliber filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Caliber
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CALIBER_FILTERS.map((caliber) => (
|
||||||
|
<button
|
||||||
|
key={caliber}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
caliberFilter === caliber
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{caliber === "all" ? "All" : caliber}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Class
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CLASS_FILTERS.map((cls) => (
|
||||||
|
<button
|
||||||
|
key={cls}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setClassFilter(cls === "all" ? "all" : cls)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
classFilter === cls
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cls === "all" ? "All" : cls}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Price Range
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRICE_FILTERS.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriceFilterId(range.id)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
priceFilterId === range.id
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Build list */}
|
||||||
|
<section>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredBuilds.map((build) => (
|
||||||
|
<article
|
||||||
|
key={build.id}
|
||||||
|
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
||||||
|
>
|
||||||
|
{/* Votes column */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<div className="text-xs font-semibold text-amber-200">
|
||||||
|
{votes[build.id] ?? build.votes}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, -1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Placeholder thumbnail */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src="https://placehold.co/320x200/png?text=Build+Photo"
|
||||||
|
alt={`${build.title} placeholder`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
{build.buildClass} · {build.caliber}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
||||||
|
<Link
|
||||||
|
href={`/builds/${build.slug}`}
|
||||||
|
className="hover:text-amber-200 transition-colors"
|
||||||
|
>
|
||||||
|
{build.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Posted by{" "}
|
||||||
|
<span className="text-zinc-300">
|
||||||
|
b/{build.creator}
|
||||||
|
</span>{" "}
|
||||||
|
· Placeholder build listing — details, parts list, and
|
||||||
|
comments will live on the build detail page later.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<div className="text-right mt-1 md:mt-0">
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Est. Build Cost
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-amber-300">
|
||||||
|
${build.price.toLocaleString()}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{build.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
||||||
|
Comments, save, and share coming soon.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{filteredBuilds.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
||||||
|
No builds match those filters yet. Once the real feed is wired
|
||||||
|
up, this will update live as the community posts rifles, pistols,
|
||||||
|
and NFA builds.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,298 +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>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
Platform:{" "}
|
|
||||||
<span className="text-zinc-300">{product.platform}</span>
|
|
||||||
</p>
|
|
||||||
<p className="text-xs text-zinc-500">
|
|
||||||
Role: <span className="text-zinc-300">{product.partRole}</span>
|
|
||||||
</p>
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</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>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,503 +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 { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
|
|
||||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
|
||||||
|
|
||||||
type ViewMode = "card" | "list";
|
|
||||||
|
|
||||||
type GunbuilderProductFromApi = {
|
|
||||||
id: 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";
|
|
||||||
|
|
||||||
|
|
||||||
// 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<Part[]>([]);
|
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// filter + sort state
|
|
||||||
const [brandFilter, setBrandFilter] = useState<string>("all");
|
|
||||||
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
|
||||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
|
||||||
|
|
||||||
// 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);
|
|
||||||
|
|
||||||
// FIX: determine which backend partRoles map to this category
|
|
||||||
const roles = CATEGORY_TO_PART_ROLES[categoryId] ?? [];
|
|
||||||
|
|
||||||
const search = new URLSearchParams();
|
|
||||||
search.set("platform", "AR-15");
|
|
||||||
roles.forEach((r) => search.append("partRoles", r));
|
|
||||||
|
|
||||||
const url = `${API_BASE_URL}/api/products/gunbuilder${
|
|
||||||
roles.length ? `?${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: Part[] = data.map((p) => ({
|
|
||||||
id: p.id,
|
|
||||||
categoryId,
|
|
||||||
name: p.name,
|
|
||||||
brand: p.brand,
|
|
||||||
price: p.price ?? 0,
|
|
||||||
imageUrl: p.mainImageUrl ?? undefined,
|
|
||||||
url: p.buyUrl ?? undefined,
|
|
||||||
notes: undefined,
|
|
||||||
}));
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
// 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("/gunbuilder");
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
// 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],
|
|
||||||
);
|
|
||||||
|
|
||||||
// filtered + sorted parts with selected part pinned to top
|
|
||||||
const filteredParts = useMemo(() => {
|
|
||||||
let result = [...parts];
|
|
||||||
|
|
||||||
if (brandFilter !== "all") {
|
|
||||||
result = result.filter((p) => p.brand === brandFilter);
|
|
||||||
}
|
|
||||||
|
|
||||||
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]);
|
|
||||||
|
|
||||||
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="/gunbuilder"
|
|
||||||
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="/gunbuilder"
|
|
||||||
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">
|
|
||||||
{/* Filter + sort toolbar */}
|
|
||||||
{!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="text-zinc-200 font-medium">
|
|
||||||
{filteredParts.length}
|
|
||||||
</span>{" "}
|
|
||||||
of{" "}
|
|
||||||
<span className="text-zinc-200 font-medium">
|
|
||||||
{parts.length}
|
|
||||||
</span>{" "}
|
|
||||||
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 text-xs text-zinc-200 px-2 py-1 pr-6 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-zinc-500 hover:text-zinc-300 text-xs leading-none"
|
|
||||||
aria-label="Clear search"
|
|
||||||
>
|
|
||||||
×
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<label
|
|
||||||
htmlFor="brand-filter"
|
|
||||||
className="text-xs text-zinc-500"
|
|
||||||
>
|
|
||||||
Brand
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
id="brand-filter"
|
|
||||||
value={brandFilter}
|
|
||||||
onChange={(e) => setBrandFilter(e.target.value)}
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-200 px-2 py-1 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
|
||||||
>
|
|
||||||
<option value="all">All brands</option>
|
|
||||||
{availableBrands.map((b) => (
|
|
||||||
<option key={b} value={b}>
|
|
||||||
{b}
|
|
||||||
</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 text-xs text-zinc-200 px-2 py-1 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>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{loading ? (
|
|
||||||
<p className="text-sm text-zinc-500 text-center py-8">
|
|
||||||
Loading {category.name.toLowerCase()}…
|
|
||||||
</p>
|
|
||||||
) : error ? (
|
|
||||||
<p className="text-sm text-red-400 text-center py-8">
|
|
||||||
{error} — check that the Ballistic API is running.
|
|
||||||
</p>
|
|
||||||
) : filteredParts.length === 0 ? (
|
|
||||||
<p className="text-sm text-zinc-500 text-center py-8">
|
|
||||||
No parts available for this category yet.
|
|
||||||
</p>
|
|
||||||
) : viewMode === "card" ? (
|
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
|
||||||
{filteredParts.map((part) => {
|
|
||||||
const isSelected = build[categoryId] === part.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={part.id}
|
|
||||||
className={`group relative rounded-md p-3 transition-all duration-200 flex flex-col border ${
|
|
||||||
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 justify-between items-center gap-2 mb-3">
|
|
||||||
<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 text-xs text-zinc-400 line-clamp-2">
|
|
||||||
{part.notes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-sm font-semibold text-amber-300 whitespace-nowrap">
|
|
||||||
${part.price.toFixed(2)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="mt-2 flex gap-2">
|
|
||||||
<Link
|
|
||||||
href={`/gunbuilder/${categoryId}/${part.id}`}
|
|
||||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-700 hover:border-zinc-600 transition-colors text-center"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleTogglePart(categoryId, part.id)}
|
|
||||||
className={`flex-1 rounded-md px-3 py-2 text-xs font-semibold transition-colors text-center ${
|
|
||||||
isSelected
|
|
||||||
? "bg-zinc-800 text-zinc-100 border border-zinc-600 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 md:grid grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] gap-4 px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
|
|
||||||
<span>Part</span>
|
|
||||||
<span>Brand</span>
|
|
||||||
<span className="text-right">Price</span>
|
|
||||||
<span className="text-right pr-2">Actions</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
{filteredParts.map((part) => {
|
|
||||||
const isSelected = build[categoryId] === part.id;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div
|
|
||||||
key={part.id}
|
|
||||||
className={`group relative rounded-md p-3 transition-all duration-200 border ${
|
|
||||||
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="flex-1 min-w-0">
|
|
||||||
<div className="text-sm font-semibold text-zinc-50">
|
|
||||||
{part.name}
|
|
||||||
</div>
|
|
||||||
{part.notes && (
|
|
||||||
<p className="mt-1 text-xs text-zinc-400 line-clamp-1">
|
|
||||||
{part.notes}
|
|
||||||
</p>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
<div className="text-xs md:text-sm text-zinc-400 md:text-left">
|
|
||||||
{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={`/gunbuilder/${categoryId}/${part.id}`}
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-700 hover:border-zinc-600 transition-colors whitespace-nowrap"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
handleTogglePart(categoryId, part.id)
|
|
||||||
}
|
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-semibold transition-colors whitespace-nowrap ${
|
|
||||||
isSelected
|
|
||||||
? "bg-zinc-800 text-zinc-100 border border-zinc-600 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>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -1,356 +0,0 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
import { useEffect, useState, useMemo } from "react";
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
type Merchant = {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
};
|
|
||||||
|
|
||||||
type Mapping = {
|
|
||||||
id: number | null; // might be null if just created client-side
|
|
||||||
merchantId: number;
|
|
||||||
merchantName: string;
|
|
||||||
rawCategory: string;
|
|
||||||
mappedPartRole: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
type UpsertRequest = {
|
|
||||||
merchantId: number;
|
|
||||||
rawCategory: string;
|
|
||||||
mappedPartRole: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
// Keep this in sync with your backend `partRole` values
|
|
||||||
const PART_ROLE_OPTIONS = [
|
|
||||||
{ value: "", label: "— Unmapped —" },
|
|
||||||
// Lower / fire control
|
|
||||||
{ value: "lower-receiver", label: "Lower Receiver" },
|
|
||||||
{ value: "lower-parts-kit", label: "Lower Parts Kit" },
|
|
||||||
{ value: "trigger", label: "Trigger / Trigger Kit" },
|
|
||||||
{ value: "pistol-grip", label: "Pistol Grip" },
|
|
||||||
{ value: "safety-selector", label: "Safety Selector" },
|
|
||||||
|
|
||||||
// Upper
|
|
||||||
{ value: "upper-receiver", label: "Upper Receiver" },
|
|
||||||
{ value: "barrel", label: "Barrel" },
|
|
||||||
{ value: "handguard", label: "Handguard" },
|
|
||||||
{ value: "gas-system", label: "Gas System / Gas Block" },
|
|
||||||
{ value: "muzzle-device", label: "Muzzle Device" },
|
|
||||||
{ value: "suppressor", label: "Suppressor" },
|
|
||||||
|
|
||||||
// Furniture / sights / optics
|
|
||||||
{ value: "stock", label: "Stock" },
|
|
||||||
{ value: "buffer-kit", label: "Buffer Kit" },
|
|
||||||
{ value: "charging-handle", label: "Charging Handle" },
|
|
||||||
{ value: "sight", label: "Iron / Backup Sight" },
|
|
||||||
{ value: "optic", label: "Optic" },
|
|
||||||
{ value: "other", label: "Other / Ignore" },
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function CategoryMappingsPage() {
|
|
||||||
const [merchants, setMerchants] = useState<Merchant[]>([]);
|
|
||||||
const [selectedMerchantId, setSelectedMerchantId] = useState<number | null>(
|
|
||||||
null,
|
|
||||||
);
|
|
||||||
const [mappings, setMappings] = useState<Mapping[]>([]);
|
|
||||||
const [loadingMappings, setLoadingMappings] = useState(false);
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [successMessage, setSuccessMessage] = useState<string | null>(null);
|
|
||||||
const [showOnlyUnmapped, setShowOnlyUnmapped] = useState(false);
|
|
||||||
|
|
||||||
const sortedMappings = useMemo(() => {
|
|
||||||
// Mapped rows first, then unmapped; within each group, alpha by rawCategory
|
|
||||||
return [...mappings].sort((a, b) => {
|
|
||||||
const aMapped = !!a.mappedPartRole;
|
|
||||||
const bMapped = !!b.mappedPartRole;
|
|
||||||
|
|
||||||
if (aMapped !== bMapped) {
|
|
||||||
return aMapped ? -1 : 1; // mapped first
|
|
||||||
}
|
|
||||||
|
|
||||||
return a.rawCategory.localeCompare(b.rawCategory);
|
|
||||||
});
|
|
||||||
}, [mappings]);
|
|
||||||
|
|
||||||
const visibleMappings = useMemo(
|
|
||||||
() =>
|
|
||||||
showOnlyUnmapped
|
|
||||||
? sortedMappings.filter((m) => !m.mappedPartRole)
|
|
||||||
: sortedMappings,
|
|
||||||
[sortedMappings, showOnlyUnmapped],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Load merchants
|
|
||||||
useEffect(() => {
|
|
||||||
async function fetchMerchants() {
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/admin/merchants`);
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to load merchants (${res.status})`);
|
|
||||||
}
|
|
||||||
const data: Merchant[] = await res.json();
|
|
||||||
setMerchants(data);
|
|
||||||
|
|
||||||
// Auto-select the first merchant if you want
|
|
||||||
if (data.length > 0 && selectedMerchantId === null) {
|
|
||||||
setSelectedMerchantId(data[0].id);
|
|
||||||
}
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
setError(err.message ?? "Failed to load merchants");
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchMerchants();
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Load mappings for selected merchant
|
|
||||||
useEffect(() => {
|
|
||||||
if (!selectedMerchantId) return;
|
|
||||||
|
|
||||||
const controller = new AbortController();
|
|
||||||
|
|
||||||
async function fetchMappings() {
|
|
||||||
try {
|
|
||||||
setLoadingMappings(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccessMessage(null);
|
|
||||||
|
|
||||||
const url = `${API_BASE_URL}/admin/merchant-category-mappings?merchantId=${selectedMerchantId}`;
|
|
||||||
const res = await fetch(url, { signal: controller.signal });
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
throw new Error(`Failed to load mappings (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as Mapping[];
|
|
||||||
setMappings(data);
|
|
||||||
} catch (err: any) {
|
|
||||||
if (err.name === "AbortError") return;
|
|
||||||
console.error(err);
|
|
||||||
setError(err.message ?? "Failed to load mappings");
|
|
||||||
} finally {
|
|
||||||
setLoadingMappings(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fetchMappings();
|
|
||||||
|
|
||||||
return () => controller.abort();
|
|
||||||
}, [selectedMerchantId]);
|
|
||||||
|
|
||||||
const handleChangePartRole = (rawCategory: string, newRole: string) => {
|
|
||||||
setMappings((prev) =>
|
|
||||||
prev.map((m) =>
|
|
||||||
m.rawCategory === rawCategory
|
|
||||||
? { ...m, mappedPartRole: newRole || null }
|
|
||||||
: m,
|
|
||||||
),
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleSave = async () => {
|
|
||||||
if (!selectedMerchantId) return;
|
|
||||||
setSaving(true);
|
|
||||||
setError(null);
|
|
||||||
setSuccessMessage(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
// Save each mapping in parallel
|
|
||||||
const requests: Promise<Response>[] = mappings.map((m) => {
|
|
||||||
const payload: UpsertRequest = {
|
|
||||||
merchantId: selectedMerchantId,
|
|
||||||
rawCategory: m.rawCategory,
|
|
||||||
mappedPartRole: m.mappedPartRole,
|
|
||||||
};
|
|
||||||
|
|
||||||
return fetch(`${API_BASE_URL}/admin/merchant-category-mappings`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
},
|
|
||||||
body: JSON.stringify(payload),
|
|
||||||
});
|
|
||||||
});
|
|
||||||
|
|
||||||
const responses = await Promise.all(requests);
|
|
||||||
const failed = responses.filter((r) => !r.ok);
|
|
||||||
|
|
||||||
if (failed.length > 0) {
|
|
||||||
throw new Error(`Some mappings failed to save (${failed.length})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
setSuccessMessage("Mappings saved. Re-run the import to apply changes.");
|
|
||||||
} catch (err: any) {
|
|
||||||
console.error(err);
|
|
||||||
setError(err.message ?? "Failed to save mappings");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const selectedMerchantName =
|
|
||||||
merchants.find((m) => m.id === selectedMerchantId)?.name ?? "—";
|
|
||||||
|
|
||||||
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 md:flex-row md:items-end md:justify-between">
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
|
||||||
Shadow Standard / Admin
|
|
||||||
</p>
|
|
||||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
|
||||||
Merchant Category Mapping
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
|
||||||
Normalize each merchant's category names into your internal
|
|
||||||
part roles (upper, barrel, handguard, etc.). Unmapped categories
|
|
||||||
will be skipped during import until they are mapped here.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="block text-xs font-semibold text-zinc-400 mb-1">
|
|
||||||
Select Merchant
|
|
||||||
</label>
|
|
||||||
<select
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-100"
|
|
||||||
value={selectedMerchantId ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
setSelectedMerchantId(
|
|
||||||
e.target.value ? Number(e.target.value) : null,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
<option value="">— Choose merchant —</option>
|
|
||||||
{merchants.map((m) => (
|
|
||||||
<option key={m.id} value={m.id}>
|
|
||||||
{m.name}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</div>
|
|
||||||
</header>
|
|
||||||
|
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
|
||||||
{error && (
|
|
||||||
<div className="mb-3 rounded border border-red-500/60 bg-red-500/10 px-3 py-2 text-xs text-red-200">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{successMessage && (
|
|
||||||
<div className="mb-3 rounded border border-emerald-500/60 bg-emerald-500/10 px-3 py-2 text-xs text-emerald-200">
|
|
||||||
{successMessage}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{!selectedMerchantId ? (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
Choose a merchant above to view category mappings.
|
|
||||||
</p>
|
|
||||||
) : loadingMappings ? (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
Loading mappings for <span className="text-zinc-300">{selectedMerchantName}</span>…
|
|
||||||
</p>
|
|
||||||
) : mappings.length === 0 ? (
|
|
||||||
<p className="text-sm text-zinc-500">
|
|
||||||
No category rows yet for this merchant. Once you run an import,
|
|
||||||
discovered categories will appear here automatically.
|
|
||||||
</p>
|
|
||||||
) : (
|
|
||||||
<>
|
|
||||||
<div className="mb-3 flex items-center justify-between gap-3 text-xs text-zinc-500">
|
|
||||||
<div>
|
|
||||||
Mappings for{" "}
|
|
||||||
<span className="text-zinc-300">{selectedMerchantName}</span>.{" "}
|
|
||||||
Set a part role for each raw category. Unmapped categories will
|
|
||||||
be skipped by the importer.
|
|
||||||
</div>
|
|
||||||
<label className="inline-flex items-center gap-1 text-[0.7rem] text-zinc-400">
|
|
||||||
<input
|
|
||||||
type="checkbox"
|
|
||||||
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900"
|
|
||||||
checked={showOnlyUnmapped}
|
|
||||||
onChange={(e) => setShowOnlyUnmapped(e.target.checked)}
|
|
||||||
/>
|
|
||||||
<span>Show only unmapped</span>
|
|
||||||
</label>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="overflow-x-auto">
|
|
||||||
<table className="min-w-full text-sm">
|
|
||||||
<thead>
|
|
||||||
<tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500">
|
|
||||||
<th className="py-2 text-left pr-4">Raw Category</th>
|
|
||||||
<th className="py-2 text-left pr-4">Mapped Part Role</th>
|
|
||||||
<th className="py-2 text-left pr-4">Status</th>
|
|
||||||
</tr>
|
|
||||||
</thead>
|
|
||||||
<tbody>
|
|
||||||
{visibleMappings.map((m) => (
|
|
||||||
<tr
|
|
||||||
key={m.rawCategory}
|
|
||||||
className="border-b border-zinc-900 last:border-b-0"
|
|
||||||
>
|
|
||||||
<td className="py-2 pr-4 align-top text-zinc-200">
|
|
||||||
{m.rawCategory}
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 align-top">
|
|
||||||
<select
|
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100"
|
|
||||||
value={m.mappedPartRole ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
handleChangePartRole(
|
|
||||||
m.rawCategory,
|
|
||||||
e.target.value,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{PART_ROLE_OPTIONS.map((opt) => (
|
|
||||||
<option key={opt.value} value={opt.value}>
|
|
||||||
{opt.label}
|
|
||||||
</option>
|
|
||||||
))}
|
|
||||||
</select>
|
|
||||||
</td>
|
|
||||||
<td className="py-2 pr-4 align-top text-sm">
|
|
||||||
{m.mappedPartRole ? (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-emerald-500/10 px-2 py-0.5 text-[0.7rem] font-medium text-emerald-300">
|
|
||||||
Mapped
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
<span className="inline-flex items-center rounded-full bg-zinc-700/40 px-2 py-0.5 text-[0.7rem] font-medium text-zinc-300">
|
|
||||||
Unmapped / Ignored
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</td>
|
|
||||||
</tr>
|
|
||||||
))}
|
|
||||||
</tbody>
|
|
||||||
</table>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-4 flex justify-end">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleSave}
|
|
||||||
disabled={saving}
|
|
||||||
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? "Saving…" : "Save Mappings"}
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
+9
-3
@@ -1,16 +1,22 @@
|
|||||||
"use client";
|
|
||||||
|
|
||||||
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"; // or default import if that's how you exported it
|
import { TopNav } from "@/components/TopNav";
|
||||||
import { BuilderNav } from "@/components/BuilderNav";
|
import { BuilderNav } from "@/components/BuilderNav";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: {
|
||||||
|
default: "Battl Builder",
|
||||||
|
template: "%s | Battl Builder",
|
||||||
|
},
|
||||||
|
description: "Battl Builder — the smarter, faster, data‑driven way to build your next firearm.",
|
||||||
|
};
|
||||||
|
|
||||||
export default function 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-100">
|
||||||
{/* AuthProvider wraps everything that uses useAuth */}
|
|
||||||
<AuthProvider>
|
<AuthProvider>
|
||||||
<TopNav />
|
<TopNav />
|
||||||
<BuilderNav />
|
<BuilderNav />
|
||||||
|
|||||||
@@ -1,73 +0,0 @@
|
|||||||
import { apiFetch } from "@/lib/useApi";
|
|
||||||
|
|
||||||
export type AdminCategory = {
|
|
||||||
id: number;
|
|
||||||
slug: string;
|
|
||||||
name: string;
|
|
||||||
description?: string | null;
|
|
||||||
groupName?: string | null;
|
|
||||||
sortOrder?: number | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export type AdminPartRoleMapping = {
|
|
||||||
id: number;
|
|
||||||
platform: string;
|
|
||||||
partRole: string;
|
|
||||||
categorySlug: string;
|
|
||||||
categoryName: string;
|
|
||||||
groupName?: string | null;
|
|
||||||
notes?: string | null;
|
|
||||||
};
|
|
||||||
|
|
||||||
export async function fetchAdminCategories(token: string) {
|
|
||||||
return apiFetch<AdminCategory[]>("/api/admin/categories", {
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function fetchPartRoleMappings(token: string, platform = "AR-15") {
|
|
||||||
const params = new URLSearchParams({ platform });
|
|
||||||
return apiFetch<AdminPartRoleMapping[]>(`/api/admin/category-mappings?${params.toString()}`, {
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function createPartRoleMapping(
|
|
||||||
token: string,
|
|
||||||
payload: {
|
|
||||||
platform: string;
|
|
||||||
partRole: string;
|
|
||||||
categorySlug: string;
|
|
||||||
notes?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return apiFetch<AdminPartRoleMapping>("/api/admin/category-mappings", {
|
|
||||||
method: "POST",
|
|
||||||
token,
|
|
||||||
body: payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function updatePartRoleMapping(
|
|
||||||
token: string,
|
|
||||||
id: number,
|
|
||||||
payload: {
|
|
||||||
platform: string;
|
|
||||||
partRole: string;
|
|
||||||
categorySlug: string;
|
|
||||||
notes?: string;
|
|
||||||
},
|
|
||||||
) {
|
|
||||||
return apiFetch<AdminPartRoleMapping>(`/api/admin/category-mappings/${id}`, {
|
|
||||||
method: "PUT",
|
|
||||||
token,
|
|
||||||
body: payload,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
export async function deletePartRoleMapping(token: string, id: number) {
|
|
||||||
await apiFetch<void>(`/api/admin/category-mappings/${id}`, {
|
|
||||||
method: "DELETE",
|
|
||||||
token,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
+12
-12
@@ -5,27 +5,27 @@ export default function HomePage() {
|
|||||||
<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 flex items-center justify-center px-4">
|
||||||
<div className="max-w-xl text-center">
|
<div className="max-w-xl text-center">
|
||||||
<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">
|
||||||
Shadow Standard Co.
|
For Those Who Build With Intent.
|
||||||
</p>
|
</p>
|
||||||
<h1 className="mt-2 text-3xl md:text-4xl font-semibold tracking-tight">
|
<h1
|
||||||
The Build Bench
|
className="mt-2 text-4xl md:text-5xl font-extrabold tracking-tight
|
||||||
|
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
|
||||||
|
text-transparent uppercase bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
|
||||||
|
>
|
||||||
|
Battl Builder
|
||||||
</h1>
|
</h1>
|
||||||
<p className="mt-3 text-sm md:text-base text-zinc-400">
|
<p className="mt-3 text-sm md:text-base text-zinc-400">
|
||||||
Shadow Standard’s Armory Build Bench isn’t a catalog — it’s a
|
Battl Builder is your command center for rifle builds. We surface live
|
||||||
workbench for people who take their rifles, and their craft,
|
parts and hard-use brands, giving you a mission-ready workbench to
|
||||||
seriously. We pull live parts and pricing from the brands that matter,
|
design smarter, track cost, swap components, and save setups. No
|
||||||
lay everything out with purpose, and let you shape a rifle the same
|
fluff. Just clean, trustworthy data.
|
||||||
way you’d shape a hunt or a mission: one smart choice at a time. Track
|
|
||||||
your build cost, swap components on the fly, save your setup, and
|
|
||||||
share it with the crew. No noise. No gimmicks. Just clean information
|
|
||||||
and the tools to build something worth carrying.
|
|
||||||
</p>
|
</p>
|
||||||
<div className="mt-6">
|
<div className="mt-6">
|
||||||
<Link
|
<Link
|
||||||
href="/gunbuilder"
|
href="/gunbuilder"
|
||||||
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"
|
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"
|
||||||
>
|
>
|
||||||
Open The Build Bench
|
Launch The Builder
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PriceHistoryPoint } from "@/app/gunbuilder/[categoryId]/[partId]/data";
|
import type { PriceHistoryPoint } from "@/app/builder/[categoryId]/[partId]/data";
|
||||||
|
|
||||||
interface PricingHistoryGraphProps {
|
interface PricingHistoryGraphProps {
|
||||||
data: PriceHistoryPoint[];
|
data: PriceHistoryPoint[];
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RetailerOffer } from "@/app/gunbuilder/[categoryId]/[partId]/data";
|
import type { RetailerOffer } from "@/app/builder/[categoryId]/[partId]/data";
|
||||||
|
|
||||||
interface RetailersListProps {
|
interface RetailersListProps {
|
||||||
retailers: RetailerOffer[];
|
retailers: RetailerOffer[];
|
||||||
|
|||||||
+28
-4
@@ -7,8 +7,11 @@ import { useAuth } from "@/context/AuthContext";
|
|||||||
export function TopNav() {
|
export function TopNav() {
|
||||||
const { user, logout, loading } = useAuth();
|
const { user, logout, loading } = useAuth();
|
||||||
|
|
||||||
|
const isAuthenticated = !!user;
|
||||||
|
|
||||||
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="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="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">
|
<div className="flex items-center gap-2">
|
||||||
@@ -25,15 +28,36 @@ export function TopNav() {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* 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">
|
||||||
{/* Brand / Home link */}
|
{/* Left: Brand / Home */}
|
||||||
<Link
|
<Link
|
||||||
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"
|
||||||
>
|
>
|
||||||
The Build Bench
|
Battl Builder
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
|
{/* Center: main nav (Builder / Admin) */}
|
||||||
|
<nav className="hidden items-center gap-4 md:flex">
|
||||||
|
<Link
|
||||||
|
href="/gunbuilder"
|
||||||
|
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 */}
|
||||||
@@ -59,9 +83,9 @@ export function TopNav() {
|
|||||||
|
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<span className="text-xs text-zinc-500">Checking session…</span>
|
<span className="text-xs text-zinc-500">Checking session…</span>
|
||||||
) : user ? (
|
) : isAuthenticated ? (
|
||||||
<>
|
<>
|
||||||
<span className="text-xs text-zinc-300">
|
<span className="hidden text-xs text-zinc-300 sm:inline">
|
||||||
{user.displayName || user.email}
|
{user.displayName || user.email}
|
||||||
</span>
|
</span>
|
||||||
<button
|
<button
|
||||||
|
|||||||
+86
-83
@@ -20,8 +20,8 @@ type AuthUser = {
|
|||||||
|
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
loading: boolean; // covers initial hydrate + active auth requests
|
loading: boolean;
|
||||||
login: (params: { email: string; password: string }) => Promise<void>;
|
login: (params: { email: string; password: string }) => Promise<void>;
|
||||||
register: (params: {
|
register: (params: {
|
||||||
email: string;
|
email: string;
|
||||||
@@ -38,136 +38,138 @@ const TOKEN_KEY = "ballistic_auth_token";
|
|||||||
const USER_KEY = "ballistic_auth_user";
|
const USER_KEY = "ballistic_auth_user";
|
||||||
|
|
||||||
export function AuthProvider({ children }: { children: ReactNode }) {
|
export function AuthProvider({ children }: { children: ReactNode }) {
|
||||||
const [user, setUser] = useState<AuthUser>(null);
|
|
||||||
const [token, setToken] = useState<string | null>(null);
|
const [token, setToken] = useState<string | null>(null);
|
||||||
const [loading, setLoading] = useState(true); // start true while we hydrate + reuse for calls
|
const [user, setUser] = useState<AuthUser>(null);
|
||||||
|
const [loading, setLoading] = useState<boolean>(true);
|
||||||
|
|
||||||
// hydrate from localStorage
|
// Hydrate from localStorage on first load
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
try {
|
if (typeof window === "undefined") return;
|
||||||
if (typeof window === "undefined") return;
|
|
||||||
|
|
||||||
const storedToken = window.localStorage.getItem(TOKEN_KEY);
|
const storedToken = window.localStorage.getItem(TOKEN_KEY);
|
||||||
const storedUser = window.localStorage.getItem(USER_KEY);
|
const storedUser = window.localStorage.getItem(USER_KEY);
|
||||||
|
|
||||||
if (storedToken && storedUser) {
|
if (storedToken) {
|
||||||
setToken(storedToken);
|
setToken(storedToken);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (storedUser) {
|
||||||
|
try {
|
||||||
setUser(JSON.parse(storedUser));
|
setUser(JSON.parse(storedUser));
|
||||||
|
} catch {
|
||||||
|
// bad JSON? wipe it
|
||||||
|
window.localStorage.removeItem(USER_KEY);
|
||||||
}
|
}
|
||||||
} catch {
|
|
||||||
// ignore
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
setLoading(false);
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const setSession = useCallback((newToken: string, newUser: AuthUser) => {
|
const persistAuth = useCallback((nextToken: string | null, nextUser: AuthUser) => {
|
||||||
setToken(newToken);
|
if (typeof window === "undefined") return;
|
||||||
setUser(newUser);
|
|
||||||
|
|
||||||
if (typeof window !== "undefined") {
|
if (nextToken) {
|
||||||
window.localStorage.setItem(TOKEN_KEY, newToken);
|
window.localStorage.setItem(TOKEN_KEY, nextToken);
|
||||||
window.localStorage.setItem(USER_KEY, JSON.stringify(newUser));
|
} else {
|
||||||
}
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const clearSession = useCallback(() => {
|
|
||||||
setToken(null);
|
|
||||||
setUser(null);
|
|
||||||
if (typeof window !== "undefined") {
|
|
||||||
window.localStorage.removeItem(TOKEN_KEY);
|
window.localStorage.removeItem(TOKEN_KEY);
|
||||||
|
}
|
||||||
|
|
||||||
|
if (nextUser) {
|
||||||
|
window.localStorage.setItem(USER_KEY, JSON.stringify(nextUser));
|
||||||
|
} else {
|
||||||
window.localStorage.removeItem(USER_KEY);
|
window.localStorage.removeItem(USER_KEY);
|
||||||
}
|
}
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
const register = useCallback(
|
const login = useCallback(
|
||||||
async (params: { email: string; password: string; displayName?: string }) => {
|
async ({ email, password }: { email: string; password: string }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
const res = await fetch(`${API_BASE_URL}/auth/login`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ email, password }),
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: params.email,
|
|
||||||
password: params.password,
|
|
||||||
displayName: params.displayName,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text().catch(() => res.statusText);
|
||||||
throw new Error(text || "Failed to create account");
|
throw new Error(text || "Login failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: {
|
const data = await res.json();
|
||||||
token: string;
|
|
||||||
email: string;
|
|
||||||
displayName?: string;
|
|
||||||
role: string;
|
|
||||||
} = await res.json();
|
|
||||||
|
|
||||||
setSession(data.token, {
|
// Adjust these to match your backend response shape
|
||||||
email: data.email,
|
const nextToken: string = data.token ?? data.accessToken;
|
||||||
displayName: data.displayName ?? null,
|
const nextUser: AuthUser =
|
||||||
role: data.role,
|
data.user ??
|
||||||
});
|
({
|
||||||
|
email: data.email ?? email,
|
||||||
|
displayName: data.displayName ?? null,
|
||||||
|
role: data.role ?? "USER",
|
||||||
|
} as AuthUser);
|
||||||
|
|
||||||
|
setToken(nextToken);
|
||||||
|
setUser(nextUser);
|
||||||
|
persistAuth(nextToken, nextUser);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setSession],
|
[persistAuth]
|
||||||
);
|
);
|
||||||
|
|
||||||
const login = useCallback(
|
const register = useCallback(
|
||||||
async (params: { email: string; password: string }) => {
|
async ({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
}: {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
displayName?: string;
|
||||||
|
}) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
const res = await fetch(`${API_BASE_URL}/auth/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: {
|
headers: { "Content-Type": "application/json" },
|
||||||
"Content-Type": "application/json",
|
body: JSON.stringify({ email, password, displayName }),
|
||||||
},
|
|
||||||
body: JSON.stringify({
|
|
||||||
email: params.email,
|
|
||||||
password: params.password,
|
|
||||||
}),
|
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text();
|
const text = await res.text().catch(() => res.statusText);
|
||||||
throw new Error(text || "Failed to log in");
|
throw new Error(text || "Registration failed");
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: {
|
const data = await res.json();
|
||||||
token: string;
|
|
||||||
email: string;
|
|
||||||
displayName?: string;
|
|
||||||
role: string;
|
|
||||||
} = await res.json();
|
|
||||||
|
|
||||||
setSession(data.token, {
|
const nextToken: string = data.token ?? data.accessToken;
|
||||||
email: data.email,
|
const nextUser: AuthUser =
|
||||||
displayName: data.displayName ?? null,
|
data.user ??
|
||||||
role: data.role,
|
({
|
||||||
});
|
email: data.email ?? email,
|
||||||
|
displayName: data.displayName ?? displayName ?? null,
|
||||||
|
role: data.role ?? "USER",
|
||||||
|
} as AuthUser);
|
||||||
|
|
||||||
|
setToken(nextToken);
|
||||||
|
setUser(nextUser);
|
||||||
|
persistAuth(nextToken, nextUser);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
[setSession],
|
[persistAuth]
|
||||||
);
|
);
|
||||||
|
|
||||||
const logout = useCallback(() => {
|
const logout = useCallback(() => {
|
||||||
clearSession();
|
setToken(null);
|
||||||
}, [clearSession]);
|
setUser(null);
|
||||||
|
persistAuth(null, null);
|
||||||
|
}, [persistAuth]);
|
||||||
|
|
||||||
const getAuthHeaders = useCallback((): HeadersInit => {
|
const getAuthHeaders = useCallback((): HeadersInit => {
|
||||||
if (!token) return {};
|
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||||
return {
|
|
||||||
Authorization: `Bearer ${token}`,
|
|
||||||
};
|
|
||||||
}, [token]);
|
}, [token]);
|
||||||
|
|
||||||
const value: AuthContextValue = {
|
const value: AuthContextValue = {
|
||||||
@@ -183,6 +185,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 🔑 This is what your useApi hook imports
|
||||||
export function useAuth(): AuthContextValue {
|
export function useAuth(): AuthContextValue {
|
||||||
const ctx = useContext(AuthContext);
|
const ctx = useContext(AuthContext);
|
||||||
if (!ctx) {
|
if (!ctx) {
|
||||||
|
|||||||
@@ -0,0 +1,134 @@
|
|||||||
|
// lib/api/adminCategoryMappings.ts
|
||||||
|
|
||||||
|
const API_BASE =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
export interface AdminCategory {
|
||||||
|
id: number;
|
||||||
|
slug: string;
|
||||||
|
name: string;
|
||||||
|
description?: string | null;
|
||||||
|
groupName?: string | null;
|
||||||
|
sortOrder?: number | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface AdminPartRoleMapping {
|
||||||
|
id: number;
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
categorySlug: string;
|
||||||
|
groupName?: string | null;
|
||||||
|
notes?: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CreatePartRoleMappingRequest {
|
||||||
|
platform: string;
|
||||||
|
partRole: string;
|
||||||
|
categorySlug: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface UpdatePartRoleMappingRequest {
|
||||||
|
platform?: string;
|
||||||
|
partRole?: string;
|
||||||
|
categorySlug?: string;
|
||||||
|
notes?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function apiFetch<T>(
|
||||||
|
path: string,
|
||||||
|
options: {
|
||||||
|
token?: string;
|
||||||
|
method?: string;
|
||||||
|
body?: any;
|
||||||
|
} = {}
|
||||||
|
): Promise<T> {
|
||||||
|
const { token, method = "GET", body } = options;
|
||||||
|
|
||||||
|
const headers: Record<string, string> = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
};
|
||||||
|
if (token) {
|
||||||
|
headers["Authorization"] = `Bearer ${token}`;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE}${path}`, {
|
||||||
|
method,
|
||||||
|
headers,
|
||||||
|
body: body ? JSON.stringify(body) : undefined,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`API error ${res.status}: ${res.statusText}${text ? ` – ${text}` : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (await res.json()) as T;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchAdminCategories(token: string) {
|
||||||
|
return apiFetch<AdminCategory[]>("/api/admin/categories", { token });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------- Part-role mappings ----------------
|
||||||
|
|
||||||
|
const PART_ROLE_BASE = "/api/admin/part-role-mappings";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fetch part-role/category mappings scoped by platform.
|
||||||
|
* Backend should expose: GET /api/admin/part-role-mappings?platform=AR-15
|
||||||
|
*/
|
||||||
|
export async function fetchPartRoleMappings(
|
||||||
|
token: string,
|
||||||
|
platform = "AR-15"
|
||||||
|
) {
|
||||||
|
const params = new URLSearchParams({ platform });
|
||||||
|
return apiFetch<AdminPartRoleMapping[]>(
|
||||||
|
`${PART_ROLE_BASE}?${params.toString()}`,
|
||||||
|
{ token }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Create a new part-role → category mapping
|
||||||
|
* POST /api/admin/part-role-mappings
|
||||||
|
*/
|
||||||
|
export async function createPartRoleMapping(
|
||||||
|
token: string,
|
||||||
|
payload: CreatePartRoleMappingRequest
|
||||||
|
) {
|
||||||
|
return apiFetch<AdminPartRoleMapping>(PART_ROLE_BASE, {
|
||||||
|
token,
|
||||||
|
method: "POST",
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Update an existing part-role mapping
|
||||||
|
* PUT /api/admin/part-role-mappings/{id}
|
||||||
|
*/
|
||||||
|
export async function updatePartRoleMapping(
|
||||||
|
token: string,
|
||||||
|
id: number,
|
||||||
|
payload: UpdatePartRoleMappingRequest
|
||||||
|
) {
|
||||||
|
return apiFetch<AdminPartRoleMapping>(`${PART_ROLE_BASE}/${id}`, {
|
||||||
|
token,
|
||||||
|
method: "PUT",
|
||||||
|
body: payload,
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Delete a part-role mapping
|
||||||
|
* DELETE /api/admin/part-role-mappings/{id}
|
||||||
|
*/
|
||||||
|
export async function deletePartRoleMapping(token: string, id: number) {
|
||||||
|
await apiFetch<void>(`${PART_ROLE_BASE}/${id}`, {
|
||||||
|
token,
|
||||||
|
method: "DELETE",
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,60 @@
|
|||||||
|
const API_BASE =
|
||||||
|
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
export interface AdminMerchantCategoryMapping {
|
||||||
|
id: number;
|
||||||
|
merchantId: number;
|
||||||
|
merchantName: string;
|
||||||
|
rawCategoryPath: string;
|
||||||
|
partCategorySlug: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function fetchMerchantCategoryMappings(
|
||||||
|
token: string,
|
||||||
|
filters?: { merchantId?: number }
|
||||||
|
): Promise<AdminMerchantCategoryMapping[]> {
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
|
||||||
|
if (filters?.merchantId) {
|
||||||
|
params.append("merchantId", String(filters.merchantId));
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE}/api/admin/category-mappings?${params.toString()}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`API error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function updateMerchantCategoryMapping(
|
||||||
|
token: string,
|
||||||
|
id: number,
|
||||||
|
payload: { partCategorySlug: string | null }
|
||||||
|
): Promise<AdminMerchantCategoryMapping> {
|
||||||
|
const res = await fetch(`${API_BASE}/api/admin/category-mappings/${id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
body: JSON.stringify(payload),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text();
|
||||||
|
throw new Error(`API error ${res.status}: ${text}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
return res.json();
|
||||||
|
}
|
||||||
@@ -0,0 +1,87 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { useCallback, useMemo } from "react";
|
||||||
|
|
||||||
|
const DEFAULT_API_BASE =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||||
|
|
||||||
|
export function useApi(baseUrl: string = DEFAULT_API_BASE) {
|
||||||
|
const { token } = useAuth();
|
||||||
|
|
||||||
|
const apiFetch = useCallback(
|
||||||
|
async <T = any>(path: string, options: RequestInit = {}): Promise<T> => {
|
||||||
|
// Allow either full URLs or just `/api/...`
|
||||||
|
const url = path.startsWith("http")
|
||||||
|
? path
|
||||||
|
: `${baseUrl.replace(/\/$/, "")}${path}`;
|
||||||
|
|
||||||
|
const headers: HeadersInit = {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(options.headers || {}),
|
||||||
|
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||||
|
};
|
||||||
|
|
||||||
|
const res = await fetch(url, {
|
||||||
|
...options,
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
let bodyText = "";
|
||||||
|
try {
|
||||||
|
bodyText = await res.text();
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
|
throw new Error(
|
||||||
|
`API error ${res.status}: ${
|
||||||
|
bodyText || res.statusText || "Unknown error"
|
||||||
|
}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// handle 204 / empty body safely
|
||||||
|
try {
|
||||||
|
return (await res.json()) as T;
|
||||||
|
} catch {
|
||||||
|
return {} as T;
|
||||||
|
}
|
||||||
|
},
|
||||||
|
[baseUrl, token] // 👈 only changes when baseUrl or token change
|
||||||
|
);
|
||||||
|
|
||||||
|
const get = useCallback(
|
||||||
|
<T = any>(path: string) => apiFetch<T>(path, { method: "GET" }),
|
||||||
|
[apiFetch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const post = useCallback(
|
||||||
|
<T = any>(path: string, body?: unknown) =>
|
||||||
|
apiFetch<T>(path, {
|
||||||
|
method: "POST",
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
}),
|
||||||
|
[apiFetch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const put = useCallback(
|
||||||
|
<T = any>(path: string, body?: unknown) =>
|
||||||
|
apiFetch<T>(path, {
|
||||||
|
method: "PUT",
|
||||||
|
body: body !== undefined ? JSON.stringify(body) : undefined,
|
||||||
|
}),
|
||||||
|
[apiFetch]
|
||||||
|
);
|
||||||
|
|
||||||
|
const del = useCallback(
|
||||||
|
<T = any>(path: string) => apiFetch<T>(path, { method: "DELETE" }),
|
||||||
|
[apiFetch]
|
||||||
|
);
|
||||||
|
|
||||||
|
// Optional: memoize the returned object so destructuring doesn't change refs
|
||||||
|
return useMemo(
|
||||||
|
() => ({ apiFetch, get, post, put, del }),
|
||||||
|
[apiFetch, get, post, put, del]
|
||||||
|
);
|
||||||
|
}
|
||||||
Generated
+21
@@ -8,6 +8,8 @@
|
|||||||
"name": "gunbuilder-prototype",
|
"name": "gunbuilder-prototype",
|
||||||
"version": "0.1.0",
|
"version": "0.1.0",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
@@ -1608,6 +1610,15 @@
|
|||||||
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
"integrity": "sha512-IV3Ou0jSMzZrd3pZ48nLkT9DA7Ag1pnPzaiQhpW7c3RbcqqzvzzVu+L8gfqMp/8IM2MQtSiqaCxrrcfu8I8rMA==",
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/clsx": {
|
||||||
|
"version": "2.1.1",
|
||||||
|
"resolved": "https://registry.npmjs.org/clsx/-/clsx-2.1.1.tgz",
|
||||||
|
"integrity": "sha512-eYm0QWBtUrBWZWG0d386OGAw16Z995PiOVo2B7bjWSbHedGl5e0ZWaq65kOGgUSNesEIDkB9ISbTg/JK9dhCZA==",
|
||||||
|
"license": "MIT",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/color-convert": {
|
"node_modules/color-convert": {
|
||||||
"version": "2.0.1",
|
"version": "2.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/color-convert/-/color-convert-2.0.1.tgz",
|
||||||
@@ -2253,6 +2264,7 @@
|
|||||||
"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",
|
||||||
@@ -3816,6 +3828,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "ISC"
|
"license": "ISC"
|
||||||
},
|
},
|
||||||
|
"node_modules/lucide-react": {
|
||||||
|
"version": "0.555.0",
|
||||||
|
"resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.555.0.tgz",
|
||||||
|
"integrity": "sha512-D8FvHUGbxWBRQM90NZeIyhAvkFfsh3u9ekrMvJ30Z6gnpBHS6HC6ldLg7tL45hwiIz/u66eKDtdA23gwwGsAHA==",
|
||||||
|
"license": "ISC",
|
||||||
|
"peerDependencies": {
|
||||||
|
"react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/math-intrinsics": {
|
"node_modules/math-intrinsics": {
|
||||||
"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",
|
||||||
|
|||||||
+5
-3
@@ -9,6 +9,8 @@
|
|||||||
"lint": "next lint"
|
"lint": "next lint"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"clsx": "^2.1.1",
|
||||||
|
"lucide-react": "^0.555.0",
|
||||||
"next": "14.2.3",
|
"next": "14.2.3",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-dom": "18.3.1"
|
"react-dom": "18.3.1"
|
||||||
@@ -18,10 +20,10 @@
|
|||||||
"@types/react": "18.3.3",
|
"@types/react": "18.3.3",
|
||||||
"@types/react-dom": "18.3.0",
|
"@types/react-dom": "18.3.0",
|
||||||
"autoprefixer": "10.4.19",
|
"autoprefixer": "10.4.19",
|
||||||
|
"eslint": "8.57.0",
|
||||||
|
"eslint-config-next": "14.2.3",
|
||||||
"postcss": "8.4.38",
|
"postcss": "8.4.38",
|
||||||
"tailwindcss": "3.4.3",
|
"tailwindcss": "3.4.3",
|
||||||
"typescript": "5.4.5",
|
"typescript": "5.4.5"
|
||||||
"eslint": "8.57.0",
|
|
||||||
"eslint-config-next": "14.2.3"
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user