little bit of ui clean up and fixing the platform labels/keys
This commit is contained in:
@@ -31,8 +31,9 @@ import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
|
||||
import type { BuilderSlotKey, Part } from "@/types/builderSlots";
|
||||
import { PART_ROLE_TO_CATEGORY } from "@/lib/catalogMappings";
|
||||
import { normalizePlatformKey } from "@/lib/platforms";
|
||||
|
||||
// ✅ Centralized overlap rules + helpers
|
||||
// Centralized overlap rules + helpers
|
||||
import OverlapChip from "@/components/builder/OverlapChip";
|
||||
import {
|
||||
getOverlapChipsForCategory,
|
||||
@@ -51,7 +52,22 @@ type GunbuilderProductFromApi = {
|
||||
buyUrl: string | null;
|
||||
};
|
||||
|
||||
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||
const PLATFORMS = ["AR15"] as const;
|
||||
|
||||
// Disabling all other platforms for now
|
||||
// const PLATFORMS = ["AR15", "AR10", "AR9"] as const;
|
||||
|
||||
const PLATFORM_LABEL: Record<(typeof PLATFORMS)[number], string> = {
|
||||
AR15: "AR-15",
|
||||
// AR10: "AR-10",
|
||||
// AR9: "AR-9",
|
||||
};
|
||||
const isValidPlatform = (
|
||||
value: string | null
|
||||
): value is (typeof PLATFORMS)[number] =>
|
||||
!!value && (PLATFORMS as readonly string[]).includes(value);
|
||||
|
||||
|
||||
|
||||
const isUuid = (v: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
@@ -146,6 +162,16 @@ export default function GunbuilderPage() {
|
||||
// ✅ Auth: we need the token ready before calling /builds/me/*
|
||||
const { token, loading: authLoading, getAuthHeaders } = useAuth();
|
||||
|
||||
|
||||
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>(() => {
|
||||
if (typeof window === "undefined") return "AR15";
|
||||
const initial = new URLSearchParams(window.location.search).get("platform");
|
||||
const normalized = normalizePlatformKey(initial ?? "");
|
||||
return isValidPlatform(normalized) ? normalized : "AR15";
|
||||
});
|
||||
|
||||
// Canonical, URL-safe platform (AR15 / AR10 / AR9)
|
||||
const canonicalPlatform = normalizePlatformKey(platform);
|
||||
// -----------------------------
|
||||
// Save As… modal state (Vault variants)
|
||||
// -----------------------------
|
||||
@@ -161,20 +187,6 @@ export default function GunbuilderPage() {
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// -----------------------------
|
||||
// Platform state (AR-15/AR-10/AR-9)
|
||||
// -----------------------------
|
||||
const isValidPlatform = (
|
||||
value: string | null
|
||||
): value is (typeof PLATFORMS)[number] =>
|
||||
!!value && (PLATFORMS as readonly string[]).includes(value);
|
||||
|
||||
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>(() => {
|
||||
if (typeof window === "undefined") return "AR-15";
|
||||
const initial = new URLSearchParams(window.location.search).get("platform");
|
||||
return isValidPlatform(initial) ? initial : "AR-15";
|
||||
});
|
||||
|
||||
// -----------------------------
|
||||
// Build state (categoryId -> productId), persisted locally
|
||||
// -----------------------------
|
||||
@@ -228,7 +240,7 @@ export default function GunbuilderPage() {
|
||||
const didHydrateRef = useRef(false);
|
||||
const lastPlatformRef = useRef(platform);
|
||||
|
||||
// ✅ Guard to prevent infinite loops when processing ?select / ?remove
|
||||
// Guard to prevent infinite loops when processing ?select / ?remove
|
||||
const processedActionKeyRef = useRef<string>("");
|
||||
|
||||
// -----------------------------
|
||||
@@ -326,7 +338,7 @@ export default function GunbuilderPage() {
|
||||
|
||||
if (normalizedRole.includes("suppress")) return "suppressor";
|
||||
|
||||
// ✅ FIX: Lower Parts Kit canonical key
|
||||
// Lower Parts Kit canonical key
|
||||
if (
|
||||
normalizedRole.includes("lower-parts") ||
|
||||
normalizedRole.includes("lpk")
|
||||
@@ -336,7 +348,7 @@ export default function GunbuilderPage() {
|
||||
if (normalizedRole.includes("trigger")) return "trigger";
|
||||
if (normalizedRole.includes("grip")) return "grip";
|
||||
|
||||
// ✅ FIX: Safety canonical key
|
||||
// Safety canonical key
|
||||
if (
|
||||
normalizedRole.includes("safety") ||
|
||||
normalizedRole.includes("selector")
|
||||
@@ -385,26 +397,26 @@ export default function GunbuilderPage() {
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
|
||||
async function hydrateSelected() {
|
||||
const ids = (Object.values(build).filter(Boolean) as string[])
|
||||
.map(String)
|
||||
.sort();
|
||||
|
||||
|
||||
const key = ids.join(",");
|
||||
if (key === lastHydrateKeyRef.current) return;
|
||||
lastHydrateKeyRef.current = key;
|
||||
|
||||
|
||||
if (ids.length === 0) {
|
||||
setParts([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
|
||||
@@ -415,27 +427,27 @@ export default function GunbuilderPage() {
|
||||
body: JSON.stringify({ ids }),
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to hydrate parts (${res.status})`);
|
||||
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
|
||||
// Map products by id for quick lookup
|
||||
const byId = new Map<string, GunbuilderProductFromApi>();
|
||||
for (const p of data) byId.set(String(p.id), p);
|
||||
|
||||
|
||||
// Build the hydrated parts by iterating the BUILD STATE (slot -> id)
|
||||
const hydrated: Part[] = Object.entries(build)
|
||||
.filter(([, id]) => Boolean(id))
|
||||
.map(([slot, id]) => {
|
||||
const p = byId.get(String(id));
|
||||
if (!p) return null;
|
||||
|
||||
|
||||
const buyUrl = p.buyUrl ?? undefined;
|
||||
|
||||
|
||||
return {
|
||||
id: String(p.id),
|
||||
categoryId: slot as any, // ✅ slot is the source of truth
|
||||
categoryId: slot as any, // slot is the source of truth
|
||||
name: p.name,
|
||||
brand: p.brand,
|
||||
price: p.price ?? 0,
|
||||
@@ -446,9 +458,8 @@ export default function GunbuilderPage() {
|
||||
} as Part;
|
||||
})
|
||||
.filter((x): x is Part => x !== null);
|
||||
|
||||
|
||||
setParts(hydrated);
|
||||
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
setError(err?.message ?? "Failed to hydrate selected parts");
|
||||
@@ -456,7 +467,7 @@ export default function GunbuilderPage() {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
hydrateSelected();
|
||||
return () => controller.abort();
|
||||
}, [build]);
|
||||
@@ -512,10 +523,10 @@ export default function GunbuilderPage() {
|
||||
|
||||
if (!uuidToLoad) return;
|
||||
|
||||
// ✅ Wait for AuthProvider hydration before calling /me endpoints.
|
||||
// Wait for AuthProvider hydration before calling /me endpoints.
|
||||
if (authLoading) return;
|
||||
|
||||
// ✅ If not logged in, don't spam the backend; show a helpful message instead.
|
||||
// If not logged in, don't spam the backend; show a helpful message instead.
|
||||
if (!token) {
|
||||
setShareStatus("Please log in to load builds from your Vault.");
|
||||
window.setTimeout(() => setShareStatus(null), 4500);
|
||||
@@ -633,7 +644,7 @@ export default function GunbuilderPage() {
|
||||
|
||||
showToast({
|
||||
type: "success",
|
||||
message: "Saved to Vault ✅ (UUID copied)",
|
||||
message: "Saved to your Vault ✅ ",
|
||||
});
|
||||
|
||||
// Close modal + reset inputs for next variant
|
||||
@@ -674,66 +685,70 @@ export default function GunbuilderPage() {
|
||||
},
|
||||
[build]
|
||||
);
|
||||
// -----------------------------
|
||||
// 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");
|
||||
// Handle URL query parameters (?select, ?remove) and keep platform synced + canonical
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
|
||||
const actionKey = `${selectParam ?? ""}|${removeParam ?? ""}|${
|
||||
qpPlatform ?? ""
|
||||
}`;
|
||||
const selectParam = sp.get("select");
|
||||
const removeParam = sp.get("remove");
|
||||
|
||||
if (!selectParam && !removeParam) {
|
||||
processedActionKeyRef.current = "";
|
||||
return;
|
||||
}
|
||||
// Normalize platform from URL (AR-15 -> AR15)
|
||||
const qpPlatformRaw = sp.get("platform");
|
||||
const qpPlatform = normalizePlatformKey(qpPlatformRaw ?? "");
|
||||
|
||||
if (processedActionKeyRef.current === actionKey) return;
|
||||
processedActionKeyRef.current = actionKey;
|
||||
// Decide canonical platform to use (URL if valid, else current state)
|
||||
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
||||
|
||||
if (selectParam) {
|
||||
const [categoryIdRaw, partId] = selectParam.split(":");
|
||||
const requestedCategoryId = categoryIdRaw as BuilderSlotKey;
|
||||
// Keep React state in sync (only when needed)
|
||||
if (isValidPlatform(qpPlatform) && qpPlatform !== platform) {
|
||||
setPlatform(qpPlatform);
|
||||
}
|
||||
|
||||
if (requestedCategoryId && partId) {
|
||||
handleSelectPart(requestedCategoryId, partId, { confirm: false });
|
||||
// Prevent repeating select/remove actions
|
||||
const actionKey = `${selectParam ?? ""}|${removeParam ?? ""}|${nextPlatform}`;
|
||||
const hasAction = !!selectParam || !!removeParam;
|
||||
|
||||
if (hasAction) {
|
||||
if (processedActionKeyRef.current !== actionKey) {
|
||||
processedActionKeyRef.current = actionKey;
|
||||
|
||||
if (selectParam) {
|
||||
const [categoryIdRaw, partId] = selectParam.split(":");
|
||||
const requestedCategoryId = categoryIdRaw as BuilderSlotKey;
|
||||
|
||||
if (requestedCategoryId && partId) {
|
||||
handleSelectPart(requestedCategoryId, partId, { confirm: false });
|
||||
}
|
||||
}
|
||||
|
||||
if (removeParam) {
|
||||
if (CATEGORIES.some((c) => c.id === removeParam)) {
|
||||
setBuild((prev) => {
|
||||
const next: BuildState = { ...prev };
|
||||
delete next[removeParam as BuilderSlotKey];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// no action params → allow future actions to run
|
||||
processedActionKeyRef.current = "";
|
||||
}
|
||||
|
||||
if (removeParam) {
|
||||
if (CATEGORIES.some((c) => c.id === removeParam)) {
|
||||
setBuild((prev) => {
|
||||
const next: BuildState = { ...prev };
|
||||
delete next[removeParam as BuilderSlotKey];
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Canonicalize URL: set platform to canonical key and remove action params
|
||||
const before = searchParams.toString();
|
||||
|
||||
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
||||
sp.set("platform", nextPlatform);
|
||||
sp.delete("select");
|
||||
sp.delete("remove");
|
||||
|
||||
// Keep URL clean/stable
|
||||
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]);
|
||||
const after = sp.toString();
|
||||
|
||||
// Keep platform in sync w/ URL
|
||||
useEffect(() => {
|
||||
const qp = searchParams.get("platform");
|
||||
if (isValidPlatform(qp) && qp !== platform) {
|
||||
setPlatform(qp);
|
||||
}
|
||||
}, [searchParams, platform]);
|
||||
if (after !== before) {
|
||||
router.replace(`/builder?${after}`, { scroll: false });
|
||||
}
|
||||
}, [searchParams, router, platform, handleSelectPart]);
|
||||
|
||||
// Build share URL whenever build changes (client-side only)
|
||||
useEffect(() => {
|
||||
@@ -971,7 +986,7 @@ export default function GunbuilderPage() {
|
||||
|
||||
// Keep URL in sync, and clear transient action params
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", next);
|
||||
qp.set("platform", normalizePlatformKey(next));
|
||||
qp.delete("select");
|
||||
qp.delete("remove");
|
||||
|
||||
@@ -983,7 +998,7 @@ export default function GunbuilderPage() {
|
||||
>
|
||||
{PLATFORMS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
{PLATFORM_LABEL[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
@@ -1352,11 +1367,8 @@ export default function GunbuilderPage() {
|
||||
</>
|
||||
) : (
|
||||
<Link
|
||||
href={`/parts/${encodeURIComponent(
|
||||
category.id
|
||||
)}?platform=${encodeURIComponent(
|
||||
platform
|
||||
)}`}
|
||||
href={`/parts/${encodeURIComponent(category.id)}?platform=${encodeURIComponent(canonicalPlatform)}`}
|
||||
|
||||
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"
|
||||
>
|
||||
<svg
|
||||
|
||||
Reference in New Issue
Block a user