fixed builder hydration issues

This commit is contained in:
2026-01-18 08:39:29 -05:00
parent b55bd7041d
commit 045e742362
5 changed files with 1143 additions and 299 deletions
+180
View File
@@ -0,0 +1,180 @@
# Gunbuilder Prototype — AI Coding Instructions
## Project Overview
**Gunbuilder** is a Next.js 14 (App Router) admin UI + consumer builder for a firearms parts marketplace. The system manages merchant feeds, normalizes product catalogs, powers a rifle builder interface, and backs a Spring Boot API.
### Key Architecture
- **Frontend**: Next.js 14 (App Router), TypeScript, TailwindCSS, React 18
- **Backend**: Spring Boot (proxied at `/api/*` via `next.config.mjs`)
- **Auth**: JWT tokens stored in cookies (`bb_access_token`, `bb_role`), validated in middleware
- **State**: React Context (AuthContext) for user/auth, client-side React hooks for UI state
- **Data**: Spring Boot REST API—responses paginated via Spring's `PageResponse<T>` structure with `content`, `totalElements`, `totalPages`, `number` (0-based), `size`
---
## File Structure & Naming Conventions
### App Router Layout
```
app/
├── layout.tsx # Root (AuthProvider, metadata, theme script)
├── (admin)/layout.tsx # Admin area: auth gate + left nav sidebar
├── (app)/layout.tsx # Consumer builder: TopNav + BuilderNav
├── (account)/ # User account routes (protected)
├── login/page.tsx, register/page.tsx
├── api/ # Server-side API route handlers
└── actions/ # Server Actions (e.g., sendEmail.ts)
```
### Naming & Organization
- **Layout grouping**: Use `(admin)`, `(app)`, `(account)` route groups to scope nav & auth logic
- **Client vs Server**: Mark pages/components with `"use client"` if they use hooks, state, or browser APIs. Default is Server Component
- **Components**: Store in `/components` with category subdirectories: `builder/`, `parts/`, `ui/`
- **Utilities**: Place reusable logic in `/lib` (API calls, parsers, helpers)
- **Types**: Centralize in `/types` (e.g., `uiPart.ts`, `builderSlots.ts`, `user.ts`)
- **Data**: Non-fetched lookup tables in `/data` (e.g., `gunbuilderParts.ts` — builder slot definitions)
---
## Critical Patterns
### 1. **Authentication & Authorization**
- **Token storage**: Stored in HTTP-only cookies (`bb_access_token`, `bb_role`)
- **Client-side context**: `AuthContext` provides `token`, `user`, `login()`, `register()`, `logout()`, `refreshMeAndSession()`
- **Middleware gate** ([middleware.ts](middleware.ts#L24-L34)): Admin routes at `/admin/*` require `bb_access_token` + role check
```typescript
if (pathname.startsWith("/admin")) {
const token = req.cookies.get("bb_access_token")?.value;
const role = req.cookies.get("bb_role")?.value;
const ok = !!token && (!role || role === "ADMIN");
if (!ok) redirect to /login
}
```
- **Wrap pages**: Use `protectedPage()` HOC for client-side auth walls
### 2. **API Integration** ([lib/api.ts](lib/api.ts))
- **useApi() hook**: Returns `{ get(), post(), put() }` methods with auto-injected Bearer token
- **Response unwrapping** ([components/parts/PartsBrowseClient.tsx](components/parts/PartsBrowseClient.tsx#L36-L43)):
```typescript
function unwrapContent<T>(json: any) {
if (Array.isArray(json)) return { items: json };
if (Array.isArray(json.content)) return { items: json.content, page: json }; // Spring PageResponse
return { items: [] };
}
```
- **Pagination**: API returns `{ content: T[], totalElements, totalPages, number, size }`—unwrap before rendering
### 3. **Builder State & Overlaps** ([lib/buildOverlaps.ts](lib/buildOverlaps.ts), [data/gunbuilderParts.ts](data/gunbuilderParts.ts))
- **Category slots**: Defined in `/data/gunbuilderParts.ts` (CATEGORIES array with id, name, group)
- **BuildState type**: `Partial<Record<CategoryId, string>>` (maps slot ID to selected product ID)
- **Overlap logic**: [getOverlapChipsForCategory()](lib/buildOverlaps.ts#L53) educates users when incompatible parts selected
- E.g., selecting "complete-upper" + "barrel" → show warning chip
- Used in [OverlapChip](components/builder/OverlapChip.tsx), [PartCard](components/PartCard.tsx)
### 4. **Parts Browsing & Filtering** ([components/parts/PartsBrowseClient.tsx](components/parts/PartsBrowseClient.tsx))
- **Page size**: Hardcoded to 24 items
- **Query params**: `platform`, `partRole`, `search`, `sort` (relevance | price-asc | price-desc | brand-asc), `page` (0-based)
- **View modes**: Card (grid) or list view via state hook
- **Product type**: `GunbuilderProductFromApi` with `id`, `name`, `brand`, `platform`, `partRole`, `price`, `imageUrl`, `buyUrl`, `inStock`
### 5. **Styling & Dark Mode**
- **TailwindCSS**: Configured for dark mode via `"class"` strategy ([tailwind.config.ts](tailwind.config.ts#L8))
- **Root script** ([app/layout.tsx](app/layout.tsx#L21-L31)): Inline JS checks localStorage for theme, applies `dark` class to `<html>`
- **Class pattern**: Use `dark:` prefix for dark-mode overrides (e.g., `dark:bg-black dark:text-zinc-50`)
- **Colors**: Zinc scale primary (white/950 light, black/50 dark); semantic grays for UI
### 6. **Form & Rich Text** ([components/ui/RichTextEditor.tsx](components/ui/RichTextEditor.tsx))
- **RichTextEditor**: Wraps Quill with image resize module for email/admin content
- **Email form**: [send_email_form.tsx](components/ui/send_email_form.tsx) uses form context + RichTextEditor combo
---
## Development Workflows
### Local Setup
```bash
npm install
npm run dev # Starts Next.js at http://localhost:3000
```
### Environment Variables (.env.local)
```
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
NEXT_PUBLIC_ADMIN_BASE_PATH=/builder/admin
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false # Set true to restrict non-launch content
```
### Building
```bash
npm run build
npm run start # Production mode
npm run lint # ESLint check
```
### Key Backend Endpoints (Spring Boot)
- `POST /api/auth/login` → returns `{ token, user }`
- `GET /api/gunbuilder/products?platform=...&partRole=...` → returns Spring PageResponse
- `POST /api/admin/...` → admin-only mutations (import, mapping, etc.)
---
## Common Tasks & Patterns
### Add a New Admin Page
1. Create `app/admin/[feature]/page.tsx` with `"use client"`
2. Add to [AdminLeftNavigation](components/AdminLeftNavigation.tsx) navGroups
3. Use `useAuth()` for permission checks; fallback `<protectedPage />` if needed
4. Fetch via `useApi()` hook
### Modify Parts Filtering
- Edit sort options in [SortBar](components/parts/SortBar.tsx) or query logic in [PartsBrowseClient](components/parts/PartsBrowseClient.tsx)
- Update `PAGE_SIZE` constant if needed
- Ensure query params flow through useRouter + useSearchParams
### Update Builder Slot Definitions
- Edit [data/gunbuilderParts.ts](data/gunbuilderParts.ts) CATEGORIES array
- Run [lib/buildOverlaps.ts](lib/buildOverlaps.ts) overlap checks if adding conflicting slots
- Update type in [types/builderSlots.ts](types/builderSlots.ts) to reflect new CategoryId union
### Add Server Action
- Create file in `app/actions/` (e.g., `sendEmail.ts`)
- Mark top with `"use server"`
- Import & call from client components; errors bubble naturally
---
## Known Constraints & Workarounds
- **No test suite**: Manual testing required; dev server hot-reload via `npm run dev`
- **Spring PageResponse unwrapping**: Always check for `.content` array when parsing API responses
- **Image URLs**: May be null; provide fallback in UI (e.g., placeholder image)
- **Mobile responsive**: TailwindCSS handles breakpoints; test with dark mode class toggle
- **Type safety**: Use strict TS config; leverage builder slot types to avoid string mismatches
---
## Integration Points & Gotchas
- **CORS**: Spring Boot backend must allow origin `http://localhost:3000` (or production domain)
- **API rewrites**: Next.js rewrites `/api/*` to backend at build time; ensure `NEXT_PUBLIC_API_BASE_URL` matches
- **Auth token refresh**: Handled in AuthContext `refreshMeAndSession()`—called on mount or nav
- **Dark mode class toggle**: Update `<html>` class, not just CSS var—required for TailwindCSS `dark:` selectors
- **Hydration**: Theme script runs inline before React hydration to avoid FOUC (flash of unstyled content)
+409 -149
View File
@@ -67,8 +67,6 @@ const isValidPlatform = (
): value is (typeof PLATFORMS)[number] => ): value is (typeof PLATFORMS)[number] =>
!!value && (PLATFORMS as readonly string[]).includes(value); !!value && (PLATFORMS as readonly string[]).includes(value);
const isUuid = (v: string) => 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( /^[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 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() { export default function GunbuilderPage() {
const searchParams = useSearchParams(); const searchParams = useSearchParams();
const router = useRouter(); const router = useRouter();
@@ -162,16 +338,10 @@ export default function GunbuilderPage() {
// ✅ Auth: we need the token ready before calling /builds/me/* // ✅ Auth: we need the token ready before calling /builds/me/*
const { token, loading: authLoading, getAuthHeaders } = useAuth(); 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) // Canonical, URL-safe platform (AR15 / AR10 / AR9)
const canonicalPlatform = normalizePlatformKey(platform); const canonicalPlatform = normalizePlatformKey(platform);
// ----------------------------- // -----------------------------
// Save As… modal state (Vault variants) // Save As… modal state (Vault variants)
// ----------------------------- // -----------------------------
@@ -190,19 +360,31 @@ const canonicalPlatform = normalizePlatformKey(platform);
// ----------------------------- // -----------------------------
// Build state (categoryId -> productId), persisted locally // Build state (categoryId -> productId), persisted locally
// ----------------------------- // -----------------------------
const [build, setBuild] = useState<BuildState>(() => { const [build, setBuild] = useState<BuildState>({});
if (typeof window !== "undefined") {
// 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); const stored = localStorage.getItem(STORAGE_KEY);
if (stored) { if (stored) {
try { try {
return JSON.parse(stored); const parsed = JSON.parse(stored);
if (parsed && typeof parsed === "object") {
setBuild(normalizeBuildState(parsed));
}
} catch { } catch {
return {}; // ignore
} }
} }
}
return {}; // Signal that hydration is complete - this allows URL param processing to run
}); setIsHydrated(true);
}, []);
type PageResponse<T> = { type PageResponse<T> = {
content: T[]; content: T[];
@@ -280,6 +462,16 @@ const canonicalPlatform = normalizePlatformKey(platform);
[build] [build]
); );
const allowedCategoryIds = useMemo(
() => new Set(CATEGORIES.map((c) => c.id as BuilderSlotKey)),
[]
);
const quickChecklist = useMemo(
() => computeQuickChecklistCounts(build, allowedCategoryIds),
[build, allowedCategoryIds]
);
const totalPrice = useMemo( const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0), () => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts] [selectedParts]
@@ -395,7 +587,7 @@ const canonicalPlatform = normalizePlatformKey(platform);
// ----------------------------- // -----------------------------
const lastHydrateKeyRef = useRef<string>(""); const lastHydrateKeyRef = useRef<string>("");
useEffect(() => { useEffect(() => {
const controller = new AbortController(); const controller = new AbortController();
async function hydrateSelected() { async function hydrateSelected() {
@@ -476,19 +668,20 @@ useEffect(() => {
hydrateSelected(); hydrateSelected();
return () => controller.abort(); return () => controller.abort();
}, [build]); }, [build]);
// ----------------------------- // -----------------------------
// Persist build to localStorage // Persist build to localStorage
// ----------------------------- // -----------------------------
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
if (!isHydrated) return;
try { try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(build)); localStorage.setItem(STORAGE_KEY, JSON.stringify(build));
} catch { } catch {
// ignore // ignore
} }
}, [build]); }, [build, isHydrated]);
// ----------------------------- // -----------------------------
// When platform changes, clear ONLY platform-scoped selections // When platform changes, clear ONLY platform-scoped selections
@@ -684,15 +877,28 @@ useEffect(() => {
const hints = getSelectionHints(categoryId, build); const hints = getSelectionHints(categoryId, build);
if (hints.length > 0) warn(hints[0]); if (hints.length > 0) warn(hints[0]);
setBuild((prev) => ({ setBuild((prev) => {
const next: BuildState = {
...prev, ...prev,
[categoryId]: partId, [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] [build]
); );
// Handle URL query parameters (?select, ?remove) and keep platform synced + canonical // Handle URL query parameters (?select, ?remove) and keep platform synced + canonical
useEffect(() => { useEffect(() => {
if (!isHydrated) return; // Wait for initial hydration to complete
const sp = new URLSearchParams(searchParams.toString()); const sp = new URLSearchParams(searchParams.toString());
const selectParam = sp.get("select"); const selectParam = sp.get("select");
@@ -711,7 +917,9 @@ useEffect(() => {
} }
// Prevent repeating select/remove actions // Prevent repeating select/remove actions
const actionKey = `${selectParam ?? ""}|${removeParam ?? ""}|${nextPlatform}`; const actionKey = `${selectParam ?? ""}|${
removeParam ?? ""
}|${nextPlatform}`;
const hasAction = !!selectParam || !!removeParam; const hasAction = !!selectParam || !!removeParam;
if (hasAction) { if (hasAction) {
@@ -732,6 +940,13 @@ useEffect(() => {
setBuild((prev) => { setBuild((prev) => {
const next: BuildState = { ...prev }; const next: BuildState = { ...prev };
delete next[removeParam as BuilderSlotKey]; delete next[removeParam as BuilderSlotKey];
try {
if (typeof window !== "undefined") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(next));
}
} catch {
// ignore
}
return next; return next;
}); });
} }
@@ -752,9 +967,13 @@ useEffect(() => {
const after = sp.toString(); const after = sp.toString();
if (after !== before) { 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 }); router.replace(`/builder?${after}`, { scroll: false });
}, 0);
} }
}, [searchParams, router, platform, handleSelectPart]); }, [searchParams, router, platform, handleSelectPart, isHydrated]);
// Build share URL whenever build changes (client-side only) // Build share URL whenever build changes (client-side only)
useEffect(() => { 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 ( return (
<main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50"> <main className="min-h-screen bg-white text-zinc-900 dark:bg-black dark:text-zinc-50">
{/* Toast (top-right) */} {/* Toast (top-right) */}
@@ -1144,35 +1375,45 @@ useEffect(() => {
</div> </div>
</section> </section>
{/* Build status strip */} {/* Quick Checklist (new user-friendly) */}
<section className="mb-6 space-y-3"> <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"> <h2 className="text-[0.7rem] font-semibold uppercase tracking-[0.16em] text-zinc-500">
Build Status (Needs Refined) Quick Checklist
</h2> </h2>
<div className="flex flex-wrap gap-2 md:gap-3"> <p className="mt-1 text-xs text-zinc-500 max-w-2xl">
{BUILDER_SLOTS.map((slot) => { New here? Focus on the{" "}
const satisfied = isSlotSatisfied(slot, selectedByCategory); <span className="text-amber-300">Required</span> items first.
Optional items live under accessories and upgrades. Use the
return ( floating hub (bottom-right) to jump to anything you&apos;re
<div missing.
key={slot.id} </p>
className={`inline-flex items-center gap-1.5 rounded-full border px-3 py-1 text-[0.7rem] md:text-xs ${ </div>
satisfied
? "border-emerald-500/70 bg-emerald-950/50 text-emerald-200" <div className="flex items-center gap-3">
: "border-zinc-800 bg-zinc-950/80 text-zinc-200" <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
{satisfied ? ( </div>
<CheckSquare className="h-4 w-4 text-emerald-400" /> <div className="mt-0.5 text-sm font-semibold text-zinc-100">
) : ( {quickChecklist.requiredDone}
<Square className="h-4 w-4 text-zinc-600/80" /> <span className="text-zinc-500"> / </span>
)} {quickChecklist.requiredTotal}
<span className="font-medium truncate max-w-[9rem] md:max-w-[11rem]"> </div>
{slot.label} </div>
</span>
<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>
);
})}
</div> </div>
</section> </section>
@@ -1201,10 +1442,44 @@ useEffect(() => {
group.categoryIds.includes(c.id as BuilderSlotKey) 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; if (groupCategories.length === 0) return null;
return ( 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 className="flex items-baseline justify-between gap-2">
<div> <div>
<h2 className="text-sm font-semibold text-zinc-100"> <h2 className="text-sm font-semibold text-zinc-100">
@@ -1217,7 +1492,6 @@ useEffect(() => {
)} )}
</div> </div>
</div> </div>
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80"> <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"> <table className="min-w-full text-xs md:text-sm">
<thead> <thead>
@@ -1244,7 +1518,44 @@ useEffect(() => {
</thead> </thead>
<tbody> <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 = const categoryParts =
partsByCategory[category.id] ?? []; partsByCategory[category.id] ?? [];
const selectedPartId = build[category.id]; const selectedPartId = build[category.id];
@@ -1255,141 +1566,90 @@ useEffect(() => {
) ?? parts.find((p) => p.id === selectedPartId) ) ?? parts.find((p) => p.id === selectedPartId)
: undefined; : undefined;
const hasParts = categoryParts.length > 0; const indent = row.indent ?? 0;
const isLocked = Boolean(row.locked);
return ( return (
<tr <tr
key={category.id} key={row.key}
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors" 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"> <td className="px-3 py-2 align-top">
<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`,
}}
/>
)}
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium text-zinc-100"> <div className="font-medium text-zinc-100">
{category.name} {row.label
? row.label
: category.name}
</div>
{row.pill === "required" && (
<span
className="ml-1 text-red-400"
aria-hidden="true"
>
*
</span>
)}
</div> </div>
{selectedPart ? ( {selectedPart ? (
<>
<div className="text-[0.7rem] text-zinc-500 line-clamp-1"> <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"
>
{selectedPart.name} {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>
) : ( ) : (
<div className="text-[0.7rem] text-zinc-600"> <div className="text-[0.7rem] text-zinc-600">
No parts available yet No part selected
</div> </div>
)} )}
</div>
</div>
</td> </td>
{/* Brand */}
<td className="px-3 py-2 align-top text-zinc-300"> <td className="px-3 py-2 align-top text-zinc-300">
{selectedPart ? selectedPart.brand : "—"} {selectedPart ? selectedPart.brand : "—"}
</td> </td>
{/* Price */}
<td className="px-3 py-2 align-top text-right text-amber-300"> <td className="px-3 py-2 align-top text-right text-amber-300">
{selectedPart {selectedPart
? `$${selectedPart.price.toFixed(2)}` ? `$${selectedPart.price.toFixed(2)}`
: "—"} : "—"}
</td> </td>
{/* Sale price placeholder */}
<td className="px-3 py-2 align-top text-right text-zinc-400"> <td className="px-3 py-2 align-top text-right text-zinc-400">
</td> </td>
{/* Caliber placeholder (future: derive from build profile) */}
<td className="px-3 py-2 align-top text-zinc-400"> <td className="px-3 py-2 align-top text-zinc-400">
</td> </td>
{/* Actions */}
<td className="px-3 py-2 align-top"> <td className="px-3 py-2 align-top">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{selectedPart && selectedPart.url ? ( {isLocked ? (
<> <div className="text-[0.7rem] text-zinc-500">
<button
type="button" </div>
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>
</>
) : ( ) : (
<Link <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" 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 Choose
</Link> </Link>
)} )}
+401
View File
@@ -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>
);
}
+7 -5
View File
@@ -21,7 +21,7 @@ import Pagination from "@/components/parts/Pagination";
import PlatformSwitcher from "@/components/parts/PlatformSwitcher"; import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
import { normalizePlatformKey } from "@/lib/platforms"; import { normalizePlatformKey } from "@/lib/platforms";
import type { UiPart } from "@/types/uiPart"; import type { UiPart } from "@/types/uiPart";
import type { Category } from "@/types/builderSlots"; import type { BuilderSlotKey, Category } from "@/types/builderSlots";
import { import {
PART_ROLE_TO_CATEGORY, PART_ROLE_TO_CATEGORY,
normalizePartRole, normalizePartRole,
@@ -310,15 +310,17 @@ export default function PartsBrowseClient(props: {
// Add → Builder handoff // Add → Builder handoff
// ---------------------------- // ----------------------------
const handleAddToBuild = (p: UiPart) => { const handleAddToBuild = (p: UiPart) => {
const normalizedRole = normalizePartRole(partRole); // Always normalize first; our mappings may alias multiple roles to one canonical builder category
const categoryId: Category | null = const roleKey = normalizePartRole(partRole);
const categoryId: BuilderSlotKey | null =
PART_ROLE_TO_CATEGORY[roleKey] ??
PART_ROLE_TO_CATEGORY[partRole] ?? PART_ROLE_TO_CATEGORY[partRole] ??
PART_ROLE_TO_CATEGORY[normalizedRole] ??
null; null;
if (!categoryId) { if (!categoryId) {
alert( 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; return;
} }
+8 -7
View File
@@ -1,4 +1,4 @@
import type { CategoryId } from "@/types/builderSlots"; import type { BuilderSlotKey } from "@/types/builderSlots";
/** /**
* Normalize backend part roles into a canonical kebab-case form. * Normalize backend part roles into a canonical kebab-case form.
@@ -16,7 +16,7 @@ export const normalizePartRole = (role: string) =>
* - Keep the list here as the *source of truth*. * - Keep the list here as the *source of truth*.
* - The derived `PART_ROLE_TO_CATEGORY` map below stores both the original and normalized keys. * - The derived `PART_ROLE_TO_CATEGORY` map below stores both the original and normalized keys.
*/ */
export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = { export const CATEGORY_TO_PART_ROLES: Partial<Record<BuilderSlotKey, string[]>> = {
// ===== UPPER ===== // ===== UPPER =====
"upper-receiver": ["upper-receiver"], "upper-receiver": ["upper-receiver"],
// (optional) Back-compat if anything still uses the old ids: // (optional) Back-compat if anything still uses the old ids:
@@ -37,7 +37,8 @@ export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
// (optional) Back-compat if anything still uses the old ids: // (optional) Back-compat if anything still uses the old ids:
"complete-lower": ["complete-lower"], "complete-lower": ["complete-lower"],
"lower-parts": ["lower-parts-kit", "lower-parts"], // Lower Parts Kit (LPK)
"lower-parts-kit": ["lower-parts-kit", "lower-parts"],
trigger: ["trigger", "trigger-kit"], trigger: ["trigger", "trigger-kit"],
grip: ["pistol-grip", "grip"], grip: ["pistol-grip", "grip"],
safety: ["safety", "safety-selector"], safety: ["safety", "safety-selector"],
@@ -86,15 +87,15 @@ export const CATEGORY_TO_PART_ROLES: Partial<Record<CategoryId, string[]>> = {
* - Stores BOTH the original role and its normalized version as keys. * - Stores BOTH the original role and its normalized version as keys.
* - De-dupes role lists per category. * - De-dupes role lists per category.
*/ */
export const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = Object.entries( export const PART_ROLE_TO_CATEGORY: Record<string, BuilderSlotKey> = Object.entries(
CATEGORY_TO_PART_ROLES CATEGORY_TO_PART_ROLES
).reduce((acc, [categoryId, roles]) => { ).reduce((acc, [categoryId, roles]) => {
const unique = Array.from(new Set(roles ?? [])); const unique = Array.from(new Set(roles ?? []));
for (const role of unique) { for (const role of unique) {
acc[role] = categoryId as CategoryId; acc[role] = categoryId as BuilderSlotKey;
acc[normalizePartRole(role)] = categoryId as CategoryId; acc[normalizePartRole(role)] = categoryId as BuilderSlotKey;
} }
return acc; return acc;
}, {} as Record<string, CategoryId>); }, {} as Record<string, BuilderSlotKey>);