"use client"; import { useMemo, useState } from "react"; export type BuildProgressHubCategoryGroup = { id: string; label: string; categoryIds: string[]; }; export type BuildProgressHubProps = { slots: any[]; selectedByCategory: Record; categoryGroups: BuildProgressHubCategoryGroup[]; isSlotSatisfiedFn: (slot: any, selectedByCategory: any) => boolean; requiredSlotIds?: Set; upgradeSlotIds?: Set; }; // Defaults (pragmatic UX for new users) export const DEFAULT_REQUIRED_SLOT_IDS = new Set([ "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([ "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 (
{label}
{sublabel ? (
{sublabel}
) : null}
); } 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 (
{open && (
What you need (fast)
Required vs optional — click anything to jump.
Required
{reqDone}/{required.length}
Optional
{optDone}/{optional.length}
Required components
{required.map((s) => ( ))}
Optional components
Upgrades
{upgrades.map((s) => ( ))}
Accessories
{accessories.map((s) => ( ))}
)}
); }