Merge branch 'develop' of ssh://gitea.gofwd.group:2225/sean/shadow-gunbuilder-ai-proto into develop
This commit is contained in:
@@ -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)
|
||||||
File diff suppressed because it is too large
Load Diff
+11
-9
@@ -344,19 +344,21 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
{/* Screenshot Section */}
|
{/* Screen Recording Section */}
|
||||||
<section className="relative mt-24">
|
<section className="relative mt-24">
|
||||||
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||||
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-black shadow-2xl">
|
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-black shadow-2xl">
|
||||||
{/* Screenshot */}
|
{/* Screen Recording */}
|
||||||
<Image
|
<video
|
||||||
src="/builder-preview.png"
|
autoPlay
|
||||||
alt="Battl Builders app screenshot"
|
loop
|
||||||
width={2400}
|
muted
|
||||||
height={1400}
|
playsInline
|
||||||
priority
|
|
||||||
className="w-full object-cover"
|
className="w-full object-cover"
|
||||||
/>
|
>
|
||||||
|
<source src="/builder-preview.mp4" type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
|
||||||
{/* Bottom fade */}
|
{/* Bottom fade */}
|
||||||
<div
|
<div
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>);
|
||||||
Binary file not shown.
Reference in New Issue
Block a user