fixed merge config

This commit is contained in:
2025-12-05 21:01:10 -05:00
38 changed files with 2908 additions and 1341 deletions
+8
View File
@@ -0,0 +1,8 @@
# Default ignored files
/shelf/
/workspace.xml
# Editor-based HTTP Client requests
/httpRequests/
# Datasource local storage ignored files
/dataSources/
/dataSources.local.xml
+6
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="Ask2AgentMigrationStateService">
<option name="migrationStatus" value="COMPLETED" />
</component>
</project>
+6
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<component name="InspectionProjectProfileManager">
<profile version="1.0">
<option name="myName" value="Project Default" />
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
</profile>
</component>
+8
View File
@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="ProjectModuleManager">
<modules>
<module fileurl="file://$PROJECT_DIR$/.idea/shadow-gunbuilder-ai-proto.iml" filepath="$PROJECT_DIR$/.idea/shadow-gunbuilder-ai-proto.iml" />
</modules>
</component>
</project>
+9
View File
@@ -0,0 +1,9 @@
<?xml version="1.0" encoding="UTF-8"?>
<module type="JAVA_MODULE" version="4">
<component name="NewModuleRootManager" inherit-compiler-output="true">
<exclude-output />
<content url="file://$MODULE_DIR$" />
<orderEntry type="inheritedJdk" />
<orderEntry type="sourceFolder" forTests="false" />
</component>
</module>
+6
View File
@@ -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
View File
@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="VcsDirectoryMappings">
<mapping directory="" vcs="Git" />
</component>
</project>
+125
View File
@@ -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>
);
}
+67
View File
@@ -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>
);
}
+228
View File
@@ -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>
);
}
+436
View File
@@ -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 couldnt 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&apos;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>
);
}
+806
View File
@@ -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 ? (
+366
View File
@@ -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 couldnt 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>
);
}
-503
View File
@@ -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&apos;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
View File
@@ -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, datadriven 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 />
-73
View File
@@ -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
View File
@@ -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 Standards Armory Build Bench isnt a catalog its 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 youd 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 -1
View File
@@ -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 -1
View File
@@ -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
View File
@@ -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
View File
@@ -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) {
View File
+134
View File
@@ -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",
});
}
+60
View File
@@ -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();
}
+87
View File
@@ -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]
);
}
+21
View File
@@ -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
View File
@@ -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"
} }
} }