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:
2025-12-15 20:59:19 -05:00
parent 0f10ff4e09
commit 607939c468
16 changed files with 1538 additions and 1065 deletions
+389 -173
View File
@@ -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 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 && (
@@ -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&apos;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&apos;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>
);
}
}