fixed builder hydration issues
This commit is contained in:
@@ -67,8 +67,6 @@ const isValidPlatform = (
|
||||
): 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(
|
||||
v
|
||||
@@ -153,6 +151,184 @@ const CATEGORY_GROUPS: {
|
||||
},
|
||||
];
|
||||
|
||||
// --- Quick Checklist (simple counters only) ---
|
||||
// Required: core rifle components (kept intentionally small + pragmatic)
|
||||
const QUICK_REQUIRED_IDS = new Set<BuilderSlotKey>([
|
||||
"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",
|
||||
]);
|
||||
|
||||
// Optional: Accessories & Gear group (these are nice-to-haves)
|
||||
const QUICK_OPTIONAL_IDS = new Set<BuilderSlotKey>(
|
||||
CATEGORY_GROUPS.find((g) => g.id === "accessories-group")?.categoryIds ?? []
|
||||
);
|
||||
|
||||
function computeQuickChecklistCounts(
|
||||
build: BuildState,
|
||||
allowedIds: Set<BuilderSlotKey>
|
||||
) {
|
||||
// Only count IDs that exist in our known category universe.
|
||||
const requiredIds = Array.from(QUICK_REQUIRED_IDS).filter((id) =>
|
||||
allowedIds.has(id)
|
||||
);
|
||||
const optionalIds = Array.from(QUICK_OPTIONAL_IDS).filter((id) =>
|
||||
allowedIds.has(id)
|
||||
);
|
||||
|
||||
const requiredDone = requiredIds.filter((id) => Boolean(build[id])).length;
|
||||
const optionalDone = optionalIds.filter((id) => Boolean(build[id])).length;
|
||||
|
||||
return {
|
||||
requiredDone,
|
||||
requiredTotal: requiredIds.length,
|
||||
optionalDone,
|
||||
optionalTotal: optionalIds.length,
|
||||
};
|
||||
}
|
||||
|
||||
// Helper to normalize all build values to strings for strict comparison
|
||||
function normalizeBuildState(input: any): BuildState {
|
||||
if (!input || typeof input !== "object") return {};
|
||||
const next: BuildState = {};
|
||||
for (const [k, v] of Object.entries(input)) {
|
||||
if (v == null) continue;
|
||||
// force all ids to strings for consistent comparisons
|
||||
next[k as BuilderSlotKey] = String(v);
|
||||
}
|
||||
return next;
|
||||
}
|
||||
|
||||
// --- Table nesting (OR paths) ---
|
||||
type TableRowKind = "note" | "category";
|
||||
|
||||
type TableRow = {
|
||||
key: string;
|
||||
kind: TableRowKind;
|
||||
label: string;
|
||||
categoryId?: BuilderSlotKey;
|
||||
indent?: 0 | 1 | 2;
|
||||
tone?: "muted" | "normal";
|
||||
pill?: string;
|
||||
locked?: boolean;
|
||||
};
|
||||
|
||||
const LOWER_PATHS = {
|
||||
complete: "complete-lower" as BuilderSlotKey,
|
||||
builderRoot: "lower-receiver" as BuilderSlotKey,
|
||||
builderChildren: [
|
||||
"lower-parts-kit",
|
||||
"trigger",
|
||||
"grip",
|
||||
"safety-selector",
|
||||
"buffer",
|
||||
"stock",
|
||||
] as BuilderSlotKey[],
|
||||
};
|
||||
|
||||
const UPPER_PATHS = {
|
||||
complete: "complete-upper" as BuilderSlotKey,
|
||||
builderRoot: "upper-receiver" as BuilderSlotKey,
|
||||
builderChildren: [
|
||||
"barrel",
|
||||
"bcg",
|
||||
"charging-handle",
|
||||
"handguard",
|
||||
"gas-block",
|
||||
"gas-tube",
|
||||
"muzzle-device",
|
||||
] as BuilderSlotKey[],
|
||||
};
|
||||
|
||||
function getAssemblyMode(
|
||||
build: BuildState,
|
||||
paths: { complete: BuilderSlotKey; builderRoot: BuilderSlotKey }
|
||||
) {
|
||||
const hasComplete = Boolean(build[paths.complete]);
|
||||
const hasBuilderRoot = Boolean(build[paths.builderRoot]);
|
||||
if (hasComplete) return "complete" as const;
|
||||
if (hasBuilderRoot) return "builder" as const;
|
||||
return "none" as const;
|
||||
}
|
||||
|
||||
function buildAssemblyRows(params: {
|
||||
groupLabel: string;
|
||||
build: BuildState;
|
||||
allowedIds: Set<BuilderSlotKey>;
|
||||
paths: {
|
||||
complete: BuilderSlotKey;
|
||||
builderRoot: BuilderSlotKey;
|
||||
builderChildren: BuilderSlotKey[];
|
||||
};
|
||||
}): TableRow[] {
|
||||
const { groupLabel, build, allowedIds, paths } = params;
|
||||
|
||||
const mode = getAssemblyMode(build, paths);
|
||||
|
||||
const rows: TableRow[] = [];
|
||||
|
||||
rows.push({
|
||||
key: `${groupLabel}-note`,
|
||||
kind: "note",
|
||||
label: `${groupLabel} (choose one path)`,
|
||||
tone: "muted",
|
||||
pill: "Complete OR Builder Path",
|
||||
});
|
||||
|
||||
if (allowedIds.has(paths.complete)) {
|
||||
rows.push({
|
||||
key: `${groupLabel}-${paths.complete}`,
|
||||
kind: "category",
|
||||
label: "Complete Assembly (Fastest)",
|
||||
categoryId: paths.complete,
|
||||
indent: 0,
|
||||
pill: mode === "complete" ? "Selected" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
if (allowedIds.has(paths.builderRoot)) {
|
||||
rows.push({
|
||||
key: `${groupLabel}-${paths.builderRoot}`,
|
||||
kind: "category",
|
||||
label: "Build From Parts (Stripped + LPK)",
|
||||
categoryId: paths.builderRoot,
|
||||
indent: 0,
|
||||
pill: mode === "builder" ? "Selected" : undefined,
|
||||
});
|
||||
}
|
||||
|
||||
const children = paths.builderChildren.filter((id) => allowedIds.has(id));
|
||||
for (const cid of children) {
|
||||
rows.push({
|
||||
key: `${groupLabel}-${cid}`,
|
||||
kind: "category",
|
||||
label: "",
|
||||
categoryId: cid,
|
||||
indent: 1,
|
||||
locked: mode === "complete",
|
||||
pill: mode === "builder" ? "required" : undefined,
|
||||
tone: mode === "complete" ? "muted" : "normal",
|
||||
});
|
||||
}
|
||||
|
||||
return rows;
|
||||
}
|
||||
|
||||
export default function GunbuilderPage() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
@@ -162,16 +338,10 @@ 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]>("AR15");
|
||||
|
||||
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);
|
||||
// Canonical, URL-safe platform (AR15 / AR10 / AR9)
|
||||
const canonicalPlatform = normalizePlatformKey(platform);
|
||||
// -----------------------------
|
||||
// Save As… modal state (Vault variants)
|
||||
// -----------------------------
|
||||
@@ -190,19 +360,31 @@ const canonicalPlatform = normalizePlatformKey(platform);
|
||||
// -----------------------------
|
||||
// Build state (categoryId -> productId), persisted locally
|
||||
// -----------------------------
|
||||
const [build, setBuild] = useState<BuildState>(() => {
|
||||
if (typeof window !== "undefined") {
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
return JSON.parse(stored);
|
||||
} catch {
|
||||
return {};
|
||||
const [build, setBuild] = useState<BuildState>({});
|
||||
|
||||
// Hydration guard: avoid SSR/CSR HTML mismatch (platform/build were previously derived from window/localStorage)
|
||||
const [isHydrated, setIsHydrated] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
|
||||
// Hydrate persisted build state only. URL actions (platform/select/remove)
|
||||
// are handled by the dedicated useSearchParams effect later in this file.
|
||||
const stored = localStorage.getItem(STORAGE_KEY);
|
||||
if (stored) {
|
||||
try {
|
||||
const parsed = JSON.parse(stored);
|
||||
if (parsed && typeof parsed === "object") {
|
||||
setBuild(normalizeBuildState(parsed));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}
|
||||
return {};
|
||||
});
|
||||
|
||||
// Signal that hydration is complete - this allows URL param processing to run
|
||||
setIsHydrated(true);
|
||||
}, []);
|
||||
|
||||
type PageResponse<T> = {
|
||||
content: T[];
|
||||
@@ -280,6 +462,16 @@ const canonicalPlatform = normalizePlatformKey(platform);
|
||||
[build]
|
||||
);
|
||||
|
||||
const allowedCategoryIds = useMemo(
|
||||
() => new Set(CATEGORIES.map((c) => c.id as BuilderSlotKey)),
|
||||
[]
|
||||
);
|
||||
|
||||
const quickChecklist = useMemo(
|
||||
() => computeQuickChecklistCounts(build, allowedCategoryIds),
|
||||
[build, allowedCategoryIds]
|
||||
);
|
||||
|
||||
const totalPrice = useMemo(
|
||||
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
|
||||
[selectedParts]
|
||||
@@ -395,100 +587,101 @@ const canonicalPlatform = normalizePlatformKey(platform);
|
||||
// -----------------------------
|
||||
const lastHydrateKeyRef = useRef<string>("");
|
||||
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function hydrateSelected() {
|
||||
const ids = (Object.values(build).filter(Boolean) as string[])
|
||||
.map(String)
|
||||
.sort();
|
||||
async function hydrateSelected() {
|
||||
const ids = (Object.values(build).filter(Boolean) as string[])
|
||||
.map(String)
|
||||
.sort();
|
||||
|
||||
const key = ids.join(",");
|
||||
const key = ids.join(",");
|
||||
|
||||
// If nothing selected, reset
|
||||
if (ids.length === 0) {
|
||||
lastHydrateKeyRef.current = "";
|
||||
setParts([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
// Only skip if we *successfully* hydrated this key previously
|
||||
if (key === lastHydrateKeyRef.current) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
|
||||
{
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to hydrate parts (${res.status})`);
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
|
||||
const byId = new Map<string, GunbuilderProductFromApi>();
|
||||
for (const p of data) byId.set(String(p.id), p);
|
||||
|
||||
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,
|
||||
name: p.name,
|
||||
brand: p.brand,
|
||||
price: p.price ?? 0,
|
||||
imageUrl: p.imageUrl ?? undefined,
|
||||
affiliateUrl: buyUrl,
|
||||
url: buyUrl,
|
||||
} as Part;
|
||||
})
|
||||
.filter((x): x is Part => x !== null);
|
||||
|
||||
setParts(hydrated);
|
||||
|
||||
// ✅ Mark key as hydrated ONLY after success
|
||||
lastHydrateKeyRef.current = key;
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") {
|
||||
// ✅ Do NOT lock the key on abort; allow retry
|
||||
// If nothing selected, reset
|
||||
if (ids.length === 0) {
|
||||
lastHydrateKeyRef.current = "";
|
||||
setParts([]);
|
||||
setError(null);
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
setError(err?.message ?? "Failed to hydrate selected parts");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
hydrateSelected();
|
||||
return () => controller.abort();
|
||||
}, [build]);
|
||||
// Only skip if we *successfully* hydrated this key previously
|
||||
if (key === lastHydrateKeyRef.current) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
|
||||
{
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({ ids }),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to hydrate parts (${res.status})`);
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
|
||||
const byId = new Map<string, GunbuilderProductFromApi>();
|
||||
for (const p of data) byId.set(String(p.id), p);
|
||||
|
||||
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,
|
||||
name: p.name,
|
||||
brand: p.brand,
|
||||
price: p.price ?? 0,
|
||||
imageUrl: p.imageUrl ?? undefined,
|
||||
affiliateUrl: buyUrl,
|
||||
url: buyUrl,
|
||||
} as Part;
|
||||
})
|
||||
.filter((x): x is Part => x !== null);
|
||||
|
||||
setParts(hydrated);
|
||||
|
||||
// ✅ Mark key as hydrated ONLY after success
|
||||
lastHydrateKeyRef.current = key;
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") {
|
||||
// ✅ Do NOT lock the key on abort; allow retry
|
||||
return;
|
||||
}
|
||||
setError(err?.message ?? "Failed to hydrate selected parts");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
hydrateSelected();
|
||||
return () => controller.abort();
|
||||
}, [build]);
|
||||
|
||||
// -----------------------------
|
||||
// Persist build to localStorage
|
||||
// -----------------------------
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
if (!isHydrated) return;
|
||||
try {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
}, [build]);
|
||||
}, [build, isHydrated]);
|
||||
|
||||
// -----------------------------
|
||||
// When platform changes, clear ONLY platform-scoped selections
|
||||
@@ -684,77 +877,103 @@ useEffect(() => {
|
||||
const hints = getSelectionHints(categoryId, build);
|
||||
if (hints.length > 0) warn(hints[0]);
|
||||
|
||||
setBuild((prev) => ({
|
||||
...prev,
|
||||
[categoryId]: partId,
|
||||
}));
|
||||
setBuild((prev) => {
|
||||
const next: BuildState = {
|
||||
...prev,
|
||||
[categoryId]: String(partId),
|
||||
};
|
||||
// Write-through so query-param selections survive Next/router replaces
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return next;
|
||||
});
|
||||
},
|
||||
[build]
|
||||
);
|
||||
// Handle URL query parameters (?select, ?remove) and keep platform synced + canonical
|
||||
useEffect(() => {
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
useEffect(() => {
|
||||
if (!isHydrated) return; // Wait for initial hydration to complete
|
||||
|
||||
const selectParam = sp.get("select");
|
||||
const removeParam = sp.get("remove");
|
||||
const sp = new URLSearchParams(searchParams.toString());
|
||||
|
||||
// Normalize platform from URL (AR-15 -> AR15)
|
||||
const qpPlatformRaw = sp.get("platform");
|
||||
const qpPlatform = normalizePlatformKey(qpPlatformRaw ?? "");
|
||||
const selectParam = sp.get("select");
|
||||
const removeParam = sp.get("remove");
|
||||
|
||||
// Decide canonical platform to use (URL if valid, else current state)
|
||||
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
||||
// Normalize platform from URL (AR-15 -> AR15)
|
||||
const qpPlatformRaw = sp.get("platform");
|
||||
const qpPlatform = normalizePlatformKey(qpPlatformRaw ?? "");
|
||||
|
||||
// Keep React state in sync (only when needed)
|
||||
if (isValidPlatform(qpPlatform) && qpPlatform !== platform) {
|
||||
setPlatform(qpPlatform);
|
||||
}
|
||||
// Decide canonical platform to use (URL if valid, else current state)
|
||||
const nextPlatform = isValidPlatform(qpPlatform) ? qpPlatform : platform;
|
||||
|
||||
// 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;
|
||||
});
|
||||
}
|
||||
}
|
||||
// Keep React state in sync (only when needed)
|
||||
if (isValidPlatform(qpPlatform) && qpPlatform !== platform) {
|
||||
setPlatform(qpPlatform);
|
||||
}
|
||||
} else {
|
||||
// no action params → allow future actions to run
|
||||
processedActionKeyRef.current = "";
|
||||
}
|
||||
|
||||
// Canonicalize URL: set platform to canonical key and remove action params
|
||||
const before = searchParams.toString();
|
||||
// Prevent repeating select/remove actions
|
||||
const actionKey = `${selectParam ?? ""}|${
|
||||
removeParam ?? ""
|
||||
}|${nextPlatform}`;
|
||||
const hasAction = !!selectParam || !!removeParam;
|
||||
|
||||
sp.set("platform", nextPlatform);
|
||||
sp.delete("select");
|
||||
sp.delete("remove");
|
||||
if (hasAction) {
|
||||
if (processedActionKeyRef.current !== actionKey) {
|
||||
processedActionKeyRef.current = actionKey;
|
||||
|
||||
const after = sp.toString();
|
||||
if (selectParam) {
|
||||
const [categoryIdRaw, partId] = selectParam.split(":");
|
||||
const requestedCategoryId = categoryIdRaw as BuilderSlotKey;
|
||||
|
||||
if (after !== before) {
|
||||
router.replace(`/builder?${after}`, { scroll: false });
|
||||
}
|
||||
}, [searchParams, router, platform, handleSelectPart]);
|
||||
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];
|
||||
try {
|
||||
if (typeof window !== "undefined") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
|
||||
}
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
return next;
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// no action params → allow future actions to run
|
||||
processedActionKeyRef.current = "";
|
||||
}
|
||||
|
||||
// Canonicalize URL: set platform to canonical key and remove action params
|
||||
const before = searchParams.toString();
|
||||
|
||||
sp.set("platform", nextPlatform);
|
||||
sp.delete("select");
|
||||
sp.delete("remove");
|
||||
|
||||
const after = sp.toString();
|
||||
|
||||
if (after !== before) {
|
||||
// Delay replace one tick so state updates from select/remove are not lost
|
||||
// during RSC navigation / dev strict mode.
|
||||
window.setTimeout(() => {
|
||||
router.replace(`/builder?${after}`, { scroll: false });
|
||||
}, 0);
|
||||
}
|
||||
}, [searchParams, router, platform, handleSelectPart, isHydrated]);
|
||||
|
||||
// Build share URL whenever build changes (client-side only)
|
||||
useEffect(() => {
|
||||
@@ -869,6 +1088,18 @@ useEffect(() => {
|
||||
}
|
||||
};
|
||||
|
||||
if (!isHydrated) {
|
||||
return (
|
||||
<main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/80 p-4">
|
||||
<div className="text-sm text-zinc-400">Loading builder…</div>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50">
|
||||
{/* Toast (top-right) */}
|
||||
@@ -1144,35 +1375,45 @@ useEffect(() => {
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Build status strip */}
|
||||
<section className="mb-6 space-y-3">
|
||||
<h2 className="text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
Build Status (Needs Refined)
|
||||
</h2>
|
||||
<div className="flex flex-wrap gap-2 md:gap-3">
|
||||
{BUILDER_SLOTS.map((slot) => {
|
||||
const satisfied = isSlotSatisfied(slot, selectedByCategory);
|
||||
{/* Quick Checklist (new user-friendly) */}
|
||||
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||
<div className="flex flex-col gap-2 md:flex-row md:items-center md:justify-between">
|
||||
<div>
|
||||
<h2 className="text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
Quick Checklist
|
||||
</h2>
|
||||
<p className="mt-1 text-xs text-zinc-500 max-w-2xl">
|
||||
New here? Focus on the{" "}
|
||||
<span className="text-amber-300">Required</span> items first.
|
||||
Optional items live under accessories and upgrades. Use the
|
||||
floating hub (bottom-right) to jump to anything you're
|
||||
missing.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
return (
|
||||
<div
|
||||
key={slot.id}
|
||||
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[0.7rem] md:text-xs ${
|
||||
satisfied
|
||||
? "border-emerald-500/70 bg-emerald-950/50 text-emerald-200"
|
||||
: "border-zinc-800 bg-zinc-950/80 text-zinc-200"
|
||||
}`}
|
||||
>
|
||||
{satisfied ? (
|
||||
<CheckSquare className="h-4 w-4 text-emerald-400" />
|
||||
) : (
|
||||
<Square className="h-4 w-4 text-zinc-600/80" />
|
||||
)}
|
||||
<span className="font-medium truncate max-w-[9rem] md:max-w-[11rem]">
|
||||
{slot.label}
|
||||
</span>
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-3 py-2">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.14em] text-zinc-500">
|
||||
Required
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
<div className="mt-0.5 text-sm font-semibold text-zinc-100">
|
||||
{quickChecklist.requiredDone}
|
||||
<span className="text-zinc-500"> / </span>
|
||||
{quickChecklist.requiredTotal}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-white/10 bg-white/5 px-3 py-2">
|
||||
<div className="text-[0.65rem] uppercase tracking-[0.14em] text-zinc-500">
|
||||
Optional
|
||||
</div>
|
||||
<div className="mt-0.5 text-sm font-semibold text-zinc-100">
|
||||
{quickChecklist.optionalDone}
|
||||
<span className="text-zinc-500"> / </span>
|
||||
{quickChecklist.optionalTotal}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
@@ -1201,10 +1442,44 @@ useEffect(() => {
|
||||
group.categoryIds.includes(c.id as BuilderSlotKey)
|
||||
);
|
||||
|
||||
const allowedIds = new Set(
|
||||
groupCategories.map((c) => c.id as BuilderSlotKey)
|
||||
);
|
||||
|
||||
const isLower = group.id === "lower-group";
|
||||
const isUpper = group.id === "upper-group";
|
||||
|
||||
const tableRows: TableRow[] = isLower
|
||||
? buildAssemblyRows({
|
||||
groupLabel: "Lower Assembly",
|
||||
build,
|
||||
allowedIds,
|
||||
paths: LOWER_PATHS,
|
||||
})
|
||||
: isUpper
|
||||
? buildAssemblyRows({
|
||||
groupLabel: "Upper Assembly",
|
||||
build,
|
||||
allowedIds,
|
||||
paths: UPPER_PATHS,
|
||||
})
|
||||
: groupCategories.map((c) => ({
|
||||
key: c.id,
|
||||
kind: "category",
|
||||
label: "",
|
||||
categoryId: c.id as BuilderSlotKey,
|
||||
indent: 0,
|
||||
}));
|
||||
|
||||
if (groupCategories.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div key={group.id} className="space-y-3">
|
||||
<div
|
||||
id={`group-${group.id}`}
|
||||
key={group.id}
|
||||
className="space-y-3"
|
||||
>
|
||||
{" "}
|
||||
<div className="flex items-baseline justify-between gap-2">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold text-zinc-100">
|
||||
@@ -1217,7 +1492,6 @@ useEffect(() => {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
|
||||
<table className="min-w-full text-xs md:text-sm">
|
||||
<thead>
|
||||
@@ -1244,7 +1518,44 @@ useEffect(() => {
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{groupCategories.map((category) => {
|
||||
{tableRows.map((row) => {
|
||||
if (row.kind === "note") {
|
||||
return (
|
||||
<tr
|
||||
key={row.key}
|
||||
className="border-t border-zinc-900"
|
||||
>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-3 py-2 text-[0.7rem] text-zinc-500 bg-zinc-950/70"
|
||||
>
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="font-semibold text-zinc-400">
|
||||
{row.label}
|
||||
</div>
|
||||
{row.pill === "Selected" && (
|
||||
<span className="rounded-full border border-zinc-800 bg-zinc-900/60 px-2 py-0.5 text-[0.65rem] text-zinc-400">
|
||||
Selected
|
||||
</span>
|
||||
)}
|
||||
|
||||
{row.pill === "required" && (
|
||||
<span className="ml-1 text-red-500">
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
}
|
||||
|
||||
const categoryId = row.categoryId as BuilderSlotKey;
|
||||
const category = CATEGORIES.find(
|
||||
(c) => c.id === categoryId
|
||||
);
|
||||
if (!category) return null;
|
||||
|
||||
const categoryParts =
|
||||
partsByCategory[category.id] ?? [];
|
||||
const selectedPartId = build[category.id];
|
||||
@@ -1255,141 +1566,90 @@ useEffect(() => {
|
||||
) ?? parts.find((p) => p.id === selectedPartId)
|
||||
: undefined;
|
||||
|
||||
const hasParts = categoryParts.length > 0;
|
||||
const indent = row.indent ?? 0;
|
||||
const isLocked = Boolean(row.locked);
|
||||
|
||||
return (
|
||||
<tr
|
||||
key={category.id}
|
||||
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
|
||||
key={row.key}
|
||||
className={`border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors ${
|
||||
row.tone === "muted" ? "opacity-70" : ""
|
||||
}`}
|
||||
>
|
||||
{/* Component */}
|
||||
<td className="px-3 py-2 align-top">
|
||||
<div className="font-medium text-zinc-100">
|
||||
{category.name}
|
||||
</div>
|
||||
<div className="flex items-start gap-2">
|
||||
{indent > 0 && (
|
||||
<div
|
||||
aria-hidden
|
||||
className="mt-1 h-3 w-3 rounded-sm border border-zinc-800 bg-zinc-950"
|
||||
style={{
|
||||
marginLeft: `${indent * 10}px`,
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
{selectedPart ? (
|
||||
<>
|
||||
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(
|
||||
platform
|
||||
)}/${encodeURIComponent(
|
||||
category.id
|
||||
)}/${encodeURIComponent(
|
||||
`${
|
||||
selectedPart.id
|
||||
}-${selectedPart.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "")}`
|
||||
)}`}
|
||||
className="block text-[0.7rem] text-zinc-500 line-clamp-1 transition-colors hover:text-amber-300 hover:underline underline-offset-2"
|
||||
title="View part details"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium text-zinc-100">
|
||||
{row.label
|
||||
? row.label
|
||||
: category.name}
|
||||
</div>
|
||||
{row.pill === "required" && (
|
||||
<span
|
||||
className="ml-1 text-red-400"
|
||||
aria-hidden="true"
|
||||
>
|
||||
*
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{selectedPart ? (
|
||||
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
|
||||
{selectedPart.name}
|
||||
</Link>{" "}
|
||||
</div>
|
||||
|
||||
{/* Overlap chips help explain conflicts / dependencies */}
|
||||
<div className="mt-1 flex flex-wrap gap-1.5">
|
||||
{getOverlapChipsForCategory(
|
||||
category.id as BuilderSlotKey,
|
||||
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
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[0.7rem] text-zinc-600">
|
||||
No part selected
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div className="text-[0.7rem] text-zinc-600">
|
||||
No parts available yet
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Brand */}
|
||||
<td className="px-3 py-2 align-top text-zinc-300">
|
||||
{selectedPart ? selectedPart.brand : "—"}
|
||||
</td>
|
||||
|
||||
{/* Price */}
|
||||
<td className="px-3 py-2 align-top text-right text-amber-300">
|
||||
{selectedPart
|
||||
? `$${selectedPart.price.toFixed(2)}`
|
||||
: "—"}
|
||||
</td>
|
||||
|
||||
{/* Sale price placeholder */}
|
||||
<td className="px-3 py-2 align-top text-right text-zinc-400">
|
||||
—
|
||||
</td>
|
||||
|
||||
{/* Caliber placeholder (future: derive from build profile) */}
|
||||
<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 ? (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
setBuild((prev) => {
|
||||
const next = { ...prev };
|
||||
delete next[category.id];
|
||||
return next;
|
||||
});
|
||||
}}
|
||||
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 hover:bg-red-500/15 hover:border-red-400 transition-colors"
|
||||
aria-label="Remove part"
|
||||
title="Remove part"
|
||||
>
|
||||
<X className="h-3.5 w-3.5 text-zinc-400 hover:text-red-400" />
|
||||
</button>
|
||||
|
||||
<a
|
||||
href={selectedPart.url}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-2 py-1 hover:bg-amber-300 transition-colors"
|
||||
aria-label="Buy part"
|
||||
title="Buy part"
|
||||
>
|
||||
<Link2 className="h-3.5 w-3.5 text-black" />
|
||||
</a>
|
||||
</>
|
||||
{isLocked ? (
|
||||
<div className="text-[0.7rem] text-zinc-500">
|
||||
—
|
||||
</div>
|
||||
) : (
|
||||
<Link
|
||||
href={`/parts/${encodeURIComponent(category.id)}?platform=${encodeURIComponent(canonicalPlatform)}`}
|
||||
|
||||
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
|
||||
className="w-3.5 h-3.5"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
viewBox="0 0 24 24"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M12 4v16m8-8H4"
|
||||
/>
|
||||
</svg>
|
||||
Choose
|
||||
</Link>
|
||||
)}
|
||||
|
||||
Reference in New Issue
Block a user