|
|
|
@@ -29,7 +29,7 @@ import { useAuth } from "@/context/AuthContext";
|
|
|
|
|
|
|
|
|
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
|
|
|
|
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
|
|
|
|
|
import type { CategoryId, Part } from "@/types/gunbuilder";
|
|
|
|
|
import type { BuilderSlotKey, Part } from "@/types/builderSlots";
|
|
|
|
|
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
|
|
|
|
|
|
|
|
|
|
// ✅ Centralized overlap rules + helpers
|
|
|
|
@@ -64,7 +64,7 @@ const API_BASE_URL =
|
|
|
|
|
const STORAGE_KEY = "gunbuilder-build-state";
|
|
|
|
|
|
|
|
|
|
// Categories that should NOT be filtered by platform (universal accessories / gear)
|
|
|
|
|
const UNIVERSAL_CATEGORIES = new Set<CategoryId>([
|
|
|
|
|
const UNIVERSAL_CATEGORIES = new Set<BuilderSlotKey>([
|
|
|
|
|
"magazine",
|
|
|
|
|
"weapon-light",
|
|
|
|
|
"foregrip",
|
|
|
|
@@ -82,7 +82,7 @@ const CATEGORY_GROUPS: {
|
|
|
|
|
id: string;
|
|
|
|
|
label: string;
|
|
|
|
|
description?: string;
|
|
|
|
|
categoryIds: CategoryId[];
|
|
|
|
|
categoryIds: BuilderSlotKey[];
|
|
|
|
|
}[] = [
|
|
|
|
|
{
|
|
|
|
|
id: "lower-group",
|
|
|
|
@@ -92,10 +92,10 @@ const CATEGORY_GROUPS: {
|
|
|
|
|
categoryIds: [
|
|
|
|
|
"lower-receiver",
|
|
|
|
|
"complete-lower",
|
|
|
|
|
"lower-parts",
|
|
|
|
|
"lower-parts-kit", // ✅ was lower-parts
|
|
|
|
|
"trigger",
|
|
|
|
|
"grip",
|
|
|
|
|
"safety",
|
|
|
|
|
"safety-selector", // ✅ was safety
|
|
|
|
|
"buffer",
|
|
|
|
|
"stock",
|
|
|
|
|
],
|
|
|
|
@@ -219,8 +219,8 @@ export default function GunbuilderPage() {
|
|
|
|
|
// -----------------------------
|
|
|
|
|
// Derived collections
|
|
|
|
|
// -----------------------------
|
|
|
|
|
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
|
|
|
|
|
const grouped = {} as Record<CategoryId, Part[]>;
|
|
|
|
|
const partsByCategory: Record<BuilderSlotKey, Part[]> = useMemo(() => {
|
|
|
|
|
const grouped = {} as Record<BuilderSlotKey, Part[]>;
|
|
|
|
|
for (const category of CATEGORIES) {
|
|
|
|
|
grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
|
|
|
|
|
}
|
|
|
|
@@ -242,14 +242,14 @@ export default function GunbuilderPage() {
|
|
|
|
|
});
|
|
|
|
|
}, [build, parts]);
|
|
|
|
|
|
|
|
|
|
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
|
|
|
|
|
const selectedByCategory: Record<BuilderSlotKey, unknown> = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
Object.fromEntries(
|
|
|
|
|
Object.entries(build).map(([categoryId, partId]) => [
|
|
|
|
|
categoryId as CategoryId,
|
|
|
|
|
categoryId as BuilderSlotKey,
|
|
|
|
|
partId ? true : false,
|
|
|
|
|
])
|
|
|
|
|
) as Record<CategoryId, unknown>,
|
|
|
|
|
) as Record<BuilderSlotKey, unknown>,
|
|
|
|
|
[build]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
@@ -262,7 +262,7 @@ export default function GunbuilderPage() {
|
|
|
|
|
// Role -> Category resolver
|
|
|
|
|
// NOTE: defensive while backend roles/mappings evolve
|
|
|
|
|
// -----------------------------
|
|
|
|
|
const resolveCategoryId = (normalizedRole: string): CategoryId | null => {
|
|
|
|
|
const resolveCategoryId = (normalizedRole: string): BuilderSlotKey | null => {
|
|
|
|
|
if (normalizedRole === "upper-receiver") return "upper-receiver";
|
|
|
|
|
if (normalizedRole.includes("complete-upper")) return "complete-upper";
|
|
|
|
|
|
|
|
|
@@ -311,15 +311,23 @@ export default function GunbuilderPage() {
|
|
|
|
|
|
|
|
|
|
if (normalizedRole.includes("suppress")) return "suppressor";
|
|
|
|
|
|
|
|
|
|
// ✅ FIX: Lower Parts Kit canonical key
|
|
|
|
|
if (
|
|
|
|
|
normalizedRole.includes("lower-parts") ||
|
|
|
|
|
normalizedRole.includes("lpk")
|
|
|
|
|
)
|
|
|
|
|
return "lower-parts";
|
|
|
|
|
return "lower-parts-kit";
|
|
|
|
|
|
|
|
|
|
if (normalizedRole.includes("trigger")) return "trigger";
|
|
|
|
|
if (normalizedRole.includes("grip")) return "grip";
|
|
|
|
|
if (normalizedRole.includes("safety")) return "safety";
|
|
|
|
|
|
|
|
|
|
// ✅ FIX: Safety canonical key
|
|
|
|
|
if (
|
|
|
|
|
normalizedRole.includes("safety") ||
|
|
|
|
|
normalizedRole.includes("selector")
|
|
|
|
|
)
|
|
|
|
|
return "safety-selector";
|
|
|
|
|
|
|
|
|
|
if (normalizedRole.includes("buffer")) return "buffer";
|
|
|
|
|
if (normalizedRole.includes("stock")) return "stock";
|
|
|
|
|
|
|
|
|
@@ -328,6 +336,7 @@ export default function GunbuilderPage() {
|
|
|
|
|
if (normalizedRole.includes("sight")) return "sights";
|
|
|
|
|
|
|
|
|
|
if (normalizedRole.includes("mag")) return "magazine";
|
|
|
|
|
|
|
|
|
|
if (
|
|
|
|
|
normalizedRole.includes("weapon-light") ||
|
|
|
|
|
(normalizedRole.includes("light") && !normalizedRole.includes("flight"))
|
|
|
|
@@ -354,27 +363,6 @@ export default function GunbuilderPage() {
|
|
|
|
|
return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null;
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
// -----------------------------
|
|
|
|
|
// Selection handler (with hint warnings)
|
|
|
|
|
// -----------------------------
|
|
|
|
|
const handleSelectPart = useCallback(
|
|
|
|
|
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
|
|
|
|
|
const warn = (msg: string) => {
|
|
|
|
|
setShareStatus(msg);
|
|
|
|
|
window.setTimeout(() => setShareStatus(null), 4000);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hints = getSelectionHints(categoryId, build);
|
|
|
|
|
if (hints.length > 0) warn(hints[0]);
|
|
|
|
|
|
|
|
|
|
setBuild((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[categoryId]: partId,
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
[build]
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
// -----------------------------
|
|
|
|
|
// Fetch products (scoped + universal merge)
|
|
|
|
|
// -----------------------------
|
|
|
|
@@ -501,7 +489,7 @@ export default function GunbuilderPage() {
|
|
|
|
|
setBuild((prevBuild) => {
|
|
|
|
|
const next: BuildState = {};
|
|
|
|
|
for (const [categoryId, partId] of Object.entries(prevBuild)) {
|
|
|
|
|
const cid = categoryId as CategoryId;
|
|
|
|
|
const cid = categoryId as BuilderSlotKey;
|
|
|
|
|
if (UNIVERSAL_CATEGORIES.has(cid)) {
|
|
|
|
|
next[cid] = partId;
|
|
|
|
|
}
|
|
|
|
@@ -661,6 +649,31 @@ export default function GunbuilderPage() {
|
|
|
|
|
}
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
|
|
|
// -----------------------------
|
|
|
|
|
// Selection handler (with hint warnings)
|
|
|
|
|
// -----------------------------
|
|
|
|
|
const handleSelectPart = useCallback(
|
|
|
|
|
(
|
|
|
|
|
categoryId: BuilderSlotKey,
|
|
|
|
|
partId: string,
|
|
|
|
|
_opts?: { confirm?: boolean }
|
|
|
|
|
) => {
|
|
|
|
|
const warn = (msg: string) => {
|
|
|
|
|
setShareStatus(msg);
|
|
|
|
|
window.setTimeout(() => setShareStatus(null), 4000);
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
const hints = getSelectionHints(categoryId, build);
|
|
|
|
|
if (hints.length > 0) warn(hints[0]);
|
|
|
|
|
|
|
|
|
|
setBuild((prev) => ({
|
|
|
|
|
...prev,
|
|
|
|
|
[categoryId]: partId,
|
|
|
|
|
}));
|
|
|
|
|
},
|
|
|
|
|
[build]
|
|
|
|
|
);
|
|
|
|
|
// -----------------------------
|
|
|
|
|
// Handle URL query parameters:
|
|
|
|
|
// - ?select=categoryId:partId
|
|
|
|
@@ -685,7 +698,7 @@ export default function GunbuilderPage() {
|
|
|
|
|
|
|
|
|
|
if (selectParam) {
|
|
|
|
|
const [categoryIdRaw, partId] = selectParam.split(":");
|
|
|
|
|
const requestedCategoryId = categoryIdRaw as CategoryId;
|
|
|
|
|
const requestedCategoryId = categoryIdRaw as BuilderSlotKey;
|
|
|
|
|
|
|
|
|
|
if (requestedCategoryId && partId) {
|
|
|
|
|
handleSelectPart(requestedCategoryId, partId, { confirm: false });
|
|
|
|
@@ -696,7 +709,7 @@ export default function GunbuilderPage() {
|
|
|
|
|
if (CATEGORIES.some((c) => c.id === removeParam)) {
|
|
|
|
|
setBuild((prev) => {
|
|
|
|
|
const next: BuildState = { ...prev };
|
|
|
|
|
delete next[removeParam as CategoryId];
|
|
|
|
|
delete next[removeParam as BuilderSlotKey];
|
|
|
|
|
return next;
|
|
|
|
|
});
|
|
|
|
|
}
|
|
|
|
@@ -744,7 +757,7 @@ export default function GunbuilderPage() {
|
|
|
|
|
}
|
|
|
|
|
}, [build]);
|
|
|
|
|
|
|
|
|
|
const summaryCategoryOrder: CategoryId[] = useMemo(
|
|
|
|
|
const summaryCategoryOrder: BuilderSlotKey[] = useMemo(
|
|
|
|
|
() =>
|
|
|
|
|
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
|
|
|
|
|
CATEGORIES.some((c) => c.id === id)
|
|
|
|
@@ -1164,7 +1177,7 @@ export default function GunbuilderPage() {
|
|
|
|
|
<div className="space-y-6">
|
|
|
|
|
{CATEGORY_GROUPS.map((group) => {
|
|
|
|
|
const groupCategories = CATEGORIES.filter((c) =>
|
|
|
|
|
group.categoryIds.includes(c.id as CategoryId)
|
|
|
|
|
group.categoryIds.includes(c.id as BuilderSlotKey)
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
if (groupCategories.length === 0) return null;
|
|
|
|
@@ -1260,7 +1273,7 @@ export default function GunbuilderPage() {
|
|
|
|
|
{/* Overlap chips help explain conflicts / dependencies */}
|
|
|
|
|
<div className="mt-1 flex flex-wrap gap-1.5">
|
|
|
|
|
{getOverlapChipsForCategory(
|
|
|
|
|
category.id as CategoryId,
|
|
|
|
|
category.id as BuilderSlotKey,
|
|
|
|
|
build
|
|
|
|
|
).map((c) => (
|
|
|
|
|
<OverlapChip
|
|
|
|
|