lots of fixes. cant remember them all

This commit is contained in:
2025-12-18 09:42:25 -05:00
parent 607939c468
commit 4c2d767d09
28 changed files with 3342 additions and 996 deletions
+68 -138
View File
@@ -6,9 +6,17 @@ import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
import type { CategoryId, Part } from "@/types/gunbuilder";
import { PART_ROLE_TO_CATEGORY } from "@/data/partRoleMappings";
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
import { Pencil, Link2, Square, CheckSquare } from "lucide-react";
// ✅ Centralized overlap rules + helpers
import OverlapChip from "@/components/builder/OverlapChip";
import {
getOverlapChipsForCategory,
getSelectionHints,
type BuildState,
} from "@/lib/buildOverlaps";
type GunbuilderProductFromApi = {
id: number; // backend numeric id
name: string;
@@ -25,8 +33,6 @@ const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
// Categories that should NOT be filtered by platform (universal accessories / gear)
@@ -103,33 +109,6 @@ const CATEGORY_GROUPS: {
},
];
// ===== Build rules: complete assemblies make sub-parts unnecessary =====
const COMPLETE_UPPER_CATEGORY: CategoryId = "complete-upper";
const COMPLETE_LOWER_CATEGORY: CategoryId = "complete-lower";
// If a complete upper is selected, these categories are considered "included".
const UPPER_INCLUDED_CATEGORIES = new Set<CategoryId>([
"upper-receiver",
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
]);
const LOWER_INCLUDED_CATEGORIES = new Set<CategoryId>([
"lower-receiver",
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
]);
export default function GunbuilderPage() {
const searchParams = useSearchParams();
const router = useRouter();
@@ -305,97 +284,25 @@ export default function GunbuilderPage() {
[selectedParts]
);
// ---------- Selection logic ----------
const handleSelectPart = useCallback(
(
categoryId: CategoryId,
partId: string,
opts?: { confirm?: boolean }
) => {
const shouldConfirm = opts?.confirm !== false;
(categoryId: CategoryId, partId: string, _opts?: { confirm?: boolean }) => {
// NOTE:
// - We DO NOT auto-remove any existing selections.
// - We show subtle overlap guidance (chips + an optional “heads up” toast).
if (shouldConfirm && typeof window !== "undefined") {
if (categoryId === COMPLETE_UPPER_CATEGORY) {
const toClear = Array.from(UPPER_INCLUDED_CATEGORIES).filter(
(cid) => !!build[cid]
);
if (toClear.length > 0) {
const labels = toClear
.map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid)
.join(", ");
const warn = (msg: string) => {
setShareStatus(msg);
window.setTimeout(() => setShareStatus(null), 4000);
};
const ok = window.confirm(
`Selecting a Complete Upper will remove your currently selected upper parts:\n\n${labels}\n\nContinue?`
);
if (!ok) return;
}
}
const hints = getSelectionHints(categoryId, build);
if (hints.length > 0) warn(hints[0]);
if (categoryId === COMPLETE_LOWER_CATEGORY) {
const toClear = Array.from(LOWER_INCLUDED_CATEGORIES).filter(
(cid) => !!build[cid]
);
if (toClear.length > 0) {
const labels = toClear
.map((cid) => CATEGORIES.find((c) => c.id === cid)?.name ?? cid)
.join(", ");
const ok = window.confirm(
`Selecting a Complete Lower will remove your currently selected lower parts:\n\n${labels}\n\nContinue?`
);
if (!ok) return;
}
}
if (
UPPER_INCLUDED_CATEGORIES.has(categoryId) &&
!!build[COMPLETE_UPPER_CATEGORY]
) {
const ok = window.confirm(
`Selecting this part will remove your selected Complete Upper. Continue?`
);
if (!ok) return;
}
if (
LOWER_INCLUDED_CATEGORIES.has(categoryId) &&
!!build[COMPLETE_LOWER_CATEGORY]
) {
const ok = window.confirm(
`Selecting this part will remove your selected Complete Lower. Continue?`
);
if (!ok) return;
}
}
setBuild((prev) => {
const updated: BuildState = {
...prev,
[categoryId]: partId,
};
if (categoryId === COMPLETE_UPPER_CATEGORY) {
for (const cid of UPPER_INCLUDED_CATEGORIES) {
delete updated[cid];
}
}
if (UPPER_INCLUDED_CATEGORIES.has(categoryId)) {
delete updated[COMPLETE_UPPER_CATEGORY];
}
if (categoryId === COMPLETE_LOWER_CATEGORY) {
for (const cid of LOWER_INCLUDED_CATEGORIES) {
delete updated[cid];
}
}
if (LOWER_INCLUDED_CATEGORIES.has(categoryId)) {
delete updated[COMPLETE_LOWER_CATEGORY];
}
return updated;
});
// ✅ Only set the selection. No deletes. No clearing.
setBuild((prev) => ({
...prev,
[categoryId]: partId,
}));
},
[build]
);
@@ -536,22 +443,18 @@ export default function GunbuilderPage() {
const removeParam = searchParams.get("remove");
const qpPlatform = searchParams.get("platform");
// Build a unique key for this action so we only apply it once.
const actionKey = `${selectParam ?? ""}|${removeParam ?? ""}|${
qpPlatform ?? ""
}`;
// If no action, clear the ref and do nothing.
if (!selectParam && !removeParam) {
processedActionKeyRef.current = "";
return;
}
// Prevent infinite loops: if we already processed this exact actionKey, stop.
if (processedActionKeyRef.current === actionKey) return;
processedActionKeyRef.current = actionKey;
// Apply actions
if (selectParam) {
const [categoryIdRaw, partId] = selectParam.split(":");
const requestedCategoryId = categoryIdRaw as CategoryId;
@@ -573,7 +476,6 @@ export default function GunbuilderPage() {
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
// Clean URL (remove select/remove) but ONLY if needed.
const nextUrl = `/builder?platform=${encodeURIComponent(nextPlatform)}`;
if (typeof window !== "undefined") {
const current = `${window.location.pathname}${window.location.search}`;
@@ -613,7 +515,6 @@ export default function GunbuilderPage() {
}
}, [build]);
// Use group ordering for the summary as well
const summaryCategoryOrder: CategoryId[] = useMemo(
() =>
CATEGORY_GROUPS.flatMap((group) => group.categoryIds).filter((id) =>
@@ -812,7 +713,9 @@ export default function GunbuilderPage() {
}}
disabled={selectedParts.length === 0}
className={`w-full sm:w-auto rounded-md border border-zinc-700 bg-zinc-900/50 px-3 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors ${
selectedParts.length === 0 ? "opacity-40 cursor-not-allowed" : ""
selectedParts.length === 0
? "opacity-40 cursor-not-allowed"
: ""
}`}
>
Clear Build
@@ -828,7 +731,8 @@ export default function GunbuilderPage() {
Share Your Build
</h3>
<p className="mt-1 text-[0.7rem] text-zinc-500">
Share this link to let others view your build or bookmark it to come back later.
Share this link to let others view your build or bookmark it
to come back later.
</p>
<div className="mt-2 flex flex-col gap-2 md:flex-row md:items-center">
<div className="flex-1">
@@ -903,8 +807,9 @@ export default function GunbuilderPage() {
{/* Layout */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<p className="mb-4 text-xs text-zinc-500">
Work top-down through the major sections. Each row shows your current pick for that part type,
with price and a direct buy linkor a quick way to choose a part if you haven&apos;t picked one yet.
Work top-down through the major sections. Each row shows your current
pick for that part type, with price and a direct buy linkor a quick
way to choose a part if you haven&apos;t picked one yet.
</p>
{loading && (
@@ -912,7 +817,8 @@ export default function GunbuilderPage() {
)}
{error && !loading && (
<p className="text-sm text-red-400">
{error} check that the Ballistic API is running and CORS is configured.
{error} check that the Ballistic API is running and CORS is
configured.
</p>
)}
@@ -967,12 +873,14 @@ export default function GunbuilderPage() {
<tbody>
{groupCategories.map((category) => {
const categoryParts = partsByCategory[category.id] ?? [];
const categoryParts =
partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id];
const selectedPart = selectedPartId
? categoryParts.find((p) => p.id === selectedPartId) ??
parts.find((p) => p.id === selectedPartId)
? categoryParts.find(
(p) => p.id === selectedPartId
) ?? parts.find((p) => p.id === selectedPartId)
: undefined;
const hasParts = categoryParts.length > 0;
@@ -987,10 +895,28 @@ export default function GunbuilderPage() {
<div className="font-medium text-zinc-100">
{category.name}
</div>
{selectedPart ? (
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
{selectedPart.name}
</div>
<>
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
{selectedPart.name}
</div>
{/* ✅ Category overlap chips */}
<div className="mt-1 flex flex-wrap gap-1.5">
{getOverlapChipsForCategory(
category.id as CategoryId,
build
).map((c) => (
<OverlapChip
key={c.key}
tone={c.tone}
label={c.label}
detail={c.detail}
/>
))}
</div>
</>
) : hasParts ? (
<div className="text-[0.7rem] text-zinc-600">
No part selected
@@ -1031,8 +957,10 @@ export default function GunbuilderPage() {
<>
<Link
href={{
pathname: `/parts/${category.id}`,
query: platform ? { platform } : {},
pathname: `/parts/p/${platform}/${category.id}`,
query: platform
? { platform }
: {},
}}
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"
@@ -1055,8 +983,10 @@ export default function GunbuilderPage() {
) : hasParts ? (
<Link
href={{
pathname: `/parts/${category.id}`,
query: platform ? { platform } : {},
pathname: `/parts/p/${platform}/${category.id}`,
query: platform
? { platform }
: {},
}}
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>