Files
2026-01-18 08:39:29 -05:00

401 lines
13 KiB
TypeScript

"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>
);
}