fixed builder hydration issues
This commit is contained in:
@@ -0,0 +1,401 @@
|
||||
"use client";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
|
||||
export type BuildProgressHubCategoryGroup = {
|
||||
id: string;
|
||||
label: string;
|
||||
categoryIds: string[];
|
||||
};
|
||||
|
||||
export type BuildProgressHubProps = {
|
||||
slots: any[];
|
||||
selectedByCategory: Record<string, unknown>;
|
||||
categoryGroups: BuildProgressHubCategoryGroup[];
|
||||
isSlotSatisfiedFn: (slot: any, selectedByCategory: any) => boolean;
|
||||
|
||||
requiredSlotIds?: Set<string>;
|
||||
upgradeSlotIds?: Set<string>;
|
||||
};
|
||||
|
||||
// Defaults (pragmatic UX for new users)
|
||||
export const DEFAULT_REQUIRED_SLOT_IDS = new Set<string>([
|
||||
"lower-receiver",
|
||||
"complete-lower",
|
||||
"upper-receiver",
|
||||
"complete-upper",
|
||||
|
||||
"barrel",
|
||||
"bcg",
|
||||
"charging-handle",
|
||||
"handguard",
|
||||
"gas-block",
|
||||
"gas-tube",
|
||||
"muzzle-device",
|
||||
|
||||
"trigger",
|
||||
"grip",
|
||||
"safety-selector",
|
||||
"buffer",
|
||||
"stock",
|
||||
]);
|
||||
|
||||
export const DEFAULT_UPGRADE_SLOT_IDS = new Set<string>([
|
||||
"optic",
|
||||
"sights",
|
||||
"suppressor",
|
||||
"weapon-light",
|
||||
"foregrip",
|
||||
"bipod",
|
||||
"sling",
|
||||
"rail-accessory",
|
||||
]);
|
||||
|
||||
function clamp01(n: number) {
|
||||
if (Number.isNaN(n)) return 0;
|
||||
return Math.max(0, Math.min(1, n));
|
||||
}
|
||||
|
||||
function CircularProgress({
|
||||
value,
|
||||
size = 64,
|
||||
stroke = 8,
|
||||
label,
|
||||
sublabel,
|
||||
}: {
|
||||
value: number; // 0..1
|
||||
size?: number;
|
||||
stroke?: number;
|
||||
label: string;
|
||||
sublabel?: string;
|
||||
}) {
|
||||
const v = clamp01(value);
|
||||
const r = (size - stroke) / 2;
|
||||
const c = 2 * Math.PI * r;
|
||||
const dash = c * v;
|
||||
|
||||
return (
|
||||
<div className="relative" style={{ width: size, height: size }}>
|
||||
<svg width={size} height={size} className="block">
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
strokeWidth={stroke}
|
||||
className="text-zinc-800"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
/>
|
||||
<circle
|
||||
cx={size / 2}
|
||||
cy={size / 2}
|
||||
r={r}
|
||||
strokeWidth={stroke}
|
||||
className="text-amber-400"
|
||||
stroke="currentColor"
|
||||
fill="transparent"
|
||||
strokeLinecap="round"
|
||||
strokeDasharray={`${dash} ${c - dash}`}
|
||||
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||
/>
|
||||
</svg>
|
||||
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||
<div className="text-[0.65rem] font-semibold text-zinc-100 leading-none">
|
||||
{label}
|
||||
</div>
|
||||
{sublabel ? (
|
||||
<div className="mt-0.5 text-[0.6rem] text-zinc-400 leading-none">
|
||||
{sublabel}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function slotGroupLabel(slotId: string, categoryGroups: BuildProgressHubCategoryGroup[]) {
|
||||
const g = categoryGroups.find((group) =>
|
||||
group.categoryIds.includes(slotId)
|
||||
);
|
||||
return g?.label ?? "Other";
|
||||
}
|
||||
|
||||
function scrollToSlot(slotId: string, categoryGroups: BuildProgressHubCategoryGroup[]) {
|
||||
const el = document.getElementById(`slot-${slotId}`);
|
||||
if (el) {
|
||||
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
el.classList.add("ring-2", "ring-amber-400/40");
|
||||
window.setTimeout(() => {
|
||||
el.classList.remove("ring-2", "ring-amber-400/40");
|
||||
}, 900);
|
||||
return;
|
||||
}
|
||||
|
||||
const groupId =
|
||||
categoryGroups.find((g) => g.categoryIds.includes(slotId))?.id ?? "";
|
||||
const gEl = groupId ? document.getElementById(`group-${groupId}`) : null;
|
||||
if (gEl) gEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||
}
|
||||
|
||||
export function BuildProgressHub({
|
||||
slots,
|
||||
selectedByCategory,
|
||||
categoryGroups,
|
||||
isSlotSatisfiedFn,
|
||||
requiredSlotIds,
|
||||
upgradeSlotIds,
|
||||
}: BuildProgressHubProps) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const requiredIds = requiredSlotIds ?? DEFAULT_REQUIRED_SLOT_IDS;
|
||||
const upgradeIds = upgradeSlotIds ?? DEFAULT_UPGRADE_SLOT_IDS;
|
||||
|
||||
const enriched = useMemo(() => {
|
||||
return (slots ?? []).map((slot: any) => {
|
||||
const slotId = String(slot.id ?? slot.key ?? slot.slotId ?? "");
|
||||
const required = slot.required === true || requiredIds.has(slotId);
|
||||
const satisfied = isSlotSatisfiedFn(slot, selectedByCategory as any);
|
||||
|
||||
return {
|
||||
id: slotId,
|
||||
label: String(slot.label ?? slotId),
|
||||
required,
|
||||
satisfied,
|
||||
groupLabel: slotGroupLabel(slotId, categoryGroups),
|
||||
};
|
||||
});
|
||||
}, [slots, selectedByCategory, categoryGroups, isSlotSatisfiedFn, requiredIds]);
|
||||
|
||||
const required = enriched.filter((s) => s.required);
|
||||
const optional = enriched.filter((s) => !s.required);
|
||||
|
||||
const upgrades = optional.filter((s) => upgradeIds.has(s.id));
|
||||
const accessories = optional.filter((s) => !upgradeIds.has(s.id));
|
||||
|
||||
const reqDone = required.filter((s) => s.satisfied).length;
|
||||
const optDone = optional.filter((s) => s.satisfied).length;
|
||||
|
||||
const reqPct = required.length ? reqDone / required.length : 0;
|
||||
const optPct = optional.length ? optDone / optional.length : 0;
|
||||
|
||||
const remainingRequired = Math.max(0, required.length - reqDone);
|
||||
|
||||
return (
|
||||
<div className="fixed bottom-4 right-4 z-40">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen((v) => !v)}
|
||||
className="group flex items-center gap-3 rounded-2xl border border-white/10 bg-zinc-950/80 px-3 py-3 shadow-xl backdrop-blur hover:bg-zinc-900/80"
|
||||
aria-label="Build progress"
|
||||
title="Build progress"
|
||||
>
|
||||
<CircularProgress
|
||||
value={reqPct}
|
||||
size={56}
|
||||
stroke={7}
|
||||
label={`${reqDone}/${required.length}`}
|
||||
sublabel="Required"
|
||||
/>
|
||||
<div className="text-left">
|
||||
<div className="text-xs font-semibold text-zinc-100">
|
||||
Build Checklist
|
||||
</div>
|
||||
<div className="mt-0.5 text-[0.7rem] text-zinc-400">
|
||||
{remainingRequired === 0
|
||||
? "Core complete ✅"
|
||||
: `${remainingRequired} required left`}
|
||||
</div>
|
||||
<div className="mt-1 text-[0.65rem] text-zinc-500">
|
||||
Tap to {open ? "hide" : "view"} details
|
||||
</div>
|
||||
</div>
|
||||
</button>
|
||||
|
||||
{open && (
|
||||
<div className="mt-3 w-[22rem] max-w-[92vw] overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/90 shadow-2xl backdrop-blur">
|
||||
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
|
||||
<div>
|
||||
<div className="text-xs font-semibold text-zinc-100">
|
||||
What you need (fast)
|
||||
</div>
|
||||
<div className="mt-0.5 text-[0.7rem] text-zinc-400">
|
||||
Required vs optional — click anything to jump.
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setOpen(false)}
|
||||
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-zinc-200 hover:bg-white/10"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 gap-0 border-b border-white/10">
|
||||
<div className="px-4 py-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-amber-300">
|
||||
Required
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-zinc-400">
|
||||
{reqDone}/{required.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CircularProgress
|
||||
value={reqPct}
|
||||
size={64}
|
||||
stroke={8}
|
||||
label={`${Math.round(reqPct * 100)}%`}
|
||||
sublabel="Core"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 py-3 border-l border-white/10">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-300">
|
||||
Optional
|
||||
</div>
|
||||
<div className="text-[0.7rem] text-zinc-400">
|
||||
{optDone}/{optional.length}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2">
|
||||
<CircularProgress
|
||||
value={optPct}
|
||||
size={64}
|
||||
stroke={8}
|
||||
label={`${Math.round(optPct * 100)}%`}
|
||||
sublabel="Extras"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="max-h-[55vh] overflow-auto">
|
||||
<div className="px-4 py-3">
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-amber-300">
|
||||
Required components
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{required.map((s) => (
|
||||
<button
|
||||
key={s.id}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
scrollToSlot(s.id, categoryGroups);
|
||||
}}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-left hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-zinc-100">
|
||||
{s.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[0.65rem] text-zinc-500">
|
||||
{s.groupLabel}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[0.65rem] border ${
|
||||
s.satisfied
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-zinc-700 bg-zinc-900/40 text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{s.satisfied ? "Added" : "Missing"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="px-4 pb-4">
|
||||
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-300">
|
||||
Optional components
|
||||
</div>
|
||||
|
||||
<div className="mt-2 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-zinc-400">
|
||||
Upgrades
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{upgrades.map((s) => (
|
||||
<button
|
||||
key={`upg-${s.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
scrollToSlot(s.id, categoryGroups);
|
||||
}}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-left hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-zinc-100">
|
||||
{s.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[0.65rem] text-zinc-500">
|
||||
{s.groupLabel}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[0.65rem] border ${
|
||||
s.satisfied
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-zinc-700 bg-zinc-900/40 text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{s.satisfied ? "Added" : "Open"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-zinc-400">
|
||||
Accessories
|
||||
</div>
|
||||
<div className="mt-2 space-y-1">
|
||||
{accessories.map((s) => (
|
||||
<button
|
||||
key={`acc-${s.id}`}
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setOpen(false);
|
||||
scrollToSlot(s.id, categoryGroups);
|
||||
}}
|
||||
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-left hover:bg-white/10"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-xs font-medium text-zinc-100">
|
||||
{s.label}
|
||||
</div>
|
||||
<div className="mt-0.5 text-[0.65rem] text-zinc-500">
|
||||
{s.groupLabel}
|
||||
</div>
|
||||
</div>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[0.65rem] border ${
|
||||
s.satisfied
|
||||
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-zinc-700 bg-zinc-900/40 text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{s.satisfied ? "Added" : "Open"}
|
||||
</span>
|
||||
</div>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -21,7 +21,7 @@ import Pagination from "@/components/parts/Pagination";
|
||||
import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
|
||||
import { normalizePlatformKey } from "@/lib/platforms";
|
||||
import type { UiPart } from "@/types/uiPart";
|
||||
import type { Category } from "@/types/builderSlots";
|
||||
import type { BuilderSlotKey, Category } from "@/types/builderSlots";
|
||||
import {
|
||||
PART_ROLE_TO_CATEGORY,
|
||||
normalizePartRole,
|
||||
@@ -310,15 +310,17 @@ export default function PartsBrowseClient(props: {
|
||||
// Add → Builder handoff
|
||||
// ----------------------------
|
||||
const handleAddToBuild = (p: UiPart) => {
|
||||
const normalizedRole = normalizePartRole(partRole);
|
||||
const categoryId: Category | null =
|
||||
// Always normalize first; our mappings may alias multiple roles to one canonical builder category
|
||||
const roleKey = normalizePartRole(partRole);
|
||||
|
||||
const categoryId: BuilderSlotKey | null =
|
||||
PART_ROLE_TO_CATEGORY[roleKey] ??
|
||||
PART_ROLE_TO_CATEGORY[partRole] ??
|
||||
PART_ROLE_TO_CATEGORY[normalizedRole] ??
|
||||
null;
|
||||
|
||||
if (!categoryId) {
|
||||
alert(
|
||||
`No Category mapping found for role "${partRole}". Add it to catalogMappings.`
|
||||
`No Category mapping found for role "${partRole}" (normalized: "${roleKey}"). Add it to catalogMappings.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user