a metric shit ton. fixed a lot of part_role issues. removed almost all hardcoded categories and used db roles.
This commit is contained in:
+389
-173
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState, useEffect } from "react";
|
||||
import { useMemo, useState, useEffect, useCallback, useRef } from "react";
|
||||
import Link from "next/link";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
@@ -16,7 +16,7 @@ type GunbuilderProductFromApi = {
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
mainImageUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
};
|
||||
|
||||
@@ -56,7 +56,7 @@ const CATEGORY_GROUPS: {
|
||||
description:
|
||||
"Everything from the serialized lower to small parts, fire control, and core controls.",
|
||||
categoryIds: [
|
||||
"lower",
|
||||
"lower-receiver",
|
||||
"complete-lower",
|
||||
"lower-parts",
|
||||
"trigger",
|
||||
@@ -72,7 +72,7 @@ const CATEGORY_GROUPS: {
|
||||
description:
|
||||
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
|
||||
categoryIds: [
|
||||
"upper",
|
||||
"upper-receiver",
|
||||
"complete-upper",
|
||||
"bcg",
|
||||
"barrel",
|
||||
@@ -103,12 +103,41 @@ 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();
|
||||
|
||||
const [parts, setParts] = useState<Part[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const isValidPlatform = (
|
||||
value: string | null
|
||||
): value is (typeof PLATFORMS)[number] =>
|
||||
@@ -119,6 +148,7 @@ export default function GunbuilderPage() {
|
||||
const initial = new URLSearchParams(window.location.search).get("platform");
|
||||
return isValidPlatform(initial) ? initial : "AR-15";
|
||||
});
|
||||
|
||||
const [build, setBuild] = useState<BuildState>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
@@ -132,9 +162,245 @@ export default function GunbuilderPage() {
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
const [shareStatus, setShareStatus] = useState<string | null>(null);
|
||||
const [shareUrl, setShareUrl] = useState<string>("");
|
||||
|
||||
// Guards so “platform change clears build” does NOT run on initial hydration / URL sync.
|
||||
const didHydrateRef = useRef(false);
|
||||
const lastPlatformRef = useRef(platform);
|
||||
|
||||
// ✅ Guard to prevent infinite loops when processing ?select / ?remove
|
||||
const processedActionKeyRef = useRef<string>("");
|
||||
|
||||
// ---------- Derived ----------
|
||||
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
|
||||
const grouped = {} as Record<CategoryId, Part[]>;
|
||||
for (const category of CATEGORIES) {
|
||||
grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
|
||||
}
|
||||
return grouped;
|
||||
}, [parts]);
|
||||
|
||||
const selectedParts: Part[] = useMemo(() => {
|
||||
const seen = new Set<string>();
|
||||
const resolved = Object.values(build)
|
||||
.map((partId) => (partId ? parts.find((p) => p.id === partId) : undefined))
|
||||
.filter(Boolean) as Part[];
|
||||
|
||||
return resolved.filter((p) => {
|
||||
if (seen.has(p.id)) return false;
|
||||
seen.add(p.id);
|
||||
return true;
|
||||
});
|
||||
}, [build, parts]);
|
||||
|
||||
// Role -> Category resolver
|
||||
// NOTE: defensive while backend roles/mappings evolve
|
||||
const resolveCategoryId = (normalizedRole: string): CategoryId | null => {
|
||||
if (normalizedRole === "upper-receiver") return "upper-receiver";
|
||||
if (normalizedRole.includes("complete-upper")) return "complete-upper";
|
||||
|
||||
if (normalizedRole === "lower-receiver") return "lower-receiver";
|
||||
if (normalizedRole.includes("complete-lower")) return "complete-lower";
|
||||
|
||||
if (normalizedRole === "upper") return "upper-receiver";
|
||||
if (normalizedRole === "lower") return "lower-receiver";
|
||||
|
||||
if (normalizedRole.includes("charging")) return "charging-handle";
|
||||
|
||||
if (
|
||||
normalizedRole.includes("handguard") ||
|
||||
(normalizedRole.includes("rail") &&
|
||||
!normalizedRole.includes("rail-accessory"))
|
||||
)
|
||||
return "handguard";
|
||||
|
||||
if (
|
||||
normalizedRole.includes("bcg") ||
|
||||
normalizedRole.includes("bolt-carrier")
|
||||
)
|
||||
return "bcg";
|
||||
|
||||
if (normalizedRole.includes("barrel")) return "barrel";
|
||||
|
||||
if (
|
||||
normalizedRole.includes("gas-block") ||
|
||||
normalizedRole.includes("gasblock")
|
||||
)
|
||||
return "gas-block";
|
||||
|
||||
if (
|
||||
normalizedRole.includes("gas-tube") ||
|
||||
normalizedRole.includes("gastube")
|
||||
)
|
||||
return "gas-tube";
|
||||
|
||||
if (
|
||||
normalizedRole.includes("muzzle") ||
|
||||
normalizedRole.includes("flash") ||
|
||||
normalizedRole.includes("brake") ||
|
||||
normalizedRole.includes("comp")
|
||||
)
|
||||
return "muzzle-device";
|
||||
|
||||
if (normalizedRole.includes("suppress")) return "suppressor";
|
||||
|
||||
if (
|
||||
normalizedRole.includes("lower-parts") ||
|
||||
normalizedRole.includes("lpk")
|
||||
)
|
||||
return "lower-parts";
|
||||
|
||||
if (normalizedRole.includes("trigger")) return "trigger";
|
||||
if (normalizedRole.includes("grip")) return "grip";
|
||||
if (normalizedRole.includes("safety")) return "safety";
|
||||
if (normalizedRole.includes("buffer")) return "buffer";
|
||||
if (normalizedRole.includes("stock")) return "stock";
|
||||
|
||||
if (normalizedRole.includes("optic") || normalizedRole.includes("scope"))
|
||||
return "optic";
|
||||
if (normalizedRole.includes("sight")) return "sights";
|
||||
|
||||
if (normalizedRole.includes("mag")) return "magazine";
|
||||
if (
|
||||
normalizedRole.includes("weapon-light") ||
|
||||
(normalizedRole.includes("light") && !normalizedRole.includes("flight"))
|
||||
)
|
||||
return "weapon-light";
|
||||
|
||||
if (
|
||||
normalizedRole.includes("foregrip") ||
|
||||
normalizedRole.includes("grip-vertical")
|
||||
)
|
||||
return "foregrip";
|
||||
|
||||
if (normalizedRole.includes("bipod")) return "bipod";
|
||||
if (normalizedRole.includes("sling")) return "sling";
|
||||
|
||||
if (
|
||||
normalizedRole.includes("rail-accessory") ||
|
||||
normalizedRole.includes("rail-attachment")
|
||||
)
|
||||
return "rail-accessory";
|
||||
|
||||
if (normalizedRole.includes("tool")) return "tools";
|
||||
|
||||
return (PART_ROLE_TO_CATEGORY as any)[normalizedRole] ?? null;
|
||||
};
|
||||
|
||||
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(build).map(([categoryId, partId]) => [
|
||||
categoryId as CategoryId,
|
||||
partId ? true : false,
|
||||
])
|
||||
) as Record<CategoryId, unknown>,
|
||||
[build]
|
||||
);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
|
||||
[selectedParts]
|
||||
);
|
||||
|
||||
// ---------- Selection logic ----------
|
||||
const handleSelectPart = useCallback(
|
||||
(
|
||||
categoryId: CategoryId,
|
||||
partId: string,
|
||||
opts?: { confirm?: boolean }
|
||||
) => {
|
||||
const shouldConfirm = opts?.confirm !== false;
|
||||
|
||||
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 ok = window.confirm(
|
||||
`Selecting a Complete Upper will remove your currently selected upper parts:\n\n${labels}\n\nContinue?`
|
||||
);
|
||||
if (!ok) return;
|
||||
}
|
||||
}
|
||||
|
||||
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;
|
||||
});
|
||||
},
|
||||
[build]
|
||||
);
|
||||
|
||||
// ---------- Fetch products ----------
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
@@ -143,14 +409,10 @@ export default function GunbuilderPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
// 1) Platform-scoped products
|
||||
const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
|
||||
platform
|
||||
)}`;
|
||||
|
||||
// 2) Universal products (no platform filter)
|
||||
// NOTE: This assumes your backend supports /api/products with no platform param.
|
||||
// If it doesn't yet, this call will fail quietly and you'll still see scoped results.
|
||||
const universalUrl = `${API_BASE_URL}/api/products`;
|
||||
|
||||
const [scopedRes, universalRes] = await Promise.all([
|
||||
@@ -172,7 +434,6 @@ export default function GunbuilderPage() {
|
||||
universalData = await universalRes.json();
|
||||
}
|
||||
|
||||
// Normalize both lists
|
||||
const normalize = (data: GunbuilderProductFromApi[]): Part[] =>
|
||||
data
|
||||
.map((p): Part | null => {
|
||||
@@ -181,11 +442,9 @@ export default function GunbuilderPage() {
|
||||
.toLowerCase()
|
||||
.replace(/_/g, "-");
|
||||
|
||||
const categoryId = PART_ROLE_TO_CATEGORY[normalizedRole];
|
||||
const categoryId = resolveCategoryId(normalizedRole);
|
||||
if (!categoryId) return null;
|
||||
|
||||
// Only keep truly-universal categories from the universal feed
|
||||
// so we don't accidentally mix platforms for platform-scoped parts.
|
||||
if (
|
||||
data === universalData &&
|
||||
!UNIVERSAL_CATEGORIES.has(categoryId)
|
||||
@@ -194,13 +453,15 @@ export default function GunbuilderPage() {
|
||||
}
|
||||
|
||||
const buyUrl = p.buyUrl ?? undefined;
|
||||
|
||||
return {
|
||||
id: String(p.id),
|
||||
categoryId,
|
||||
name: p.name,
|
||||
brand: p.brand,
|
||||
price: p.price ?? 0,
|
||||
imageUrl: p.mainImageUrl ?? undefined,
|
||||
imageUrl:
|
||||
((p as any).imageUrl ?? (p as any).mainImageUrl) ?? undefined,
|
||||
affiliateUrl: buyUrl,
|
||||
url: buyUrl,
|
||||
notes: undefined,
|
||||
@@ -211,7 +472,6 @@ export default function GunbuilderPage() {
|
||||
const scopedParts = normalize(scopedData);
|
||||
const universalParts = normalize(universalData);
|
||||
|
||||
// Merge + de-dupe by (categoryId + id)
|
||||
const seen = new Set<string>();
|
||||
const merged: Part[] = [];
|
||||
for (const p of [...scopedParts, ...universalParts]) {
|
||||
@@ -231,16 +491,34 @@ export default function GunbuilderPage() {
|
||||
}
|
||||
|
||||
fetchProducts();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [platform]);
|
||||
|
||||
// ✅ Persist build state whenever it changes
|
||||
useEffect(() => {
|
||||
// When the platform changes, clear ONLY platform-scoped selections.
|
||||
// Keep universal accessories (optic/light/sling/etc) so the user doesn't lose them.
|
||||
setBuild((prev) => {
|
||||
if (typeof window === "undefined") return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [build]);
|
||||
|
||||
// When the platform changes, clear ONLY platform-scoped selections.
|
||||
useEffect(() => {
|
||||
const prev = lastPlatformRef.current;
|
||||
lastPlatformRef.current = platform;
|
||||
|
||||
if (!didHydrateRef.current) {
|
||||
didHydrateRef.current = true;
|
||||
return;
|
||||
}
|
||||
|
||||
if (prev === platform) return;
|
||||
|
||||
setBuild((prevBuild) => {
|
||||
const next: BuildState = {};
|
||||
for (const [categoryId, partId] of Object.entries(prev)) {
|
||||
for (const [categoryId, partId] of Object.entries(prevBuild)) {
|
||||
const cid = categoryId as CategoryId;
|
||||
if (UNIVERSAL_CATEGORIES.has(cid)) {
|
||||
next[cid] = partId;
|
||||
@@ -250,36 +528,64 @@ export default function GunbuilderPage() {
|
||||
});
|
||||
}, [platform]);
|
||||
|
||||
// Handle URL query parameter for part selection (?select=upper:165)
|
||||
// Handle URL query parameters:
|
||||
// - ?select=categoryId:partId
|
||||
// - ?remove=categoryId
|
||||
useEffect(() => {
|
||||
const selectParam = searchParams.get("select");
|
||||
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 [categoryId, partId] = selectParam.split(":");
|
||||
if (categoryId && partId && CATEGORIES.some((c) => c.id === categoryId)) {
|
||||
setBuild((prev) => {
|
||||
const updated = {
|
||||
...prev,
|
||||
[categoryId as CategoryId]: partId,
|
||||
};
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
const [categoryIdRaw, partId] = selectParam.split(":");
|
||||
const requestedCategoryId = categoryIdRaw as CategoryId;
|
||||
|
||||
const qp = searchParams.get("platform");
|
||||
const nextPlatform = isValidPlatform(qp) ? qp : platform;
|
||||
|
||||
router.replace(
|
||||
`/builder?platform=${encodeURIComponent(nextPlatform)}`,
|
||||
{
|
||||
scroll: false,
|
||||
}
|
||||
);
|
||||
if (requestedCategoryId && partId) {
|
||||
handleSelectPart(requestedCategoryId, partId, { confirm: false });
|
||||
}
|
||||
}
|
||||
}, [searchParams, router]);
|
||||
|
||||
if (removeParam) {
|
||||
if (CATEGORIES.some((c) => c.id === removeParam)) {
|
||||
setBuild((prev) => {
|
||||
const next: BuildState = { ...prev };
|
||||
delete next[removeParam as CategoryId];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
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}`;
|
||||
if (current !== nextUrl) {
|
||||
router.replace(nextUrl, { scroll: false });
|
||||
}
|
||||
} else {
|
||||
router.replace(nextUrl, { scroll: false });
|
||||
}
|
||||
}, [searchParams, router, platform, handleSelectPart]);
|
||||
|
||||
// Keep platform in sync w/ URL
|
||||
useEffect(() => {
|
||||
const qp = searchParams.get("platform");
|
||||
if (isValidPlatform(qp) && qp !== platform) {
|
||||
@@ -287,49 +593,10 @@ export default function GunbuilderPage() {
|
||||
}
|
||||
}, [searchParams, platform]);
|
||||
|
||||
// Persist build state to localStorage whenever it changes
|
||||
useEffect(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
|
||||
}
|
||||
}, [build]);
|
||||
|
||||
const partsByCategory: Record<CategoryId, Part[]> = useMemo(() => {
|
||||
const grouped = {} as Record<CategoryId, Part[]>;
|
||||
for (const category of CATEGORIES) {
|
||||
grouped[category.id] = parts.filter((p) => p.categoryId === category.id);
|
||||
}
|
||||
return grouped;
|
||||
}, [parts]);
|
||||
|
||||
const selectedParts: Part[] = useMemo(() => {
|
||||
return Object.entries(build)
|
||||
.map(([categoryId, partId]) =>
|
||||
parts.find((p) => p.id === partId && p.categoryId === categoryId)
|
||||
)
|
||||
.filter(Boolean) as Part[];
|
||||
}, [build, parts]);
|
||||
|
||||
const selectedByCategory: Record<CategoryId, unknown> = useMemo(
|
||||
() =>
|
||||
Object.fromEntries(
|
||||
Object.entries(build).map(([categoryId, partId]) => [
|
||||
categoryId as CategoryId,
|
||||
partId ? true : false,
|
||||
])
|
||||
) as Record<CategoryId, unknown>,
|
||||
[build]
|
||||
);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
|
||||
[selectedParts]
|
||||
);
|
||||
|
||||
// Build share URL whenever build changes
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// If no parts selected, clear the share URL
|
||||
if (Object.keys(build).length === 0) {
|
||||
setShareUrl("");
|
||||
return;
|
||||
@@ -339,28 +606,13 @@ export default function GunbuilderPage() {
|
||||
const payload = JSON.stringify(build);
|
||||
const encoded = window.btoa(payload);
|
||||
const origin = window.location?.origin ?? "";
|
||||
const url = `${origin}/builder/build?build=${encodeURIComponent(
|
||||
encoded
|
||||
)}`;
|
||||
const url = `${origin}/builder/build?build=${encodeURIComponent(encoded)}`;
|
||||
setShareUrl(url);
|
||||
} catch {
|
||||
setShareUrl("");
|
||||
}
|
||||
}, [build]);
|
||||
|
||||
const handleSelectPart = (categoryId: CategoryId, partId: string) => {
|
||||
setBuild((prev) => {
|
||||
const updated = {
|
||||
...prev,
|
||||
[categoryId]: partId,
|
||||
};
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(updated));
|
||||
}
|
||||
return updated;
|
||||
});
|
||||
};
|
||||
|
||||
// Use group ordering for the summary as well
|
||||
const summaryCategoryOrder: CategoryId[] = useMemo(
|
||||
() =>
|
||||
@@ -443,13 +695,11 @@ export default function GunbuilderPage() {
|
||||
});
|
||||
setShareStatus("Share dialog opened.");
|
||||
} catch {
|
||||
// User canceled or share failed; keep it quiet but let them know
|
||||
setShareStatus(
|
||||
"Share canceled or unavailable. You can copy the link instead."
|
||||
);
|
||||
}
|
||||
} else {
|
||||
// Fallback to copying the link
|
||||
await handleCopyLink();
|
||||
}
|
||||
};
|
||||
@@ -482,10 +732,10 @@ export default function GunbuilderPage() {
|
||||
const next = e.target.value as (typeof PLATFORMS)[number];
|
||||
setPlatform(next);
|
||||
|
||||
// Keep URL in sync so navigation + refresh preserve platform
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", next);
|
||||
qp.delete("select");
|
||||
qp.delete("remove");
|
||||
|
||||
router.replace(`/builder?${qp.toString()}`, {
|
||||
scroll: false,
|
||||
@@ -509,7 +759,7 @@ export default function GunbuilderPage() {
|
||||
|
||||
{/* Build summary panel */}
|
||||
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4 flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
{/* Left: title + totals + primary actions */}
|
||||
{/* Left */}
|
||||
<div className="flex flex-col gap-2">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
||||
My Build Breakdown
|
||||
@@ -524,10 +774,12 @@ export default function GunbuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Primary actions now live under the total */}
|
||||
<div className="mt-2 flex flex-col items-stretch gap-2 sm:flex-row sm:flex-wrap">
|
||||
<Link
|
||||
href="/builder/build"
|
||||
href={{
|
||||
pathname: "/builder/build",
|
||||
query: platform ? { platform } : {},
|
||||
}}
|
||||
className={`w-full sm:w-auto rounded-md border border-amber-400/60 bg-amber-400/10 px-3 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 text-center transition-colors ${
|
||||
selectedParts.length === 0
|
||||
? "opacity-40 cursor-not-allowed pointer-events-none"
|
||||
@@ -536,6 +788,7 @@ export default function GunbuilderPage() {
|
||||
>
|
||||
Build Summary
|
||||
</Link>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleShare}
|
||||
@@ -548,6 +801,7 @@ export default function GunbuilderPage() {
|
||||
>
|
||||
Copy Build Summary
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
@@ -558,9 +812,7 @@ 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
|
||||
@@ -568,7 +820,7 @@ export default function GunbuilderPage() {
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: Share Your Build */}
|
||||
{/* Right */}
|
||||
<div className="flex flex-col gap-3 md:items-end md:text-right md:flex-1">
|
||||
{selectedParts.length > 0 && shareUrl && (
|
||||
<div className="w-full md:w-auto">
|
||||
@@ -576,8 +828,7 @@ 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">
|
||||
@@ -652,9 +903,8 @@ 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 link—or
|
||||
a quick way to choose a part if you haven'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 link—or a quick way to choose a part if you haven't picked one yet.
|
||||
</p>
|
||||
|
||||
{loading && (
|
||||
@@ -662,8 +912,7 @@ 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>
|
||||
)}
|
||||
|
||||
@@ -674,9 +923,7 @@ export default function GunbuilderPage() {
|
||||
group.categoryIds.includes(c.id as CategoryId)
|
||||
);
|
||||
|
||||
if (groupCategories.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (groupCategories.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={group.id} className="space-y-3">
|
||||
@@ -717,14 +964,17 @@ export default function GunbuilderPage() {
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{groupCategories.map((category) => {
|
||||
const categoryParts =
|
||||
partsByCategory[category.id] ?? [];
|
||||
const categoryParts = partsByCategory[category.id] ?? [];
|
||||
const selectedPartId = build[category.id];
|
||||
const selectedPart = categoryParts.find(
|
||||
(p) => p.id === selectedPartId
|
||||
);
|
||||
|
||||
const selectedPart = selectedPartId
|
||||
? categoryParts.find((p) => p.id === selectedPartId) ??
|
||||
parts.find((p) => p.id === selectedPartId)
|
||||
: undefined;
|
||||
|
||||
const hasParts = categoryParts.length > 0;
|
||||
|
||||
return (
|
||||
@@ -732,7 +982,7 @@ export default function GunbuilderPage() {
|
||||
key={category.id}
|
||||
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
|
||||
>
|
||||
{/* Component / Part Type */}
|
||||
{/* Component */}
|
||||
<td className="px-3 py-2 align-top">
|
||||
<div className="font-medium text-zinc-100">
|
||||
{category.name}
|
||||
@@ -764,31 +1014,33 @@ export default function GunbuilderPage() {
|
||||
: "—"}
|
||||
</td>
|
||||
|
||||
{/* Sale Price (placeholder until we wire through sale/original) */}
|
||||
{/* Sale price placeholder */}
|
||||
<td className="px-3 py-2 align-top text-right text-zinc-400">
|
||||
{/* TODO: wire in sale/original prices from backend */}
|
||||
{selectedPart ? "—" : "—"}
|
||||
</td>
|
||||
|
||||
{/* Caliber (placeholder for now) */}
|
||||
<td className="px-3 py-2 align-top text-zinc-400">
|
||||
{/* TODO: wire in caliber from ProductSummaryDto when available */}
|
||||
—
|
||||
</td>
|
||||
|
||||
{/* Buy / Choose */}
|
||||
{/* Caliber placeholder */}
|
||||
<td className="px-3 py-2 align-top text-zinc-400">
|
||||
—
|
||||
</td>
|
||||
|
||||
{/* Actions */}
|
||||
<td className="px-3 py-2 align-top">
|
||||
<div className="flex justify-end gap-2">
|
||||
{selectedPart && selectedPart.url ? (
|
||||
<>
|
||||
<Link
|
||||
href={`/builder/${category.id}`}
|
||||
href={{
|
||||
pathname: `/parts/${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"
|
||||
title="Change part"
|
||||
>
|
||||
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
|
||||
</Link>
|
||||
|
||||
<a
|
||||
href={selectedPart.url}
|
||||
target="_blank"
|
||||
@@ -803,7 +1055,7 @@ export default function GunbuilderPage() {
|
||||
) : hasParts ? (
|
||||
<Link
|
||||
href={{
|
||||
pathname: `/builder/${category.id}`,
|
||||
pathname: `/parts/${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"
|
||||
@@ -821,7 +1073,7 @@ export default function GunbuilderPage() {
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
<span>Choose a Part</span>
|
||||
Choose
|
||||
</Link>
|
||||
) : (
|
||||
<span className="text-[0.7rem] text-zinc-600">
|
||||
@@ -842,43 +1094,7 @@ export default function GunbuilderPage() {
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* What's New */}
|
||||
<section className="mt-8 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
||||
What's New in Early Access
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-1 text-sm text-zinc-400">
|
||||
<li>• Live parts and pricing pulled from multiple merchants.</li>
|
||||
<li>
|
||||
• Grouped layout for lower and upper receiver parts so you can see
|
||||
at a glance which sections of your build are still missing.
|
||||
</li>
|
||||
<li>
|
||||
• Running build total that updates automatically as you add or
|
||||
swap components.
|
||||
</li>
|
||||
<li>
|
||||
• Local build persistence so your selections stick around between
|
||||
visits on this device.
|
||||
</li>
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
{/* Roadmap */}
|
||||
<section className="mt-8 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3 md:p-4">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-amber-300">
|
||||
What's on our roadmap
|
||||
</h2>
|
||||
<ul className="mt-2 space-y-1 text-sm text-zinc-400">
|
||||
<li>• Platform filters (AR-15, AR-9, AR-10)</li>
|
||||
<li>• More part categories and furniture</li>
|
||||
<li>• Richer pricing/stock sync</li>
|
||||
<li>• Smarter compatibility checks between components</li>
|
||||
<li>• AR9/AR10 support and more</li>
|
||||
</ul>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user