diff --git a/.github/copilot-instructions.md b/.github/copilot-instructions.md new file mode 100644 index 0000000..bbe2556 --- /dev/null +++ b/.github/copilot-instructions.md @@ -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` 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(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>` (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 `` +- **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 `` 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 `` 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) diff --git a/app/(app)/(builder)/builder/page.tsx b/app/(app)/(builder)/builder/page.tsx index c4c336c..1e6c584 100644 --- a/app/(app)/(builder)/builder/page.tsx +++ b/app/(app)/(builder)/builder/page.tsx @@ -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([ + "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( + CATEGORY_GROUPS.find((g) => g.id === "accessories-group")?.categoryIds ?? [] +); + +function computeQuickChecklistCounts( + build: BuildState, + allowedIds: Set +) { + // 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; + 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(() => { - if (typeof window !== "undefined") { - const stored = localStorage.getItem(STORAGE_KEY); - if (stored) { - try { - return JSON.parse(stored); - } catch { - return {}; + const [build, setBuild] = useState({}); + + // 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 = { 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(""); -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(); - 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(); + 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 ( +
+
+
+
Loading builder…
+
+
+
+ ); + } + return (
{/* Toast (top-right) */} @@ -1144,35 +1375,45 @@ useEffect(() => { - {/* Build status strip */} -
-

- Build Status (Needs Refined) -

-
- {BUILDER_SLOTS.map((slot) => { - const satisfied = isSlotSatisfied(slot, selectedByCategory); + {/* Quick Checklist (new user-friendly) */} +
+
+
+

+ Quick Checklist +

+

+ New here? Focus on the{" "} + Required items first. + Optional items live under accessories and upgrades. Use the + floating hub (bottom-right) to jump to anything you're + missing. +

+
- return ( -
- {satisfied ? ( - - ) : ( - - )} - - {slot.label} - +
+
+
+ Required
- ); - })} +
+ {quickChecklist.requiredDone} + / + {quickChecklist.requiredTotal} +
+
+ +
+
+ Optional +
+
+ {quickChecklist.optionalDone} + / + {quickChecklist.optionalTotal} +
+
+
@@ -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 ( -
+
+ {" "}

@@ -1217,7 +1492,6 @@ useEffect(() => { )}

-
@@ -1244,7 +1518,44 @@ useEffect(() => { - {groupCategories.map((category) => { + {tableRows.map((row) => { + if (row.kind === "note") { + return ( + + + + ); + } + + 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 ( - {/* Component */} - {/* Brand */} - {/* Price */} - {/* Sale price placeholder */} - - {/* Caliber placeholder (future: derive from build profile) */} - {/* Actions */}
+
+
+ {row.label} +
+ {row.pill === "Selected" && ( + + Selected + + )} + + {row.pill === "required" && ( + + * + + )} +
+
-
- {category.name} -
+
+ {indent > 0 && ( +
+ )} - {selectedPart ? ( - <> -
- +
+
+
+ {row.label + ? row.label + : category.name} +
+ {row.pill === "required" && ( + + )} +
+ + {selectedPart ? ( +
{selectedPart.name} - {" "} -
- - {/* Overlap chips help explain conflicts / dependencies */} -
- {getOverlapChipsForCategory( - category.id as BuilderSlotKey, - build - ).map((c) => ( - - ))} -
- - ) : hasParts ? ( -
- No part selected +
+ ) : ( +
+ No part selected +
+ )}
- ) : ( -
- No parts available yet -
- )} +
{selectedPart ? selectedPart.brand : "—"} {selectedPart ? `$${selectedPart.price.toFixed(2)}` : "—"}
- {selectedPart && selectedPart.url ? ( - <> - - - - - - + {isLocked ? ( +
+ — +
) : ( - - - Choose )} diff --git a/components/builder/BuildProgressHub.tsx b/components/builder/BuildProgressHub.tsx new file mode 100644 index 0000000..8db29e1 --- /dev/null +++ b/components/builder/BuildProgressHub.tsx @@ -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; + 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) => ( + + ))} +
+
+
+
+ )} +
+ ); +} \ No newline at end of file diff --git a/components/parts/PartsBrowseClient.tsx b/components/parts/PartsBrowseClient.tsx index a846e4e..6bb31b4 100644 --- a/components/parts/PartsBrowseClient.tsx +++ b/components/parts/PartsBrowseClient.tsx @@ -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; } diff --git a/lib/catalogMappings.ts b/lib/catalogMappings.ts index 9fa3676..8f2c297 100644 --- a/lib/catalogMappings.ts +++ b/lib/catalogMappings.ts @@ -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. @@ -16,7 +16,7 @@ export const normalizePartRole = (role: string) => * - Keep the list here as the *source of truth*. * - The derived `PART_ROLE_TO_CATEGORY` map below stores both the original and normalized keys. */ -export const CATEGORY_TO_PART_ROLES: Partial> = { +export const CATEGORY_TO_PART_ROLES: Partial> = { // ===== UPPER ===== "upper-receiver": ["upper-receiver"], // (optional) Back-compat if anything still uses the old ids: @@ -37,7 +37,8 @@ export const CATEGORY_TO_PART_ROLES: Partial> = { // (optional) Back-compat if anything still uses the old ids: "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"], grip: ["pistol-grip", "grip"], safety: ["safety", "safety-selector"], @@ -86,15 +87,15 @@ export const CATEGORY_TO_PART_ROLES: Partial> = { * - Stores BOTH the original role and its normalized version as keys. * - De-dupes role lists per category. */ -export const PART_ROLE_TO_CATEGORY: Record = Object.entries( +export const PART_ROLE_TO_CATEGORY: Record = Object.entries( CATEGORY_TO_PART_ROLES ).reduce((acc, [categoryId, roles]) => { const unique = Array.from(new Set(roles ?? [])); for (const role of unique) { - acc[role] = categoryId as CategoryId; - acc[normalizePartRole(role)] = categoryId as CategoryId; + acc[role] = categoryId as BuilderSlotKey; + acc[normalizePartRole(role)] = categoryId as BuilderSlotKey; } return acc; -}, {} as Record); \ No newline at end of file +}, {} as Record); \ No newline at end of file