62 Commits

Author SHA1 Message Date
sean 77c31ae4f9 fixed all admin email pages. added admin user info and button on builder page to route to admin page.
CI / test (push) Successful in 5s
2026-01-26 14:58:56 -05:00
sean fb2effca47 productSlug fixes
CI / test (push) Successful in 7s
2026-01-26 07:10:37 -05:00
sean b09ccc542e fixed login session for user name, and updated the api calls on the product detials pages.
CI / test (push) Successful in 6s
2026-01-25 20:20:48 -05:00
sean 3aee0e6755 fixing admin pages
CI / test (push) Successful in 6s
2026-01-25 14:22:58 -05:00
sean e49dc96522 clean up
CI / test (push) Successful in 5s
2026-01-25 14:05:36 -05:00
sean 50ef395a38 woof. killed useClient and use nextjs api
CI / test (push) Successful in 5s
2026-01-25 13:40:10 -05:00
dstrawsb 5b73d59c2c Merge branch 'develop' of ssh://gitea.gofwd.group:2225/sean/shadow-gunbuilder-ai-proto into develop
CI / test (push) Successful in 5s
2026-01-23 21:40:29 -05:00
dstrawsb c4f69c0811 the cleanup 2026-01-23 21:40:16 -05:00
sean df46788570 Refactor admin layout and merchants page for improved UI and functionality; add URL copy feature and enhance date formatting
CI / test (push) Successful in 5s
2026-01-22 05:04:19 -05:00
sean 411c3bc05d Add new merchant creation form and functionality in MerchantsAdminPage
CI / test (push) Successful in 5s
2026-01-20 19:25:58 -05:00
dstrawsb 2ea1f19863 must be important
CI / test (push) Successful in 5s
2026-01-20 17:24:20 -05:00
Don Strawsburg 7ad0b47819 ci file
CI / test (push) Successful in 5s
2026-01-20 15:10:55 -05:00
sean 3ab697b58d Add Suspense fallback for loading states in multiple pages and layouts 2026-01-20 07:43:19 -05:00
sean 775a351425 fixed build issues. its builds now 2026-01-19 09:21:19 -05:00
dstrawsb b0db3167c5 push-image file 2026-01-19 09:16:30 -05:00
dstrawsb 60d034d643 last 2 changes 2026-01-19 08:43:25 -05:00
dstrawsb ec6c88fa90 Merge branch 'develop' of ssh://gitea.gofwd.group:2225/sean/shadow-gunbuilder-ai-proto into develop 2026-01-18 21:54:07 -05:00
dstrawsb b1e2b38506 fixes to get this to build using 'npm run build' 2026-01-18 21:53:35 -05:00
sean 9460c7df24 builder preview video loop 2026-01-18 09:09:56 -05:00
sean 045e742362 fixed builder hydration issues 2026-01-18 08:39:29 -05:00
Don Strawsburg b55bd7041d small stuff 2026-01-16 12:55:44 -05:00
dstrawsb 19ec410a16 fixed vault link 2026-01-15 23:09:14 -05:00
dstrawsb 5c1c38f5e9 Privacy Page 2026-01-09 14:16:13 -05:00
sean c509cb6cd0 fixes to admin/products 2026-01-09 10:18:32 -05:00
sean ae86fe6867 support for filtering and updating caliber 2026-01-08 15:26:40 -05:00
sean 72d8f559c9 fixing hydration issue 2026-01-08 06:02:12 -05:00
sean 1b348c3ee7 short link support 2026-01-06 05:21:46 -05:00
sean 055ebb4cd9 little bit of ui clean up and fixing the platform labels/keys 2026-01-05 13:47:59 -05:00
sean b7e3db8d34 no longer price sort of frontend 2026-01-02 09:56:51 -05:00
sean 228d08337f new admin ui 2025-12-30 17:22:02 -05:00
sean 85d4629d3c fixed a hydratioin issue 2025-12-30 14:14:43 -05:00
sean e69205e6f8 cleaning up api calls and the builder page 2025-12-30 13:53:52 -05:00
sean ee91fa1353 new product admin page to flag bad products 2025-12-30 09:04:56 -05:00
sean cc386d14f4 mad cleanup. 2025-12-29 13:59:17 -05:00
sean 0dd89a751b homepage tweaks 2025-12-28 17:37:02 -05:00
sean 2202abe89f account settings and account creation 2025-12-27 20:01:17 -05:00
sean aa2dfb0407 Merge branch 'clean-data' into develop 2025-12-27 06:17:02 -05:00
dstrawsb b8a5fe0e7a Adding basic terms of service page 2025-12-26 22:42:07 -05:00
sean 8d7809ccf1 added stuff to builds/details 2025-12-26 21:04:33 -05:00
sean e202d811ed new builds/id page 2025-12-26 20:53:04 -05:00
sean c8bf032f7a set admin behind a cookie and check role on profile 2025-12-26 17:19:11 -05:00
sean 5401f6464d disable beta sign up email blasts 2025-12-24 08:34:39 -05:00
sean 57034eefc3 added enrichment ui and functionality 2025-12-23 11:18:34 -05:00
sean bb8ddb6823 small product grid cleanup 2025-12-23 06:17:34 -05:00
dstrawsb 70fac2857e fixing titles and stuff 2025-12-22 23:39:12 -05:00
sean c01aabb003 cleaned up product details page. added a link on builder to the selected product details page 2025-12-22 17:31:04 -05:00
sean 061d737c6c fixing platform switcher bugs 2025-12-22 09:03:32 -05:00
sean 34e915f904 fixed layouts, routing and the platform switcher. 2025-12-22 08:36:19 -05:00
sean b1a8dae8ed added favicon 2025-12-21 07:52:16 -05:00
sean 13edcdce69 fix possible mem leaks on photos 2025-12-21 07:40:45 -05:00
sean 856b99b933 allow users to upload build images. No backend, just local previews. need to build backend 2025-12-21 07:39:32 -05:00
sean 008936ff98 fixing vault. allow users to delete their builds or update 2025-12-21 07:20:22 -05:00
sean 47d5cf239d magic link support and forgot password 2025-12-20 18:58:25 -05:00
sean 8d9013d0dd beta sign up sends an email with a magic token. then a separate email to login. 2025-12-20 17:15:10 -05:00
sean c06696e66d fixed an vault edit error. 2025-12-20 12:24:40 -05:00
sean 54ea3243fd Merge remote-tracking branch 'refs/remotes/origin/develop' into develop 2025-12-20 10:01:22 -05:00
sean 61935982b3 new pages for user buils (vault), edit saved builds, and the community build page. 2025-12-20 10:01:07 -05:00
dstrawsb b224e2d0e0 new email management page 2025-12-19 23:16:50 -05:00
sean f81974cc0b pass builds from vault to builder 2025-12-19 08:36:48 -05:00
sean b76ced45f1 wired up an accounts page and my builds (my vault) 2025-12-19 08:05:03 -05:00
sean c6d1bce771 setting up dark/white mode. fixed some other small things 2025-12-18 21:06:08 -05:00
sean 7f3818f795 ui using new v1 api for products 2025-12-18 19:18:15 -05:00
160 changed files with 14970 additions and 5571 deletions
+3
View File
@@ -5,6 +5,9 @@ BACKEND_BASE_URL=http://localhost:8080
# Middleware to limited site to only root page # Middleware to limited site to only root page
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
# Brevo API key for beta sign up collection # Brevo API key for beta sign up collection
BREVO_API_KEY=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 BREVO_API_KEY=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
BREVO_LIST_ID=9 BREVO_LIST_ID=9
+4 -2
View File
@@ -1,8 +1,10 @@
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080 NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
# Middleware to limited site to only root page # Middleware to limited site to only root page
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false NEXT_PUBLIC_LAUNCH_ONLY_ROOT=true
NEXT_PUBLIC_SHORTLINK_BASE_URL=https://battl.build
# Brevo API key for beta sign up collection # Brevo API key for beta sign up collection
BREVO_API_KEY=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 BREVO_API_KEY=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
+11
View File
@@ -0,0 +1,11 @@
name: CI
on:
push:
pull_request:
jobs:
test:
runs-on: ubuntu
steps:
- uses: actions/checkout@v4
- run: echo "Hello from Gitea Actions"
+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)
+14
View File
@@ -0,0 +1,14 @@
// app/(account)/AccountChrome.tsx
"use client";
import TopNav from "@/components/TopNav";
import BuilderNav from "@/components/BuilderNav";
export default function AccountChrome() {
return (
<>
<TopNav />
<BuilderNav />
</>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function FavoritesPage() {
return (
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-lg font-semibold">Favorite Parts</h2>
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
</div>
);
}
+350
View File
@@ -0,0 +1,350 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function OnboardingPage() {
const router = useRouter();
const { user, loading, token, logout, refreshMeAndSession } = useAuth();
// Step: 1=password, 2=username, 3=optional display name
const [step, setStep] = useState<1 | 2 | 3>(1);
// Password
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
// Username
const [username, setUsername] = useState("");
const [checking, setChecking] = useState(false);
const [available, setAvailable] = useState<boolean | null>(null);
const [userMsg, setUserMsg] = useState<string | null>(null);
const [savingUsername, setSavingUsername] = useState(false);
// Display name (optional)
const [displayName, setDisplayName] = useState("");
const [savingName, setSavingName] = useState(false);
const [nameMsg, setNameMsg] = useState<string | null>(null);
const hasUsername = useMemo(() => {
const current = String((user as any)?.username ?? "").trim();
return current.length > 0;
}, [user]);
const hasPassword = useMemo(() => {
return !!(user as any)?.passwordSetAt;
}, [user]);
const pwMismatch = pw1.length > 0 && pw2.length > 0 && pw1 !== pw2;
const pwTooShort = pw1.length > 0 && pw1.length < 8;
const canSavePassword = !pwSaving && pw1.length >= 8 && pw1 === pw2;
// Hydrate local inputs when user becomes available
useEffect(() => {
if (!user) return;
setUsername(String((user as any)?.username ?? ""));
setDisplayName(String(user.displayName ?? ""));
}, [user]);
// Route guards + step sync (NO redirects during render)
useEffect(() => {
if (loading) return;
if (!user) return;
// If complete, bounce them out
if (hasUsername && hasPassword) {
router.replace("/builder");
return;
}
// Choose the right step based on what's missing
if (!hasPassword) setStep(1);
else if (!hasUsername) setStep(2);
else setStep(3);
}, [loading, user, hasUsername, hasPassword, router]);
if (loading) return <div className="text-sm opacity-70">Loading</div>;
if (!user) return <div className="text-sm opacity-70">Not logged in.</div>;
async function savePassword() {
setPwMsg(null);
if (pw1.length < 8) return setPwMsg("Password must be at least 8 characters.");
if (pw1 !== pw2) return setPwMsg("Passwords dont match.");
setPwSaving(true);
try {
const res = await fetch(`/api/users/me/password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ password: pw1 }),
});
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to set password");
}
await refreshMeAndSession();
setPwMsg("Password saved ✅");
setPw1("");
setPw2("");
// step will auto-sync via useEffect once user updates, but this makes UI feel instant:
setStep(2);
} catch (e: any) {
setPwMsg(e?.message || "Couldnt set password.");
} finally {
setPwSaving(false);
}
}
async function checkUsername() {
setChecking(true);
setUserMsg(null);
setAvailable(null);
try {
const u = username.trim().toLowerCase();
const res = await fetch(
`/api/users/me/username-available?username=${encodeURIComponent(u)}`,
{
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
cache: "no-store",
}
);
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to check username");
}
const data = await res.json();
setAvailable(!!data?.available);
} catch (e: any) {
setUserMsg(e?.message || "Couldnt check username.");
} finally {
setChecking(false);
}
}
async function saveUsername() {
setSavingUsername(true);
setUserMsg(null);
try {
const u = username.trim().toLowerCase();
const res = await fetch(`/api/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ username: u }),
});
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to save username");
}
await refreshMeAndSession();
setAvailable(true);
setUserMsg("Username saved ✅");
setStep(3);
} catch (e: any) {
setUserMsg(e?.message || "Couldnt save username.");
} finally {
setSavingUsername(false);
}
}
async function saveDisplayName() {
setSavingName(true);
setNameMsg(null);
try {
const next = displayName.trim();
const res = await fetch(`/api/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ displayName: next }),
});
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to save display name");
}
await refreshMeAndSession();
setNameMsg("Saved ✅");
} catch (e: any) {
setNameMsg(e?.message || "Couldnt save display name.");
} finally {
setSavingName(false);
}
}
return (
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-6">
<h2 className="text-lg font-semibold">Welcome to the beta 👋</h2>
<p className="mt-1 text-sm text-zinc-300/80">
Step {step} of 3. Password + username required.
</p>
{step === 1 && (
<div className="mt-6 space-y-3">
<div className="text-sm font-medium">Set password *</div>
<input
type="password"
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="Password (min 8 chars)"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
<input
type="password"
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Confirm password"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
{(pwTooShort || pwMismatch) && (
<div className="text-xs text-red-300">
{pwTooShort ? "Password must be at least 8 characters." : "Passwords dont match."}
</div>
)}
<button
type="button"
onClick={savePassword}
disabled={!canSavePassword}
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{pwSaving ? "Saving…" : "Save password"}
</button>
{pwMsg && <div className="text-xs text-zinc-200/80">{pwMsg}</div>}
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<button
type="button"
onClick={logout}
className="text-xs underline opacity-70 hover:opacity-100"
>
Log out
</button>
</div>
</div>
)}
{step === 2 && (
<div className="mt-6 space-y-3">
<div className="text-sm font-medium">Choose username *</div>
<input
value={username}
onChange={(e) => {
setUsername(e.target.value);
setAvailable(null);
setUserMsg(null);
}}
placeholder="e.g. s2tactical"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
<div className="flex items-center gap-2">
<button
type="button"
onClick={checkUsername}
disabled={checking || savingUsername || username.trim().length < 3}
className="rounded-md border border-white/10 px-3 py-2 text-xs hover:bg-white/5 disabled:opacity-50"
>
{checking ? "Checking…" : "Check availability"}
</button>
<button
type="button"
onClick={saveUsername}
disabled={savingUsername || available !== true}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
>
{savingUsername ? "Saving…" : "Save username"}
</button>
{available !== null && (
<span className={`text-xs ${available ? "text-emerald-300" : "text-red-300"}`}>
{available ? "Available" : "Taken / invalid"}
</span>
)}
</div>
{userMsg && <div className="text-xs text-zinc-200/80">{userMsg}</div>}
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
<button
type="button"
onClick={logout}
className="text-xs underline opacity-70 hover:opacity-100"
>
Log out
</button>
</div>
</div>
)}
{step === 3 && (
<div className="mt-6 space-y-3">
<div className="text-sm font-medium">
Display name <span className="text-zinc-400">(optional)</span>
</div>
<input
value={displayName}
onChange={(e) => setDisplayName(e.target.value)}
placeholder="e.g. Sean / S2Tactical / DirtNinja69"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
/>
<button
type="button"
onClick={saveDisplayName}
disabled={savingName || displayName.trim().length === 0}
className="rounded-md border border-white/10 px-3 py-2 text-xs hover:bg-white/5 disabled:opacity-50"
>
{savingName ? "Saving…" : "Save display name"}
</button>
{nameMsg && <div className="text-xs text-zinc-200/80">{nameMsg}</div>}
<div className="mt-6 flex items-center justify-between border-t border-white/10 pt-4">
<button
type="button"
onClick={() => router.replace("/builder")}
disabled={!(hasUsername && hasPassword)}
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
>
Continue to Builder
</button>
<button
type="button"
onClick={() => router.replace("/account/profile")}
className="text-xs underline opacity-70 hover:opacity-100"
>
Ill finish later
</button>
</div>
</div>
)}
</div>
);
}
+5
View File
@@ -0,0 +1,5 @@
import { redirect } from "next/navigation";
export default function AccountIndex() {
redirect("/account/profile");
}
+270
View File
@@ -0,0 +1,270 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
export default function ProfilePage() {
const router = useRouter();
const { user, loading, token, logout, refreshMeAndSession } = useAuth();
const [displayName, setDisplayName] = useState("");
const [savingName, setSavingName] = useState(false);
const [nameMsg, setNameMsg] = useState<string | null>(null);
const [showPassword, setShowPassword] = useState(false);
const [pw1, setPw1] = useState("");
const [pw2, setPw2] = useState("");
const [pwSaving, setPwSaving] = useState(false);
const [pwMsg, setPwMsg] = useState<string | null>(null);
const hasUsername = useMemo(() => {
const current = String((user as any)?.username ?? "").trim();
return current.length > 0;
}, [user]);
const hasPassword = useMemo(() => {
return !!(user as any)?.passwordSetAt;
}, [user]);
const needsOnboarding = !!user && (!hasUsername || !hasPassword);
const pwMismatch = pw1.length > 0 && pw2.length > 0 && pw1 !== pw2;
const pwTooShort = pw1.length > 0 && pw1.length < 8;
const canSavePassword = !pwSaving && pw1.length >= 8 && pw1 === pw2;
useEffect(() => {
if (!user) return;
setDisplayName(String(user.displayName ?? ""));
}, [user]);
// If missing required setup, bounce to onboarding
useEffect(() => {
if (loading) return;
if (!user) return;
if (needsOnboarding) {
router.replace("/account/onboarding");
}
}, [loading, user, needsOnboarding, router]);
if (loading) return <div className="text-sm opacity-70">Loading</div>;
if (!user) return <div className="text-sm opacity-70">Not logged in.</div>;
// While redirecting, don't flash profile UI
if (needsOnboarding) return null;
async function saveDisplayName() {
setSavingName(true);
setNameMsg(null);
try {
const next = displayName.trim();
const res = await fetch(`/api/users/me`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ displayName: next }),
});
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to save display name");
}
await refreshMeAndSession();
setNameMsg("Saved ✅");
} catch (e: any) {
setNameMsg(e?.message || "Couldnt save display name.");
} finally {
setSavingName(false);
}
}
async function savePassword() {
setPwMsg(null);
if (pw1.length < 8) return setPwMsg("Password must be at least 8 characters.");
if (pw1 !== pw2) return setPwMsg("Passwords dont match.");
setPwSaving(true);
try {
const res = await fetch(`/api/users/me/password`, {
method: "POST",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ password: pw1 }),
});
if (!res.ok) {
throw new Error((await res.text().catch(() => "")) || "Failed to set password");
}
await refreshMeAndSession();
setPwMsg("Password updated ✅");
setPw1("");
setPw2("");
setShowPassword(false);
} catch (e: any) {
setPwMsg(e?.message || "Couldnt set password. Try again.");
} finally {
setPwSaving(false);
}
}
return (
<div className="space-y-6">
<div>
<h2 className="text-lg font-semibold">Profile</h2>
<p className="text-sm opacity-70">Basic account info.</p>
</div>
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-4">
<div className="text-sm">
<span className="opacity-70">Email:</span>{" "}
<span className="font-medium">{user.email}</span>
</div>
<div className="space-y-2">
<div className="text-sm">
<span className="opacity-70">Username:</span>{" "}
<span className="font-medium">{String((user as any)?.username ?? "—")}</span>
</div>
<div className="text-sm font-medium">Display name</div>
<input
value={displayName}
onChange={(e) => {
setDisplayName(e.target.value);
setNameMsg(null);
}}
placeholder="e.g. Sean / S2Tactical"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<div className="flex items-center gap-3">
<button
type="button"
onClick={saveDisplayName}
disabled={savingName || displayName.trim().length === 0}
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100 disabled:opacity-50"
>
{savingName ? "Saving…" : "Save display name"}
</button>
{nameMsg && (
<span
className={`text-xs ${
nameMsg.toLowerCase().includes("couldn") || nameMsg.toLowerCase().includes("failed")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{nameMsg}
</span>
)}
</div>
</div>
<div className="text-sm">
<span className="opacity-70">Role:</span>{" "}
<span className="font-medium">{user.role}</span>
</div>
<div className="pt-3 border-t border-white/10 flex items-center justify-between">
<button
onClick={logout}
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100"
>
Log out
</button>
<button
onClick={() => {
setShowPassword((v) => !v);
setPwMsg(null);
}}
className="text-xs underline opacity-70 hover:opacity-100"
>
{showPassword ? "Hide password" : "Set / change password"}
</button>
</div>
{showPassword && (
<div className="pt-4 border-t border-white/10 space-y-2">
<div className="text-sm font-medium">Set password</div>
<input
type="password"
value={pw1}
onChange={(e) => setPw1(e.target.value)}
placeholder="New password (min 8 chars)"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
<input
type="password"
value={pw2}
onChange={(e) => setPw2(e.target.value)}
placeholder="Confirm password"
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
/>
{(pwTooShort || pwMismatch) && (
<div className="text-xs text-red-300">
{pwTooShort ? "Password must be at least 8 characters." : "Passwords dont match."}
</div>
)}
<div className="flex items-center gap-3">
<button
onClick={savePassword}
disabled={!canSavePassword}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
>
{pwSaving ? "Saving…" : "Save password"}
</button>
<button
onClick={() => {
setShowPassword(false);
setPw1("");
setPw2("");
setPwMsg(null);
}}
className="text-xs opacity-80 hover:opacity-100"
>
Cancel
</button>
</div>
{pwMsg && (
<div
className={`text-xs ${
pwMsg.toLowerCase().includes("couldn") || pwMsg.toLowerCase().includes("failed")
? "text-red-300"
: "text-zinc-200/80"
}`}
>
{pwMsg}
</div>
)}
</div>
)}
<div className="pt-3 border-t border-white/10 flex items-center justify-end">
<button
type="button"
onClick={() => router.replace("/builder")}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black"
>
Back to Builder
</button>
</div>
</div>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function SecurityPage() {
return (
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-lg font-semibold">Security</h2>
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
</div>
);
}
+8
View File
@@ -0,0 +1,8 @@
export default function VaultPage() {
return (
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
<h2 className="text-lg font-semibold">My Vault</h2>
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
</div>
);
}
+59
View File
@@ -0,0 +1,59 @@
import Link from "next/link";
const nav = [
{ name: "Profile", href: "/account/profile" },
{ name: "Security", href: "/account/security" },
{ name: "My Vault", href: "/vault" },
{ name: "Favorite Parts", href: "/account/favorites" },
];
export default function AccountLayout({
children,
}: {
children: React.ReactNode;
}) {
return (
<div className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-10">
<div className="flex items-start justify-between gap-4">
<div>
<div className="text-xs tracking-[0.25em] uppercase text-zinc-400">
Account
</div>
<h1 className="mt-2 text-3xl font-semibold">Settings & Profile</h1>
<p className="mt-2 text-sm text-zinc-400">
Profile, password, and account settings.
</p>
</div>
<Link
href="/builder"
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm hover:bg-white/10"
>
Back to Builder
</Link>
</div>
<div className="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-12">
{/* Sidebar */}
<aside className="lg:col-span-3">
<nav className="rounded-xl border border-white/10 bg-white/5 p-2">
{nav.map((item) => (
<Link
key={item.href}
href={item.href}
className="block rounded-lg px-3 py-2 text-sm text-zinc-200 hover:bg-white/5"
>
{item.name}
</Link>
))}
</nav>
</aside>
{/* Content */}
<main className="lg:col-span-9">{children}</main>
</div>
</div>
</div>
);
}
File diff suppressed because it is too large Load Diff
@@ -0,0 +1,16 @@
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
export default async function PartsBrowsePage({
params,
}: {
params: Promise<{ partRole: string }>;
}) {
const { partRole } = await params;
return (
<PartsBrowseClient
partRole={decodeURIComponent(partRole)}
platform={null} // let client read ?platform=... or default
/>
);
}
@@ -4,13 +4,21 @@ import { useEffect, useMemo, useState } from "react";
import Link from "next/link"; import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation"; import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { CategoryId } from "@/types/gunbuilder"; import type { BuilderSlotKey } from "@/types/builderSlots";
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings"; import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
/**
* API Shapes
* - OfferFromApi: what /api/v1/products/:id/offers returns
* - GunbuilderProductFromApi: product detail contract (v1)
*/
type OfferFromApi = { type OfferFromApi = {
id?: string | number;
merchantName?: string | null; merchantName?: string | null;
price?: number | null; price?: number | null;
originalPrice?: number | null;
buyUrl?: string | null; buyUrl?: string | null;
buyShortUrl?: string | null;
inStock?: boolean | null; inStock?: boolean | null;
lastUpdated?: string | null; lastUpdated?: string | null;
}; };
@@ -22,34 +30,38 @@ type GunbuilderProductFromApi = {
platform: string; platform: string;
partRole: string; partRole: string;
price: number | null; price: number | null;
caliber?: string | null;
// Optional (legacy fallback label if product.offers is missing)
merchantName?: string | null;
// image fields can vary depending on endpoint/version
imageUrl?: string | null; imageUrl?: string | null;
mainImageUrl?: string | null; mainImageUrl?: string | null;
// single best-link (legacy)
buyUrl?: string | null; buyUrl?: string | null;
buyShortUrl?: string | null;
// stock info
inStock?: boolean | null; inStock?: boolean | null;
// richer details (optional)
shortDescription?: string | null; shortDescription?: string | null;
description?: string | null; description?: string | null;
// offers (optional) // (May be empty depending on endpoint; we now fetch offers separately)
offers?: OfferFromApi[] | null; offers?: OfferFromApi[] | null;
}; };
type BuildState = Partial<Record<CategoryId, string>>; type BuildState = Partial<Record<BuilderSlotKey, string>>;
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const STORAGE_KEY = "gunbuilder-build-state"; const STORAGE_KEY = "gunbuilder-build-state";
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const; const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
/**
* Extract numeric id from slug like: "1217-radian-ar-15..."
* This keeps URLs pretty while still letting us fetch by ID.
*/
function extractNumericId(productSlug: string): number | null { function extractNumericId(productSlug: string): number | null {
if (!productSlug) return null; if (!productSlug) return null;
const first = productSlug.split("-")[0]; const first = productSlug.split("-")[0];
@@ -62,19 +74,57 @@ function formatPrice(price: number | null | undefined): string {
return `$${price.toFixed(2)}`; return `$${price.toFixed(2)}`;
} }
/**
* Derive a friendly merchant label.
* Priority:
* 1) offer.merchantName (best)
* 2) hostname from offer.buyUrl (fallback)
* 3) "Retailer"
*/
function getHostFromUrl(url?: string | null): string | null {
if (!url) return null;
try {
const u = new URL(url);
return u.host.replace(/^www\./, "");
} catch {
return null;
}
}
function toTitleCase(s: string): string {
return s
.replace(/[-_]+/g, " ")
.replace(/\b\w/g, (c) => c.toUpperCase());
}
function merchantLabel(offer: OfferFromApi): string {
const name = offer.merchantName?.trim();
if (name) return name;
const host = getHostFromUrl(offer.buyUrl);
if (host) return toTitleCase(host.split(".")[0]);
return "Retailer";
}
/**
* Sort offers:
* 1) lowest price first
* 2) in-stock before out-of-stock
* 3) merchant label tiebreaker
*/
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] { function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
return [...offers].sort((a, b) => { return [...offers].sort((a, b) => {
const ap = a.price ?? Number.POSITIVE_INFINITY; const ap = a.price ?? Number.POSITIVE_INFINITY;
const bp = b.price ?? Number.POSITIVE_INFINITY; const bp = b.price ?? Number.POSITIVE_INFINITY;
if (ap !== bp) return ap - bp; if (ap !== bp) return ap - bp;
// in-stock first // in-stock first (false sorts later)
const aStock = a.inStock === false ? 1 : 0; const aStock = a.inStock === false ? 1 : 0;
const bStock = b.inStock === false ? 1 : 0; const bStock = b.inStock === false ? 1 : 0;
if (aStock !== bStock) return aStock - bStock; if (aStock !== bStock) return aStock - bStock;
// merchant name return merchantLabel(a).localeCompare(merchantLabel(b));
return (a.merchantName ?? "").localeCompare(b.merchantName ?? "");
}); });
} }
@@ -97,6 +147,10 @@ export default function ProductDetailsPage() {
[partRoleParam] [partRoleParam]
); );
/**
* categoryId drives builder state selection/removal
* (based on partRole -> category mapping).
*/
const categoryId = useMemo(() => { const categoryId = useMemo(() => {
return ( return (
PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[partRoleParam] ??
@@ -136,13 +190,25 @@ export default function ProductDetailsPage() {
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined; const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
const isSelected = const isSelected =
!!categoryId && selectedPartIdForCategory === (numericId ? String(numericId) : ""); !!categoryId &&
selectedPartIdForCategory === (numericId ? String(numericId) : "");
// Product fetch state
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null); const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
const [loading, setLoading] = useState<boolean>(true); const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
// If platform segment is missing/empty, normalize into the new route // Offers fetch state (from /products/:id/offers)
const [offersFromApi, setOffersFromApi] = useState<OfferFromApi[]>([]);
const [offersLoading, setOffersLoading] = useState(false);
// Image modal state
const [isImageOpen, setIsImageOpen] = useState(false);
/**
* Route normalization:
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
*/
useEffect(() => { useEffect(() => {
if (platformParam) return; if (platformParam) return;
@@ -157,7 +223,10 @@ export default function ProductDetailsPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]); }, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
// Fetch product /**
* Fetch product details from API:
* GET /api/v1/products/:id
*/
useEffect(() => { useEffect(() => {
if (!numericId) { if (!numericId) {
setError("Invalid product id."); setError("Invalid product id.");
@@ -172,14 +241,23 @@ export default function ProductDetailsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/products/${numericId}`; const url = `/api/catalog/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, {
signal: controller.signal,
credentials: 'include',
});
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load product (${res.status})`); throw new Error(`Failed to load product (${res.status})`);
} }
const data: GunbuilderProductFromApi = await res.json(); const data: GunbuilderProductFromApi = await res.json();
console.log('[DEBUG] Product from API:', {
id: data.id,
name: data.name,
buyUrl: data.buyUrl,
buyShortUrl: data.buyShortUrl
});
setProduct(data); setProduct(data);
} catch (err: any) { } catch (err: any) {
if (err?.name === "AbortError") return; if (err?.name === "AbortError") return;
@@ -193,10 +271,53 @@ export default function ProductDetailsPage() {
return () => controller.abort(); return () => controller.abort();
}, [numericId]); }, [numericId]);
/**
* Fetch offers (rich offer data)
* GET /api/v1/products/:id/offers
*/
useEffect(() => {
if (!numericId) return;
const controller = new AbortController();
async function fetchOffers() {
try {
setOffersLoading(true);
const url = `/api/catalog/products/${numericId}/offers`;
const res = await fetch(url, {
signal: controller.signal,
credentials: 'include',
});
if (!res.ok) {
throw new Error(`Failed to load offers (${res.status})`);
}
const data: OfferFromApi[] = await res.json();
console.log('[DEBUG] Offers from API:', data);
setOffersFromApi(Array.isArray(data) ? data : []);
} catch (err: any) {
if (err?.name === "AbortError") return;
// offers failing shouldn't kill the page
setOffersFromApi([]);
} finally {
setOffersLoading(false);
}
}
fetchOffers();
return () => controller.abort();
}, [numericId]);
/**
* Builder selection toggle:
* - If selected -> remove from build
* - Else -> add to build
*/
const handleTogglePart = () => { const handleTogglePart = () => {
if (!numericId) return; if (!numericId) return;
// If mapping is missing, dont send a bogus builder action
if (!categoryId) { if (!categoryId) {
alert( alert(
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.` `No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
@@ -219,28 +340,59 @@ export default function ProductDetailsPage() {
const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null; const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null;
// Offers normalization: /**
// - If product.offers exists, use it. * Offers normalization:
// - Otherwise, fall back to a single “offer” from product.buyUrl/product.price. * - Prefer offers endpoint data
* - Fallback to legacy single-offer derived from product if needed
*/
const offers = useMemo(() => { const offers = useMemo(() => {
if (!product) return []; if (offersFromApi.length > 0) {
console.log('[DEBUG] Using offers from API, first offer:', offersFromApi[0]);
const fallbackShortUrl = product?.buyShortUrl ?? null;
const normalized = offersFromApi.map((offer) => ({
...offer,
buyShortUrl: offer.buyShortUrl || fallbackShortUrl,
}));
return sortOffers(normalized);
}
const raw = (product.offers ?? []).filter(Boolean) as OfferFromApi[]; if (product?.buyUrl || product?.buyShortUrl) {
if (raw.length > 0) return sortOffers(raw); console.log('[DEBUG] Using fallback from product, buyShortUrl:', product.buyShortUrl, 'buyUrl:', product.buyUrl);
if (product.buyUrl) {
return sortOffers([ return sortOffers([
{ {
merchantName: "Merchant", merchantName: product.merchantName ?? "Retailer",
price: product.price, price: product.price,
buyUrl: product.buyUrl, buyUrl: product.buyShortUrl || product.buyUrl,
inStock: product.inStock ?? true, inStock: product.inStock ?? true,
}, },
]); ]);
} }
return []; return [];
}, [product]); }, [offersFromApi, product]);
/**
* Best offer (sorted offers[0])
*/
const bestOffer = offers[0] ?? null;
/**
* Helper: identify the best offer row
*/
const isBestOffer = (offer: OfferFromApi) =>
!!bestOffer &&
offer.id != null &&
bestOffer.id != null &&
String(offer.id) === String(bestOffer.id);
useEffect(() => {
console.log("OFFERS", offers);
console.log("BEST", bestOffer);
}, [offers, bestOffer]);
// Use best offer for “truth” when available
const stockValue = bestOffer?.inStock ?? product?.inStock ?? null;
const currentPrice = bestOffer?.price ?? product?.price ?? null;
if (!categoryId) { if (!categoryId) {
return ( return (
@@ -256,9 +408,9 @@ export default function ProductDetailsPage() {
<span className="text-zinc-200">catalogMappings</span>. <span className="text-zinc-200">catalogMappings</span>.
</p> </p>
<Link <Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent( href={`/parts/p/${encodeURIComponent(
partRoleParam platform
)}`} )}/${encodeURIComponent(partRoleParam)}`}
className="text-amber-300 hover:text-amber-200 underline" className="text-amber-300 hover:text-amber-200 underline"
> >
Back to Parts List Back to Parts List
@@ -276,16 +428,19 @@ export default function ProductDetailsPage() {
<header className="mb-6"> <header className="mb-6">
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500"> <div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<Link <Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }} href={{
pathname: "/builder",
query: platform ? { platform } : {},
}}
className="hover:text-zinc-300" className="hover:text-zinc-300"
> >
The Armory Builder
</Link> </Link>
<span className="text-zinc-700">/</span> <span className="text-zinc-700">/</span>
<Link <Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent( href={`/parts/p/${encodeURIComponent(
partRoleParam platform
)}`} )}/${encodeURIComponent(partRoleParam)}`}
className="hover:text-zinc-300" className="hover:text-zinc-300"
> >
Parts Parts
@@ -303,36 +458,10 @@ export default function ProductDetailsPage() {
Product <span className="text-amber-300">Details</span> Product <span className="text-amber-300">Details</span>
</h1> </h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl"> <p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Offers, pricing placeholders, and builder actions all on the canonical Offers, pricing placeholders, and builder actions all on the
/parts/p route. canonical /parts/p route.
</p> </p>
</div> </div>
{/* Platform switch (updates NEW route) */}
<div className="flex items-center gap-2">
<label htmlFor="platform-select" className="text-xs text-zinc-500">
Platform
</label>
<select
id="platform-select"
value={platform}
onChange={(e) => {
const next = e.target.value;
router.replace(
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
partRoleParam
)}/${encodeURIComponent(productSlug)}`
);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div> </div>
</header> </header>
@@ -352,18 +481,25 @@ export default function ProductDetailsPage() {
</p> </p>
) : ( ) : (
<div className="grid gap-6 md:grid-cols-[280px_1fr]"> <div className="grid gap-6 md:grid-cols-[280px_1fr]">
{/* Left: image + core actions */} {/* Left */}
<div className="space-y-3"> <div className="space-y-3">
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3"> <div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black"> <div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
{imageUrl ? ( {imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element <button
type="button"
onClick={() => setIsImageOpen(true)}
className="h-full w-full"
title="Click to enlarge"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img <img
src={imageUrl} src={imageUrl}
alt={product.name} alt={product.name}
className="h-full w-full object-contain p-2" className="h-full w-full object-contain p-2"
loading="lazy" loading="lazy"
/> />
</button>
) : ( ) : (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600"> <div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
No image No image
@@ -377,20 +513,20 @@ export default function ProductDetailsPage() {
</span> </span>
<span <span
className={`text-xs font-semibold ${ className={`text-xs font-semibold ${
product.inStock === false stockValue === false
? "text-red-300" ? "text-red-300"
: "text-emerald-300" : "text-emerald-300"
}`} }`}
> >
{product.inStock === false ? "Out of stock" : "In stock"} {stockValue === false ? "Out of stock" : "In stock"}
</span> </span>
</div> </div>
<div className="mt-3 flex gap-2"> <div className="mt-3 flex gap-2">
<Link <Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent( href={`/parts/p/${encodeURIComponent(
partRoleParam platform
)}`} )}/${encodeURIComponent(partRoleParam)}`}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800" className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
> >
Back to List Back to List
@@ -405,12 +541,12 @@ export default function ProductDetailsPage() {
: "bg-amber-400 text-black hover:bg-amber-300" : "bg-amber-400 text-black hover:bg-amber-300"
}`} }`}
> >
{isSelected ? "Remove" : "Add"} {isSelected ? "Remove" : "Add to Build"}
</button> </button>
</div> </div>
</div> </div>
{/* Pricing history placeholder */} {/* Price history placeholder */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3"> <div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500"> <h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
@@ -422,12 +558,13 @@ export default function ProductDetailsPage() {
Pricing graph will go here Pricing graph will go here
</div> </div>
<p className="mt-2 text-[11px] text-zinc-500"> <p className="mt-2 text-[11px] text-zinc-500">
Well wire this to price snapshots once the offer model is stable. Well wire this to price snapshots once the offer model is
stable.
</p> </p>
</div> </div>
</div> </div>
{/* Right: details + offers */} {/* Right */}
<div className="min-w-0 space-y-4"> <div className="min-w-0 space-y-4">
{/* Product meta */} {/* Product meta */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4"> <div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
@@ -447,6 +584,14 @@ export default function ProductDetailsPage() {
{product.platform || platform} {product.platform || platform}
</span> </span>
</span> </span>
{product.caliber && (
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Caliber:{" "}
<span className="font-semibold text-zinc-100">
{product.caliber}
</span>
</span>
)}
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300"> <span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Role:{" "} Role:{" "}
<span className="font-semibold text-zinc-100"> <span className="font-semibold text-zinc-100">
@@ -467,7 +612,7 @@ export default function ProductDetailsPage() {
Current price Current price
</div> </div>
<div className="mt-1 text-2xl font-semibold text-amber-300"> <div className="mt-1 text-2xl font-semibold text-amber-300">
{formatPrice(product.price)} {formatPrice(currentPrice)}
</div> </div>
</div> </div>
</div> </div>
@@ -495,7 +640,11 @@ export default function ProductDetailsPage() {
Merchant Offers Merchant Offers
</h3> </h3>
<span className="text-[10px] text-zinc-600"> <span className="text-[10px] text-zinc-600">
{offers.length ? `${offers.length} offers` : "no offers"} {offersLoading
? "loading…"
: offers.length
? `${offers.length} offers`
: "no offers"}
</span> </span>
</div> </div>
@@ -512,24 +661,41 @@ export default function ProductDetailsPage() {
</thead> </thead>
<tbody className="divide-y divide-zinc-800"> <tbody className="divide-y divide-zinc-800">
{offers.map((o, idx) => ( {offers.map((o, idx) => (
<tr key={idx} className="hover:bg-zinc-900/40"> <tr
<td className="px-3 py-2 text-zinc-200"> key={String(o.id ?? idx)}
{o.merchantName ?? "Merchant"} className={`hover:bg-zinc-900/40 ${
isBestOffer(o)
? "bg-amber-400/10 border-l-2 border-amber-400"
: ""
}`}
>
<td className="px-3 py-2 text-zinc-200 flex items-center">
<span>{merchantLabel(o)}</span>
{isBestOffer(o) && (
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
BEST DEAL
</span>
)}
</td> </td>
<td className="px-3 py-2 text-xs"> <td className="px-3 py-2 text-xs">
{o.inStock === false ? ( {o.inStock === false ? (
<span className="text-red-300">Out of stock</span> <span className="text-red-300">
Out of stock
</span>
) : ( ) : (
<span className="text-emerald-300">In stock</span> <span className="text-emerald-300">
In stock
</span>
)} )}
</td> </td>
<td className="px-3 py-2 text-right font-semibold text-amber-300"> <td className="px-3 py-2 text-right font-semibold text-amber-300">
{o.price != null ? `$${o.price.toFixed(2)}` : "—"} {o.price != null ? `$${o.price.toFixed(2)}` : "—"}
</td> </td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
{o.buyUrl ? ( {(o.buyShortUrl || o.buyUrl) ? (
<a <a
href={o.buyUrl} href={o.buyShortUrl || o.buyUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300" className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
@@ -547,21 +713,21 @@ export default function ProductDetailsPage() {
</div> </div>
) : ( ) : (
<p className="mt-3 text-sm text-zinc-500"> <p className="mt-3 text-sm text-zinc-500">
No offers attached yet. Once offers are available, this becomes the No offers attached yet. Once offers are available, this
where to buy table. becomes the where to buy table.
</p> </p>
)} )}
{/* Keep a single "best" buy CTA if buyUrl exists */} {/* Best Offer CTA */}
{product.buyUrl ? ( {(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<a <a
href={product.buyUrl} href={bestOffer.buyShortUrl || bestOffer.buyUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800" className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
> >
Buy from Merchant Buy from {merchantLabel(bestOffer)}
</a> </a>
</div> </div>
) : null} ) : null}
@@ -569,20 +735,48 @@ export default function ProductDetailsPage() {
{/* Footer tips */} {/* Footer tips */}
<div className="flex flex-wrap gap-2 text-xs text-zinc-500"> <div className="flex flex-wrap gap-2 text-xs text-zinc-500">
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1"> {/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Tip: Add this part, then go back and compare alternates. Tip: Add this part, then go back and compare alternates.
</span> </span> */}
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1"> {/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Canonical route:{" "} Canonical route:{" "}
<span className="text-zinc-300"> <span className="text-zinc-300">
/parts/p/{platform}/{partRoleParam}/{productSlug} /parts/p/{platform}/{partRoleParam}/{productSlug}
</span> </span>
</span> </span> */}
</div> </div>
</div> </div>
</div> </div>
)} )}
</section> </section>
{/* Image modal */}
{isImageOpen && imageUrl ? (
<div
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
onClick={() => setIsImageOpen(false)}
>
<div
className="relative max-h-[90vh] max-w-[90vw]"
onClick={(e) => e.stopPropagation()}
>
<button
type="button"
onClick={() => setIsImageOpen(false)}
className="absolute -top-3 -right-3 rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800"
>
Close
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={imageUrl}
alt={product?.name ?? "Product image"}
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
/>
</div>
</div>
) : null}
</div> </div>
</main> </main>
); );
+52
View File
@@ -0,0 +1,52 @@
"use client";
import React from "react";
export default function Page() {
return (
<main className="mx-auto max-w-3xl px-4 py-10">
<h1 className="text-3xl font-bold tracking-tight mb-4">
About Ballistic Builder
</h1>
<p className="text-sm text-muted-foreground mb-8">
Last updated: {new Date().getFullYear()}
</p>
<section className="space-y-4">
<p className="text-base leading-relaxed">
Ballistic Builder is a tooling and catalog platform designed to make
it easier to explore, compare, and assemble builds from across
multiple merchants. Our goal is to give builders fast, clear,
highsignal information so they can focus on decisions, not data
wrangling.
</p>
<p className="text-base leading-relaxed">
Behind the scenes, we normalize merchant feeds, track offers, and
surface structured parts data. On the front end, we present that data
in a way thats optimized for building, not just browsing.
</p>
<p className="text-base leading-relaxed">
This project is under active development. Expect frequent iteration,
new features, and occasional sharp edges as we ship and refine.
</p>
</section>
<section className="mt-10 space-y-3">
<h2 className="text-xl font-semibold">Contact</h2>
<p className="text-base leading-relaxed">
For questions, feedback, or bug reports, reach out to us at{" "}
<a
href="mailto:support@example.com"
className="underline underline-offset-4"
>
support@example.com
</a>
.
</p>
</section>
</main>
);
}
+20
View File
@@ -0,0 +1,20 @@
// app/(builder)/layout.tsx
import type { ReactNode } from "react";
import { Suspense } from "react";
import { BuilderNav } from "@/components/BuilderNav";
import { TopNav } from "@/components/TopNav";
import { Footer } from "@/components/Footer";
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
<TopNav />
<Suspense fallback={<div className="h-[52px]" />}>
<BuilderNav />
</Suspense>
<main className="min-h-screen">{children}</main>
<Footer />
</div>
);
}
+445
View File
@@ -0,0 +1,445 @@
import React from "react";
import Link from "next/link";
export const metadata = {
title: "Privacy Policy | Battl Builder",
description:
"Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.",
};
export default function PrivacyPolicy() {
const lastUpdated = "January 9, 2026";
return (
<div className="mx-auto max-w-4xl px-4 py-12">
<div className="mb-10">
<h1 className="text-4xl font-bold tracking-tight">Privacy Policy</h1>
<p className="mt-2 text-sm opacity-60">Last Updated: {lastUpdated}</p>
</div>
<div className="space-y-12 text-sm leading-relaxed text-white/90">
{/* 1. Introduction */}
<section>
<h2 className="mb-4 text-xl font-semibold">1. Introduction</h2>
<p>
Battl Builder (&quot;we,&quot; &quot;us,&quot; &quot;our,&quot; or &quot;Company&quot;) respects your privacy
and is committed to protecting it through this Privacy Policy. This
policy explains our practices regarding the collection, use, and
protection of your personal information when you access our website
and use our services (the &quot;Service&quot;).
</p>
<p className="mt-4">
Please read this Privacy Policy carefully. If you do not agree with
our practices, please do not use the Service. Your use of the Service
constitutes your acceptance of this Privacy Policy.
</p>
</section>
{/* 2. Information We Collect */}
<section>
<h2 className="mb-4 text-xl font-semibold">
2. Information We Collect
</h2>
<div className="space-y-4">
<div>
<h3 className="font-semibold text-amber-100">
A. Information You Provide Directly
</h3>
<ul className="mt-2 list-disc space-y-2 pl-5">
<li>
<strong>Account Information:</strong> Email address, username,
password, and profile details
</li>
<li>
<strong>Build Content:</strong> Firearm builds, images,
descriptions, and configurations you create or share
</li>
<li>
<strong>Communication:</strong> Messages, feedback, comments,
and support inquiries
</li>
<li>
<strong>Payment Information:</strong> Billing address, payment
method details (processed securely by third-party providers)
</li>
</ul>
</div>
<div>
<h3 className="font-semibold text-amber-100">
B. Information Collected Automatically
</h3>
<ul className="mt-2 list-disc space-y-2 pl-5">
<li>
<strong>Device & Browser Data:</strong> Device type, operating
system, browser type, IP address, and unique identifiers
</li>
<li>
<strong>Usage Data:</strong> Pages visited, time spent,
features used, clicks, searches, and interactions
</li>
<li>
<strong>Cookies & Tracking:</strong> Session cookies, persistent
cookies, and web analytics tools
</li>
<li>
<strong>Geolocation Data:</strong> Approximate location based on
IP address (not precise GPS)
</li>
</ul>
</div>
<div>
<h3 className="font-semibold text-amber-100">
C. Information from Third Parties
</h3>
<ul className="mt-2 list-disc space-y-2 pl-5">
<li>
<strong>Merchant & Product Data:</strong> Product catalogs, pricing,
and specifications from affiliate partners
</li>
<li>
<strong>Social Media:</strong> Profile information if you link
social accounts
</li>
<li>
<strong>Analytics Providers:</strong> Aggregated usage statistics
</li>
</ul>
</div>
</div>
</section>
{/* 3. How We Use Your Information */}
<section>
<h2 className="mb-4 text-xl font-semibold">
3. How We Use Your Information
</h2>
<p>We use your information for the following purposes:</p>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>Provide, operate, and improve the Service</li>
<li>Create and manage your account</li>
<li>Process transactions and send related confirmations</li>
<li>Personalize your experience and recommend features</li>
<li>Send transactional and informational emails</li>
<li>Conduct analytics and monitor Service performance</li>
<li>
Detect, investigate, and prevent fraud, abuse, and security issues
</li>
<li>Comply with legal obligations and enforce our Terms</li>
<li>
Send marketing communications (with your consent, where required)
</li>
</ul>
</section>
{/* 4. Data Sharing */}
<section>
<h2 className="mb-4 text-xl font-semibold">4. Data Sharing</h2>
<p>
We may share your information in the following circumstances:
</p>
<div className="mt-4 space-y-3">
<div>
<p className="font-semibold">Service Providers</p>
<p className="text-white/75">
Third-party vendors who assist with hosting, analytics, email,
payments, and customer support. These providers are bound by
confidentiality agreements.
</p>
</div>
<div>
<p className="font-semibold">Affiliate Partners</p>
<p className="text-white/75">
We may share limited information with merchant affiliates for
product fulfillment and tracking purposes.
</p>
</div>
<div>
<p className="font-semibold">Public Content</p>
<p className="text-white/75">
Builds, images, and descriptions you publish are visible to other
users of the Service.
</p>
</div>
<div>
<p className="font-semibold">Legal Compliance</p>
<p className="text-white/75">
We may disclose information when required by law, court order, or
government request, or to protect our rights and safety.
</p>
</div>
<div>
<p className="font-semibold">Business Transfers</p>
<p className="text-white/75">
In the event of a merger, acquisition, or sale of assets, your
information may be transferred as part of that transaction.
</p>
</div>
</div>
<p className="mt-4 font-semibold text-orange-200">
We do NOT sell your personal information to third parties for
marketing purposes.
</p>
</section>
{/* 5. Cookies & Tracking Technologies */}
<section>
<h2 className="mb-4 text-xl font-semibold">
5. Cookies & Tracking Technologies
</h2>
<p>
We use cookies and similar technologies to enhance your experience:
</p>
<div className="mt-4 space-y-3">
<div>
<p className="font-semibold">Essential Cookies</p>
<p className="text-white/75">
Required for authentication, security, and basic functionality.
</p>
</div>
<div>
<p className="font-semibold">Performance Cookies</p>
<p className="text-white/75">
Collect analytics data to improve Service performance and user
experience.
</p>
</div>
<div>
<p className="font-semibold">Preference Cookies</p>
<p className="text-white/75">
Remember your settings and preferences for future visits.
</p>
</div>
</div>
<p className="mt-4">
You can control cookies through your browser settings. Disabling cookies
may affect Service functionality.
</p>
</section>
{/* 6. Data Security */}
<section>
<h2 className="mb-4 text-xl font-semibold">6. Data Security</h2>
<p>
We implement industry-standard security measures to protect your
personal information, including encryption, secure servers, and access
controls.
</p>
<p className="mt-4">
However, no security system is impenetrable. While we strive to
protect your data, we cannot guarantee absolute security. You use the
Service at your own risk.
</p>
<p className="mt-4">
If you believe your account has been compromised, please contact us
immediately.
</p>
</section>
{/* 7. Data Retention */}
<section>
<h2 className="mb-4 text-xl font-semibold">7. Data Retention</h2>
<p>
We retain your personal information for as long as necessary to
provide the Service and comply with legal obligations:
</p>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>
<strong>Account Information:</strong> Retained while your account is
active; deleted or anonymized upon deletion request
</li>
<li>
<strong>Build Content:</strong> Retained as long as you choose to
keep it; may be retained in backups for operational purposes
</li>
<li>
<strong>Transactional Data:</strong> Retained for 7 years for
tax/legal compliance
</li>
<li>
<strong>Analytics Data:</strong> Aggregated and anonymized after
1224 months
</li>
</ul>
</section>
{/* 8. Your Privacy Rights */}
<section className="rounded-xl border border-blue-500/30 bg-blue-500/5 p-6">
<h2 className="mb-4 text-xl font-semibold text-blue-200">
8. Your Privacy Rights
</h2>
<p className="mb-4">
Depending on your location, you may have the following rights:
</p>
<ul className="space-y-3">
<li>
<strong>Right to Access:</strong> Request a copy of your personal
data
</li>
<li>
<strong>Right to Correction:</strong> Request correction of inaccurate
data
</li>
<li>
<strong>Right to Deletion:</strong> Request deletion of your data (with
exceptions for legal compliance)
</li>
<li>
<strong>Right to Opt-Out:</strong> Opt out of marketing communications
and certain tracking
</li>
<li>
<strong>Right to Portability:</strong> Request your data in a portable
format
</li>
<li>
<strong>Right to Objection:</strong> Object to certain data processing
activities
</li>
</ul>
<p className="mt-4">
To exercise these rights, email us at{" "}
<span className="text-blue-300">privacy@battlbuilder.com</span> with
your request. We will respond within 30 days.
</p>
</section>
{/* 9. CCPA / California Privacy Rights */}
<section>
<h2 className="mb-4 text-xl font-semibold">
9. CCPA & California Residents
</h2>
<p>
If you are a California resident, the California Consumer Privacy Act
(CCPA) grants you additional rights:
</p>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>
Know what personal information is collected, used, and shared
</li>
<li>Delete personal information under certain circumstances</li>
<li>Opt-out of the sale or sharing of personal information</li>
<li>Non-discrimination for exercising your CCPA rights</li>
</ul>
<p className="mt-4">
We do not sell personal information. To submit a CCPA request, email
us at{" "}
<span className="text-amber-300">privacy@battlbuilder.com</span>.
</p>
</section>
{/* 10. Children's Privacy */}
<section>
<h2 className="mb-4 text-xl font-semibold">10. Children&apos;s Privacy</h2>
<p>
The Service is not intended for users under 18 years of age. We do not
knowingly collect personal information from children under 18.
</p>
<p className="mt-4">
If we become aware that we have collected information from a child
under 18, we will delete such information immediately and terminate the
child&apos;s account.
</p>
<p className="mt-4">
If you believe we have collected information from a child under 18,
please contact us immediately.
</p>
</section>
{/* 11. Third-Party Links & Services */}
<section>
<h2 className="mb-4 text-xl font-semibold">
11. Third-Party Links & Services
</h2>
<p>
The Service may contain links to third-party websites and services
operated by other companies. This Privacy Policy applies only to
Battl Builder. We are not responsible for the privacy practices of
third-party sites.
</p>
<p className="mt-4">
Please review the privacy policies of any third-party services before
providing your information.
</p>
</section>
{/* 12. International Data Transfers */}
<section>
<h2 className="mb-4 text-xl font-semibold">
12. International Data Transfers
</h2>
<p>
If you access the Service from outside the United States, your
information may be transferred to, stored in, and processed in the
United States or other countries where we operate.
</p>
<p className="mt-4">
By using the Service, you consent to the transfer of your information
to countries outside your country of residence, which may have
different data protection rules.
</p>
</section>
{/* 13. Updates to This Policy */}
<section>
<h2 className="mb-4 text-xl font-semibold">
13. Updates to This Privacy Policy
</h2>
<p>
We may update this Privacy Policy from time to time to reflect changes
in our practices or for other operational, legal, or regulatory reasons.
</p>
<p className="mt-4">
We will notify you of material changes by posting the updated policy
here and updating the &quot;Last Updated&quot; date. Your continued use of the
Service following the posting of changes constitutes your acceptance
of the updated Privacy Policy.
</p>
</section>
{/* 14. Contact Us */}
<section className="rounded-xl border border-amber-500/30 bg-amber-500/5 p-6">
<h2 className="mb-4 text-xl font-semibold text-amber-200">
14. Contact Us
</h2>
<p>
If you have questions about this Privacy Policy or wish to exercise
your privacy rights, please contact us:
</p>
<div className="mt-4 space-y-2 text-white/80">
<p>
<strong>Email:</strong>{" "}
<span className="text-amber-300">privacy@battlbuilder.com</span>
</p>
<p>
<strong>Mailing Address:</strong>
<br />
Battl Builder, LLC.
<br />
[Your Address]
<br />
[City, State, ZIP]
</p>
</div>
<p className="mt-4 text-sm">
We will respond to all privacy inquiries within 30 days of receipt.
</p>
</section>
<div className="space-y-3 pt-10">
<p className="text-xs opacity-50">
This Privacy Policy is aligned with GDPR, CCPA, and other major
privacy regulations.
</p>
<Link
href="/"
className="block text-sm text-amber-400 hover:text-amber-300 transition-colors"
>
Return to Home
</Link>
</div>
</div>
</div>
);
}
+260
View File
@@ -0,0 +1,260 @@
import React from "react";
import Link from "next/link";
export const metadata = {
title: "Terms of Service | Battl Builder",
description:
"Terms of Service and legal disclaimers for Battl Builder, including firearm safety, compatibility, and community content.",
};
export default function TermsOfService() {
const lastUpdated = "December 27, 2025";
return (
<div className="mx-auto max-w-4xl px-4 py-12">
<div className="mb-10">
<h1 className="text-4xl font-bold tracking-tight">Terms of Service</h1>
<p className="mt-2 text-sm opacity-60">Last Updated: {lastUpdated}</p>
</div>
<div className="space-y-12 text-sm leading-relaxed text-white/90">
{/* 1. Acceptance */}
<section>
<h2 className="mb-4 text-xl font-semibold">1. Acceptance of Terms</h2>
<p>
These Terms of Service (Terms) govern your access to and use of
Battl Builder (the Service), including all content, tools,
features, and community functionality provided through the website.
</p>
<p className="mt-4">
By accessing or using any part of the Service, you agree to be bound
by these Terms and our Privacy Policy. If you do not agree, you may
not access or use the Service.
</p>
<p className="mt-4">
The Service is offered only to individuals who are at least 18 years
of age.
</p>
</section>
{/* 2. Nature of the Service */}
<section>
<h2 className="mb-4 text-xl font-semibold">
2. Nature of the Service
</h2>
<p>
Battl Builder is an informational and planning platform designed to
help users explore, visualize, and compare firearm-related
components and configurations. The Service may include pricing data,
compatibility indicators, build summaries, and community-generated
content.
</p>
<p className="mt-4 font-medium text-orange-200">
Battl Builder does NOT provide gunsmithing services, mechanical
instructions, professional advice, training, or certification of any
kind.
</p>
</section>
{/* 3. Legal Compliance & Firearms Safety */}
<section className="rounded-xl border border-orange-500/30 bg-orange-500/5 p-6">
<h2 className="mb-4 text-xl font-semibold text-orange-200">
3. Legal Compliance, Firearms Safety & Assumption of Risk
</h2>
<div className="space-y-4">
<p className="font-medium text-orange-100">
You are solely responsible for ensuring that any firearm,
component, or configuration complies with all applicable local,
state, federal, and international laws.
</p>
<ul className="list-disc space-y-2 pl-5">
<li>
Firearm laws vary significantly by jurisdiction and change
frequently.
</li>
<li>
Battl Builder does not provide legal advice or determine the
legality of any build.
</li>
<li>
Compatibility indicators are informational only and may be
incomplete or inaccurate.
</li>
</ul>
<p className="font-semibold">
YOU ACKNOWLEDGE AND AGREE THAT THE PHYSICAL ASSEMBLY,
MODIFICATION, POSSESSION, OR USE OF FIREARMS INVOLVES INHERENT
RISKS, INCLUDING SERIOUS INJURY OR DEATH. YOU ASSUME ALL SUCH
RISKS.
</p>
<p>
Nothing on the Service constitutes legal, mechanical, firearms,
safety, or professional advice of any kind.
</p>
<p className="mt-4 font-semibold">
You are solely responsible for verifying all component
compatibility, legality, and safe operation before acquiring,
assembling, or using any firearm or related component.
</p>
</div>
</section>
{/* 4. No Warranty / No Reliance */}
<section>
<h2 className="mb-4 text-xl font-semibold">
4. No Warranty; No Reliance
</h2>
<p>
The Service is provided for general informational purposes only. You
agree that you will not rely on the Service as a substitute for
professional advice, inspection, or training.
</p>
<p className="mt-4">
All content, data, and tools are provided AS IS and AS
AVAILABLE, without warranties of any kind, whether express or
implied.
</p>
</section>
{/* 5. User Content */}
<section>
<h2 className="mb-4 text-xl font-semibold">
5. User Content & Community Builds
</h2>
<p>
By submitting builds, images, descriptions, comments, or other
content (User Content), you represent and warrant that:
</p>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>You own or have rights to submit the content</li>
<li>The content does not infringe third-party rights</li>
<li>The content is not unlawful, misleading, or harmful</li>
<li>The content does not contain malware or malicious code</li>
</ul>
<p className="mt-4">
You grant Battl Builder a worldwide, royalty-free, non-exclusive
license to host, display, modify, and distribute your User Content
in connection with the Service.
</p>
</section>
{/* 6. Affiliate & Third-Party Data */}
<section>
<h2 className="mb-4 text-xl font-semibold">
6. Pricing, Affiliates & Third-Party Links
</h2>
<p>
Product pricing, availability, images, and specifications are
provided by third parties and may be inaccurate or outdated. Battl
Builder does not guarantee the accuracy of such data.
</p>
<p className="mt-4">
We may earn affiliate commissions from qualifying purchases made
through links on the Service.
</p>
</section>
{/* 7. Access & Restrictions */}
<section>
<h2 className="mb-4 text-xl font-semibold">
7. Access, License & Restrictions
</h2>
<p>
Battl Builder grants you a limited, non-exclusive, non-transferable
license to access the Service for personal, non-commercial use.
</p>
<p className="mt-4">You may NOT:</p>
<ul className="mt-3 list-disc space-y-2 pl-5">
<li>Scrape, crawl, or harvest data from the Service</li>
<li>Use automated tools, bots, or data-mining techniques</li>
<li>Republish or resell Service data</li>
<li>Use the Service for commercial purposes without permission</li>
</ul>
</section>
{/* 8. Termination */}
<section>
<h2 className="mb-4 text-xl font-semibold">8. Termination</h2>
<p>
Battl Builder may suspend or terminate your access to the Service at
any time, with or without notice, for any reason.
</p>
</section>
{/* 9. Limitation of Liability */}
<section className="border-t border-white/10 pt-10">
<h2 className="mb-4 text-xl font-semibold">
9. Limitation of Liability
</h2>
<p className="uppercase text-xs opacity-70">
TO THE MAXIMUM EXTENT PERMITTED BY LAW, BATTL BUILDER SHALL NOT BE
LIABLE FOR ANY INDIRECT, INCIDENTAL, SPECIAL, CONSEQUENTIAL, OR
PUNITIVE DAMAGES, INCLUDING PERSONAL INJURY, LOSS OF DATA, OR LEGAL
CONSEQUENCES ARISING FROM USE OF THE SERVICE.
</p>
<p className="mt-4">
OUR TOTAL LIABILITY SHALL NOT EXCEED THE AMOUNT PAID BY YOU TO BATTL
BUILDER IN THE TWELVE (12) MONTHS PRECEDING THE CLAIM, OR $100,
WHICHEVER IS GREATER.
</p>
</section>
{/* 10. Indemnification */}
<section>
<h2 className="mb-4 text-xl font-semibold">10. Indemnification</h2>
<p>
You agree to indemnify and hold harmless Battl Builder and its
operators from any claims, damages, losses, or expenses arising from
your use of the Service or violation of these Terms.
</p>
</section>
{/* 11. Arbitration */}
<section>
<h2 className="mb-4 text-xl font-semibold">
11. Arbitration & Class Action Waiver
</h2>
<p>
Any dispute arising out of or relating to these Terms shall be
resolved by binding arbitration on an individual basis. YOU WAIVE
THE RIGHT TO PARTICIPATE IN A CLASS ACTION.
</p>
<p className="mt-4">
Any arbitration shall take place in the State of Florida, conducted
in the English language, unless otherwise required by applicable
law.
</p>
<p className="mt-4">
This provision does not prevent either party from seeking injunctive
relief for intellectual property violations.
</p>
</section>
{/* 12. Misc */}
<section>
<h2 className="mb-4 text-xl font-semibold">12. Miscellaneous</h2>
<p>
These Terms constitute the entire agreement between you and Battl
Builder. If any provision is found unenforceable, the remaining
provisions will remain in effect.
</p>
<p className="mt-4">
These Terms and any dispute arising out of or relating to the
Service shall be governed by and construed in accordance with the
laws of the State of Florida, without regard to its conflict of law
principles.
</p>
</section>
<div className="pt-10">
<Link
href="/"
className="text-sm text-amber-400 hover:text-amber-300 transition-colors"
>
Return to Home
</Link>
</div>
</div>
</div>
);
}
+617
View File
@@ -0,0 +1,617 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
import Image from "next/image";
type BuildDto = {
uuid: string;
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
items?: Array<{
slot: string;
productId: number;
productName?: string | null;
productBrand?: string | null;
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
}>;
photos?: BuildPhotoDto[];
};
type BuildPhotoDto = {
uuid: string;
url: string;
caption?: string | null;
sortOrder?: number | null;
createdAt?: string | null;
};
type LocalPreview = {
id: string;
file: File;
url: string; // object URL
};
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
return Number.isNaN(dt.getTime()) ? d : dt.toLocaleString();
}
export default function VaultBuildEditPage() {
const { uuid } = useParams<{ uuid: string }>();
const router = useRouter();
const api = useApi();
const { token, loading: authLoading } = useAuth();
const [build, setBuild] = useState<BuildDto | null>(null);
const [form, setForm] = useState({
title: "",
description: "",
isPublic: false,
});
const [busy, setBusy] = useState(false);
const [error, setError] = useState<string | null>(null);
const [savedMsg, setSavedMsg] = useState<string | null>(null);
// photo upload UI state
const [previews, setPreviews] = useState<LocalPreview[]>([]);
const [uploading, setUploading] = useState(false);
// avoid object URL leaks
useEffect(() => {
return () => {
previews.forEach((p) => URL.revokeObjectURL(p.url));
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
const authed = !!token;
// Load build once auth is ready + token exists
useEffect(() => {
if (!uuid) return;
if (authLoading) return;
if (!authed) {
setError("Please log in to edit builds.");
return;
}
let cancelled = false;
(async () => {
try {
setError(null);
const res = await api.get(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load build (${res.status})`);
}
const data = (await res.json()) as BuildDto;
if (cancelled) return;
setBuild(data);
setForm({
title: data.title ?? "",
description: data.description ?? "",
isPublic: Boolean(data.isPublic),
});
} catch (e: any) {
if (!cancelled) setError(e?.message ?? "Failed to load build");
}
})();
return () => {
cancelled = true;
};
}, [uuid, authLoading, authed, api]);
const dirty = useMemo(() => {
if (!build) return false;
return (
form.title !== (build.title ?? "") ||
form.description !== (build.description ?? "") ||
form.isPublic !== Boolean(build.isPublic)
);
}, [build, form]);
async function onSave() {
if (!uuid) return;
setBusy(true);
setSavedMsg(null);
setError(null);
try {
const payload = {
title: form.title.trim() || "Untitled Build",
description: form.description.trim() || null,
isPublic: form.isPublic,
};
const res = await api.put(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`,
payload
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Save failed (${res.status})`);
}
const updated = (await res.json()) as BuildDto;
setBuild(updated);
setForm({
title: updated.title ?? "",
description: updated.description ?? "",
isPublic: Boolean(updated.isPublic),
});
setSavedMsg("Saved.");
setTimeout(() => setSavedMsg(null), 2000);
} catch (e: any) {
setError(e?.message ?? "Save failed");
} finally {
setBusy(false);
}
}
function isImage(file: File) {
return file.type.startsWith("image/");
}
async function fileToUpload(file: File, buildUuid: string) {
// 1) ask backend for presigned upload URL
const res = await api.post(
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos/upload-url`,
{
fileName: file.name,
contentType: file.type,
}
);
if (!res.ok) {
throw new Error(await res.text().catch(() => "Failed to get upload URL"));
}
const { uploadUrl, publicUrl } = await res.json();
// 2) upload directly to storage
const put = await fetch(uploadUrl, {
method: "PUT",
headers: { "Content-Type": file.type },
body: file,
});
if (!put.ok) throw new Error("Upload failed");
// 3) confirm + create photo record
const create = await api.post(
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos`,
{
url: publicUrl,
caption: null,
sortOrder: 0,
}
);
if (!create.ok) {
throw new Error(await create.text().catch(() => "Failed to save photo"));
}
return (await create.json()) as BuildPhotoDto;
}
async function onDelete() {
if (!uuid) return;
const ok = window.confirm("Delete this build?\n\nThis cannot be undone.");
if (!ok) return;
setBusy(true);
setError(null);
try {
const res = await api.del(
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
);
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Delete failed (${res.status})`);
}
router.replace("/vault");
router.refresh();
} catch (e: any) {
setError(e?.message ?? "Delete failed");
} finally {
setBusy(false);
}
}
return (
<main className="mx-auto max-w-3xl p-6">
<div className="mb-4 flex items-center justify-between gap-3">
<div>
<h1 className="text-lg font-semibold">Edit Build</h1>
<div className="mt-1 text-xs text-zinc-500">
UUID: <span className="font-mono">{uuid}</span>
</div>
</div>
<div className="flex items-center gap-2">
<Link
href="/vault"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
>
Vault
</Link>
<button
className="rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
disabled={!uuid}
onClick={() =>
router.push(`/builder?load=${encodeURIComponent(uuid)}`)
}
title="Open this build in the builder to swap parts"
>
Open in Builder
</button>
</div>
</div>
{authLoading && <div className="text-sm text-zinc-400">Loading</div>}
{!authLoading && error && (
<pre className="mb-4 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
{error}
</pre>
)}
{!authLoading && build && (
<div className="space-y-4">
{/* meta */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex flex-wrap items-center justify-between gap-2">
<div className="text-xs text-zinc-400">
Created: {fmt(build.createdAt)} Updated:{" "}
{fmt(build.updatedAt)}
</div>
<span
className={`rounded-full border px-2 py-0.5 text-[11px] ${
form.isPublic
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/5 text-white/70"
}`}
>
{form.isPublic ? "Published" : "Draft"}
</span>
</div>
</div>
{/* parts */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<div className="mb-3 flex items-center justify-between">
<div className="text-sm font-semibold">Parts in this build</div>
<div className="text-xs text-zinc-400">
{build.items?.length ? `${build.items.length} items` : "—"}
</div>
</div>
{!build.items?.length ? (
<div className="text-sm opacity-70">No parts saved yet.</div>
) : (
<div className="overflow-hidden rounded-lg border border-white/10 bg-black/20">
{/* header */}
<div className="grid grid-cols-12 gap-3 border-b border-white/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
<div className="col-span-5">Product</div>
<div className="col-span-4">Component (Role)</div>
<div className="col-span-2">Brand</div>
<div className="col-span-1 text-right">Price</div>
</div>
{/* rows */}
{build.items
.slice()
.sort((a, b) => a.slot.localeCompare(b.slot))
.map((it, idx) => {
const price =
typeof it.bestPrice === "number"
? it.bestPrice
: it.bestPrice
? Number(it.bestPrice)
: null;
return (
<div
key={`${it.slot}-${it.productId}-${idx}`}
className="grid grid-cols-12 gap-2 border-b border-white/5 px-3 py-2 text-sm last:border-b-0"
>
{/* Product */}
<div className="col-span-5 min-w-0">
<div className="truncate text-zinc-100">
{it.productName ?? `Product #${it.productId}`}
</div>
<div className="text-xs text-zinc-500">
ID:{" "}
<span className="font-mono">{it.productId}</span>
</div>
</div>
{/* Component (Role) */}
<div className="col-span-4 truncate text-zinc-200">
{it.slot}
</div>
{/* Brand */}
<div className="col-span-2 truncate text-zinc-300">
{it.productBrand ?? "—"}
</div>
{/* Price */}
<div className="col-span-1 text-right font-semibold text-zinc-100">
{price != null && !Number.isNaN(price)
? `$${price.toFixed(2)}`
: "—"}
</div>
</div>
);
})}
</div>
)}
</div>
{/* form */}
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
<label className="block text-xs font-medium text-zinc-300">
Title
</label>
<input
value={form.title}
onChange={(e) =>
setForm((f) => ({ ...f, title: e.target.value }))
}
className="mt-2 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
placeholder="e.g. Suppressed MK2"
maxLength={120}
/>
<label className="mt-4 block text-xs font-medium text-zinc-300">
Notes / Description
</label>
<textarea
value={form.description}
onChange={(e) =>
setForm((f) => ({ ...f, description: e.target.value }))
}
className="mt-2 min-h-[120px] w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
placeholder="Why this build? Goals, suppressor setup, ammo, intended use, etc."
maxLength={2000}
/>
<div className="mt-4 flex items-center justify-between gap-3">
<label className="flex items-center gap-2 text-sm">
<input
type="checkbox"
checked={form.isPublic}
onChange={(e) =>
setForm((f) => ({ ...f, isPublic: e.target.checked }))
}
/>
<span className="text-zinc-200">Publish to community</span>
</label>
<div className="flex items-center gap-2">
{savedMsg && (
<span className="text-xs text-emerald-300">{savedMsg}</span>
)}
<button
onClick={onSave}
disabled={busy || !dirty}
className="rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black hover:bg-zinc-200 disabled:opacity-50"
>
{busy ? "Working…" : dirty ? "Save" : "Saved"}
</button>
</div>
</div>
</div>
{/* Build Photos */}
<div className="mt-8 rounded-xl border border-white/10 bg-white/5 p-4">
<div className="flex items-center justify-between gap-3">
<div>
<div className="text-sm font-semibold">Build Photos</div>
<div className="text-xs opacity-70">
Select photos, remove any you dont want, then upload.
</div>
</div>
<div className="flex items-center gap-2">
{/* Select (no upload) */}
<label
className={`cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs hover:bg-white/10 ${
uploading ? "opacity-60 pointer-events-none" : ""
}`}
title={uploading ? "Uploading…" : "Select photos"}
>
Select Photos
<input
type="file"
accept="image/*"
multiple
className="hidden"
onChange={(e) => {
const files = Array.from(e.target.files ?? []).filter(
isImage
);
if (!files.length) return;
const local: LocalPreview[] = files.map((f) => ({
id:
typeof crypto !== "undefined" &&
"randomUUID" in crypto
? crypto.randomUUID()
: `${Date.now()}-${Math.random()}`,
file: f,
url: URL.createObjectURL(f),
}));
setPreviews((prev) => [...local, ...prev]);
e.target.value = ""; // reset input so re-selecting same file works
}}
/>
</label>
{/* Upload (real upload) */}
<button
type="button"
disabled={uploading || previews.length === 0 || !build?.uuid}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
onClick={async () => {
if (!build?.uuid || previews.length === 0) return;
setError(null);
setUploading(true);
// snapshot so user can keep selecting more while current batch uploads
const batch = [...previews];
try {
for (const p of batch) {
// if user removed it before we got to it, skip
const stillThere = previews.some((x) => x.id === p.id);
if (!stillThere) continue;
const created = await fileToUpload(p.file, build.uuid);
setBuild((prev) =>
prev
? {
...prev,
photos: [created, ...(prev.photos ?? [])],
}
: prev
);
// remove preview after success
setPreviews((prev) => {
const hit = prev.find((x) => x.id === p.id);
if (hit) URL.revokeObjectURL(hit.url);
return prev.filter((x) => x.id !== p.id);
});
}
} catch (err: any) {
setError(err?.message ?? "Photo upload failed");
} finally {
setUploading(false);
}
}}
>
{uploading
? "Uploading…"
: `Upload${previews.length ? ` (${previews.length})` : ""}`}
</button>
</div>
</div>
{/* Gallery */}
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
{/* Local previews (pending upload) */}
{previews.map((p) => (
<div
key={p.id}
className="relative overflow-hidden rounded-lg border border-white/10 bg-black/20"
>
{/* remove X */}
<button
type="button"
aria-label="Remove"
className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs text-white hover:bg-black/80"
onClick={() => {
setPreviews((prev) => {
const hit = prev.find((x) => x.id === p.id);
if (hit) URL.revokeObjectURL(hit.url);
return prev.filter((x) => x.id !== p.id);
});
}}
disabled={uploading}
title={
uploading
? "Wait for upload to finish"
: "Remove from upload list"
}
>
</button>
<Image
src={p.url}
alt="Pending upload"
className="h-32 w-full object-cover"
/>
<div className="p-2 text-xs opacity-80">
{uploading ? "Uploading…" : "Pending upload"}
</div>
</div>
))}
{/* Uploaded photos */}
{(build?.photos ?? []).map((p) => (
<div
key={p.uuid}
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
>
<Image
src={p.url}
alt={p.caption ?? "Build photo"}
className="h-32 w-full object-cover"
/>
<div className="p-2 text-xs opacity-80 line-clamp-2">
{p.caption ?? "—"}
</div>
</div>
))}
{previews.length === 0 && (build?.photos?.length ?? 0) === 0 && (
<div className="col-span-full text-xs opacity-70">
No photos yet. Select a few and upload when ready.
</div>
)}
</div>
</div>
{/* danger zone */}
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
<div className="text-sm font-semibold text-red-200">
Danger zone
</div>
<div className="mt-1 text-xs text-red-200/70">
Deleting a build is permanent.
</div>
<button
onClick={onDelete}
disabled={busy}
className="mt-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs font-semibold text-red-100 hover:bg-red-500/15 disabled:opacity-50"
>
Delete build
</button>
</div>
</div>
)}
</main>
);
}
+254
View File
@@ -0,0 +1,254 @@
// app/vault/page.tsx
"use client";
import Link from "next/link";
import { useEffect, useMemo, useState } from "react";
import { useRouter } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { useApi } from "@/lib/api";
type BuildDto = {
id: string; // internal numeric id (not used by UI)
uuid: string; // public identifier
title: string;
description?: string | null;
isPublic?: boolean | null;
createdAt?: string | null;
updatedAt?: string | null;
};
// API routes now handled by Next.js /api routes (server-side)
// UUID validator (defensive)
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
);
function fmt(d?: string | null) {
if (!d) return "—";
const dt = new Date(d);
if (Number.isNaN(dt.getTime())) return d;
return dt.toLocaleString();
}
export default function VaultPage() {
const router = useRouter();
const api = useApi();
// NOTE:
// - `loading` here is AuthContext hydration, not network.
// - Auth is handled by HTTP-only cookies automatically.
const { user, loading: authLoading } = useAuth();
const [builds, setBuilds] = useState<BuildDto[]>([]);
const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false);
const authed = !!user;
// Redirect if not logged in (after auth hydrates)
useEffect(() => {
if (!authLoading && !authed) {
router.replace("/login");
}
}, [authLoading, authed, router]);
// When auth identity changes, clear stale list to avoid "ghost builds"
useEffect(() => {
if (authLoading) return;
setBuilds([]);
setError(null);
}, [authLoading, user?.uuid]);
// Fetch user builds
useEffect(() => {
if (authLoading || !authed) return;
let cancelled = false;
(async () => {
setBusy(true);
setError(null);
try {
// IMPORTANT:
// Use api wrapper which includes credentials (cookies) automatically.
const res = await api.get("/api/builds");
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Request failed (${res.status})`);
}
const data = (await res.json()) as BuildDto[];
if (!cancelled) setBuilds(Array.isArray(data) ? data : []);
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds");
} finally {
if (!cancelled) setBusy(false);
}
})();
return () => {
cancelled = true;
};
}, [authLoading, authed, api]);
if (authLoading) {
return (
<div className="mx-auto max-w-6xl px-4 py-6">
<div className="text-sm opacity-70">Loading</div>
</div>
);
}
// Already redirected
if (!authed) return null;
return (
<div className="mx-auto max-w-6xl px-4 py-6">
{/* Header */}
<div className="mb-6 flex items-start justify-between gap-4">
<div>
<h1 className="text-2xl font-semibold">Vault</h1>
<p className="text-sm opacity-70">
Your saved builds. Open one to keep tweaking parts, or add details
to submit it to the community.
</p>
</div>
<div className="flex items-center gap-3">
<Link
href="/builder"
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
title="Create a new build in the builder"
>
New Build
</Link>
<Link href="/account" className="text-sm opacity-80 hover:underline" title={"Your account info"}>
Account
</Link>
</div>
</div>
{/* Error */}
{error && (
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
{error}
</div>
)}
{/* Builds list */}
<div className="rounded-xl border border-white/10 bg-white/5">
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
<div className="text-sm font-medium">My Builds</div>
<div className="text-xs opacity-70">
{busy ? "Refreshing…" : `${builds.length} total`}
</div>
</div>
{builds.length === 0 ? (
<div className="p-4 text-sm opacity-70">
No builds yet. Create one in the builder and hit Save As
</div>
) : (
<ul className="divide-y divide-white/10">
{builds.map((b) => {
const valid = !!b?.uuid && isUuid(String(b.uuid));
const published = Boolean(b.isPublic);
const openHref = valid
? `/builder?load=${encodeURIComponent(b.uuid)}`
: "#";
const detailsHref = valid
? `/vault/${encodeURIComponent(b.uuid)}/edit`
: "#";
return (
<li
key={b.uuid}
className="p-4 flex items-start justify-between gap-4"
>
<div className="min-w-0">
<div className="flex items-center gap-2">
<div className="font-medium truncate">{b.title}</div>
{/* Status chip */}
<span
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${
published
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border-white/10 bg-white/5 text-white/70"
}`}
title={
published
? "This build is public (community-visible)."
: "Draft (only visible in your Vault)."
}
>
{published ? "Published" : "Draft"}
</span>
{!valid && (
<span
className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200"
title="This build record has an invalid UUID."
>
Invalid UUID
</span>
)}
</div>
<div className="mt-1 text-xs opacity-70">
Updated: {fmt(b.updatedAt)} UUID:{" "}
<span className="font-mono">{b.uuid}</span>
</div>
{b.description && (
<div className="mt-2 text-sm opacity-80 line-clamp-2">
{b.description}
</div>
)}
</div>
<div className="flex shrink-0 items-center gap-2">
<Link
href={openHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={valid ? "Load this build into the builder" : "Invalid build id"}
>
View in Builder
</Link>
<Link
href={detailsHref}
aria-disabled={!valid}
tabIndex={!valid ? -1 : 0}
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
valid
? "border-white/10 bg-white/5 hover:bg-white/10"
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
}`}
title={valid ? "Edit details / publish later" : "Invalid build id"}
>
View / Edit
</Link>
</div>
</li>
);
})}
</ul>
)}
</div>
</div>
);
}
-482
View File
@@ -1,482 +0,0 @@
//
// This page is most likely temporary. We probably do not need this page
//
//
//
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
import {
Pencil,
Link2,
Square,
CheckSquare,
} from "lucide-react";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const STORAGE_KEY = "gunbuilder-build-state";
type BuildState = Partial<Record<CategoryId, string>>;
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
};
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
const UNIVERSAL_CATEGORIES = new Set<CategoryId>([
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
"optic",
"sights",
]);
const CATEGORY_GROUPS: {
id: string;
label: string;
description?: string;
categoryIds: CategoryId[];
}[] = [
{
id: "lower-group",
label: "Lower Receiver Parts",
description:
"Everything from the serialized lower to small parts, fire control, and core controls.",
categoryIds: [
"lower-receiver",
"complete-lower",
"lower-parts",
"trigger",
"grip",
"safety",
"buffer",
"stock",
],
},
{
id: "upper-group",
label: "Upper Receiver Parts",
description:
"Barrel, upper, gas system, and the parts that keep the rifle cycling.",
categoryIds: [
"upper-receiver",
"complete-upper",
"bcg",
"barrel",
"gas-block",
"gas-tube",
"muzzle-device",
"suppressor",
"handguard",
"charging-handle",
"sights",
"optic",
],
},
{
id: "accessories-group",
label: "Accessories & Gear",
description:
"Universal add-ons that work across platforms — mags, lights, slings, tools, and more.",
categoryIds: [
"magazine",
"weapon-light",
"foregrip",
"bipod",
"sling",
"rail-accessory",
"tools",
],
},
];
function isValidPlatform(
value: string | null
): value is (typeof PLATFORMS)[number] {
return !!value && (PLATFORMS as readonly string[]).includes(value);
}
function safeDecodeBuildParam(buildParam: string | null): BuildState | null {
if (!buildParam) return null;
try {
// build param is base64(json)
const json = window.atob(buildParam);
const parsed = JSON.parse(json) as BuildState;
return parsed && typeof parsed === "object" ? parsed : null;
} catch {
return null;
}
}
function formatPrice(n: number) {
return `$${n.toFixed(2)}`;
}
export default function BuildSummaryPage() {
const searchParams = useSearchParams();
const router = useRouter();
const qpPlatform = searchParams.get("platform");
const platform = isValidPlatform(qpPlatform) ? qpPlatform : "AR-15";
const [build, setBuild] = useState<BuildState>({});
const [parts, setParts] = useState<Part[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// Load build from ?build=... OR localStorage
useEffect(() => {
if (typeof window === "undefined") return;
const buildParam = searchParams.get("build");
const decoded = safeDecodeBuildParam(buildParam);
if (decoded) {
setBuild(decoded);
return;
}
try {
const stored = localStorage.getItem(STORAGE_KEY);
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
} catch {
setBuild({});
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Fetch product data so we can resolve ids -> Part info
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
try {
setLoading(true);
setError(null);
// scoped (platform)
const scopedUrl = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
platform
)}`;
// universal (optional)
const universalUrl = `${API_BASE_URL}/api/products`;
const [scopedRes, universalRes] = await Promise.all([
fetch(scopedUrl, { signal: controller.signal }),
fetch(universalUrl, { signal: controller.signal }).catch(
() => null as any
),
]);
if (!scopedRes || !scopedRes.ok) {
throw new Error(`Failed to load products (${scopedRes?.status ?? ""})`);
}
const scopedData: GunbuilderProductFromApi[] = await scopedRes.json();
let universalData: GunbuilderProductFromApi[] = [];
if (universalRes && universalRes.ok) {
universalData = await universalRes.json();
}
// NOTE: We only need enough to render the summary.
// We will map “everything we can” and ignore unknown categories.
const toCategoryId = (rawRole: string): CategoryId | null => {
const role = (rawRole ?? "").trim().toLowerCase().replace(/_/g, "-");
// quick defensive mapping (mirrors your builder page strategy)
if (role.includes("complete-upper")) return "complete-upper";
if (role.includes("complete-lower")) return "complete-lower";
if (role === "upper-receiver" || role === "upper") return "upper-receiver";
if (role === "lower-receiver" || role === "lower") return "lower-receiver";
if (role.includes("charging")) return "charging-handle";
if (role.includes("handguard")) return "handguard";
if (role.includes("bcg") || role.includes("bolt-carrier")) return "bcg";
if (role.includes("barrel")) return "barrel";
if (role.includes("gas-block") || role.includes("gasblock")) return "gas-block";
if (role.includes("gas-tube") || role.includes("gastube")) return "gas-tube";
if (role.includes("muzzle") || role.includes("flash") || role.includes("brake") || role.includes("comp"))
return "muzzle-device";
if (role.includes("suppress")) return "suppressor";
if (role.includes("lower-parts") || role.includes("lpk")) return "lower-parts";
if (role.includes("trigger")) return "trigger";
if (role.includes("grip")) return "grip";
if (role.includes("safety")) return "safety";
if (role.includes("buffer")) return "buffer";
if (role.includes("stock")) return "stock";
if (role.includes("optic") || role.includes("scope")) return "optic";
if (role.includes("sight")) return "sights";
if (role.includes("mag")) return "magazine";
if (role.includes("weapon-light") || (role.includes("light") && !role.includes("flight")))
return "weapon-light";
if (role.includes("foregrip") || role.includes("grip-vertical")) return "foregrip";
if (role.includes("bipod")) return "bipod";
if (role.includes("sling")) return "sling";
if (role.includes("rail-accessory") || role.includes("rail-attachment")) return "rail-accessory";
if (role.includes("tool")) return "tools";
return null;
};
const normalize = (data: GunbuilderProductFromApi[], isUniversal: boolean): Part[] =>
data
.map((p): Part | null => {
const categoryId = toCategoryId(p.partRole);
if (!categoryId) return null;
if (isUniversal && !UNIVERSAL_CATEGORIES.has(categoryId)) return null;
const buyUrl = p.buyUrl ?? undefined;
return {
id: String(p.id),
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: (p.imageUrl as any) ?? (p as any).mainImageUrl ?? undefined,
affiliateUrl: buyUrl,
url: buyUrl,
notes: undefined,
};
})
.filter((p): p is Part => p !== null);
const scopedParts = normalize(scopedData, false);
const universalParts = normalize(universalData, true);
const seen = new Set<string>();
const merged: Part[] = [];
for (const p of [...scopedParts, ...universalParts]) {
const key = `${p.categoryId}:${p.id}`;
if (seen.has(key)) continue;
seen.add(key);
merged.push(p);
}
setParts(merged);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchProducts();
return () => controller.abort();
}, [platform]);
const summaryCategoryOrder: CategoryId[] = useMemo(
() =>
CATEGORY_GROUPS.flatMap((g) => g.categoryIds).filter((id) =>
CATEGORIES.some((c) => c.id === id)
),
[]
);
const selectedParts = useMemo(() => {
const out: { categoryId: CategoryId; part?: Part }[] = [];
for (const cid of summaryCategoryOrder) {
const partId = build[cid];
const part = partId ? parts.find((p) => p.id === partId && p.categoryId === cid) : undefined;
out.push({ categoryId: cid, part });
}
return out;
}, [build, parts, summaryCategoryOrder]);
const total = useMemo(
() => selectedParts.reduce((sum, row) => sum + (row.part?.price ?? 0), 0),
[selectedParts]
);
const filledCount = useMemo(
() => selectedParts.filter((r) => !!r.part).length,
[selectedParts]
);
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Build <span className="text-amber-300">Summary</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Snapshot of your current selections. (V1: data resolves from your local build state + live products.)
</p>
<div className="mt-3 text-xs text-zinc-500">
Platform: <span className="text-zinc-200 font-semibold">{platform}</span>
</div>
</div>
<div className="flex flex-col items-end gap-2">
<div className="text-right">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Total
</div>
<div className="text-2xl font-semibold text-amber-300">
{formatPrice(total)}
</div>
<div className="text-xs text-zinc-500 mt-1">
{filledCount} / {summaryCategoryOrder.length} selected
</div>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => {
const qp = new URLSearchParams();
qp.set("platform", platform);
router.push(`/builder?${qp.toString()}`);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Back to Builder
</button>
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
>
Edit Build
</Link>
</div>
</div>
</div>
</header>
{/* Body */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
{loading ? (
<p className="py-10 text-center text-sm text-zinc-500">Loading</p>
) : error ? (
<p className="py-10 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : (
<div className="space-y-4">
{CATEGORY_GROUPS.map((group) => {
const rows = group.categoryIds
.filter((cid) => summaryCategoryOrder.includes(cid))
.map((cid) => {
const cat = CATEGORIES.find((c) => c.id === cid);
const partId = build[cid];
const part = partId
? parts.find((p) => p.id === partId && p.categoryId === cid)
: undefined;
return { cid, cat, part };
});
return (
<div key={group.id} className="rounded-md border border-zinc-800 bg-zinc-950/80">
<div className="border-b border-zinc-800 px-4 py-3">
<div className="text-sm font-semibold text-zinc-100">{group.label}</div>
{group.description && (
<div className="mt-1 text-xs text-zinc-500">{group.description}</div>
)}
</div>
<div className="divide-y divide-zinc-900">
{rows.map(({ cid, cat, part }) => (
<div key={cid} className="flex items-start justify-between gap-4 px-4 py-3">
<div className="min-w-0">
<div className="text-xs font-semibold text-zinc-300">
{cat?.name ?? cid}
</div>
{part ? (
<div className="mt-1 text-sm text-zinc-100 truncate">
<span className="text-zinc-400">{part.brand}</span>{" "}
{part.name}
</div>
) : (
<div className="mt-1 text-sm text-zinc-600">
Not selected
</div>
)}
</div>
<div className="shrink-0 text-right">
<div className="text-xs text-zinc-500">Price</div>
<div className={`text-sm font-semibold ${part ? "text-amber-300" : "text-zinc-600"}`}>
{part ? formatPrice(part.price) : "—"}
</div>
<div className="mt-2 flex justify-end gap-2">
<Link
href={{
pathname: `/parts/p/${platform}/${cid}`,
query: platform ? { platform } : {},
}}
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 px-2.5 py-1.5 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
title={part ? "Change part" : "Choose part"}
aria-label={part ? "Change part" : "Choose part"}
>
<Pencil className="h-3.5 w-3.5 text-zinc-300" />
</Link>
{part?.url ? (
<a
href={part.url}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-2.5 py-1.5 hover:bg-amber-300 transition-colors"
title="Buy part"
aria-label="Buy part"
>
<Link2 className="h-3.5 w-3.5 text-black" />
</a>
) : (
<span className="inline-flex items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 px-2.5 py-1.5 text-[11px] text-zinc-600">
</span>
)}
</div>
</div>
</div>
))}
</div>
</div>
);
})}
</div>
)}
</section>
<div className="mt-4 text-center text-xs text-zinc-600">
Tip: If you landed here from a shared link, it uses <code>?build=</code> first; otherwise it reads your local build.
</div>
</div>
</main>
);
}
File diff suppressed because it is too large Load Diff
-460
View File
@@ -1,460 +0,0 @@
"use client";
import { useMemo, useStat, useEffect } from "react";
import Link from "next/link";
import { useSearchParams, useRouter } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
import { buildDetailHref } from "@/app/parts/_components/buildDetailHref";
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type GunbuilderProductFromApi = {
id: string; // backend UUID string
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
mainImageUrl: string | null;
buyUrl: string | null;
};
// Map backend partRole -> CategoryId (align with builder categories)
const PART_ROLE_TO_CATEGORY: Record<string, CategoryId> = {
"upper-receiver": "upper",
barrel: "barrel",
handguard: "handguard",
"charging-handle": "chargingHandle",
"buffer-kit": "buffer",
"lower-parts-kit": "lowerParts",
sight: "sights",
};
// Inverse: CategoryId -> backend partRole (for building detail URLs)
const CATEGORY_TO_PART_ROLE: Partial<Record<CategoryId, string>> =
Object.fromEntries(
Object.entries(PART_ROLE_TO_CATEGORY).map(([role, cat]) => [cat, role]),
) as Partial<Record<CategoryId, string>>;
// Encode build state to URL-friendly string
function encodeBuildState(build: BuildState): string {
const entries = Object.entries(build)
.filter(([_, partId]) => partId)
.map(([categoryId, partId]) => `${categoryId}:${partId}`)
.join(",");
return btoa(entries);
}
// Decode URL string to build state
function decodeBuildState(encoded: string): BuildState {
try {
const decoded = atob(encoded);
const build: BuildState = {};
decoded.split(",").forEach((entry) => {
const [categoryId, partId] = entry.split(":");
if (categoryId && partId) {
build[categoryId as CategoryId] = partId;
}
});
return build;
} catch {
return {};
}
}
export default function BuildDetailsPage() {
const searchParams = useSearchParams();
const router = useRouter();
// ✅ Option A: platform is a query param and is passed through to backend
const platform = searchParams.get("platform") ?? "AR-15";
const [build, setBuild] = useState<BuildState>({});
const [shareUrl, setShareUrl] = useState<string>("");
const [copied, setCopied] = useState(false);
const [parts, setParts] = useState<Part[]>([]);
const [loadingParts, setLoadingParts] = useState(true);
const [partsError, setPartsError] = useState<string | null>(null);
// Load build from URL params or localStorage
useEffect(() => {
const buildParam = searchParams.get("build");
if (buildParam) {
const decoded = decodeBuildState(buildParam);
setBuild(decoded);
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
} catch {
// ignore storage failures (private mode, etc.)
}
} else if (typeof window !== "undefined") {
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
setBuild(parsed);
} catch {
// ignore invalid stored data
}
}
}
}, [searchParams]);
// Fetch live parts from backend (uses selected platform)
useEffect(() => {
const controller = new AbortController();
async function fetchProducts() {
try {
setLoadingParts(true);
setPartsError(null);
const res = await fetch(
`${API_BASE_URL}/api/products?platform=${encodeURIComponent(platform)}`,
{ signal: controller.signal },
);
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: Part[] = data
.map((p): Part | null => {
const categoryId = PART_ROLE_TO_CATEGORY[p.partRole];
if (!categoryId) return null;
return {
id: p.id,
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.mainImageUrl ?? undefined,
url: p.buyUrl ?? undefined,
notes: undefined,
};
})
.filter(Boolean) as Part[];
setParts(normalized);
} catch (err: any) {
if (err?.name === "AbortError") return;
setPartsError(err?.message ?? "Failed to load products");
} finally {
setLoadingParts(false);
}
}
fetchProducts();
return () => controller.abort();
}, [platform]);
// Generate shareable URL (includes platform)
useEffect(() => {
if (typeof window !== "undefined" && Object.keys(build).length > 0) {
const encoded = encodeBuildState(build);
const url = `${window.location.origin}/builder/build?platform=${encodeURIComponent(
platform,
)}&build=${encoded}`;
setShareUrl(url);
} else if (typeof window !== "undefined") {
setShareUrl("");
}
}, [build, platform]);
const selectedParts: Part[] = useMemo(() => {
if (parts.length === 0) return [];
return Object.entries(build)
.map(([categoryId, partId]) =>
parts.find(
(p) =>
p.id === partId && p.categoryId === (categoryId as CategoryId),
),
)
.filter(Boolean) as Part[];
}, [build, parts]);
const totalPrice = useMemo(
() => selectedParts.reduce((sum, p) => sum + p.price, 0),
[selectedParts],
);
const handleCopyLink = async () => {
if (!shareUrl) return;
try {
await navigator.clipboard.writeText(shareUrl);
setCopied(true);
setTimeout(() => setCopied(false), 2000);
} catch (err) {
console.error("Failed to copy:", err);
}
};
const handleShare = async () => {
if (!shareUrl) return;
if (navigator.share) {
try {
await navigator.share({
title: `My ${platform} Build`,
text: `Check out my ${platform} build: $${totalPrice.toFixed(2)}`,
url: shareUrl,
});
return;
} catch {
// fall back to copy
}
}
await handleCopyLink();
};
if (!loadingParts && selectedParts.length === 0) {
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-4xl px-4 py-6 lg:py-10">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
No Build Selected
</h1>
<p className="text-sm text-zinc-400 mb-6">
You need to select at least one part to view your build.
</p>
<Link
href={`/builder?platform=${encodeURIComponent(platform)}`}
className="inline-block rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
Go to Builder
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<Link
href={`/builder?platform=${encodeURIComponent(platform)}`}
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
</Link>
<div className="flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Build <span className="text-amber-300">Summary</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Review your build, purchase parts from our affiliate partners, or
share your build with others.
</p>
<p className="mt-2 text-xs text-zinc-500">
Platform: <span className="text-zinc-200">{platform}</span>
</p>
</div>
<div className="mt-3 md:mt-0">
<div className="text-xs text-zinc-400">Total Build Price</div>
<div className="text-3xl font-semibold text-amber-300">
${totalPrice.toFixed(2)}
</div>
<div className="text-xs text-zinc-500">
{selectedParts.length} / {CATEGORIES.length} parts selected
</div>
</div>
</div>
</header>
{/* Share Section */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/60 p-4">
<div className="flex flex-col gap-3 sm:flex-row sm:items-center sm:justify-between">
<div className="flex-1">
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400 mb-2">
Share Your Build
</h2>
<p className="text-sm text-zinc-400">
Share this link to let others view your build or bookmark it to
come back later.
</p>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={handleShare}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Share
</button>
<button
type="button"
onClick={handleCopyLink}
className={`rounded-md border px-4 py-2 text-sm font-medium transition-colors ${
copied
? "border-green-500 bg-green-500/10 text-green-400"
: "border-zinc-700 bg-zinc-900/50 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{copied ? "Copied!" : "Copy Link"}
</button>
</div>
</div>
{shareUrl && (
<div className="mt-3 p-3 rounded-md bg-zinc-900/50 border border-zinc-800">
<p className="text-xs text-zinc-500 mb-1">Shareable URL:</p>
<p className="text-xs text-zinc-400 break-all font-mono">
{shareUrl}
</p>
</div>
)}
</section>
{/* Build Parts */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400 mb-4">
Selected Parts
</h2>
{loadingParts && (
<p className="text-sm text-zinc-500 mb-4">Loading parts</p>
)}
{partsError && (
<p className="text-sm text-red-400 mb-4">
{partsError} check the Ballistic API.
</p>
)}
<div className="space-y-4">
{CATEGORIES.map((category) => {
const selectedPartId = build[category.id];
const part = parts.find(
(p) =>
p.id === selectedPartId &&
p.categoryId === (category.id as CategoryId),
);
const partRole =
(CATEGORY_TO_PART_ROLE[category.id] as string | undefined) ??
"unknown";
return (
<div
key={category.id}
className="border border-zinc-800 rounded-md p-4 bg-zinc-900/30"
>
<div className="flex items-start justify-between gap-4">
<div className="flex-1">
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-2">
{category.name}
</div>
{part ? (
<>
<div className="text-lg font-semibold text-zinc-50 mb-1">
<span className="text-zinc-400">{part.brand}</span>{" "}
{part.name}
</div>
<div className="mt-3 flex gap-2 flex-wrap">
{part.url && (
<a
href={part.url}
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-2 rounded-md border border-amber-400/60 bg-amber-400/10 px-4 py-2 text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
Purchase from Retailer
<svg
className="w-4 h-4"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
strokeLinecap="round"
strokeLinejoin="round"
strokeWidth={2}
d="M10 6H6a2 2 0 00-2 2v10a2 2 0 002 2h10a2 2 0 002-2v-4M14 4h6m0 0v6m0-6L10 14"
/>
</svg>
</a>
)}
<Link
href={buildDetailHref({
id: part.id,
name: part.name,
platform,
partRole,
} as any)}
className="inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900/50 px-4 py-2 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
View Details
</Link>
</div>
</>
) : (
<div className="text-sm text-zinc-600">Not selected</div>
)}
</div>
{part && (
<div className="text-right">
<div className="text-2xl font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
</div>
)}
</div>
</div>
);
})}
</div>
</section>
{/* Actions */}
<div className="mt-6 flex flex-col gap-3 sm:flex-row sm:justify-between">
<Link
href={`/builder?platform=${encodeURIComponent(platform)}`}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors text-center"
>
Edit Build
</Link>
<div className="flex gap-3">
<button
type="button"
onClick={() => {
try {
localStorage.removeItem(STORAGE_KEY);
} catch {
// ignore
}
router.push(`/builder?platform=${encodeURIComponent(platform)}`);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/50 px-6 py-3 text-sm font-medium text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Clear Build
</button>
</div>
</div>
</div>
</main>
);
}
-18
View File
@@ -1,18 +0,0 @@
// app/(builder)/layout.tsx
import type { ReactNode } from "react";
import { AuthProvider } from "@/context/AuthContext";
import { BuilderNav } from "@/components/BuilderNav";
import { TopNav } from "@/components/TopNav";
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-neutral-950 text-zinc-50">
<AuthProvider>
<TopNav />
<BuilderNav />
<main className="min-h-screen">{children}</main>
</AuthProvider>
</div>
);
}
@@ -1,431 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { CategoryId } from "@/types/gunbuilder";
import {
PART_ROLE_TO_CATEGORY,
normalizePartRole,
} from "@/lib/catalogMappings";
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
inStock?: boolean | null;
};
type BuildState = Partial<Record<CategoryId, string>>;
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
const STORAGE_KEY = "gunbuilder-build-state";
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
function extractNumericId(productSlug: string): number | null {
if (!productSlug) return null;
const first = productSlug.split("-")[0];
const n = Number(first);
return Number.isFinite(n) && n > 0 ? n : null;
}
function formatPrice(price: number | null | undefined): string {
if (price == null) return "—";
return `$${price.toFixed(2)}`;
}
export default function ProductDetailsPage() {
const params = useParams();
const router = useRouter();
const searchParams = useSearchParams();
// NEW ROUTE PARAMS:
const platformParam = String(params.platform ?? "");
const partRoleParam = String(params.partRole ?? "");
const productSlug = String(params.productSlug ?? "");
// Platform comes from path segment (fallback to ?platform just in case)
const platform =
platformParam ||
(searchParams.get("platform") as string) ||
"AR-15";
const normalizedRole = useMemo(
() => normalizePartRole(partRoleParam),
[partRoleParam]
);
const categoryId = useMemo(() => {
return PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null;
}, [partRoleParam, normalizedRole]);
const numericId = useMemo(
() => extractNumericId(productSlug),
[productSlug]
);
// Read-only build state (builder page owns writes)
const [build] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
const isSelected =
!!categoryId &&
selectedPartIdForCategory === (numericId ? String(numericId) : "");
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
// If platform segment is missing/empty, normalize into the new route (not /builder)
useEffect(() => {
if (platformParam) return;
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", platform || "AR-15");
router.replace(
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
partRoleParam
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
// Fetch product
useEffect(() => {
if (!numericId) {
setError("Invalid product id.");
setLoading(false);
return;
}
const controller = new AbortController();
async function fetchProduct() {
try {
setLoading(true);
setError(null);
const url = `${API_BASE_URL}/api/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load product (${res.status})`);
}
const data: GunbuilderProductFromApi = await res.json();
setProduct(data);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load product");
} finally {
setLoading(false);
}
}
fetchProduct();
return () => controller.abort();
}, [numericId]);
const handleTogglePart = () => {
if (!numericId) return;
// If mapping is missing, dont send a bogus builder action
if (!categoryId) {
alert(
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
);
return;
}
const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
if (isSelected) {
qp.set("remove", String(categoryId));
router.push(`/builder?${qp.toString()}`);
return;
}
qp.set("select", `${categoryId}:${numericId}`);
router.push(`/builder?${qp.toString()}`);
};
if (!categoryId) {
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
Unknown Part Role
</h1>
<p className="text-sm text-zinc-400 mb-6">
No mapping found for <span className="text-zinc-200">{partRoleParam}</span>.
Add it to <span className="text-zinc-200">catalogMappings</span>.
</p>
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRoleParam
)}`}
className="text-amber-300 hover:text-amber-200 underline"
>
Back to Parts List
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Breadcrumbs */}
<header className="mb-6">
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="hover:text-zinc-300"
>
The Armory
</Link>
<span className="text-zinc-700">/</span>
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRoleParam
)}`}
className="hover:text-zinc-300"
>
Parts
</Link>
<span className="text-zinc-700">/</span>
<span className="text-zinc-300">Details</span>
</div>
<div className="mt-4 flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Product <span className="text-amber-300">Details</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Live product data pulled from your Ballistic backend. Add it to your build
or jump back to compare options.
</p>
</div>
{/* Platform switch (updates NEW route) */}
<div className="flex items-center gap-2">
<label htmlFor="platform-select" className="text-xs text-zinc-500">
Platform
</label>
<select
id="platform-select"
value={platform}
onChange={(e) => {
const next = e.target.value;
router.replace(
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
partRoleParam
)}/${encodeURIComponent(productSlug)}`
);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
</div>
</header>
{/* Body */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
{loading ? (
<p className="py-10 text-center text-sm text-zinc-500">Loading product</p>
) : error ? (
<p className="py-10 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : !product ? (
<p className="py-10 text-center text-sm text-zinc-500">Product not found.</p>
) : (
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
{/* Left: image */}
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
{product.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={product.imageUrl}
alt={product.name}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
No image
</div>
)}
</div>
<div className="mt-3 flex items-center justify-between">
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Stock
</span>
<span
className={`text-xs font-semibold ${
product.inStock === false ? "text-red-300" : "text-emerald-300"
}`}
>
{product.inStock === false ? "Out of stock" : "In stock"}
</span>
</div>
<div className="mt-3 flex gap-2">
<Link
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
partRoleParam
)}`}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
>
Back to List
</Link>
<button
type="button"
onClick={handleTogglePart}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
{/* Right: details */}
<div className="min-w-0">
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
<div className="min-w-0">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
{product.brand}
</p>
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
{product.name}
</h2>
<div className="mt-2 flex flex-wrap gap-2 text-xs">
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Platform:{" "}
<span className="font-semibold text-zinc-100">
{product.platform || platform}
</span>
</span>
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Role:{" "}
<span className="font-semibold text-zinc-100">
{product.partRole || partRoleParam}
</span>
</span>
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
ID:{" "}
<span className="font-semibold text-zinc-100">
{product.id}
</span>
</span>
</div>
</div>
<div className="shrink-0 text-right">
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
Current price
</div>
<div className="mt-1 text-2xl font-semibold text-amber-300">
{formatPrice(product.price)}
</div>
</div>
</div>
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
<button
type="button"
onClick={handleTogglePart}
className={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
{product.buyUrl ? (
<a
href={product.buyUrl}
target="_blank"
rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-center text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
>
Buy from Merchant
</a>
) : (
<span className="rounded-md border border-zinc-800 bg-zinc-950 px-4 py-2 text-center text-sm text-zinc-500">
No buy link available
</span>
)}
</div>
<div className="mt-4 border-t border-zinc-800 pt-4">
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Notes
</h3>
<p className="mt-2 text-sm text-zinc-400">
This details page is intentionally thin for now once we wire in
offers, price history, and richer product fields, this section becomes
the spec + comparison hub.
</p>
</div>
</div>
<div className="mt-4 flex flex-wrap gap-2 text-xs text-zinc-500">
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Tip: use Back to List to compare multiple parts quickly.
</span>
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
Platform comes from the URL segment:{" "}
<span className="text-zinc-300">/parts/p/{platform}/...</span>
</span>
</div>
</div>
</div>
)}
</section>
</div>
</main>
);
}
@@ -1,116 +0,0 @@
// app/builder/[categoryId]/[partId]/data.ts
// Simulated data functions - Replace these with API calls when ready
// Example: export async function getPricingHistory(partId: string) { ... }
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
export interface PriceHistoryPoint {
date: string; // ISO date string
price: number;
}
export interface RetailerOffer {
id: string;
retailerName: string;
price: number;
originalPrice?: number;
inStock: boolean;
affiliateUrl: string;
lastUpdated: string; // ISO date string
}
/**
* Get pricing history for a part
* TODO: Replace with API call: GET /api/parts/{partId}/pricing-history
*
* For now, we keep this simulated.
*/
export function getPricingHistory(
partId: string,
basePrice: number,
): PriceHistoryPoint[] {
// Generate 30 days of price history with some variation
const days = 30;
const history: PriceHistoryPoint[] = [];
const today = new Date();
for (let i = days - 1; i >= 0; i--) {
const date = new Date(today);
date.setDate(date.getDate() - i);
// Simulate price fluctuations (±15% variation)
const variation = (Math.random() - 0.5) * 0.3; // -15% to +15%
const price = basePrice * (1 + variation);
history.push({
date: date.toISOString().split("T")[0],
price: Math.round(price * 100) / 100,
});
}
return history;
}
/**
* Get retailer offers for a part
* Real implementation using Ballistic backend:
* GET /api/products/{productId}/offers
*/
export async function getRetailerOffers(
productId: string,
): Promise<RetailerOffer[]> {
const res = await fetch(
`${API_BASE_URL}/api/products/${productId}/offers`,
{
// This will be called from the client detail page,
// so we *don't* use cache here.
cache: "no-store",
},
);
if (!res.ok) {
throw new Error(
`Failed to load retailer offers (${res.status}) for product ${productId}`,
);
}
// Expected backend payload (example):
// [
// {
// "id": "uuid-or-int",
// "merchantName": "Aero Precision",
// "price": 189.99,
// "originalPrice": 210.0,
// "inStock": true,
// "buyUrl": "https://classic.avantlink.com/click.php?...",
// "lastUpdated": "2025-11-30T15:22:00Z"
// },
// ...
// ]
const data: Array<{
id: string | number;
merchantName: string;
price: number;
originalPrice?: number | null;
inStock: boolean;
buyUrl: string;
lastUpdated: string;
}> = await res.json();
const offers: RetailerOffer[] = data
.map((o) => ({
id: String(o.id),
retailerName: o.merchantName,
price: o.price,
originalPrice: o.originalPrice ?? undefined,
inStock: o.inStock,
affiliateUrl: o.buyUrl,
lastUpdated: o.lastUpdated,
}))
// Sort by lowest price first
.sort((a, b) => a.price - b.price);
return offers;
}
@@ -1,846 +0,0 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { useParams, useRouter, useSearchParams } from "next/navigation";
import { CATEGORIES } from "@/data/gunbuilderParts";
import type { CategoryId, Part } from "@/types/gunbuilder";
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
type ViewMode = "card" | "list";
type GunbuilderProductFromApi = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
imageUrl: string | null;
buyUrl: string | null;
inStock?: boolean | null;
};
type UiPart = Part & {
// local-only stock flag for filtering
inStock?: boolean;
};
const PLATFORMS = ["AR-15", "AR-10", "AR-9"];
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
const STORAGE_KEY = "gunbuilder-build-state";
// sort options
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
// support for url id-slug
const slugify = (str: string) =>
str
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "");
const normalizeRole = (role: string) =>
role.trim().toLowerCase().replace(/_/g, "-");
function getNormalizedPartRolesForCategory(categoryId: string): string[] {
const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as
| string[]
| undefined;
if (!roles || roles.length === 0) return [];
return Array.from(new Set(roles.map(normalizeRole)));
}
export default function CategoryPage() {
const params = useParams();
const categoryId = params.categoryId as CategoryId;
const partRoles = useMemo(
() => getNormalizedPartRolesForCategory(String(categoryId)),
[categoryId]
);
const router = useRouter();
const searchParams = useSearchParams();
const [viewMode, setViewMode] = useState<ViewMode>("list");
const [parts, setParts] = useState<UiPart[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
// filter + sort state
const [brandFilter, setBrandFilter] = useState<string[]>([]);
const [sortBy, setSortBy] = useState<SortOption>("relevance");
const [searchQuery, setSearchQuery] = useState<string>("");
const [priceRange, setPriceRange] = useState<{
min: number | null;
max: number | null;
}>({
min: null,
max: null,
});
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
const [currentPage, setCurrentPage] = useState<number>(1);
const PAGE_SIZE = 24;
// build state for this page (read-only), hydrated from localStorage
// NOTE: The main /builder page is the single source of truth for writing to STORAGE_KEY.
const [build] = useState<BuildState>(() => {
if (typeof window === "undefined") return {};
try {
const stored = window.localStorage.getItem(STORAGE_KEY);
return stored ? (JSON.parse(stored) as BuildState) : {};
} catch {
return {};
}
});
const initialPlatform = (searchParams.get("platform") as string) || "AR-15";
const [platform, setPlatform] = useState<string>(initialPlatform);
// If a user lands here without ?platform=..., normalize the URL so downstream navigation keeps platform.
useEffect(() => {
const qpPlatform = searchParams.get("platform");
if (qpPlatform) return;
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", platform || "AR-15");
router.replace(`/builder/${categoryId}?${qp.toString()}`);
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [categoryId, router, searchParams]);
const category = useMemo(
() => CATEGORIES.find((c) => c.id === categoryId),
[categoryId]
);
useEffect(() => {
if (!categoryId) return;
const controller = new AbortController();
async function fetchCategoryParts() {
try {
setLoading(true);
setError(null);
const search = new URLSearchParams();
search.set("platform", platform);
// ✅ Scope results to this category's backend roles
for (const r of partRoles) {
search.append("partRoles", r);
}
const url = `${API_BASE_URL}/api/products?${search.toString()}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data: GunbuilderProductFromApi[] = await res.json();
const normalized: UiPart[] = data.map((p) => ({
id: String(p.id), // normalize to string for BuildState
categoryId,
name: p.name,
brand: p.brand,
price: p.price ?? 0,
imageUrl: p.imageUrl ?? undefined, // match backend field name
url: p.buyUrl ?? undefined,
notes: undefined,
inStock: p.inStock ?? true,
}));
setParts(normalized);
} catch (err: any) {
if (err.name === "AbortError") return;
setError(err.message ?? "Failed to load products");
} finally {
setLoading(false);
}
}
fetchCategoryParts();
return () => controller.abort();
}, [categoryId, platform, partRoles]);
// handler to toggle Add / Remove for this category
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
const isSelected = build[categoryId] === partId;
const qp = new URLSearchParams();
qp.set("platform", platform || "AR-15");
if (isSelected) {
// Tell the main builder to remove this category selection
qp.set("remove", String(categoryId));
router.push(`/builder?${qp.toString()}`);
return;
}
// Tell the main builder to add/replace this category selection
qp.set("select", `${categoryId}:${partId}`);
router.push(`/builder?${qp.toString()}`);
};
// available brands for filter
const availableBrands = useMemo(
() =>
Array.from(new Set(parts.map((p) => p.brand)))
.filter(Boolean)
.sort((a, b) => a.localeCompare(b)),
[parts]
);
const priceBounds = useMemo(() => {
if (parts.length === 0) {
return { min: null as number | null, max: null as number | null };
}
let min = Number.POSITIVE_INFINITY;
let max = Number.NEGATIVE_INFINITY;
for (const p of parts) {
const price = p.price ?? 0;
if (price < min) min = price;
if (price > max) max = price;
}
if (!Number.isFinite(min) || !Number.isFinite(max)) {
return { min: null as number | null, max: null as number | null };
}
return { min, max };
}, [parts]);
// initialize / clamp priceRange whenever bounds change
useEffect(() => {
if (priceBounds.min == null || priceBounds.max == null) return;
setPriceRange((prev) => {
const min = prev.min ?? priceBounds.min!;
const max = prev.max ?? priceBounds.max!;
return {
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
};
});
}, [priceBounds.min, priceBounds.max]);
const filteredParts = useMemo(() => {
let result = [...parts];
// Price range filter
const effectiveMin =
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
const effectiveMax =
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
if (effectiveMin != null && effectiveMax != null) {
result = result.filter((p) => {
const price = p.price ?? 0;
return price >= effectiveMin && price <= effectiveMax;
});
}
// Brand filter
if (brandFilter.length > 0) {
result = result.filter((p) => brandFilter.includes(p.brand));
}
// In-stock filter
if (inStockOnly) {
result = result.filter((p) => p.inStock ?? true);
}
const normalizedQuery = searchQuery.trim().toLowerCase();
if (normalizedQuery) {
result = result.filter((p) => {
const name = p.name.toLowerCase();
const brand = p.brand.toLowerCase();
return name.includes(normalizedQuery) || brand.includes(normalizedQuery);
});
}
switch (sortBy) {
case "price-asc":
result.sort((a, b) => a.price - b.price);
break;
case "price-desc":
result.sort((a, b) => b.price - a.price);
break;
case "brand-asc":
result.sort((a, b) => a.brand.localeCompare(b.brand));
break;
case "relevance":
default:
// leave in backend order
break;
}
// Pin the currently selected part (for this category) to the top, if any
const selectedId = build[categoryId];
if (selectedId) {
const selected = result.filter((p) => p.id === selectedId);
const others = result.filter((p) => p.id !== selectedId);
result = [...selected, ...others];
}
return result;
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery, priceBounds, priceRange, inStockOnly]);
const totalPages = useMemo(
() =>
filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE),
[filteredParts, PAGE_SIZE]
);
const paginatedParts = useMemo(() => {
if (filteredParts.length === 0) return [];
const startIndex = (currentPage - 1) * PAGE_SIZE;
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
}, [filteredParts, currentPage, PAGE_SIZE]);
useEffect(() => {
// whenever the category or filters change, jump back to page 1
setCurrentPage(1);
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly, platform]);
useEffect(() => {
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
setPlatform(nextPlatform);
}, [searchParams]);
// Keep selection highlight in sync if the build is changed elsewhere and user comes back.
useEffect(() => {
if (typeof window === "undefined") return;
const onStorage = (e: StorageEvent) => {
if (e.key !== STORAGE_KEY) return;
// Force a re-render by reading current storage (since build state is read-only)
// This keeps the selected row pinned/highlighted when navigating back.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
window.localStorage.getItem(STORAGE_KEY);
};
window.addEventListener("storage", onStorage);
return () => window.removeEventListener("storage", onStorage);
}, []);
const visibleRange = useMemo(() => {
if (filteredParts.length === 0) {
return { start: 0, end: 0 };
}
const start = (currentPage - 1) * PAGE_SIZE + 1;
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
return { start, end };
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
if (!category) {
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
<div className="text-center">
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
Category Not Found
</h1>
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="text-amber-300 hover:text-amber-200 underline"
>
Return to Builder
</Link>
</div>
</div>
</main>
);
}
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6">
<Link
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
>
Back to The Armory
</Link>
<div className="flex items-start justify-between gap-4">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Shadow Standard
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
{category.name} <span className="text-amber-300">Parts</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
Browse all available {category.name.toLowerCase()} options for your
build, pulled live from your Ballistic backend.
</p>
</div>
{!loading && !error && parts.length > 0 && (
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
<button
type="button"
onClick={() => setViewMode("card")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "card"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="Card view"
>
Card
</button>
<button
type="button"
onClick={() => setViewMode("list")}
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
viewMode === "list"
? "bg-zinc-800 text-zinc-50"
: "text-zinc-400 hover:text-zinc-300"
}`}
aria-label="List view"
>
List
</button>
</div>
)}
</div>
</header>
{/* Parts Grid/List */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<div className="flex flex-col gap-4 md:flex-row">
{/* Left sidebar: filters */}
{!loading && !error && parts.length > 0 && (
<aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
<div>
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
Filters
</h2>
<p className="mt-1 text-[11px] text-zinc-500">
Narrow down the {category.name.toLowerCase()} list.
</p>
</div>
{/* Price range */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Price
</span>
{priceRange.min !== null || priceRange.max !== null ? (
<button
type="button"
onClick={() =>
setPriceRange({ min: priceBounds.min, max: priceBounds.max })
}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Reset
</button>
) : null}
</div>
{priceBounds.min == null || priceBounds.max == null ? (
<p className="text-[11px] text-zinc-500">
No price data available.
</p>
) : (
<div className="space-y-3">
<div className="flex items-center justify-between text-[10px] text-zinc-400">
<span>
Min:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.min ?? priceBounds.min).toFixed(0)}
</span>
</span>
<span>
Max:{" "}
<span className="font-semibold text-zinc-200">
${(priceRange.max ?? priceBounds.max).toFixed(0)}
</span>
</span>
</div>
<div className="space-y-2">
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.min ?? priceBounds.min ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: Math.min(value, prev.max ?? priceBounds.max ?? value),
max: prev.max ?? priceBounds.max ?? value,
}));
}}
className="w-full"
/>
<input
type="range"
min={priceBounds.min ?? 0}
max={priceBounds.max ?? 0}
value={priceRange.max ?? priceBounds.max ?? 0}
onChange={(e) => {
const value = Number(e.target.value);
setPriceRange((prev) => ({
min: prev.min ?? priceBounds.min ?? value,
max: Math.max(value, prev.min ?? priceBounds.min ?? value),
}));
}}
className="w-full"
/>
</div>
</div>
)}
</div>
{/* Brand multi-select */}
<div>
<div className="mb-1.5 flex items-center justify-between">
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
Brand
</span>
{brandFilter.length > 0 && (
<button
type="button"
onClick={() => setBrandFilter([])}
className="text-[10px] text-zinc-400 hover:text-zinc-200"
>
Clear
</button>
)}
</div>
{availableBrands.length === 0 ? (
<p className="text-[11px] text-zinc-500">
No brands available yet.
</p>
) : (
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
{availableBrands.map((b) => {
const checked = brandFilter.includes(b);
return (
<label
key={b}
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={checked}
onChange={(e) => {
setBrandFilter((prev) =>
e.target.checked
? [...prev, b]
: prev.filter((brand) => brand !== b)
);
}}
/>
<span className="truncate">{b}</span>
</label>
);
})}
</div>
)}
{brandFilter.length === 0 && availableBrands.length > 0 && (
<p className="mt-1 text-[10px] text-zinc-500">
No brands selected showing all.
</p>
)}
</div>
{/* In-stock toggle */}
<div className="border-t border-zinc-800 pt-3">
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
In stock only
</span>
<input
type="checkbox"
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
checked={inStockOnly}
onChange={(e) => setInStockOnly(e.target.checked)}
/>
</label>
<p className="mt-1 text-[10px] text-zinc-500">
Hides items marked out of stock by the backend.
</p>
</div>
</aside>
)}
{/* Right side: toolbar + results */}
<div className="flex-1">
{/* Top toolbar: count + search + sort */}
{!loading && !error && parts.length > 0 && (
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="font-medium text-zinc-200">
{visibleRange.start}-{visibleRange.end}
</span>{" "}
of{" "}
<span className="font-medium text-zinc-200">
{filteredParts.length}
</span>{" "}
matching parts
</div>
<div className="flex flex-wrap gap-3">
<div className="flex items-center gap-2">
<label htmlFor="part-search" className="text-xs text-zinc-500">
Search
</label>
<div className="relative">
<input
id="part-search"
type="text"
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
placeholder={`Search ${category.name.toLowerCase()}...`}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
{searchQuery && (
<button
type="button"
onClick={() => setSearchQuery("")}
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
aria-label="Clear search"
>
×
</button>
)}
</div>
</div>
<div className="flex items-center gap-2">
<label htmlFor="platform-select" className="text-xs text-zinc-500">
Platform
</label>
<select
id="platform-select"
value={platform}
onChange={(e) => {
const next = e.target.value;
setPlatform(next);
const qp = new URLSearchParams(searchParams.toString());
qp.set("platform", next);
router.replace(`/builder/${categoryId}?${qp.toString()}`);
}}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{PLATFORMS.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</div>
<div className="flex items-center gap-2">
<label htmlFor="sort-by" className="text-xs text-zinc-500">
Sort
</label>
<select
id="sort-by"
value={sortBy}
onChange={(e) => setSortBy(e.target.value as SortOption)}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
<option value="relevance">Relevance</option>
<option value="price-asc">Price: Low High</option>
<option value="price-desc">Price: High Low</option>
<option value="brand-asc">Brand: A Z</option>
</select>
</div>
</div>
</div>
)}
{/* Results / states */}
{loading ? (
<p className="py-8 text-center text-sm text-zinc-500">
Loading {category.name.toLowerCase()}
</p>
) : error ? (
<p className="py-8 text-center text-sm text-red-400">
{error} check that the Ballistic API is running.
</p>
) : filteredParts.length === 0 ? (
<p className="py-8 text-center text-sm text-zinc-500">
No parts available for this category yet.
</p>
) : viewMode === "card" ? (
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="mb-3 flex items-center gap-2 justify-between">
<div className="flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.brand}{" "}
<span className="font-normal"> {part.name}</span>
</div>
{part.notes && (
<p className="mt-1 line-clamp-2 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
${part.price.toFixed(2)}
</div>
</div>
<div className="mt-2 flex gap-2">
<Link
href={{
pathname: `/builder/${categoryId}/${part.id}-${slugify(
part.name
)}`,
query: { platform },
}}
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() => handleTogglePart(categoryId, part.id)}
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
);
})}
</div>
) : (
<>
{/* Header row for list view */}
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
<span>Part</span>
<span>Brand</span>
<span className="text-right">Price</span>
<span className="pr-2 text-right">Actions</span>
</div>
<div className="space-y-2">
{paginatedParts.map((part) => {
const isSelected = build[categoryId] === part.id;
return (
<div
key={part.id}
className={`group relative rounded-md border p-3 transition-all duration-200 ${
isSelected
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
}`}
>
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
<div className="min-w-0 flex-1">
<div className="text-sm font-semibold text-zinc-50">
{part.name}
</div>
{part.notes && (
<p className="mt-1 line-clamp-1 text-xs text-zinc-400">
{part.notes}
</p>
)}
</div>
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
{part.brand}
</div>
<div className="text-sm font-semibold text-amber-300 md:text-right">
${part.price.toFixed(2)}
</div>
<div className="flex justify-end gap-2">
<Link
href={{
pathname: `/builder/${categoryId}/${part.id}-${slugify(
part.name
)}`,
query: { platform },
}}
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
>
View Details
</Link>
<button
type="button"
onClick={() => handleTogglePart(categoryId, part.id)}
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
isSelected
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{isSelected ? "Remove from Build" : "Add to Build"}
</button>
</div>
</div>
</div>
);
})}
</div>
</>
)}
{/* Pagination controls */}
{filteredParts.length > 0 && totalPages > 1 && (
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
<div>
Page{" "}
<span className="font-semibold text-zinc-200">{currentPage}</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">{totalPages}</span>
</div>
<div className="flex gap-2">
<button
type="button"
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
disabled={currentPage === 1}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
disabled={currentPage === totalPages}
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
</div>
</div>
</section>
</div>
</main>
);
}
-14
View File
@@ -1,14 +0,0 @@
import { redirect } from "next/navigation";
export default async function PartsRoleRedirectPage({
params,
searchParams,
}: {
params: { partRole: string };
searchParams?: { platform?: string };
}) {
const partRole = params.partRole;
const platform = searchParams?.platform ?? "AR-15";
redirect(`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`);
}
+440
View File
@@ -0,0 +1,440 @@
"use client";
import { useEffect, useState } from "react";
import { Button, Field, Input } from "@/components/ui/form";
type AdminBetaRequestDto = {
id: number;
uuid: string;
email: string;
displayName?: string | null;
invited: boolean;
verified: boolean;
active: boolean;
createdAt?: string;
updatedAt?: string;
};
type PageResponse<T> = {
content: T[];
number: number;
size: number;
totalElements: number;
totalPages: number;
};
// Expected invite response shape (adjust fields if your DTO differs)
type AdminInviteResponse = {
ok?: boolean;
email?: string;
// backend returns this
inviteUrl?: string;
// keep these optional if you ever add them later
magicUrl?: string;
token?: string;
};
async function fetchJson(path: string, init: RequestInit = {}) {
const res = await fetch(path, {
...init,
headers: {
"Content-Type": "application/json",
...(init.headers ?? {}),
},
cache: "no-store",
});
const text = await res.text().catch(() => "");
const data = text
? (() => {
try {
return JSON.parse(text);
} catch {
return text;
}
})()
: null;
if (!res.ok) {
const msg =
typeof data === "string"
? data
: data?.message || data?.error || `Request failed (${res.status})`;
throw new Error(msg);
}
return data;
}
function Badge({
ok,
yes = "Yes",
no = "No",
}: {
ok: boolean;
yes?: string;
no?: string;
}) {
return (
<span
className={[
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
ok
? "border border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
: "border border-zinc-700 bg-zinc-950/40 text-zinc-400",
].join(" ")}
>
{ok ? yes : no}
</span>
);
}
export default function AdminBetaInvitesPage() {
// ------------------------------
// Batch invites
// ------------------------------
const [dryRun, setDryRun] = useState(true);
const [limit, setLimit] = useState("25");
const [tokenMinutes, setTokenMinutes] = useState("30");
const [loadingBatch, setLoadingBatch] = useState(false);
const [batchResult, setBatchResult] = useState<any>(null);
const [batchError, setBatchError] = useState<string | null>(null);
async function runInvites() {
setLoadingBatch(true);
setBatchError(null);
setBatchResult(null);
try {
const qs = new URLSearchParams({
dryRun: String(dryRun),
limit: String(limit || "0"),
tokenMinutes: String(tokenMinutes || "30"),
});
const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
method: "POST",
});
setBatchResult(data);
await loadRequests();
} catch (e: any) {
setBatchError(e?.message ?? "Failed");
} finally {
setLoadingBatch(false);
}
}
// ------------------------------
// Requests table
// ------------------------------
const [page, setPage] = useState(0);
const size = 25;
const [loadingList, setLoadingList] = useState(false);
const [listError, setListError] = useState<string | null>(null);
const [requests, setRequests] =
useState<PageResponse<AdminBetaRequestDto> | null>(null);
// per-row loading state
const [rowBusy, setRowBusy] = useState<Record<number, boolean>>({});
// last invite response (for visibility / debug)
const [lastInvite, setLastInvite] = useState<AdminInviteResponse | null>(
null
);
async function loadRequests() {
setLoadingList(true);
setListError(null);
try {
const data = (await fetchJson(
`/api/admin/beta/requests?page=${page}&size=${size}`,
{ method: "GET" }
)) as PageResponse<AdminBetaRequestDto>;
setRequests(data);
} catch (e: any) {
setListError(e?.message ?? "Failed to load beta requests");
} finally {
setLoadingList(false);
}
}
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
method: "POST",
})) as AdminInviteResponse;
}
async function onSendInviteEmail(userId: number) {
setListError(null);
setLastInvite(null);
setRowBusy((m) => ({ ...m, [userId]: true }));
try {
const resp = await inviteSingle(userId);
setLastInvite(resp);
await loadRequests();
} catch (e: any) {
setListError(e?.message ?? "Failed to send invite");
} finally {
setRowBusy((m) => ({ ...m, [userId]: false }));
}
}
async function onCopyInviteLink(userId: number) {
setListError(null);
setLastInvite(null);
setRowBusy((m) => ({ ...m, [userId]: true }));
try {
const resp = await inviteSingle(userId);
setLastInvite(resp);
await loadRequests();
// build URL
const url =
resp.inviteUrl || // what your backend returns
resp.magicUrl ||
(resp.token
? `${window.location.origin}/beta/magic?token=${resp.token}`
: null);
if (!url) {
throw new Error(
`Invite response missing inviteUrl. Backend should return {"inviteUrl": "..."}`
);
}
await navigator.clipboard.writeText(url);
} catch (e: any) {
setListError(e?.message ?? "Failed to copy invite link");
} finally {
setRowBusy((m) => ({ ...m, [userId]: false }));
}
}
useEffect(() => {
loadRequests();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]);
return (
<div className="space-y-6">
<div>
<h1 className="text-xl font-semibold">Beta Access</h1>
<p className="mt-1 text-sm text-zinc-400">
Review pending beta requests and send invite links (batch or
per-user).
</p>
</div>
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)]">
{/* Left: Batch Invites */}
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<div className="flex items-center justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
Batch Invites
</h2>
<p className="mt-1 text-sm text-zinc-400">
Runs the backend batch invite sender.
</p>
</div>
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">
{dryRun ? "Dry run" : "Live send"}
</span>
</div>
<label className="flex items-center gap-2 text-sm text-zinc-200">
<input
type="checkbox"
checked={dryRun}
onChange={(e) => setDryRun(e.target.checked)}
/>
Dry run (do everything except actually send)
</label>
<div className="grid gap-4 md:grid-cols-2">
<Field label="Limit" htmlFor="limit">
<Input
id="limit"
value={limit}
onChange={(e) => setLimit(e.target.value)}
/>
</Field>
<Field label="Token minutes" htmlFor="tokenMinutes">
<Input
id="tokenMinutes"
value={tokenMinutes}
onChange={(e) => setTokenMinutes(e.target.value)}
/>
</Field>
</div>
<Button onClick={runInvites} disabled={loadingBatch}>
{loadingBatch ? "Running…" : "Send Beta Invites"}
</Button>
{batchError && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{batchError}
</div>
)}
{batchResult && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(batchResult, null, 2)}
</pre>
)}
</div>
{/* Right: Requests List */}
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
<div className="flex items-start justify-between gap-3">
<div>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
Beta Requests
</h2>
<p className="mt-1 text-sm text-zinc-400">
Users with role <span className="text-zinc-200">BETA</span> and{" "}
<span className="text-zinc-200">is_active=false</span>.
</p>
</div>
<Button
onClick={loadRequests}
disabled={loadingList}
className="w-auto px-6"
>
{loadingList ? "Refreshing…" : "Refresh"}
</Button>
</div>
{listError && (
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
{listError}
</div>
)}
<div className="overflow-x-auto rounded-md border border-zinc-800">
{" "}
<table className="min-w-[680px] w-full text-left text-sm">
<thead className="bg-black/30 text-xs text-zinc-500">
<tr className="border-b border-zinc-800">
<th className="px-3 py-2">Email</th>
<th className="px-3 py-2">Invited</th>
<th className="px-3 py-2">Verified</th>
<th className="px-3 py-2">Active</th>
<th className="px-3 py-2 text-right w-[190px] whitespace-nowrap">
Actions
</th>
</tr>
</thead>
<tbody className="text-zinc-200">
{requests?.content?.length ? (
requests.content.map((u) => {
const busy = !!rowBusy[u.id];
return (
<tr key={u.id} className="border-b border-zinc-900">
<td className="px-3 py-2">
<div className="font-medium">{u.email}</div>
<div className="text-xs text-zinc-500">
{u.displayName ?? "—"} #{u.id}
</div>
</td>
<td className="px-3 py-2">
<Badge ok={u.invited} yes="Invited" no="—" />
</td>
<td className="px-3 py-2">
<Badge ok={u.verified} yes="Verified" no="—" />
</td>
<td className="px-3 py-2">
<Badge ok={u.active} yes="Active" no="Inactive" />
</td>
<td className="px-3 py-2 w-[190px]">
<div className="flex justify-end gap-2">
<button
onClick={() => onCopyInviteLink(u.id)}
disabled={busy}
className="rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-900 disabled:opacity-60"
title="Dev helper: generate invite and copy URL (also triggers invite generation server-side)"
>
{busy ? "Working…" : "Copy link (dev)"}
</button>
<button
onClick={() => onSendInviteEmail(u.id)}
disabled={busy}
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 hover:bg-amber-500/15 disabled:opacity-60"
title="Send invite email to this user"
>
{busy ? "Working…" : "Send invite email"}
</button>
</div>
</td>
</tr>
);
})
) : (
<tr>
<td className="px-3 py-6 text-sm text-zinc-400" colSpan={5}>
{loadingList ? "Loading…" : "No pending beta requests."}
</td>
</tr>
)}
</tbody>
</table>
</div>
{/* pager */}
<div className="flex items-center justify-between text-xs text-zinc-500">
<button
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
disabled={!requests || requests.number <= 0 || loadingList}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Prev
</button>
<span>
Page {requests ? requests.number + 1 : 1} of{" "}
{requests ? requests.totalPages : 1} {" "}
{requests ? requests.totalElements : 0} total
</span>
<button
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
disabled={
!requests ||
loadingList ||
requests.number >= requests.totalPages - 1
}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
{/* optional debug panel */}
{lastInvite && (
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
{JSON.stringify(lastInvite, null, 2)}
</pre>
)}
</div>
</div>
</div>
);
}
+339
View File
@@ -0,0 +1,339 @@
"use client";
import React, { useMemo, useState } from "react";
type Counts = Record<string, number>;
type DiffRow = {
productId: number;
name: string;
platform: string | null;
rawCategoryKey: string | null;
resolvedMerchantId: number | null;
existingPartRole: string | null;
existingSource: string | null;
partRoleLocked: boolean | null;
platformLocked: boolean | null;
resolvedPartRole: string | null;
resolvedSource: string | null;
resolvedConfidence: number | null;
resolvedReason: string | null;
status: string;
meta?: Record<string, any>;
};
type ReconcileResponse = {
dryRun: boolean;
scanned: number;
counts: Counts;
samples: DiffRow[];
};
const DEFAULT_LIMIT = 500;
const DEFAULT_PLATFORM = "AR-15";
// Update these if your admin mapping UI lives elsewhere.
const MAPPING_UI_PATH = "/admin/mapping"; // <- change if needed
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const url = `${API_BASE}/admin/classification/reconcile?dryRun=true&limit=500&platform=AR-15&merchantId=4`;
export default function AdminClassificationReconcilePage() {
const [merchantId, setMerchantId] = useState<string>("4"); // your current test merchant
const [platform, setPlatform] = useState<string>(DEFAULT_PLATFORM);
const [limit, setLimit] = useState<number>(DEFAULT_LIMIT);
const [includeLocked, setIncludeLocked] = useState<boolean>(false);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
const [data, setData] = useState<ReconcileResponse | null>(null);
const [statusFilter, setStatusFilter] = useState<string>("ALL");
const filteredSamples = useMemo(() => {
if (!data?.samples) return [];
if (statusFilter === "ALL") return data.samples;
return data.samples.filter((r) => r.status === statusFilter);
}, [data, statusFilter]);
const uniqueStatuses = useMemo(() => {
const set = new Set<string>();
(data?.samples ?? []).forEach((r) => set.add(r.status));
return ["ALL", ...Array.from(set.values()).sort()];
}, [data]);
async function runReconcile() {
setLoading(true);
setError(null);
try {
const params = new URLSearchParams();
params.set("dryRun", "true");
params.set("limit", String(limit));
if (platform?.trim()) params.set("platform", platform.trim());
if (merchantId?.trim()) params.set("merchantId", merchantId.trim());
if (includeLocked) params.set("includeLocked", "true");
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const url = `${API_BASE}/admin/classification/reconcile?${params.toString()}`;
const res = await fetch(url, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({}),
cache: "no-store",
});
// Helpful error body (also avoids the "<!DOCTYPE" confusion)
const text = await res.text();
if (!res.ok) throw new Error(text || `Request failed (${res.status})`);
const json = JSON.parse(text) as ReconcileResponse;
setData(json);
} catch (e: any) {
setError(e?.message ?? "Unknown error");
setData(null);
} finally {
setLoading(false);
}
}
function mappingLink(row: DiffRow) {
// Deep-link into existing mapping UI with prefilled values.
// Adjust param names to match your mapping UI, if needed.
const qs = new URLSearchParams();
if (merchantId) qs.set("merchantId", merchantId);
if (platform) qs.set("platform", platform);
if (row.rawCategoryKey) qs.set("rawCategoryKey", row.rawCategoryKey);
return `${MAPPING_UI_PATH}?${qs.toString()}`;
}
return (
<div className="mx-auto max-w-6xl px-6 py-10">
<div className="mb-8">
<h1 className="text-2xl font-semibold tracking-tight">Classification Reconcile</h1>
<p className="mt-2 text-sm text-zinc-400">
Dry-run inspector for part-role classification. Finds <span className="text-zinc-200">UNMAPPED</span>,{" "}
<span className="text-zinc-200">IGNORED</span>, rule hits, and drift. (No writes. No regrets.)
</p>
</div>
{/* Controls */}
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-5">
<div className="grid grid-cols-1 gap-4 md:grid-cols-5">
<label className="block">
<div className="text-xs text-zinc-400">Merchant ID</div>
<input
value={merchantId}
onChange={(e) => setMerchantId(e.target.value)}
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
placeholder="4"
/>
</label>
<label className="block">
<div className="text-xs text-zinc-400">Platform</div>
<select
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
>
<option value="AR-15">AR-15</option>
<option value="AR-10">AR-10</option>
<option value="AR-9">AR-9</option>
<option value="AK-47">AK-47</option>
<option value="">(any)</option>
</select>
</label>
<label className="block">
<div className="text-xs text-zinc-400">Limit</div>
<input
type="number"
value={limit}
onChange={(e) => setLimit(Number(e.target.value))}
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
min={1}
max={20000}
/>
</label>
<label className="flex items-end gap-2">
<input
type="checkbox"
checked={includeLocked}
onChange={(e) => setIncludeLocked(e.target.checked)}
className="h-4 w-4 accent-zinc-200"
/>
<span className="text-sm text-zinc-200">Include locked</span>
</label>
<div className="flex items-end justify-end">
<button
onClick={runReconcile}
disabled={loading}
className="inline-flex w-full items-center justify-center rounded-md bg-zinc-100 px-4 py-2 text-sm font-medium text-zinc-900 hover:bg-white disabled:opacity-60 md:w-auto"
>
{loading ? "Running…" : "Run reconcile"}
</button>
</div>
</div>
{error && (
<div className="mt-4 rounded-md border border-red-900/40 bg-red-950/30 px-4 py-3 text-sm text-red-200">
{error}
</div>
)}
</div>
{/* Results */}
{data && (
<div className="mt-8 space-y-6">
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-5">
<div className="flex flex-wrap items-center justify-between gap-3">
<div className="text-sm text-zinc-300">
Scanned: <span className="text-zinc-100 font-medium">{data.scanned}</span> DryRun:{" "}
<span className="text-zinc-100 font-medium">{String(data.dryRun)}</span>
</div>
<div className="flex items-center gap-2">
<span className="text-xs text-zinc-400">Filter</span>
<select
value={statusFilter}
onChange={(e) => setStatusFilter(e.target.value)}
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-100"
>
{uniqueStatuses.map((s) => (
<option key={s} value={s}>
{s}
</option>
))}
</select>
</div>
</div>
{/* Counts */}
<div className="mt-4 grid grid-cols-2 gap-3 md:grid-cols-7">
{Object.entries(data.counts ?? {}).map(([k, v]) => (
<div key={k} className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2">
<div className="text-[11px] uppercase tracking-wider text-zinc-500">{k}</div>
<div className="text-lg font-semibold text-zinc-100">{v}</div>
</div>
))}
</div>
</div>
{/* Samples table */}
<div className="rounded-lg border border-zinc-800 bg-zinc-950/60">
<div className="flex items-center justify-between border-b border-zinc-800 px-5 py-4">
<div>
<div className="text-sm font-medium text-zinc-100">Samples</div>
<div className="text-xs text-zinc-400">
Showing up to 50 interesting rows (UNMAPPED / IGNORED / CONFLICT / WOULD_UPDATE + rule hits).
</div>
</div>
<a
href={MAPPING_UI_PATH}
className="text-sm text-zinc-200 underline decoration-zinc-700 underline-offset-4 hover:text-white"
>
Go to mappings
</a>
</div>
<div className="overflow-auto">
<table className="w-full text-left text-sm">
<thead className="sticky top-0 bg-zinc-950">
<tr className="border-b border-zinc-800 text-xs text-zinc-400">
<th className="px-5 py-3">Status</th>
<th className="px-5 py-3">Product</th>
<th className="px-5 py-3">Raw Category</th>
<th className="px-5 py-3">Existing Role</th>
<th className="px-5 py-3">Resolved</th>
<th className="px-5 py-3">Why</th>
<th className="px-5 py-3"></th>
</tr>
</thead>
<tbody>
{filteredSamples.length === 0 ? (
<tr>
<td colSpan={7} className="px-5 py-6 text-zinc-400">
No rows match this filter.
</td>
</tr>
) : (
filteredSamples.map((r) => (
<tr key={r.productId} className="border-b border-zinc-900 align-top">
<td className="px-5 py-3">
<span className="rounded-md border border-zinc-800 bg-zinc-950 px-2 py-1 text-xs text-zinc-200">
{r.status}
</span>
{r.resolvedSource?.startsWith("rules_") && (
<div className="mt-2 text-[11px] text-zinc-500">
rule: <span className="text-zinc-300">{r.resolvedSource}</span>
</div>
)}
</td>
<td className="px-5 py-3">
<div className="text-zinc-100">{r.name}</div>
<div className="mt-1 text-xs text-zinc-500">
#{r.productId} {r.platform ?? "—"} merchantUsed: {r.resolvedMerchantId ?? "—"}
</div>
</td>
<td className="px-5 py-3">
<div className="text-zinc-200">{r.rawCategoryKey ?? "—"}</div>
</td>
<td className="px-5 py-3">
<div className="text-zinc-200">{r.existingPartRole ?? "—"}</div>
<div className="mt-1 text-xs text-zinc-500">
src: {r.existingSource ?? "—"}
{r.partRoleLocked ? " • locked" : ""}
{r.platformLocked ? " • platformLocked" : ""}
</div>
</td>
<td className="px-5 py-3">
<div className="text-zinc-100">{r.resolvedPartRole ?? "—"}</div>
<div className="mt-1 text-xs text-zinc-500">
src: {r.resolvedSource ?? "—"} conf:{" "}
{typeof r.resolvedConfidence === "number" ? r.resolvedConfidence.toFixed(2) : "—"}
</div>
</td>
<td className="px-5 py-3">
<div className="text-zinc-300">{r.resolvedReason ?? "—"}</div>
</td>
<td className="px-5 py-3 text-right">
{r.status === "UNMAPPED" && r.rawCategoryKey ? (
<a
href={mappingLink(r)}
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 hover:text-white"
>
Map category
</a>
) : (
<span className="text-xs text-zinc-600"></span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
</div>
</div>
)}
</div>
);
}
+331
View File
@@ -0,0 +1,331 @@
"use client";
import { useCallback, useEffect, useMemo, useState } from "react";
import {
fetchEmailRequests,
createEmailRequest,
updateEmailRequest,
deleteEmailRequest,
type EmailRequest,
type CreateEmailRequestDto,
type UpdateEmailRequestDto,
} from "@/lib/api/emailRequests";
import { Pencil, Trash2, Plus, X } from "lucide-react";
import { formatDate } from "@/lib/dateFormattingUtility";
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
export default function AdminEmailPage() {
const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [showModal, setShowModal] = useState(false);
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
const [selectedEmail, setSelectedEmail] = useState<EmailRequest | null>(null);
const [formData, setFormData] = useState<CreateEmailRequestDto>({
email: "",
status: "",
});
const [submitting, setSubmitting] = useState(false);
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
const loadEmails = useCallback(async () => {
try {
setLoading(true);
const data = await fetchEmailRequests();
setEmails(data);
setError(null);
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to load email requests");
} finally {
setLoading(false);
}
}, []);
useEffect(() => {
loadEmails();
}, [loadEmails]);
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
const counts = useMemo(() => {
const c = { ALL: 0, PENDING: 0, SENT: 0, FAILED: 0, OTHER: 0 } as Record<
EmailStatusTab,
number
>;
c.ALL = emails.length;
for (const e of emails) {
const st = normalizeStatus(e.status);
if (st === "PENDING") c.PENDING += 1;
else if (st === "SENT") c.SENT += 1;
else if (st === "FAILED") c.FAILED += 1;
else c.OTHER += 1;
}
return c;
}, [emails]);
const filteredEmails = useMemo(() => {
if (statusTab === "ALL") return emails;
return emails.filter((e) => {
const st = normalizeStatus(e.status);
if (statusTab === "OTHER") {
return st !== "PENDING" && st !== "SENT" && st !== "FAILED";
}
return st === statusTab;
});
}, [emails, statusTab]);
const tabs: { key: EmailStatusTab; label: string }[] = [
{ key: "ALL", label: "All" },
{ key: "PENDING", label: "Pending" },
{ key: "SENT", label: "Sent" },
{ key: "FAILED", label: "Failed" },
{ key: "OTHER", label: "Other" },
];
const handleCreate = () => {
setModalMode("create");
setFormData({ email: "", status: "" });
setSelectedEmail(null);
setShowModal(true);
};
const handleEdit = (email: EmailRequest) => {
setModalMode("edit");
setFormData({ email: email.email, status: email.status });
setSelectedEmail(email);
setShowModal(true);
};
const handleDelete = async (id: number) => {
if (!confirm("Are you sure you want to delete this email request?")) return;
try {
await deleteEmailRequest(id);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to delete email request");
}
};
const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault();
try {
setSubmitting(true);
if (modalMode === "create") {
await createEmailRequest(formData);
} else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(selectedEmail.id, updateData);
}
setShowModal(false);
await loadEmails();
} catch (e: any) {
alert(e?.message ?? "Failed to save email request");
} finally {
setSubmitting(false);
}
};
return (
<div className="p-6 space-y-4">
{/* Header */}
<div className="flex items-center justify-between">
<div>
<h1 className="text-lg font-semibold tracking-tight">Emails</h1>
<p className="mt-1 text-xs text-zinc-400">
Manage Email and their statuses.
</p>
</div>
<button
onClick={handleCreate}
className="flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500"
>
<Plus size={14} />
Add Email
</button>
</div>
{/* Status Tabs */}
<div className="flex flex-wrap items-center gap-2">
{tabs.map((t) => {
const active = statusTab === t.key;
return (
<button
key={t.key}
type="button"
onClick={() => setStatusTab(t.key)}
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs transition ${
active
? "border-amber-500/60 bg-amber-500/10 text-amber-200"
: "border-zinc-800 bg-zinc-950/60 text-zinc-300 hover:border-amber-400/60 hover:text-amber-300"
}`}
>
<span>{t.label}</span>
<span
className={`rounded-full px-2 py-0.5 text-[10px] ${
active ? "bg-amber-500/15 text-amber-200" : "bg-zinc-900 text-zinc-400"
}`}
>
{counts[t.key]}
</span>
</button>
);
})}
</div>
{/* Error */}
{error && (
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
{error}
</div>
)}
{/* Table */}
{loading ? (
<div className="text-sm text-zinc-400">Loading</div>
) : (
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
<table className="min-w-full text-left text-xs">
<thead className="bg-zinc-900/80 text-zinc-400">
<tr>
<th className="px-3 py-2 font-medium">ID</th>
<th className="px-3 py-2 font-medium">Email</th>
<th className="px-3 py-2 font-medium">Status</th>
<th className="px-3 py-2 font-medium">Created At</th>
<th className="px-3 py-2 font-medium">Updated At</th>
<th className="px-3 py-2 font-medium text-right">Actions</th>
</tr>
</thead>
<tbody>
{filteredEmails.map((email) => (
<tr
key={email.id}
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
>
<td className="px-3 py-2 text-zinc-400">{email.id}</td>
<td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
<td className="px-3 py-2">
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
{email.status}
</span>
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{new Date(email.createdAt).toLocaleString()}
</td>
<td className="px-3 py-2 text-[11px] text-zinc-400">
{email.updatedAt ? formatDate(email.updatedAt) : "—"}
</td>
<td className="px-3 py-2 text-right">
<div className="flex items-center justify-end gap-2">
<button
onClick={() => handleEdit(email)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
title="Edit"
>
<Pencil size={14} />
</button>
<button
onClick={() => handleDelete(email.id)}
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
title="Delete"
>
<Trash2 size={14} />
</button>
</div>
</td>
</tr>
))}
{filteredEmails.length === 0 && (
<tr>
<td
colSpan={6}
className="px-3 py-4 text-center text-xs text-zinc-500"
>
No email requests found for{" "}
<span className="text-zinc-300">{tabs.find((t) => t.key === statusTab)?.label}</span>.
</td>
</tr>
)}
</tbody>
</table>
</div>
)}
{/* Modal */}
{showModal && (
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
<div className="mb-4 flex items-center justify-between">
<h2 className="text-base font-semibold">
{modalMode === "create" ? "Create Email Request" : "Edit Email Request"}
</h2>
<button
onClick={() => setShowModal(false)}
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
>
<X size={16} />
</button>
</div>
<form onSubmit={handleSubmit} className="space-y-4">
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Email
</label>
<input
type="email"
value={formData.email}
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="user@example.com"
required
/>
</div>
<div>
<label className="mb-1 block text-xs font-medium text-zinc-300">
Status
</label>
<input
type="text"
value={formData.status}
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
placeholder="pending"
/>
</div>
<div className="flex gap-2 pt-2">
<button
type="button"
onClick={() => setShowModal(false)}
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
disabled={submitting}
>
Cancel
</button>
<button
type="submit"
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
disabled={submitting}
>
{submitting ? "Saving..." : modalMode === "create" ? "Create" : "Update"}
</button>
</div>
</form>
</div>
</div>
)}
</div>
);
}
+12 -27
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import { import {
fetchEmailRequests, fetchEmailRequests,
createEmailRequest, createEmailRequest,
@@ -12,9 +11,8 @@ import {
type UpdateEmailRequestDto, type UpdateEmailRequestDto,
} from "@/lib/api/emailRequests"; } from "@/lib/api/emailRequests";
import { Pencil, Trash2, Plus, X } from "lucide-react"; import { Pencil, Trash2, Plus, X } from "lucide-react";
import { formatDate } from "@/lib/api/dateFormattingUtility"; import { formatDate } from "@/lib/dateFormattingUtility";
export default function AdminEmailPage() { export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]); const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -27,13 +25,11 @@ export default function AdminEmailPage() {
}); });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const loadEmails = async () => { const loadEmails = useCallback(async () => {
if (!token) return;
try { try {
setLoading(true); setLoading(true);
const data = await fetchEmailRequests(token); const data = await fetchEmailRequests();
setEmails(data.data); setEmails(data);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
@@ -41,11 +37,11 @@ export default function AdminEmailPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}; }, []);
useEffect(() => { useEffect(() => {
loadEmails(); loadEmails();
}, [token]); }, [loadEmails]);
const handleCreate = () => { const handleCreate = () => {
setModalMode("create"); setModalMode("create");
@@ -62,11 +58,10 @@ export default function AdminEmailPage() {
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return; if (!confirm("Are you sure you want to delete this email request?")) return;
try { try {
await deleteEmailRequest(token, id); await deleteEmailRequest(id);
await loadEmails(); await loadEmails();
} catch (e: any) { } catch (e: any) {
alert(e?.message ?? "Failed to delete email request"); alert(e?.message ?? "Failed to delete email request");
@@ -75,17 +70,15 @@ export default function AdminEmailPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!token) return;
try { try {
setSubmitting(true); setSubmitting(true);
if (modalMode === "create") { if (modalMode === "create") {
await createEmailRequest(token, formData); await createEmailRequest(formData);
} else if (selectedEmail) { } else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {}; const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email; if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status; if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(token, selectedEmail.id, updateData); await updateEmailRequest(selectedEmail.id, updateData);
} }
setShowModal(false); setShowModal(false);
await loadEmails(); await loadEmails();
@@ -96,21 +89,13 @@ export default function AdminEmailPage() {
} }
}; };
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Header */} {/* Header */}
<div className="flex items-center justify-between"> <div className="flex items-center justify-between">
<div> <div>
<h1 className="text-lg font-semibold tracking-tight"> <h1 className="text-lg font-semibold tracking-tight">
Email Requests Processed E-mail
</h1> </h1>
<p className="mt-1 text-xs text-zinc-400"> <p className="mt-1 text-xs text-zinc-400">
Manage email requests and their statuses. Manage email requests and their statuses.
@@ -155,7 +140,7 @@ export default function AdminEmailPage() {
className="border-t border-zinc-800/70 hover:bg-zinc-900/60" className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
> >
<td className="px-3 py-2 text-zinc-400">{email.id}</td> <td className="px-3 py-2 text-zinc-400">{email.id}</td>
<td className="px-3 py-2 text-zinc-100">{email.email}</td> <td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
<td className="px-3 py-2"> <td className="px-3 py-2">
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300"> <span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
{email.status} {email.status}
+1 -1
View File
@@ -1,7 +1,7 @@
"use client"; "use client";
import { useEffect, useRef, useState } from "react"; import { useEffect, useRef, useState } from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail"; import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form"; import { Button, Field, Input, Textarea } from "@/components/ui/form";
export default function SendEmailForm(): JSX.Element { export default function SendEmailForm(): JSX.Element {
@@ -0,0 +1,585 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import { Check, X, Wand2, RefreshCw } from "lucide-react";
type EnrichmentStatus = "PENDING_REVIEW" | "APPROVED" | "REJECTED" | "APPLIED";
type EnrichmentType = "CALIBER" | "CALIBER_GROUP";
type EnrichmentSource = "RULES" | "AI";
type QueueItem = {
id: number;
productId: number;
productName?: string;
productSlug?: string;
mainImageUrl?: string;
brandName?: string;
enrichmentType: EnrichmentType;
source: EnrichmentSource;
status: EnrichmentStatus;
attributes: Record<string, any>;
confidence: number;
rationale?: string;
createdAt: string;
// NEW: current value on products table (so we can prevent bad applies)
productCaliber?: string | null;
productCaliberGroup?: string | null;
};
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
function getAuthHeaders(): HeadersInit {
if (typeof window === "undefined") return {};
const token =
localStorage.getItem("token") ||
localStorage.getItem("jwt") ||
localStorage.getItem("accessToken");
return token ? { Authorization: `Bearer ${token}` } : {};
}
async function apiFetch(path: string, init?: RequestInit) {
const res = await fetch(`${API_BASE}${path}`, {
...init,
headers: {
"Content-Type": "application/json",
...getAuthHeaders(),
...(init?.headers ?? {}),
},
// If you're using cookie auth instead of bearer, flip this on:
// credentials: "include",
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || `Request failed (${res.status})`);
}
const ct = res.headers.get("content-type") ?? "";
if (ct.includes("application/json")) return res.json();
return res.text();
}
function hasProductCaliber(it: QueueItem) {
const c = it.productCaliber;
return typeof c === "string" && c.trim().length > 0;
}
function hasAlreadySet(it: QueueItem) {
if (it.enrichmentType === "CALIBER") {
return hasProductCaliber(it);
}
const g = it.productCaliberGroup;
return typeof g === "string" && g.trim().length > 0;
}
// ✅ ADD THIS: text for the “Already set:” pill
function alreadySetLabel(it: QueueItem) {
return it.enrichmentType === "CALIBER" ? it.productCaliber : it.productCaliberGroup;
}
export default function EnrichmentQueueClient() {
const [type, setType] = useState<EnrichmentType>("CALIBER");
const [status, setStatus] = useState<EnrichmentStatus>("PENDING_REVIEW");
const [limit, setLimit] = useState<number>(50);
const [items, setItems] = useState<QueueItem[]>([]);
const [loading, setLoading] = useState(false);
const [busyId, setBusyId] = useState<number | null>(null);
const [error, setError] = useState<string | null>(null);
// Bulk selection
const [selected, setSelected] = useState<Record<number, boolean>>({});
// “Fancy” per-row UI: treat an item as “locally approved/rejected” immediately
// so the row swaps buttons without waiting for a reload.
const [localStatus, setLocalStatus] = useState<
Record<number, EnrichmentStatus>
>({});
const count = useMemo(() => items.length, [items]);
const selectedIds = useMemo(
() =>
Object.entries(selected)
.filter(([, v]) => v)
.map(([k]) => Number(k)),
[selected]
);
const allOnPageSelected = useMemo(() => {
if (items.length === 0) return false;
return items.every((it) => selected[it.id]);
}, [items, selected]);
function toggleAllOnPage() {
const next = { ...selected };
const nextValue = !allOnPageSelected;
for (const it of items) next[it.id] = nextValue;
setSelected(next);
}
function toggleOne(id: number) {
setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
}
function effectiveStatus(it: QueueItem): EnrichmentStatus {
return localStatus[it.id] ?? it.status;
}
async function load() {
setLoading(true);
setError(null);
try {
const data = (await apiFetch(
`/api/admin/enrichment/queue2?type=${encodeURIComponent(
type
)}&status=${encodeURIComponent(status)}&limit=${limit}`
)) as QueueItem[];
setItems(data ?? []);
// Keep selection only for items still visible
setSelected((prev) => {
const keep = new Set((data ?? []).map((x) => x.id));
const next: Record<number, boolean> = {};
for (const [k, v] of Object.entries(prev)) {
const id = Number(k);
if (keep.has(id)) next[id] = v;
}
return next;
});
// Reset local status for items not in view
setLocalStatus((prev) => {
const keep = new Set((data ?? []).map((x) => x.id));
const next: Record<number, EnrichmentStatus> = {};
for (const [k, v] of Object.entries(prev)) {
const id = Number(k);
if (keep.has(id)) next[id] = v;
}
return next;
});
} catch (e: any) {
setError(e?.message ?? "Failed to load queue");
} finally {
setLoading(false);
}
}
async function runRules() {
setLoading(true);
setError(null);
try {
await apiFetch(
`/api/admin/enrichment/run?type=${encodeURIComponent(type)}&limit=200`,
{ method: "POST" }
);
setStatus("PENDING_REVIEW");
setTimeout(load, 50);
} catch (e: any) {
setError(e?.message ?? "Failed to run rules");
setLoading(false);
}
}
async function runAi() {
setLoading(true);
setError(null);
try {
await apiFetch(
`/api/admin/enrichment/ai/run?type=${encodeURIComponent(type)}&limit=200`,
{ method: "POST" }
);
setStatus("PENDING_REVIEW");
setTimeout(load, 50);
} catch (e: any) {
setError(e?.message ?? "Failed to run AI");
setLoading(false);
}
}
async function act(id: number, action: "approve" | "reject" | "apply") {
setBusyId(id);
setError(null);
// Optimistic UI for approve/reject so the row instantly changes
if (action === "approve")
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
if (action === "reject")
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
try {
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
method: "POST",
});
await load();
} catch (e: any) {
// rollback optimistic local status on error
setLocalStatus((p) => {
const next = { ...p };
delete next[id];
return next;
});
setError(e?.message ?? `Failed to ${action}`);
} finally {
setBusyId(null);
}
}
async function bulk(action: "approve" | "reject" | "apply") {
const ids = selectedIds;
if (ids.length === 0) return;
setLoading(true);
setError(null);
try {
// Apply should only run for items that are APPROVED (effective status) AND product caliber is blank
const byId = new Map(items.map((x) => [x.id, x]));
const filtered =
action === "apply"
? ids.filter((id) => {
const it = byId.get(id);
if (!it) return false;
return (
effectiveStatus(it) === "APPROVED" && !hasAlreadySet(it)
);
})
: ids;
for (const id of filtered) {
// optimistic statuses for approve/reject in bulk
if (action === "approve")
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
if (action === "reject")
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
method: "POST",
});
}
setSelected({});
await load();
} catch (e: any) {
setError(e?.message ?? `Failed to bulk ${action}`);
setLoading(false);
} finally {
setLoading(false);
}
}
useEffect(() => {
load();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [type, status, limit]);
return (
<div className="space-y-4">
{/* Controls */}
<div className="flex flex-col gap-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-2">
<div className="text-sm text-zinc-400">
Showing <span className="text-zinc-200 font-medium">{count}</span>
</div>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={type}
onChange={(e) => setType(e.target.value as EnrichmentType)}
>
<option value="CALIBER">CALIBER</option>
<option value="CALIBER_GROUP">CALIBER_GROUP</option>
</select>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={status}
onChange={(e) => {
setSelected({});
setLocalStatus({});
setStatus(e.target.value as EnrichmentStatus);
}}
>
<option value="PENDING_REVIEW">PENDING_REVIEW</option>
<option value="APPROVED">APPROVED</option>
<option value="REJECTED">REJECTED</option>
<option value="APPLIED">APPLIED</option>
</select>
<select
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
value={limit}
onChange={(e) => setLimit(parseInt(e.target.value, 10))}
>
<option value={25}>25</option>
<option value={50}>50</option>
<option value={100}>100</option>
<option value={200}>200</option>
</select>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={load}
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
<RefreshCw
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
/>
Refresh
</button>
<button
type="button"
onClick={runRules}
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
disabled={loading}
title="Run enrichment rules to create new suggestions"
>
<Wand2 className="h-4 w-4" />
Run Rules
</button>
<button
type="button"
onClick={runAi}
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800"
disabled={loading}
title="Run AI enrichment to create new suggestions"
>
<Wand2 className="h-4 w-4" />
Run AI
</button>
</div>
</div>
{/* Bulk action bar */}
{selectedIds.length > 0 ? (
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
<div className="text-sm text-zinc-300">
Selected{" "}
<span className="font-semibold">{selectedIds.length}</span>
<span className="ml-2 text-xs text-zinc-500">
(Apply only works on APPROVED + product caliber blank)
</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => bulk("approve")}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
Approve Selected
</button>
<button
type="button"
onClick={() => bulk("reject")}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
Reject Selected
</button>
<button
type="button"
onClick={() => bulk("apply")}
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
disabled={loading}
title="Applies only APPROVED rows where product caliber is blank"
>
Apply Selected
</button>
<button
type="button"
onClick={() => setSelected({})}
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
disabled={loading}
>
Clear
</button>
</div>
</div>
) : null}
</div>
{error ? (
<div className="rounded-md border border-red-900/50 bg-red-950/30 p-3 text-sm text-red-200">
{error}
</div>
) : null}
{/* Table */}
<div className="overflow-hidden rounded-md border border-zinc-800">
<div className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 border-b border-zinc-800 bg-zinc-950 px-4 py-3 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={toggleAllOnPage}
className="h-4 w-4 accent-amber-400"
aria-label="Select all"
/>
</div>
<div>Product</div>
<div className="text-right">Confidence</div>
<div className="text-right">Suggested</div>
<div className="text-right">Actions</div>
</div>
<div className="divide-y divide-zinc-800 bg-zinc-950/40">
{items.length === 0 ? (
<div className="p-6 text-sm text-zinc-400">No items found.</div>
) : (
items.map((it) => {
const suggested =
it.enrichmentType === "CALIBER"
? it.attributes?.caliber
: it.attributes?.caliberGroup; const isBusy = busyId === it.id;
const st = effectiveStatus(it);
const alreadySet = hasAlreadySet(it);
const alreadySetValue = alreadySetLabel(it);
return (
<div
key={it.id}
className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 px-4 py-3"
>
<div className="flex items-center justify-center">
<input
type="checkbox"
checked={!!selected[it.id]}
onChange={() => toggleOne(it.id)}
className="h-4 w-4 accent-amber-400"
aria-label={`Select enrichment ${it.id}`}
/>
</div>
<div className="min-w-0">
<div className="flex items-start gap-3">
{it.mainImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.mainImageUrl}
alt=""
className="h-10 w-10 rounded border border-zinc-800 object-cover"
/>
) : (
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-900" />
)}
<div className="min-w-0">
<a
href={`/admin/products/${it.productId}`}
className="text-sm font-semibold text-zinc-100 hover:underline line-clamp-2"
>
{it.productName ?? `Product #${it.productId}`}
</a>
<div className="mt-0.5 text-xs text-zinc-500">
{it.brandName ? `${it.brandName}` : ""}
{it.source} {st}
{alreadySet ? (
<span className="ml-2 inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-zinc-300">
Already set: {alreadySetValue}
</span>
) : null}
</div>
{it.rationale ? (
<div className="mt-1 text-xs text-zinc-400 line-clamp-2">
{it.rationale}
</div>
) : null}
</div>
</div>
</div>
<div className="text-right text-sm font-semibold text-zinc-200">
{(it.confidence ?? 0).toFixed(2)}
</div>
<div className="text-right text-sm text-amber-300">
{suggested ?? "—"}
</div>
<div className="flex justify-end gap-2">
{st === "PENDING_REVIEW" && (
<>
<button
type="button"
onClick={() => act(it.id, "approve")}
disabled={isBusy || alreadySet}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
title={
alreadySet
? "Product caliber already set"
: "Approve"
}
aria-label="Approve"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => act(it.id, "reject")}
disabled={isBusy}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
title="Reject"
aria-label="Reject"
>
<X className="h-4 w-4" />
</button>
</>
)}
{st === "APPROVED" && (
<button
type="button"
onClick={() => act(it.id, "apply")}
disabled={isBusy || alreadySet}
className="inline-flex h-8 items-center justify-center rounded-md bg-amber-400 px-3 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
title={
alreadySet
? `Already set on product: ${alreadySetValue}`
: "Apply (writes to product if blank)"
}
>
Apply
</button>
)}
{st === "APPLIED" && (
<span className="inline-flex h-8 items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-200">
Applied
</span>
)}
{st === "REJECTED" && (
<span className="inline-flex h-8 items-center rounded-md border border-red-500/30 bg-red-500/10 px-3 text-xs font-semibold text-red-200">
Rejected
</span>
)}
</div>
</div>
);
})
)}
</div>
</div>
</div>
);
}
+18
View File
@@ -0,0 +1,18 @@
import EnrichmentQueueClient from "./EnrichmentQueueClient";
export const dynamic = "force-dynamic";
export default function AdminEnrichmentPage() {
return (
<div className="mx-auto w-full max-w-6xl px-6 py-8">
<div className="mb-6">
<h1 className="text-xl font-semibold text-zinc-50">Enrichment Queue</h1>
<p className="mt-1 text-sm text-zinc-400">
Review AI/rules suggestions, approve/reject, then apply to products.
</p>
</div>
<EnrichmentQueueClient />
</div>
);
}
+5 -4
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const formatCount = (value: number | null | undefined) => const formatCount = (value: number | null | undefined) =>
(typeof value === "number" ? value : 0).toLocaleString(); (typeof value === "number" ? value : 0).toLocaleString();
@@ -46,8 +46,8 @@ export default function ImportStatusPage() {
setError(null); setError(null);
const [summaryRes, byMerchantRes] = await Promise.all([ const [summaryRes, byMerchantRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/admin/import-status/summary`), fetch('/api/admin/import-status/summary', { credentials: 'include' }),
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`), fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
]); ]);
if (!summaryRes.ok || !byMerchantRes.ok) { if (!summaryRes.ok || !byMerchantRes.ok) {
@@ -108,9 +108,10 @@ export default function ImportStatusPage() {
try { try {
setImportingId(merchantId); setImportingId(merchantId);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`, `/api/admin/merchants/${merchantId}/import`,
{ {
method: "POST", method: "POST",
credentials: 'include',
} }
); );
+114 -48
View File
@@ -1,7 +1,12 @@
"use client"; "use client";
import type React from "react"; import type React from "react";
import { useState } from "react"; import { useEffect, useMemo, useState } from "react";
import AdminLeftNavigation from "@/components/AdminLeftNavigation"; import { usePathname, useRouter } from "next/navigation";
import AdminLeftNavigation, {
type AdminNavGroup,
} from "@/components/AdminLeftNavigation";
import { useAuth } from "@/context/AuthContext";
import { import {
LayoutDashboard, LayoutDashboard,
Download, Download,
@@ -10,15 +15,44 @@ import {
Store, Store,
Users, Users,
Settings, Settings,
LucideMail, Mail,
Wand2,
FolderKanban,
Shield,
} from "lucide-react"; } from "lucide-react";
const navItems = [ const navGroups: AdminNavGroup[] = [
{
label: "Overview",
icon: <LayoutDashboard className="h-4 w-4" />,
items: [
{ {
label: "Dashboard", label: "Dashboard",
href: "/admin", href: "/admin",
icon: <LayoutDashboard className="h-4 w-4" />, icon: <LayoutDashboard className="h-4 w-4" />,
}, },
],
},
{
label: "Catalog",
icon: <FolderKanban className="h-4 w-4" />,
items: [
{
label: "Products",
href: "/admin/products",
icon: <Boxes className="h-4 w-4" />,
},
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
],
},
{
label: "Data Ops",
icon: <Download className="h-4 w-4" />,
items: [
{ {
label: "Imports", label: "Imports",
href: "/admin/import-status", href: "/admin/import-status",
@@ -30,88 +64,120 @@ const navItems = [
icon: <Layers className="h-4 w-4" />, icon: <Layers className="h-4 w-4" />,
}, },
{ {
label: "Products", label: "Enrichment",
href: "/admin/products", href: "/admin/enrichment",
icon: <Boxes className="h-4 w-4" />, icon: <Wand2 className="h-4 w-4" />,
},
],
},
{
label: "Growth & Comms",
icon: <Mail className="h-4 w-4" />,
items: [
{
label: "Beta Invites",
href: "/admin/beta-invites",
icon: <Mail className="h-4 w-4" />,
},
{
label: "List Emails",
href: "/admin/email",
icon: <Mail className="h-4 w-4" />,
},
{
label: "Send an Email",
href: "/admin/email/send",
icon: <Mail className="h-4 w-4" />,
},
],
},
{
label: "Access & Config",
icon: <Shield className="h-4 w-4" />,
items: [
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
}, },
{ {
label: "Merchants", label: "Merchants",
href: "/admin/merchants", href: "/admin/merchants",
icon: <Store className="h-4 w-4" />, icon: <Store className="h-4 w-4" />,
}, },
{
label: "Platforms",
href: "/admin/platforms",
icon: <Layers className="h-4 w-4" />,
},
{
label: "Users",
href: "/admin/users",
icon: <Users className="h-4 w-4" />,
},
{ {
label: "Settings", label: "Settings",
href: "/admin/settings", href: "/admin/settings",
icon: <Settings className="h-4 w-4" />, icon: <Settings className="h-4 w-4" />,
}, },
{ ],
label: "List Emails",
href: "/admin/email",
icon: <LucideMail className="h-4 w-4" />,
},
{
label: "Send a Email",
href: "/admin/email/send",
icon: <LucideMail className="h-4 w-4" />,
}, },
]; ];
// ... existing code ...
// ADMIN CHECK FOR LOGIN
// const { user, loading } = useAuth();
// if (!loading && user?.role !== "ADMIN") {
// redirect("/"); // or /login
// }
export default function AdminLayout({ export default function AdminLayout({
children, children,
}: { }: {
children: React.ReactNode; children: React.ReactNode;
}) { }) {
const [collapsed, setCollapsed] = useState(false); const [collapsed, setCollapsed] = useState(false);
const { user, loading } = useAuth();
const router = useRouter();
const pathname = usePathname();
const next = useMemo(
() => encodeURIComponent(pathname || "/admin"),
[pathname]
);
useEffect(() => {
if (loading) return;
if (!user) {
router.replace(`/login?next=${next}`);
return;
}
if (user.role !== "ADMIN") {
router.replace("/");
}
}, [loading, user, router, next]);
if (loading) return null;
if (!user) return null;
if (user.role !== "ADMIN") return null;
return ( return (
<div className="flex min-h-screen bg-black text-zinc-50"> // ✅ prevent browser-level horizontal scroll from any wide children
<div className="h-screen bg-black text-zinc-50 overflow-hidden">
<AdminLeftNavigation <AdminLeftNavigation
collapsed={collapsed} collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)} onToggleCollapsed={() => setCollapsed((v) => !v)}
groups={navGroups}
/> />
{/* Main column */} {/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
<div className="flex min-h-screen flex-1 flex-col"> <div
{/* Top bar */} className="flex h-full flex-1 min-w-0 flex-col transition-all duration-300"
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3"> style={{ marginLeft: collapsed ? '68px' : '280px' }}
>
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3 sticky top-0 z-30 backdrop-blur-sm">
<div> <div>
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500"> <p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
Admin Admin
</p> </p>
<p className="text-sm text-zinc-200"> <p className="text-sm text-zinc-200">Battl Control Panel</p>
Battl Builders Control Panel
</p>
</div> </div>
<div className="flex items-center gap-3"> <div className="flex items-center gap-3">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300"> <span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
Internal v0.1 Internal v0.1
</span> </span>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
ADMIN
</div>
</div> </div>
</header> </header>
{/* Content */} {/* ✅ min-w-0 here prevents table min-width from widening the page */}
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6"> <main className="flex w-full flex-1 min-w-0 flex-col overflow-y-auto px-4 py-6">
{children} {children}
</main> </main>
</div> </div>
+538 -78
View File
@@ -1,10 +1,21 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { useSearchParams, useRouter } from "next/navigation";
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
/**
* Tabs:
* - roles: map raw category -> canonical_part_role (builder slot)
* - catalog: map raw category -> canonical_category_id (FK to canonical_categories)
*/
type TabKey = "roles" | "catalog";
/**
* Existing (you already have this working)
*/
type PendingBucket = { type PendingBucket = {
merchantId: number; merchantId: number;
merchantName: string; merchantName: string;
@@ -14,8 +25,47 @@ type PendingBucket = {
}; };
/** /**
* Canonical part roles (kebab-case) — should match what the importer normalizes to. * New “raw categories” row (for the combined UI).
* Keep this list tight + intentional so mappings don't create junk roles in the DB. * If your backend returns fewer fields at first, its fine—just keep the ones you have.
*/
type RawCategoryRow = {
merchantId: number;
merchantName?: string | null;
platform?: string | null;
rawCategoryKey: string;
productCount: number;
// current mapping state (merchant_category_map row)
mcmId?: number | null;
enabled?: boolean | null;
canonicalPartRole?: string | null;
canonicalCategoryId?: number | null;
canonicalCategoryName?: string | null;
};
type CanonicalCategoryOption = {
id: number;
name: string;
slug?: string | null;
};
type MerchantOption = {
id: number;
name: string;
};
type MappingOptionsResponse = {
merchants: MerchantOption[];
canonicalCategories: CanonicalCategoryOption[];
};
const DEFAULT_PLATFORM = "AR-15";
/**
* Canonical part roles (kebab-case)
*/ */
const PART_ROLE_OPTIONS = [ const PART_ROLE_OPTIONS = [
// Assemblies / receivers // Assemblies / receivers
@@ -56,23 +106,62 @@ const PART_ROLE_OPTIONS = [
"tools", "tools",
] as const; ] as const;
type PartRole = (typeof PART_ROLE_OPTIONS)[number];
function toLabel(role: string) { function toLabel(role: string) {
// "complete-upper" -> "Complete Upper"
return role return role
.split("-") .split("-")
.map((w) => w.charAt(0).toUpperCase() + w.slice(1)) .map((w) => w.charAt(0).toUpperCase() + w.slice(1))
.join(" "); .join(" ");
} }
function normalizeRole(role: string) {
return role.trim().toLowerCase().replace(/_/g, "-");
}
function safeNum(v: any): number | null {
if (v == null) return null;
if (typeof v === "number") return Number.isFinite(v) ? v : null;
if (typeof v === "string") {
const n = Number(v);
return Number.isFinite(n) ? n : null;
}
return null;
}
export default function MappingAdminPage() { export default function MappingAdminPage() {
const [rows, setRows] = useState<PendingBucket[]>([]); const searchParams = useSearchParams();
const [loading, setLoading] = useState(true); const router = useRouter();
const [savingKey, setSavingKey] = useState<string | null>(null);
// URL-driven state
const initialTab = (searchParams.get("tab") as TabKey) || "roles";
const initialMerchantId = searchParams.get("merchantId") || "";
const initialPlatform = searchParams.get("platform") || DEFAULT_PLATFORM;
const initialQ = searchParams.get("q") || "";
const [tab, setTab] = useState<TabKey>(initialTab);
const [merchantId, setMerchantId] = useState<string>(initialMerchantId);
const [platform, setPlatform] = useState<string>(initialPlatform);
const [q, setQ] = useState<string>(initialQ);
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const options = useMemo( // Options (for catalog tab + merchant dropdown)
const [optionsLoading, setOptionsLoading] = useState<boolean>(false);
const [merchants, setMerchants] = useState<MerchantOption[]>([]);
const [canonicalCategories, setCanonicalCategories] = useState<
CanonicalCategoryOption[]
>([]);
// Rows
const [roleBuckets, setRoleBuckets] = useState<PendingBucket[]>([]);
const [rawRows, setRawRows] = useState<RawCategoryRow[]>([]);
// Saving state
const [savingKey, setSavingKey] = useState<string | null>(null);
// Part role dropdown options
const roleOptions = useMemo(
() => () =>
PART_ROLE_OPTIONS.map((value) => ({ PART_ROLE_OPTIONS.map((value) => ({
value, value,
@@ -81,16 +170,85 @@ export default function MappingAdminPage() {
[] []
); );
// Keep URL in sync (so reconcile can deep-link)
function syncUrl(next: Partial<{ tab: TabKey; merchantId: string; platform: string; q: string }>) {
const params = new URLSearchParams(searchParams.toString());
if (next.tab) params.set("tab", next.tab);
if (next.merchantId !== undefined) {
if (next.merchantId) params.set("merchantId", next.merchantId);
else params.delete("merchantId");
}
if (next.platform !== undefined) {
if (next.platform) params.set("platform", next.platform);
else params.delete("platform");
}
if (next.q !== undefined) {
if (next.q) params.set("q", next.q);
else params.delete("q");
}
router.replace(`/admin/mapping?${params.toString()}`);
}
// Load merchants + canonical categories (needed for catalog tab, but also nice for filtering everywhere)
useEffect(() => { useEffect(() => {
async function load() { async function loadOptions() {
setOptionsLoading(true);
try { try {
setError(null);
// EXPECTED backend endpoint (new):
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
const res = await fetch('/api/admin/mapping/options', {
headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store",
});
if (!res.ok) {
// Dont hard-fail the whole page if options arent wired yet.
// Roles tab can still function using pending-buckets.
const txt = await res.text().catch(() => "");
console.warn("options endpoint not ready:", res.status, txt);
return;
}
const json = (await res.json()) as MappingOptionsResponse;
setMerchants(json.merchants ?? []);
setCanonicalCategories(json.canonicalCategories ?? []);
} catch (e) {
console.warn("Failed to load mapping options:", e);
} finally {
setOptionsLoading(false);
}
}
loadOptions();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, []);
// Default merchant if not provided and merchants list exists
useEffect(() => {
if (!merchantId && merchants.length > 0) {
setMerchantId(String(merchants[0].id));
syncUrl({ merchantId: String(merchants[0].id) });
}
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [merchants]);
// Core loader: depends on tab
async function load() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch( try {
`${API_BASE_URL}/api/admin/mapping/pending-buckets`, if (tab === "roles") {
{ headers: { Accept: "application/json" } } // Existing endpoint you already have:
); // GET /api/admin/mapping/pending-buckets
const res = await fetch('/api/admin/mapping/pending-buckets', {
headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store",
});
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
@@ -100,40 +258,71 @@ export default function MappingAdminPage() {
} }
const data: PendingBucket[] = await res.json(); const data: PendingBucket[] = await res.json();
setRows(data); setRoleBuckets(data);
setRawRows([]);
return;
}
// tab === "catalog"
// EXPECTED new endpoint:
// GET /api/admin/mapping/raw-categories?merchantId=4&platform=AR-15&q=...
// returns rows with productCount + mapping state including canonicalCategoryId
if (!merchantId) {
setRawRows([]);
setError("Pick a merchant to view catalog mappings.");
return;
}
const params = new URLSearchParams();
params.set("merchantId", merchantId);
if (platform?.trim()) params.set("platform", platform.trim());
if (q?.trim()) params.set("q", q.trim());
const res = await fetch(
`/api/admin/mapping/raw-categories?${params.toString()}`,
{ headers: { Accept: "application/json" }, credentials: 'include', cache: "no-store" }
);
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Catalog mapping endpoint not ready (${res.status})${text ? `: ${text}` : ""}`
);
}
const data: RawCategoryRow[] = await res.json();
setRawRows(data);
setRoleBuckets([]);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
setError(e.message ?? "Failed to load pending buckets"); setError(e?.message ?? "Failed to load mapping data");
} finally { } finally {
setLoading(false); setLoading(false);
} }
} }
// Load on mount + when tab changes
useEffect(() => {
load(); load();
}, []); // eslint-disable-next-line react-hooks/exhaustive-deps
}, [tab]);
// ===== Roles tab actions =====
const handleChangeRole = (idx: number, value: string) => { const handleChangeRole = (idx: number, value: string) => {
// Always store normalized kebab-case (defensive) const normalized = value ? normalizeRole(value) : "";
const normalized = value setRoleBuckets((prev) => {
? value.trim().toLowerCase().replace(/_/g, "-")
: "";
setRows((prev) => {
const next = [...prev]; const next = [...prev];
next[idx] = { ...next[idx], mappedPartRole: normalized || null }; next[idx] = { ...next[idx], mappedPartRole: normalized || null };
return next; return next;
}); });
}; };
const handleSave = async (row: PendingBucket) => { const handleSaveRole = async (row: PendingBucket) => {
if (!row.mappedPartRole) return; if (!row.mappedPartRole) return;
const mapped = row.mappedPartRole const mapped = normalizeRole(row.mappedPartRole);
.trim()
.toLowerCase()
.replace(/_/g, "-");
// Basic guard: prevent saving random roles from stale data
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]); const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
if (!allowed.has(mapped)) { if (!allowed.has(mapped)) {
setError( setError(
@@ -142,14 +331,17 @@ export default function MappingAdminPage() {
return; return;
} }
const key = `${row.merchantId}-${row.rawCategoryKey}`; const key = `roles-${row.merchantId}-${row.rawCategoryKey}`;
try { try {
setSavingKey(key); setSavingKey(key);
setError(null); setError(null);
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, { // Existing endpoint you already have:
// POST /api/admin/mapping/apply
const res = await fetch('/api/admin/mapping/apply', {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
merchantId: row.merchantId, merchantId: row.merchantId,
rawCategoryKey: row.rawCategoryKey, rawCategoryKey: row.rawCategoryKey,
@@ -164,8 +356,7 @@ export default function MappingAdminPage() {
); );
} }
// After save, remove this bucket from the list setRoleBuckets((prev) =>
setRows((prev) =>
prev.filter( prev.filter(
(r) => (r) =>
!( !(
@@ -176,46 +367,229 @@ export default function MappingAdminPage() {
); );
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
setError(e.message ?? "Failed to save mapping"); setError(e?.message ?? "Failed to save mapping");
} finally { } finally {
setSavingKey(null); setSavingKey(null);
} }
}; };
// ===== Catalog tab actions =====
const updateRawRow = (idx: number, patch: Partial<RawCategoryRow>) => {
setRawRows((prev) => {
const next = [...prev];
next[idx] = { ...next[idx], ...patch };
return next;
});
};
const handleSaveCatalog = async (row: RawCategoryRow) => {
if (!merchantId) return;
const key = `catalog-${row.merchantId}-${row.rawCategoryKey}`;
try {
setSavingKey(key);
setError(null);
// EXPECTED new endpoint:
// POST /api/admin/mapping/upsert
const res = await fetch('/api/admin/mapping/upsert', {
method: "POST",
headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({
merchantId: row.merchantId,
platform: row.platform ?? platform ?? null,
rawCategory: row.rawCategoryKey,
enabled: row.enabled ?? true,
canonicalCategoryId: row.canonicalCategoryId ?? null,
// do NOT overwrite part role here; backend should merge/partial update
canonicalPartRole: null,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(
`Failed to save catalog mapping (${res.status})${text ? `: ${text}` : ""}`
);
}
// Reload after save so we reflect the servers truth.
await load();
} catch (e: any) {
console.error(e);
setError(e?.message ?? "Failed to save catalog mapping");
} finally {
setSavingKey(null);
}
};
const totals = useMemo(() => {
if (tab === "roles") {
return {
buckets: roleBuckets.length,
products: roleBuckets.reduce((s, r) => s + (r.productCount ?? 0), 0),
};
}
return {
buckets: rawRows.length,
products: rawRows.reduce((s, r) => s + (r.productCount ?? 0), 0),
};
}, [tab, roleBuckets, rawRows]);
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6"> <div className="mx-auto max-w-6xl px-4 py-6">
<h1 className="text-xl font-semibold tracking-tight"> <div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
Bulk Mapping Pending Categories <div>
</h1> <h1 className="text-xl font-semibold tracking-tight">Admin Mapping</h1>
<p className="mt-1 text-sm text-zinc-400"> <p className="mt-1 text-sm text-zinc-400">
Bucketed by merchant + raw category. Map each bucket to a part role to Manage <span className="text-zinc-200">builder part roles</span> and{" "}
clean up the builder catalog. <span className="text-zinc-200">catalog categories</span> without
blending worlds.
</p> </p>
</div>
<div className="flex items-center gap-2">
<button
onClick={() => load()}
className="rounded-md border border-zinc-700 bg-zinc-950/60 px-3 py-1.5 text-xs font-semibold text-zinc-100 hover:bg-zinc-900/60"
>
Refresh
</button>
</div>
</div>
{/* Tabs */}
<div className="mt-4 flex items-center gap-2">
<button
onClick={() => {
setTab("roles");
syncUrl({ tab: "roles" });
}}
className={[
"rounded-md px-3 py-1.5 text-xs font-semibold",
tab === "roles"
? "bg-amber-400 text-black"
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
].join(" ")}
>
Builder Part Roles
</button>
<button
onClick={() => {
setTab("catalog");
syncUrl({ tab: "catalog" });
}}
className={[
"rounded-md px-3 py-1.5 text-xs font-semibold",
tab === "catalog"
? "bg-amber-400 text-black"
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
].join(" ")}
>
Catalog Categories
</button>
<span className="ml-auto text-xs text-zinc-400">
{totals.buckets} rows {totals.products} products
</span>
</div>
{/* Filters (catalog tab only) */}
{tab === "catalog" && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
<div>
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
Merchant
</label>
<select
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={merchantId}
onChange={(e) => {
setMerchantId(e.target.value);
syncUrl({ merchantId: e.target.value });
}}
>
<option value="">Select merchant</option>
{merchants.map((m) => (
<option key={m.id} value={String(m.id)}>
{m.name} (#{m.id})
</option>
))}
</select>
{optionsLoading && (
<p className="mt-1 text-[0.7rem] text-zinc-500">Loading merchants</p>
)}
</div>
<div>
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
Platform
</label>
<input
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={platform}
onChange={(e) => {
setPlatform(e.target.value);
syncUrl({ platform: e.target.value });
}}
placeholder="AR-15"
/>
</div>
<div>
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
Search raw categories
</label>
<div className="mt-1 flex gap-2">
<input
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={q}
onChange={(e) => {
setQ(e.target.value);
syncUrl({ q: e.target.value });
}}
placeholder="e.g. Charging Handles"
/>
<button
onClick={() => load()}
className="rounded-md bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
>
Search
</button>
</div>
</div>
</div>
</section>
)}
{loading && ( {loading && (
<p className="mt-4 text-sm text-zinc-400">Loading pending buckets</p> <p className="mt-4 text-sm text-zinc-400">Loading</p>
)} )}
{error && ( {error && (
<p className="mt-4 text-sm text-red-400">Error: {error}</p> <p className="mt-4 text-sm text-red-400">Error: {error}</p>
)} )}
{!loading && rows.length === 0 && !error && ( {/* ROLES TAB TABLE */}
{!loading && tab === "roles" && !error && roleBuckets.length === 0 && (
<p className="mt-4 text-sm text-emerald-400"> <p className="mt-4 text-sm text-emerald-400">
No pending mapping buckets 🎉 No pending role buckets 🎉
</p> </p>
)} )}
{!loading && rows.length > 0 && ( {!loading && tab === "roles" && roleBuckets.length > 0 && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3"> <section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<div className="mb-2 flex items-center justify-between"> <div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500"> <h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
Pending Buckets Pending Part Role Buckets
</h2> </h2>
<span className="text-xs text-zinc-400"> <span className="text-xs text-zinc-400">
{rows.length} buckets {" "} {totals.buckets} buckets {totals.products} products
{rows.reduce((s, r) => s + r.productCount, 0)} products
</span> </span>
</div> </div>
@@ -223,26 +597,17 @@ export default function MappingAdminPage() {
<table className="min-w-full text-xs"> <table className="min-w-full text-xs">
<thead> <thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60"> <tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-2 py-1 text-left text-zinc-400"> <th className="px-2 py-1 text-left text-zinc-400">Merchant</th>
Merchant <th className="px-2 py-1 text-left text-zinc-400">Raw Category</th>
</th> <th className="px-2 py-1 text-left text-zinc-400">Products</th>
<th className="px-2 py-1 text-left text-zinc-400"> <th className="px-2 py-1 text-left text-zinc-400">Part Role</th>
Raw Category <th className="px-2 py-1 text-right text-zinc-400">Action</th>
</th>
<th className="px-2 py-1 text-left text-zinc-400">
Products
</th>
<th className="px-2 py-1 text-left text-zinc-400">
Part Role
</th>
<th className="px-2 py-1 text-right text-zinc-400">
Action
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
{rows.map((row, idx) => { {roleBuckets.map((row, idx) => {
const rowKey = `${row.merchantId}-${row.rawCategoryKey}`; const rowKey = `roles-${row.merchantId}-${row.rawCategoryKey}`;
const isSaving = savingKey === rowKey; const isSaving = savingKey === rowKey;
return ( return (
@@ -250,25 +615,17 @@ export default function MappingAdminPage() {
key={rowKey} key={rowKey}
className="border-b border-zinc-900 hover:bg-zinc-900/40" className="border-b border-zinc-900 hover:bg-zinc-900/40"
> >
<td className="px-2 py-1 text-zinc-100"> <td className="px-2 py-1 text-zinc-100">{row.merchantName}</td>
{row.merchantName} <td className="px-2 py-1 text-zinc-300">{row.rawCategoryKey}</td>
</td> <td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
<td className="px-2 py-1 text-zinc-300">
{row.rawCategoryKey}
</td>
<td className="px-2 py-1 text-zinc-200">
{row.productCount}
</td>
<td className="px-2 py-1 text-zinc-100"> <td className="px-2 py-1 text-zinc-100">
<select <select
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400" className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={row.mappedPartRole ?? ""} value={row.mappedPartRole ?? ""}
onChange={(e) => onChange={(e) => handleChangeRole(idx, e.target.value)}
handleChangeRole(idx, e.target.value)
}
> >
<option value="">Select part role</option> <option value="">Select part role</option>
{options.map((opt) => ( {roleOptions.map((opt) => (
<option key={opt.value} value={opt.value}> <option key={opt.value} value={opt.value}>
{opt.label} ({opt.value}) {opt.label} ({opt.value})
</option> </option>
@@ -277,7 +634,7 @@ export default function MappingAdminPage() {
</td> </td>
<td className="px-2 py-1 text-right"> <td className="px-2 py-1 text-right">
<button <button
onClick={() => handleSave(row)} onClick={() => handleSaveRole(row)}
disabled={!row.mappedPartRole || isSaving} disabled={!row.mappedPartRole || isSaving}
className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700" className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700"
> >
@@ -292,6 +649,109 @@ export default function MappingAdminPage() {
</div> </div>
</section> </section>
)} )}
{/* CATALOG TAB TABLE */}
{!loading && tab === "catalog" && !error && rawRows.length === 0 && (
<p className="mt-4 text-sm text-zinc-400">
No rows loaded. Pick a merchant + click Search.
</p>
)}
{!loading && tab === "catalog" && rawRows.length > 0 && (
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
<div className="mb-2 flex items-center justify-between">
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
Catalog Category Mapping
</h2>
<span className="text-xs text-zinc-400">
{totals.buckets} rows {totals.products} products
</span>
</div>
<div className="mt-1 overflow-x-auto">
<table className="min-w-full text-xs">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-2 py-1 text-left text-zinc-400">Raw Category</th>
<th className="px-2 py-1 text-left text-zinc-400">Products</th>
<th className="px-2 py-1 text-left text-zinc-400">Canonical Category</th>
<th className="px-2 py-1 text-left text-zinc-400">Enabled</th>
<th className="px-2 py-1 text-right text-zinc-400">Action</th>
</tr>
</thead>
<tbody>
{rawRows.map((row, idx) => {
const rowKey = `catalog-${row.merchantId}-${row.rawCategoryKey}`;
const isSaving = savingKey === rowKey;
const currentCategoryId =
row.canonicalCategoryId != null ? String(row.canonicalCategoryId) : "";
return (
<tr
key={rowKey}
className="border-b border-zinc-900 hover:bg-zinc-900/40"
>
<td className="px-2 py-1 text-zinc-300">{row.rawCategoryKey}</td>
<td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
<td className="px-2 py-1 text-zinc-100">
<select
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
value={currentCategoryId}
onChange={(e) => {
const id = safeNum(e.target.value);
const opt = canonicalCategories.find((c) => c.id === id);
updateRawRow(idx, {
canonicalCategoryId: id,
canonicalCategoryName: opt?.name ?? null,
});
}}
>
<option value="">Select canonical category</option>
{canonicalCategories.map((c) => (
<option key={c.id} value={String(c.id)}>
{c.name}
</option>
))}
</select>
{canonicalCategories.length === 0 && (
<p className="mt-1 text-[0.7rem] text-zinc-500">
No canonical categories loaded (options endpoint may not be wired yet).
</p>
)}
</td>
<td className="px-2 py-1 text-zinc-100">
<label className="inline-flex items-center gap-2">
<input
type="checkbox"
checked={row.enabled ?? true}
onChange={(e) => updateRawRow(idx, { enabled: e.target.checked })}
/>
<span className="text-zinc-300">Enabled</span>
</label>
</td>
<td className="px-2 py-1 text-right">
<button
onClick={() => handleSaveCatalog(row)}
disabled={isSaving}
className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700"
>
{isSaving ? "Saving…" : "Save"}
</button>
</td>
</tr>
);
})}
</tbody>
</table>
</div>
</section>
)}
</div> </div>
</main> </main>
); );
@@ -1,6 +1,6 @@
"use client"; "use client";
import { useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useApi } from "@/lib/useApi"; import { useApi } from "@/lib/useApi";
type Merchant = { type Merchant = {
@@ -91,7 +91,7 @@ export default function MerchantCategoryMappingPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, []); }, []);
// 2) Load mappings when merchant changes (THIS sends merchantId) // 2) Load mappings when merchant changes (THIS sends merchantId)
useEffect(() => { const loadMappings = useCallback(() => {
if (selectedMerchantId == null) return; if (selectedMerchantId == null) return;
setLoadingMappings(true); setLoadingMappings(true);
@@ -112,7 +112,11 @@ export default function MerchantCategoryMappingPage() {
); );
}) })
.finally(() => setLoadingMappings(false)); .finally(() => setLoadingMappings(false));
}, [selectedMerchantId]); }, [selectedMerchantId, get]);
useEffect(() => {
loadMappings();
}, [loadMappings]);
// 3) Save a single mapping row (adjust endpoint if yours differs) // 3) Save a single mapping row (adjust endpoint if yours differs)
const handleSaveMapping = async ( const handleSaveMapping = async (
+435 -62
View File
@@ -2,8 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type MerchantAdminDto = { type MerchantAdminDto = {
id: number; id: number;
@@ -16,13 +15,50 @@ type MerchantAdminDto = {
lastOfferSyncAt: string | null; lastOfferSyncAt: string | null;
}; };
function formatDate(value: string | null) { // Helper: relative time formatting
if (!value) return "—"; function formatRelativeTime(value: string | null): string {
if (!value) return "Never";
const d = new Date(value);
if (Number.isNaN(d.getTime())) return "Invalid date";
const now = Date.now();
const diff = now - d.getTime();
const minutes = Math.floor(diff / 60000);
const hours = Math.floor(diff / 3600000);
const days = Math.floor(diff / 86400000);
if (minutes < 1) return "Just now";
if (minutes < 60) return `${minutes}m ago`;
if (hours < 24) return `${hours}h ago`;
if (days < 30) return `${days}d ago`;
return d.toLocaleDateString();
}
// Helper: full date formatting for tooltips
function formatFullDate(value: string | null): string {
if (!value) return "Never";
const d = new Date(value); const d = new Date(value);
if (Number.isNaN(d.getTime())) return value; if (Number.isNaN(d.getTime())) return value;
return d.toLocaleString(); return d.toLocaleString();
} }
// Helper: truncate URL for display
function truncateUrl(url: string | null, maxLength = 40): string {
if (!url) return "—";
if (url.length <= maxLength) return url;
return url.substring(0, maxLength) + "...";
}
// Helper: copy to clipboard
async function copyToClipboard(text: string) {
try {
await navigator.clipboard.writeText(text);
return true;
} catch {
return false;
}
}
export default function MerchantsAdminPage() { export default function MerchantsAdminPage() {
const [merchants, setMerchants] = useState<MerchantAdminDto[]>([]); const [merchants, setMerchants] = useState<MerchantAdminDto[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -31,6 +67,19 @@ export default function MerchantsAdminPage() {
const [importingId, setImportingId] = useState<number | null>(null); const [importingId, setImportingId] = useState<number | null>(null);
const [offerSyncId, setOfferSyncId] = useState<number | null>(null); const [offerSyncId, setOfferSyncId] = useState<number | null>(null);
const [banner, setBanner] = useState<string | null>(null); const [banner, setBanner] = useState<string | null>(null);
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
// --- new merchant form state ---
const [showNewMerchantForm, setShowNewMerchantForm] = useState(false);
const [creating, setCreating] = useState(false);
const [newMerchant, setNewMerchant] = useState({
name: "",
avantlinkMid: "",
feedUrl: "",
offerFeedUrl: "",
isActive: true,
});
// --- load merchants --- // --- load merchants ---
useEffect(() => { useEffect(() => {
@@ -41,7 +90,7 @@ export default function MerchantsAdminPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/admin/merchants`; const url = `/api/admin/merchants`;
console.log("Loading merchants from:", url); console.log("Loading merchants from:", url);
const res = await fetch(url, { const res = await fetch(url, {
@@ -81,6 +130,15 @@ export default function MerchantsAdminPage() {
); );
}; };
// --- copy URL handler ---
const handleCopyUrl = async (url: string, id: number) => {
const success = await copyToClipboard(url);
if (success) {
setCopiedUrl(`${id}-${url}`);
setTimeout(() => setCopiedUrl(null), 2000);
}
};
// --- save merchant (name/feed URLs/isActive) --- // --- save merchant (name/feed URLs/isActive) ---
const handleSave = async (id: number) => { const handleSave = async (id: number) => {
const merchant = merchants.find((m) => m.id === id); const merchant = merchants.find((m) => m.id === id);
@@ -91,7 +149,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, { const res = await fetch(`/api/admin/merchants/${id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -124,12 +182,13 @@ export default function MerchantsAdminPage() {
// --- run full import --- // --- run full import ---
const handleRunFullImport = async (id: number) => { const handleRunFullImport = async (id: number) => {
setOpenDropdown(null);
try { try {
setImportingId(id); setImportingId(id);
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, { const res = await fetch(`/api/admin/merchants/${id}/import`, {
method: "POST", method: "POST",
}); });
@@ -151,13 +210,14 @@ export default function MerchantsAdminPage() {
// --- run offer sync --- // --- run offer sync ---
const handleRunOfferSync = async (id: number) => { const handleRunOfferSync = async (id: number) => {
setOpenDropdown(null);
try { try {
setOfferSyncId(id); setOfferSyncId(id);
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`, `/api/admin/merchants/${id}/offer-sync`,
{ method: "POST" } { method: "POST" }
); );
@@ -179,9 +239,59 @@ export default function MerchantsAdminPage() {
} }
}; };
// --- create new merchant ---
const handleCreateMerchant = async () => {
try {
setCreating(true);
setError(null);
setBanner(null);
const res = await fetch(`/api/admin/merchants`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
name: newMerchant.name,
avantlinkMid: newMerchant.avantlinkMid,
feedUrl: newMerchant.feedUrl,
offerFeedUrl: newMerchant.offerFeedUrl || null,
isActive: newMerchant.isActive,
}),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
console.error("Merchant creation failed", res.status, text);
throw new Error(
`Creation failed (${res.status})${text ? `: ${text}` : ""}`
);
}
const created: MerchantAdminDto = await res.json();
setMerchants((prev) => [...prev, created]);
// Reset form
setNewMerchant({
name: "",
avantlinkMid: "",
feedUrl: "",
offerFeedUrl: "",
isActive: true,
});
setShowNewMerchantForm(false);
setBanner("Merchant created successfully.");
} catch (e: any) {
console.error("Error creating merchant", e);
setError(e?.message ?? "Failed to create merchant");
} finally {
setCreating(false);
setTimeout(() => setBanner(null), 3000);
}
};
return ( return (
<main className="min-h-screen bg-black text-zinc-50"> <main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10"> <div className="mx-auto max-w-7xl px-4 py-6 lg:py-10">
{/* Header */} {/* Header */}
<header className="mb-6 flex items-center justify-between gap-4"> <header className="mb-6 flex items-center justify-between gap-4">
<div> <div>
@@ -211,136 +321,399 @@ export default function MerchantsAdminPage() {
</div> </div>
)} )}
{/* Table */} {/* Add New Merchant Section */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-3 md:p-4"> <section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
{!showNewMerchantForm ? (
<button
type="button"
onClick={() => setShowNewMerchantForm(true)}
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
>
<span className="text-lg">+</span>
Add New Merchant
</button>
) : (
<div>
<div className="flex items-center justify-between mb-4">
<h2 className="text-lg font-semibold text-zinc-100">
Add New Merchant
</h2>
<button
type="button"
onClick={() => {
setShowNewMerchantForm(false);
setNewMerchant({
name: "",
avantlinkMid: "",
feedUrl: "",
offerFeedUrl: "",
isActive: true,
});
}}
className="text-zinc-400 hover:text-zinc-200 text-sm"
>
Cancel
</button>
</div>
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">
Merchant Name *
</label>
<input
type="text"
value={newMerchant.name}
onChange={(e) =>
setNewMerchant((prev) => ({ ...prev, name: e.target.value }))
}
placeholder="e.g., Brownells"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</div>
<div>
<label className="block text-xs font-medium text-zinc-400 mb-1">
AvantLink MID *
</label>
<input
type="text"
value={newMerchant.avantlinkMid}
onChange={(e) =>
setNewMerchant((prev) => ({
...prev,
avantlinkMid: e.target.value,
}))
}
placeholder="e.g., 12345"
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-zinc-400 mb-1">
Feed URL *
</label>
<input
type="text"
value={newMerchant.feedUrl}
onChange={(e) =>
setNewMerchant((prev) => ({ ...prev, feedUrl: e.target.value }))
}
placeholder="https://..."
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</div>
<div className="md:col-span-2">
<label className="block text-xs font-medium text-zinc-400 mb-1">
Offer Feed URL (optional)
</label>
<input
type="text"
value={newMerchant.offerFeedUrl}
onChange={(e) =>
setNewMerchant((prev) => ({
...prev,
offerFeedUrl: e.target.value,
}))
}
placeholder="https://..."
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/>
</div>
<div className="md:col-span-2">
<label className="inline-flex items-center gap-2 text-sm text-zinc-300">
<input
type="checkbox"
checked={newMerchant.isActive}
onChange={(e) =>
setNewMerchant((prev) => ({
...prev,
isActive: e.target.checked,
}))
}
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
/>
Active
</label>
</div>
</div>
<div className="mt-4 flex gap-3">
<button
type="button"
onClick={handleCreateMerchant}
disabled={
creating ||
!newMerchant.name ||
!newMerchant.avantlinkMid ||
!newMerchant.feedUrl
}
className={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
creating ||
!newMerchant.name ||
!newMerchant.avantlinkMid ||
!newMerchant.feedUrl
? "bg-amber-400/20 text-amber-200 cursor-not-allowed"
: "bg-amber-400 text-black hover:bg-amber-300"
}`}
>
{creating ? "Creating…" : "Create Merchant"}
</button>
</div>
</div>
)}
</section>
{/* Improved Table */}
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 overflow-hidden">
{loading ? ( {loading ? (
<p className="text-sm text-zinc-500">Loading merchants</p> <p className="text-sm text-zinc-500 p-4">Loading merchants</p>
) : merchants.length === 0 ? ( ) : merchants.length === 0 ? (
<p className="text-sm text-zinc-500"> <p className="text-sm text-zinc-500 p-4">
No merchants found. Seed some merchants in the backend. No merchants found. Use the "Add New Merchant" button above.
</p> </p>
) : ( ) : (
<div className="overflow-x-auto"> <div className="overflow-x-auto">
<table className="min-w-full text-left text-sm"> <table className="min-w-full text-left text-sm">
<thead> <thead className="sticky top-0 bg-zinc-950/95 backdrop-blur-sm">
<tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500"> <tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500">
<th className="py-2 pr-3">Merchant</th> <th className="py-3 px-4 font-medium">Merchant</th>
<th className="py-2 px-3">AvantLink MID</th> <th className="py-3 px-4 font-medium">MID</th>
<th className="py-2 px-3">Feed URL</th> <th className="py-3 px-4 font-medium">Feed URL</th>
<th className="py-2 px-3">Offer Feed URL</th> <th className="py-3 px-4 font-medium">Offer URL</th>
<th className="py-2 px-3">Active</th> <th className="py-3 px-4 font-medium">Status</th>
<th className="py-2 px-3">Last Full Import</th> <th className="py-3 px-4 font-medium">Last Import</th>
<th className="py-2 px-3">Last Offer Sync</th> <th className="py-3 px-4 font-medium text-right">Actions</th>
<th className="py-2 pl-3 text-right">Actions</th>
</tr> </tr>
</thead> </thead>
<tbody className="divide-y divide-zinc-800"> <tbody className="divide-y divide-zinc-800/50">
{merchants.map((m) => ( {merchants.map((m) => (
<tr key={m.id} className="align-top"> <tr key={m.id} className="hover:bg-zinc-900/30 transition-colors">
<td className="py-2 pr-3"> {/* Merchant Name */}
<td className="py-3 px-4">
<input <input
type="text" type="text"
value={m.name} value={m.name}
onChange={(e) => onChange={(e) =>
updateMerchantField(m.id, "name", e.target.value) updateMerchantField(m.id, "name", e.target.value)
} }
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0" className="w-full min-w-[140px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-sm font-medium text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/> />
</td> </td>
<td className="py-2 px-3"> {/* AvantLink MID */}
<td className="py-3 px-4">
<input <input
type="text" type="text"
value={m.avantlinkMid} value={m.avantlinkMid}
onChange={(e) => onChange={(e) =>
updateMerchantField(m.id, "avantlinkMid", e.target.value) updateMerchantField(m.id, "avantlinkMid", e.target.value)
} }
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0" className="w-full min-w-[80px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/> />
</td> </td>
<td className="py-2 px-3"> {/* Feed URL with copy button */}
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<input <input
type="text" type="text"
value={m.feedUrl ?? ""} value={m.feedUrl ?? ""}
onChange={(e) => onChange={(e) =>
updateMerchantField(m.id, "feedUrl", e.target.value) updateMerchantField(m.id, "feedUrl", e.target.value)
} }
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0" title={m.feedUrl ?? ""}
className="flex-1 min-w-[180px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/> />
<button
type="button"
onClick={() => handleCopyUrl(m.feedUrl, m.id)}
className="flex-shrink-0 p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
title="Copy URL"
>
{copiedUrl === `${m.id}-${m.feedUrl}` ? (
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
</div>
</td> </td>
<td className="py-2 px-3"> {/* Offer Feed URL with copy button */}
<td className="py-3 px-4">
<div className="flex items-center gap-2">
<input <input
type="text" type="text"
value={m.offerFeedUrl ?? ""} value={m.offerFeedUrl ?? ""}
onChange={(e) => onChange={(e) =>
updateMerchantField(m.id, "offerFeedUrl", e.target.value || null) updateMerchantField(m.id, "offerFeedUrl", e.target.value || null)
} }
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0" placeholder=""
title={m.offerFeedUrl ?? "None"}
className="flex-1 min-w-[180px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
/> />
{m.offerFeedUrl && (
<button
type="button"
onClick={() => handleCopyUrl(m.offerFeedUrl!, m.id)}
className="flex-shrink-0 p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
title="Copy URL"
>
{copiedUrl === `${m.id}-${m.offerFeedUrl}` ? (
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
</svg>
) : (
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
</svg>
)}
</button>
)}
</div>
</td> </td>
<td className="py-2 px-3"> {/* Status Badge (replacing checkbox) */}
<label className="inline-flex items-center gap-2 text-xs text-zinc-300"> <td className="py-3 px-4">
<input <button
type="checkbox" type="button"
checked={m.isActive} onClick={() =>
onChange={(e) => updateMerchantField(m.id, "isActive", !m.isActive)
updateMerchantField(m.id, "isActive", e.target.checked)
} }
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400" className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
m.isActive
? "bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20"
: "bg-zinc-700/50 text-zinc-400 hover:bg-zinc-700"
}`}
>
<span
className={`w-1.5 h-1.5 rounded-full ${
m.isActive ? "bg-emerald-400" : "bg-zinc-500"
}`}
/> />
Active {m.isActive ? "Active" : "Inactive"}
</label> </button>
</td> </td>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap"> {/* Last Import with tooltip */}
{formatDate(m.lastFullImportAt)} <td className="py-3 px-4">
<div className="flex flex-col gap-0.5">
<span
className="text-xs text-zinc-400"
title={`Full: ${formatFullDate(m.lastFullImportAt)}`}
>
{formatRelativeTime(m.lastFullImportAt)}
</span>
<span
className="text-[10px] text-zinc-500"
title={`Offers: ${formatFullDate(m.lastOfferSyncAt)}`}
>
Offers: {formatRelativeTime(m.lastOfferSyncAt)}
</span>
</div>
</td> </td>
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap"> {/* Actions Dropdown */}
{formatDate(m.lastOfferSyncAt)} <td className="py-3 px-4">
</td> <div className="flex items-center justify-end gap-2">
{/* Primary Save Button */}
<td className="py-2 pl-3">
<div className="flex flex-col items-end gap-1">
<button <button
type="button" type="button"
onClick={() => handleSave(m.id)} onClick={() => handleSave(m.id)}
disabled={savingId === m.id} disabled={savingId === m.id}
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${ className={`rounded px-3 py-1.5 text-xs font-medium transition-colors ${
savingId === m.id savingId === m.id
? "bg-amber-400/20 text-amber-200 cursor-wait" ? "bg-amber-400/20 text-amber-200 cursor-wait"
: "bg-amber-400 text-black hover:bg-amber-300" : "bg-amber-400 text-black hover:bg-amber-300"
}`} }`}
> >
{savingId === m.id ? "Saving…" : "Save"} {savingId === m.id ? (
<span className="flex items-center gap-1">
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Saving
</span>
) : (
"Save"
)}
</button> </button>
{/* Dropdown Menu */}
<div className="relative">
<button
type="button"
onClick={() =>
setOpenDropdown(openDropdown === m.id ? null : m.id)
}
className="p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
title="More actions"
>
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
</svg>
</button>
{openDropdown === m.id && (
<>
<div
className="fixed inset-0 z-10"
onClick={() => setOpenDropdown(null)}
/>
<div className="absolute right-0 mt-1 w-48 rounded-md border border-zinc-700 bg-zinc-900 shadow-lg z-20">
<div className="py-1">
<button <button
type="button" type="button"
onClick={() => handleRunFullImport(m.id)} onClick={() => handleRunFullImport(m.id)}
disabled={importingId === m.id} disabled={importingId === m.id}
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${ className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-wait"
importingId === m.id ? "opacity-60 cursor-wait" : ""
}`}
> >
{importingId === m.id ? "Importing…" : "Run Full Import"} {importingId === m.id ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Importing...
</span>
) : (
"Run Full Import"
)}
</button> </button>
<button <button
type="button" type="button"
onClick={() => handleRunOfferSync(m.id)} onClick={() => handleRunOfferSync(m.id)}
disabled={offerSyncId === m.id} disabled={offerSyncId === m.id}
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${ className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-wait"
offerSyncId === m.id ? "opacity-60 cursor-wait" : ""
}`}
> >
{offerSyncId === m.id ? "Syncing Offers…" : "Run Offer Sync"} {offerSyncId === m.id ? (
<span className="flex items-center gap-2">
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
</svg>
Syncing...
</span>
) : (
"Run Offer Sync"
)}
</button> </button>
</div> </div>
</div>
</>
)}
</div>
</div>
</td> </td>
</tr> </tr>
))} ))}
+4 -2
View File
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
import Link from "next/link"; import Link from "next/link";
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type AdminOverview = { type AdminOverview = {
totalProducts: number; totalProducts: number;
@@ -24,7 +24,9 @@ export default function AdminLandingPage() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`); const res = await fetch('/api/admin/dashboard/overview', {
credentials: 'include',
});
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load dashboard (${res.status})`); throw new Error(`Failed to load dashboard (${res.status})`);
} }
+27 -7
View File
@@ -71,7 +71,8 @@ export default function AdminPlatformsPage() {
} }
async function onDelete(p: Platform) { async function onDelete(p: Platform) {
if (!window.confirm(`Delete platform "${p.key}"? This cannot be undone.`)) return; if (!window.confirm(`Delete platform "${p.key}"? This cannot be undone.`))
return;
try { try {
setSaving(true); setSaving(true);
@@ -107,7 +108,8 @@ export default function AdminPlatformsPage() {
const update: UpdatePlatformDto = {}; const update: UpdatePlatformDto = {};
if (key !== selected.key) update.key = key; if (key !== selected.key) update.key = key;
if (label !== selected.label) update.label = label; if (label !== selected.label) update.label = label;
if ((form.isActive ?? true) !== selected.isActive) update.isActive = form.isActive; if ((form.isActive ?? true) !== selected.isActive)
update.isActive = form.isActive;
await updatePlatform(getAuthHeaders(), selected.id, update); await updatePlatform(getAuthHeaders(), selected.id, update);
} }
@@ -164,7 +166,9 @@ export default function AdminPlatformsPage() {
<th className="border-b border-zinc-900 px-3 py-2">Key</th> <th className="border-b border-zinc-900 px-3 py-2">Key</th>
<th className="border-b border-zinc-900 px-3 py-2">Label</th> <th className="border-b border-zinc-900 px-3 py-2">Label</th>
<th className="border-b border-zinc-900 px-3 py-2">Active</th> <th className="border-b border-zinc-900 px-3 py-2">Active</th>
<th className="border-b border-zinc-900 px-3 py-2 text-right">Actions</th> <th className="border-b border-zinc-900 px-3 py-2 text-right">
Actions
</th>
</tr> </tr>
</thead> </thead>
<tbody> <tbody>
@@ -245,7 +249,15 @@ export default function AdminPlatformsPage() {
</label> </label>
<input <input
value={form.key} value={form.key}
onChange={(e) => setForm((s) => ({ ...s, key: e.target.value }))} onChange={(e) =>
setForm((s) => ({
...s,
key: e.target.value
.toUpperCase()
.replace(/\s+/g, "") // remove spaces
.replace(/-/g, ""),
}))
}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400" className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400"
placeholder="AR15" placeholder="AR15"
required required
@@ -258,7 +270,9 @@ export default function AdminPlatformsPage() {
</label> </label>
<input <input
value={form.label} value={form.label}
onChange={(e) => setForm((s) => ({ ...s, label: e.target.value }))} onChange={(e) =>
setForm((s) => ({ ...s, label: e.target.value }))
}
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400" className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400"
placeholder="AR-15" placeholder="AR-15"
required required
@@ -269,7 +283,9 @@ export default function AdminPlatformsPage() {
<input <input
type="checkbox" type="checkbox"
checked={!!form.isActive} checked={!!form.isActive}
onChange={(e) => setForm((s) => ({ ...s, isActive: e.target.checked }))} onChange={(e) =>
setForm((s) => ({ ...s, isActive: e.target.checked }))
}
className="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-amber-400" className="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-amber-400"
/> />
Active Active
@@ -289,7 +305,11 @@ export default function AdminPlatformsPage() {
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50" className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
disabled={saving} disabled={saving}
> >
{saving ? "Saving…" : modalMode === "create" ? "Create" : "Update"} {saving
? "Saving…"
: modalMode === "create"
? "Create"
: "Update"}
</button> </button>
</div> </div>
</form> </form>
+366
View File
@@ -0,0 +1,366 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import type { CaliberDto, PlatformDto } from "../_lib/types";
import { RightDrawer } from "./RightDrawer";
type ProductVisibility = "PUBLIC" | "HIDDEN";
type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
type BulkUpdateSet = Partial<{
visibility: ProductVisibility;
status: ProductStatus;
builderEligible: boolean;
adminLocked: boolean;
adminNote: string;
platform: string;
platformLocked: boolean;
caliber: string;
caliberGroup: string;
caliberLocked: boolean;
forceCaliberUpdate: boolean;
partRole: string;
partRoleLocked: boolean;
classificationReason: "Admin bulk override";
}>;
export function BulkBar(props: {
selectedCount: number;
loading: boolean;
// options
platformOptions: PlatformDto[];
roleOptions: string[];
caliberOptions: CaliberDto[];
// state
bulkPlatformKey: string;
setBulkPlatformKey: (v: string) => void;
bulkPlatformLock: boolean;
setBulkPlatformLock: (v: boolean) => void;
bulkRole: string;
setBulkRole: (v: string) => void;
bulkRoleLock: boolean;
setBulkRoleLock: (v: boolean) => void;
bulkCaliber: string;
setBulkCaliber: (v: string) => void;
bulkCaliberGroup: string;
setBulkCaliberGroup: (v: string) => void;
bulkCaliberLock: boolean;
setBulkCaliberLock: (v: boolean) => void;
bulkForceCaliber: boolean;
setBulkForceCaliber: (v: boolean) => void;
// action
runBulk: (set: BulkUpdateSet) => void;
}) {
const {
selectedCount,
loading,
platformOptions,
roleOptions,
caliberOptions,
bulkPlatformKey,
setBulkPlatformKey,
bulkPlatformLock,
setBulkPlatformLock,
bulkRole,
setBulkRole,
bulkRoleLock,
setBulkRoleLock,
bulkCaliber,
setBulkCaliber,
bulkCaliberGroup,
setBulkCaliberGroup,
bulkCaliberLock,
setBulkCaliberLock,
bulkForceCaliber,
setBulkForceCaliber,
runBulk,
} = props;
const [open, setOpen] = useState(false);
const disabled = loading || selectedCount <= 0;
// one button to apply staged field updates
const applyFieldChanges = useCallback(() => {
const patch: BulkUpdateSet = { classificationReason: "Admin bulk override" };
if (bulkPlatformKey) {
patch.platform = bulkPlatformKey;
if (bulkPlatformLock) patch.platformLocked = true;
}
if (bulkRole) {
patch.partRole = bulkRole;
patch.partRoleLocked = bulkRoleLock;
}
if (bulkCaliber) patch.caliber = bulkCaliber;
if (bulkCaliberGroup.trim()) patch.caliberGroup = bulkCaliberGroup.trim();
if (bulkCaliber || bulkCaliberGroup.trim()) {
patch.caliberLocked = bulkCaliberLock;
patch.forceCaliberUpdate = bulkForceCaliber;
}
// If user didn't stage anything, do nothing
const keys = Object.keys(patch).filter((k) => k !== "classificationReason");
if (keys.length === 0) return;
runBulk(patch);
}, [bulkPlatformKey, bulkPlatformLock, bulkRole, bulkRoleLock, bulkCaliber, bulkCaliberGroup, bulkCaliberLock, bulkForceCaliber, runBulk]);
if (selectedCount <= 0) return null;
let footer: JSX.Element;
// eslint-disable-next-line react-hooks/rules-of-hooks
footer = useMemo(() => {
return (
<div className="flex items-center justify-between gap-2">
<div className="text-sm text-white/60">
Selected: <span className="font-semibold text-white">{selectedCount}</span>
</div>
<button
className="rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold hover:bg-emerald-500 disabled:opacity-50"
disabled={disabled}
onClick={() => {
applyFieldChanges();
setOpen(false);
}}
>
Apply changes
</button>
</div>
);
}, [applyFieldChanges, disabled, selectedCount]);
return (
<>
{/* Tiny inline bar */}
<div className="mb-3 flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
<div className="text-sm opacity-80">
Selected: <span className="font-semibold">{selectedCount}</span>
</div>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => setOpen(true)}
>
Bulk actions
</button>
</div>
{/* Drawer: all bulk controls */}
<RightDrawer
open={open}
onClose={setOpen}
title="Bulk actions"
widthClassName="max-w-lg"
footer={footer}
>
<div className="space-y-6">
{/* Set fields */}
<section className="rounded-lg border border-white/10 bg-white/5 p-4">
<div className="mb-3 text-xs font-semibold uppercase tracking-wide text-white/60">
Set fields
</div>
<div className="grid grid-cols-1 gap-3">
{/* Platform */}
<div className="grid gap-2">
<label className="text-xs text-white/60">Platform</label>
<div className="flex items-center gap-2">
<select
value={bulkPlatformKey}
onChange={(e) => setBulkPlatformKey(e.target.value)}
className="flex-1 rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value=""> No change </option>
{platformOptions.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
<label className="flex items-center gap-2 text-xs text-white/70">
<input
type="checkbox"
checked={bulkPlatformLock}
onChange={(e) => setBulkPlatformLock(e.target.checked)}
/>
Lock
</label>
</div>
</div>
{/* Role */}
<div className="grid gap-2">
<label className="text-xs text-white/60">Role</label>
<div className="flex items-center gap-2">
<select
value={bulkRole}
onChange={(e) => setBulkRole(e.target.value)}
className="flex-1 rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value=""> No change </option>
{roleOptions.filter(Boolean).map((r) => (
<option key={r} value={r}>
{r}
</option>
))}
</select>
<label className="flex items-center gap-2 text-xs text-white/70">
<input
type="checkbox"
checked={bulkRoleLock}
onChange={(e) => setBulkRoleLock(e.target.checked)}
/>
Lock
</label>
</div>
</div>
{/* Caliber */}
<div className="grid gap-2">
<label className="text-xs text-white/60">Caliber</label>
<select
value={bulkCaliber}
onChange={(e) => setBulkCaliber(e.target.value)}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value=""> No change </option>
{caliberOptions
.filter((c) => Boolean(c && c.isActive && c.key))
.map((c) => (
<option key={c.id ?? c.key} value={c.key}>
{c.label || c.key}
</option>
))}
</select>
<input
value={bulkCaliberGroup}
onChange={(e) => setBulkCaliberGroup(e.target.value)}
placeholder="Caliber group (optional)"
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
/>
<div className="flex flex-wrap items-center gap-4">
<label className="flex items-center gap-2 text-xs text-white/70">
<input
type="checkbox"
checked={bulkCaliberLock}
onChange={(e) => setBulkCaliberLock(e.target.checked)}
/>
Lock caliber
</label>
<label className="flex items-center gap-2 text-xs text-white/70">
<input
type="checkbox"
checked={bulkForceCaliber}
onChange={(e) => setBulkForceCaliber(e.target.checked)}
/>
Force update locked
</label>
</div>
</div>
</div>
<div className="mt-4 text-xs text-white/40">
Tip: stage changes above, then use <span className="text-white/70">Apply changes</span>.
</div>
</section>
{/* Quick actions */}
<section className="rounded-lg border border-white/10 bg-white/5 p-4">
<div className="mb-3 text-xs font-semibold uppercase tracking-wide text-white/60">
Quick actions
</div>
<div className="flex flex-wrap gap-2">
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ builderEligible: false })}
>
Remove from Builder
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ visibility: "HIDDEN" })}
>
Hide
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ status: "DISABLED" })}
>
Disable
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ adminLocked: true })}
>
Lock
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading}
onClick={() => runBulk({ adminLocked: false })}
>
Unlock
</button>
</div>
</section>
{/* Danger zone */}
<section className="rounded-lg border border-red-900/40 bg-red-950/20 p-4">
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-red-200">
Danger zone
</div>
<button
className="rounded-md bg-red-600/80 px-3 py-2 text-sm font-semibold hover:bg-red-600 disabled:opacity-50"
disabled={loading}
onClick={() =>
runBulk({
visibility: "HIDDEN",
builderEligible: false,
adminLocked: true,
adminNote: "Hidden + removed from builder (bulk)",
})
}
title="Hides products, removes from builder, and locks editing"
>
Hide + Remove + Lock
</button>
</section>
</div>
</RightDrawer>
</>
);
}
@@ -0,0 +1,440 @@
/* eslint-disable */
"use client";
import type { AdminFilters, PlatformDto, CaliberDto } from "../_lib/types";
import { useMemo, useState } from "react";
import { RightDrawer } from "./RightDrawer";
import { Field } from "./ui";
type VisibleCols = {
brand: boolean;
platform: boolean;
role: boolean;
caliber: boolean;
import: boolean;
flags: boolean;
updated: boolean;
};
function countActiveFilters(draft: AdminFilters) {
const entries: [string, any][] = [
["platform", draft.platform],
["partRole", draft.partRole],
["caliber", draft.caliber],
["visibility", draft.visibility],
["status", draft.status],
["builderEligible", draft.builderEligible],
["adminLocked", draft.adminLocked],
];
return entries.filter(([, v]) => v !== "" && v != null).length;
}
export default function ProductsToolbar({
draft,
setDraft,
platformOptions,
roleOptions,
caliberOptions,
loading,
onApply,
onClear,
selectedCount,
visibleCols,
setVisibleCols,
}: {
draft: AdminFilters;
setDraft: React.Dispatch<React.SetStateAction<AdminFilters>>;
platformOptions: PlatformDto[];
roleOptions: string[];
caliberOptions: CaliberDto[];
loading: boolean;
onApply: () => void;
onClear: () => void;
selectedCount: number;
// column toggles
visibleCols: VisibleCols;
setVisibleCols: React.Dispatch<React.SetStateAction<VisibleCols>>;
}) {
const [open, setOpen] = useState(false);
const activeCount = useMemo(() => countActiveFilters(draft), [draft]);
return (
<>
{/* Top toolbar: stays small even on 14" */}
<div className="mb-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
<div className="flex flex-wrap items-end gap-2">
<Field label="Search" widthClass="min-w-[280px] flex-1">
<input
value={draft.q}
onChange={(e) => setDraft((s) => ({ ...s, q: e.target.value }))}
placeholder="name, slug, mpn, upc…"
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
/>
</Field>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900"
onClick={() => setOpen(true)}
disabled={loading}
>
Filters{activeCount ? ` (${activeCount})` : ""}
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={selectedCount === 0 || loading}
title={
selectedCount === 0
? "Select rows to enable bulk actions"
: undefined
}
onClick={() => setOpen(true)}
>
Bulk ({selectedCount})
</button>
<div className="ml-auto flex items-center gap-2">
<button
className="rounded-md bg-emerald-600 px-3 py-2 text-sm hover:bg-emerald-500 disabled:opacity-50"
onClick={onApply}
disabled={loading}
>
Refresh
</button>
</div>
</div>
</div>
{/* Drawer: all the heavy controls */}
<RightDrawer
open={open}
onClose={setOpen}
title="Filters & Actions"
widthClassName="max-w-lg"
footer={
<div className="flex items-center justify-between gap-2">
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
onClick={onClear}
disabled={loading}
>
Clear
</button>
<div className="flex items-center gap-2">
<button
className="rounded-md bg-emerald-600 px-3 py-2 text-sm hover:bg-emerald-500 disabled:opacity-50"
onClick={() => {
onApply();
setOpen(false);
}}
disabled={loading}
>
Apply
</button>
</div>
</div>
}
>
<div className="space-y-6">
{/* Filters */}
<div className="text-xs uppercase tracking-wide text-white/50">
Filters
</div>
<div className="grid grid-cols-1 gap-3">
<Field label="Platform">
<select
value={draft.platform}
onChange={(e) =>
setDraft((s) => ({ ...s, platform: e.target.value }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
{platformOptions.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
</Field>
<Field label="Role">
<select
value={draft.partRole}
onChange={(e) =>
setDraft((s) => ({ ...s, partRole: e.target.value }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
{roleOptions.map((r) => (
<option key={r || "any"} value={r}>
{r || "Any"}
</option>
))}
</select>
</Field>
<Field label="Caliber">
<select
value={draft.caliber}
onChange={(e) =>
setDraft((s) => ({ ...s, caliber: e.target.value }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="__UNSET__"> Not set </option>
{caliberOptions
.filter((c) => !!c && !!c.key)
.map((c) => (
<option key={c.id ?? c.key} value={c.key}>
{c.label || c.key}
</option>
))}
</select>
</Field>
<Field label="Visibility">
<select
value={draft.visibility}
onChange={(e) =>
setDraft((s) => ({
...s,
visibility: e.target.value as any,
}))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="PUBLIC">PUBLIC</option>
<option value="HIDDEN">HIDDEN</option>
</select>
</Field>
<Field label="Status">
<select
value={draft.status}
onChange={(e) =>
setDraft((s) => ({ ...s, status: e.target.value as any }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="ACTIVE">ACTIVE</option>
<option value="DISABLED">DISABLED</option>
<option value="ARCHIVED">ARCHIVED</option>
</select>
</Field>
<Field label="Builder">
<select
value={draft.builderEligible}
onChange={(e) =>
setDraft((s) => ({
...s,
builderEligible: e.target.value as any,
}))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="true">Eligible</option>
<option value="false">Not eligible</option>
</select>
</Field>
<Field label="Locked">
<select
value={draft.adminLocked}
onChange={(e) =>
setDraft((s) => ({
...s,
adminLocked: e.target.value as any,
}))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="true">Locked</option>
<option value="false">Unlocked</option>
</select>
</Field>
</div>
{/* Columns */}
<div className="pt-2">
<div className="text-xs uppercase tracking-wide text-white/50">
Columns
</div>
<div className="mt-2 rounded-lg border border-white/10 bg-white/5 p-4">
{/* Presets */}
<div className="mb-3 flex flex-wrap gap-2">
<button
type="button"
className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs hover:bg-zinc-900"
onClick={() =>
setVisibleCols({
brand: true,
platform: true,
role: true,
caliber: true,
import: true,
flags: true,
updated: true,
})
}
>
Full
</button>
<button
type="button"
className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs hover:bg-zinc-900"
onClick={() =>
setVisibleCols({
brand: true,
platform: true,
role: true,
caliber: true,
import: true,
flags: false,
updated: false,
})
}
>
Compact
</button>
<button
type="button"
className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs hover:bg-zinc-900"
onClick={() =>
setVisibleCols({
brand: false,
platform: true,
role: true,
caliber: true,
import: true,
flags: true,
updated: false,
})
}
>
Review
</button>
</div>
{/* Toggles */}
<div className="grid grid-cols-2 gap-2 text-sm">
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={visibleCols.brand}
onChange={() =>
setVisibleCols((c) => ({ ...c, brand: !c.brand }))
}
/>
Brand
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={visibleCols.platform}
onChange={() =>
setVisibleCols((c) => ({
...c,
platform: !c.platform,
}))
}
/>
Platform
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={visibleCols.role}
onChange={() =>
setVisibleCols((c) => ({ ...c, role: !c.role }))
}
/>
Role
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={visibleCols.caliber}
onChange={() =>
setVisibleCols((c) => ({ ...c, caliber: !c.caliber }))
}
/>
Caliber
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={visibleCols.import}
onChange={() =>
setVisibleCols((c) => ({ ...c, import: !c.import }))
}
/>
Import
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={visibleCols.flags}
onChange={() =>
setVisibleCols((c) => ({ ...c, flags: !c.flags }))
}
/>
Flags
</label>
<label className="flex items-center gap-2">
<input
type="checkbox"
checked={visibleCols.updated}
onChange={() =>
setVisibleCols((c) => ({ ...c, updated: !c.updated }))
}
/>
Updated
</label>
</div>
<p className="mt-3 text-xs text-white/40">
Product + checkbox are always shown.
</p>
</div>
</div>
{/* Bulk actions placeholder */}
<div className="pt-2">
<div className="text-xs uppercase tracking-wide text-white/50">
Bulk actions
</div>
<div className="mt-2 rounded-md border border-zinc-800 bg-black/30 p-3 text-sm text-white/70">
{selectedCount === 0
? "Select rows to enable bulk actions."
: `Ready for bulk actions on ${selectedCount} selected item(s).`}
<div className="mt-2 text-xs text-white/40">
(Hook your actual bulk controls here next.)
</div>
</div>
</div>
</div>
</RightDrawer>
</>
);
}
@@ -0,0 +1,86 @@
"use client";
import { Fragment } from "react";
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
import { XMarkIcon } from "@heroicons/react/24/outline";
export function RightDrawer({
open,
onClose,
title,
children,
footer,
widthClassName = "max-w-lg",
}: {
open: boolean;
onClose: (open: boolean) => void;
title: string;
children: React.ReactNode;
footer?: React.ReactNode;
widthClassName?: string;
}) {
return (
<Transition show={open} as={Fragment}>
<Dialog onClose={onClose} className="relative z-50">
<TransitionChild
as={Fragment}
enter="ease-out duration-200"
enterFrom="opacity-0"
enterTo="opacity-100"
leave="ease-in duration-150"
leaveFrom="opacity-100"
leaveTo="opacity-0"
>
<div className="fixed inset-0 bg-black/60" />
</TransitionChild>
<div className="fixed inset-0 overflow-hidden">
<div className="absolute inset-0 overflow-hidden">
<div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
<TransitionChild
as={Fragment}
enter="transform transition ease-in-out duration-300"
enterFrom="translate-x-full"
enterTo="translate-x-0"
leave="transform transition ease-in-out duration-200"
leaveFrom="translate-x-0"
leaveTo="translate-x-full"
>
<DialogPanel
className={`pointer-events-auto w-screen ${widthClassName} bg-zinc-950 shadow-xl ring-1 ring-white/10`}
>
<div className="flex h-full flex-col">
<div className="flex items-start justify-between border-b border-white/10 px-4 py-4">
<DialogTitle className="text-base font-semibold text-white">
{title}
</DialogTitle>
<button
type="button"
onClick={() => onClose(false)}
className="rounded-md p-2 text-white/60 hover:text-white hover:bg-white/5 focus:outline-none focus:ring-2 focus:ring-emerald-500"
>
<span className="sr-only">Close panel</span>
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
</button>
</div>
<div className="flex-1 overflow-y-auto px-4 py-4">
{children}
</div>
{footer ? (
<div className="border-t border-white/10 px-4 py-3">
{footer}
</div>
) : null}
</div>
</DialogPanel>
</TransitionChild>
</div>
</div>
</div>
</Dialog>
</Transition>
);
}
@@ -0,0 +1,330 @@
"use client";
import { useEffect, useMemo, useState } from "react";
import type {
AdminFilters,
AdminProductRow,
PageResponse,
PlatformDto,
CaliberDto,
} from "../_lib/types";
import { initialFilters } from "../_lib/types";
import {
bulkUpdate,
fetchAdminCalibers,
fetchAdminProducts,
fetchAdminRoles,
fetchPlatforms,
} from "../_lib/api";
import FilterChips from "./filterChips";
import ProductsTable from "./productsTable";
import PaginationBar from "./paginationBar";
import { BulkBar } from "./BulkBar";
import ProductsToolbar from "./ProductsToolbar";
export default function AdminProductsClient() {
const [page, setPage] = useState(0);
const [size, setSize] = useState(50);
const [platformOptions, setPlatformOptions] = useState<PlatformDto[]>([]);
const [data, setData] = useState<PageResponse<AdminProductRow> | null>(null);
const [loading, setLoading] = useState(false);
const [err, setErr] = useState<string | null>(null);
const [selected, setSelected] = useState<Set<number>>(new Set());
const [draft, setDraft] = useState<AdminFilters>(initialFilters);
const [applied, setApplied] = useState<AdminFilters>(initialFilters);
const [roleOptions, setRoleOptions] = useState<string[]>([""]);
const [caliberOptions, setCaliberOptions] = useState<CaliberDto[]>([]);
// ------------------------------
// Column Collasping
// ------------------------------
const [visibleCols, setVisibleCols] = useState({
brand: true,
platform: true,
role: true,
caliber: true,
import: true,
flags: true,
updated: true,
});
// ------------------------------
// Bulk action state
// ------------------------------
const [bulkPlatformKey, setBulkPlatformKey] = useState("");
const [bulkPlatformLock, setBulkPlatformLock] = useState(false);
const [bulkRole, setBulkRole] = useState("");
const [bulkRoleLock, setBulkRoleLock] = useState(false);
const [bulkCaliber, setBulkCaliber] = useState("");
const [bulkCaliberGroup, setBulkCaliberGroup] = useState("");
const [bulkCaliberLock, setBulkCaliberLock] = useState(false);
const [bulkForceCaliber, setBulkForceCaliber] = useState(false);
const queryParams = useMemo(() => {
return {
...applied,
builderEligible:
applied.builderEligible === "" ? null : applied.builderEligible,
adminLocked: applied.adminLocked === "" ? null : applied.adminLocked,
page,
size,
sort: "updatedAt,desc",
};
}, [applied, page, size]);
const refresh = async () => {
setLoading(true);
setErr(null);
try {
const res: any = await fetchAdminProducts(queryParams);
// API returns `{ content, page: { size, number, totalElements, totalPages } }`
// but the UI expects `totalPages/totalElements/number/size` at the top-level.
const normalized: any = res?.page
? {
content: res.content ?? [],
...res.page,
}
: res;
setData(normalized);
setSelected(new Set());
} catch (e: any) {
setErr(e?.message ?? "Failed to load.");
} finally {
setLoading(false);
}
};
useEffect(() => {
refresh();
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [queryParams]);
useEffect(() => {
(async () => {
try {
const roles = await fetchAdminRoles({});
setRoleOptions(["", ...roles]);
} catch {
setRoleOptions([""]);
}
})();
}, []);
useEffect(() => {
(async () => {
try {
const cals = await fetchAdminCalibers({ activeOnly: true });
setCaliberOptions(Array.isArray(cals) ? cals : []);
} catch {
setCaliberOptions([]);
}
})();
}, []);
useEffect(() => {
(async () => {
try {
const plats = await fetchPlatforms();
plats.sort((a, b) => a.label.localeCompare(b.label));
setPlatformOptions(plats);
} catch (e: any) {
console.warn(e?.message ?? "Failed to load platforms");
}
})();
}, []);
const content = data?.content ?? [];
const selectedIds = Array.from(selected);
const allOnPageSelected =
content.length > 0 && content.every((r) => selected.has(r.id));
const toggleAllOnPage = () => {
const next = new Set(selected);
if (allOnPageSelected) content.forEach((r) => next.delete(r.id));
else content.forEach((r) => next.add(r.id));
setSelected(next);
};
const toggleOne = (id: number) => {
const next = new Set(selected);
if (next.has(id)) next.delete(id);
else next.add(id);
setSelected(next);
};
const applyFilters = () => {
setPage(0);
setApplied({ ...draft });
};
const clearFilters = () => {
setDraft(initialFilters);
setApplied(initialFilters);
setPage(0);
};
const chips = useMemo(() => {
const out: { key: keyof AdminFilters; label: string }[] = [];
if (applied.q) out.push({ key: "q", label: `Search: ${applied.q}` });
if (applied.platform)
out.push({ key: "platform", label: `Platform: ${applied.platform}` });
if (applied.partRole)
out.push({ key: "partRole", label: `Role: ${applied.partRole}` });
if (applied.caliber)
out.push({ key: "caliber", label: `Caliber: ${applied.caliber}` });
if (applied.visibility)
out.push({
key: "visibility",
label: `Visibility: ${applied.visibility}`,
});
if (applied.status)
out.push({ key: "status", label: `Status: ${applied.status}` });
if (applied.builderEligible)
out.push({
key: "builderEligible",
label: `Builder: ${applied.builderEligible}`,
});
if (applied.adminLocked)
out.push({ key: "adminLocked", label: `Locked: ${applied.adminLocked}` });
return out;
}, [applied]);
const clearOne = async (key: keyof AdminFilters) => {
setDraft((d) => ({ ...d, [key]: "" } as AdminFilters));
setApplied((a) => ({ ...a, [key]: "" } as AdminFilters));
setPage(0);
};
const runBulk = async (set: Parameters<typeof bulkUpdate>[1]) => {
if (selectedIds.length === 0) return;
setLoading(true);
setErr(null);
try {
await bulkUpdate(selectedIds, set);
await refresh();
} catch (e: any) {
setErr(e?.message ?? "Bulk update failed.");
setLoading(false);
}
};
return (
// Prevent page-level horizontal scroll; allow table-only horizontal scroll.
<div className="w-full max-w-full overflow-x-hidden">
{/* Header */}
<div className="mb-4 flex items-start justify-between gap-4">
<div className="min-w-0">
<h1 className="text-2xl font-semibold">Admin · Products</h1>
<p className="text-sm opacity-70">
Filter, select, and apply bulk actions. This is where bad imports
come to die.
</p>
</div>
<button
className="shrink-0 rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
onClick={() => refresh()}
disabled={loading}
>
Refresh
</button>
</div>
{/* OPTIONAL IMPROVEMENT:
Sticky controls area so table scroll doesn't push search/actions away.
If you hate sticky, remove the wrapper div and keep the inner content. */}
<div className="sticky top-0 z-20 -mx-2 px-2 pb-3 pt-2 bg-black/70 backdrop-blur border-b border-zinc-900">
<ProductsToolbar
draft={draft}
setDraft={setDraft}
platformOptions={platformOptions}
roleOptions={roleOptions}
caliberOptions={caliberOptions}
loading={loading}
onApply={applyFilters}
onClear={clearFilters}
selectedCount={selected.size}
visibleCols={visibleCols}
setVisibleCols={setVisibleCols}
/>
{/* Chips stay visible too */}
<FilterChips chips={chips} onClearOne={(k) => void clearOne(k)} />
{/* Bulk actions */}
{selectedIds.length > 0 && (
<BulkBar
selectedCount={selectedIds.length}
loading={loading}
platformOptions={platformOptions}
roleOptions={roleOptions}
caliberOptions={caliberOptions}
bulkPlatformKey={bulkPlatformKey}
setBulkPlatformKey={setBulkPlatformKey}
bulkPlatformLock={bulkPlatformLock}
setBulkPlatformLock={setBulkPlatformLock}
bulkRole={bulkRole}
setBulkRole={setBulkRole}
bulkRoleLock={bulkRoleLock}
setBulkRoleLock={setBulkRoleLock}
bulkCaliber={bulkCaliber}
setBulkCaliber={setBulkCaliber}
bulkCaliberGroup={bulkCaliberGroup}
setBulkCaliberGroup={setBulkCaliberGroup}
bulkCaliberLock={bulkCaliberLock}
setBulkCaliberLock={setBulkCaliberLock}
bulkForceCaliber={bulkForceCaliber}
setBulkForceCaliber={setBulkForceCaliber}
runBulk={runBulk}
/>
)}
{err && (
<div className="mt-3 rounded-md border border-red-800 bg-red-950/40 px-3 py-2 text-sm">
{err}
</div>
)}
</div>
{/* Table + pagination area
min-w-0 is crucial to stop flex children from forcing horizontal overflow */}
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 pt-3">
{/* This min-w-0 wrapper ensures the table container can shrink to viewport width.
The ProductsTable itself should have overflow-x-auto on its wrapper and a min-w on the table. */}
<div className="min-w-0">
<ProductsTable
rows={content}
loading={loading}
selected={selected}
onToggleAll={toggleAllOnPage}
onToggleOne={toggleOne}
allOnPageSelected={allOnPageSelected}
visibleCols={visibleCols}
/>
</div>
<PaginationBar
page={page}
setPage={setPage}
size={size}
setSize={setSize}
loading={loading}
totalPages={(data as any)?.totalPages ?? (data as any)?.page?.totalPages ?? 1}
totalElements={(data as any)?.totalElements ?? (data as any)?.page?.totalElements ?? 0}
number={(data as any)?.number ?? (data as any)?.page?.number ?? page}
/>
</div>
</div>
);
}
@@ -0,0 +1,170 @@
import type { AdminFilters, PlatformDto } from "../_lib/types";
import { Field } from "./ui";
export default function FilterBar({
draft,
setDraft,
platformOptions,
roleOptions,
caliberOptions,
loading,
onApply,
onClear,
}: {
draft: AdminFilters;
setDraft: React.Dispatch<React.SetStateAction<AdminFilters>>;
platformOptions: PlatformDto[];
roleOptions: string[];
caliberOptions: string[];
loading: boolean;
onApply: () => void;
onClear: () => void;
}) {
return (
<div className="mb-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
<div className="flex flex-wrap items-end gap-2">
<Field label="Search" widthClass="min-w-[260px] flex-1">
<input
value={draft.q}
onChange={(e) => setDraft((s) => ({ ...s, q: e.target.value }))}
placeholder="name, slug, mpn, upc…"
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
/>
</Field>
<Field label="Platform">
<select
value={draft.platform}
onChange={(e) =>
setDraft((s) => ({ ...s, platform: e.target.value }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
{platformOptions.map((p) => (
<option key={p.key} value={p.key}>
{p.key}
</option>
))}
</select>
</Field>
<Field label="Role">
<select
value={draft.partRole}
onChange={(e) =>
setDraft((s) => ({ ...s, partRole: e.target.value }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
{roleOptions.map((r) => (
<option key={r || "any"} value={r}>
{r || "Any"}
</option>
))}
</select>
</Field>
<Field label="Caliber">
<select
value={draft.caliber}
onChange={(e) =>
setDraft((s) => ({ ...s, caliber: e.target.value }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="__UNSET__"> Not set </option>
{caliberOptions
.filter((c) => c)
.map((c) => (
<option key={c} value={c}>
{c}
</option>
))}
</select>
</Field>
<Field label="Visibility">
<select
value={draft.visibility}
onChange={(e) =>
setDraft((s) => ({ ...s, visibility: e.target.value as any }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="PUBLIC">PUBLIC</option>
<option value="HIDDEN">HIDDEN</option>
</select>
</Field>
<Field label="Status">
<select
value={draft.status}
onChange={(e) =>
setDraft((s) => ({ ...s, status: e.target.value as any }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="ACTIVE">ACTIVE</option>
<option value="DISABLED">DISABLED</option>
<option value="ARCHIVED">ARCHIVED</option>
</select>
</Field>
<Field label="Builder">
<select
value={draft.builderEligible}
onChange={(e) =>
setDraft((s) => ({
...s,
builderEligible: e.target.value as any,
}))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="true">Eligible</option>
<option value="false">Not eligible</option>
</select>
</Field>
<Field label="Locked">
<select
value={draft.adminLocked}
onChange={(e) =>
setDraft((s) => ({ ...s, adminLocked: e.target.value as any }))
}
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
<option value="">Any</option>
<option value="true">Locked</option>
<option value="false">Unlocked</option>
</select>
</Field>
<div className="ml-auto flex items-center gap-2">
<button
className="rounded-md bg-emerald-600 px-3 py-2 text-sm hover:bg-emerald-500 disabled:opacity-50"
onClick={onApply}
disabled={loading}
>
Apply
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900"
onClick={onClear}
disabled={loading}
>
Clear
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,20 @@
import type { AdminFilters } from "../_lib/types";
import { Chip } from "./ui";
export default function FilterChips({
chips,
onClearOne,
}: {
chips: { key: keyof AdminFilters; label: string }[];
onClearOne: (key: keyof AdminFilters) => void;
}) {
if (chips.length === 0) return null;
return (
<div className="mt-3 flex flex-wrap gap-2">
{chips.map((c) => (
<Chip key={c.key} text={c.label} onClear={() => onClearOne(c.key)} />
))}
</div>
);
}
@@ -0,0 +1,60 @@
export default function PaginationBar({
page,
setPage,
size,
setSize,
loading,
totalPages,
totalElements,
number,
}: {
page: number;
setPage: React.Dispatch<React.SetStateAction<number>>;
size: number;
setSize: React.Dispatch<React.SetStateAction<number>>;
loading: boolean;
totalPages: number;
totalElements: number;
number: number;
}) {
return (
<div className="mt-4 flex items-center justify-between gap-3">
<div className="text-sm opacity-70">
Page {number + 1} of {totalPages} {totalElements} total
</div>
<div className="flex items-center gap-2">
<select
value={size}
onChange={(e) => {
setSize(Number(e.target.value));
setPage(0);
}}
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
>
{[25, 50, 100, 200].map((n) => (
<option key={n} value={n}>
{n}/page
</option>
))}
</select>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading || page <= 0}
onClick={() => setPage((p) => Math.max(0, p - 1))}
>
Prev
</button>
<button
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
disabled={loading || page >= totalPages - 1}
onClick={() => setPage((p) => p + 1)}
>
Next
</button>
</div>
</div>
);
}
@@ -0,0 +1,204 @@
import type { AdminProductRow } from "../_lib/types";
export type ProductsTableVisibleCols = {
brand: boolean;
platform: boolean;
role: boolean;
caliber: boolean;
import: boolean;
flags: boolean;
updated: boolean;
};
export default function ProductsTable({
rows,
loading,
selected,
onToggleAll,
onToggleOne,
allOnPageSelected,
visibleCols,
}: {
rows: AdminProductRow[];
loading: boolean;
selected: Set<number>;
onToggleAll: () => void;
onToggleOne: (id: number) => void;
allOnPageSelected: boolean;
visibleCols: ProductsTableVisibleCols;
}) {
// Compute colSpan based on visible columns + 2 sticky columns (checkbox + product)
const colSpan =
2 +
(visibleCols.brand ? 1 : 0) +
(visibleCols.platform ? 1 : 0) +
(visibleCols.role ? 1 : 0) +
(visibleCols.caliber ? 1 : 0) +
(visibleCols.import ? 1 : 0) +
(visibleCols.flags ? 1 : 0) +
(visibleCols.updated ? 1 : 0);
return (
<div className="overflow-x-auto max-w-full min-w-0 rounded-md border border-zinc-800">
<table className="w-full min-w-[1100px] text-sm">
<thead className="bg-zinc-950">
<tr className="text-left">
{/* Sticky checkbox col */}
<th className="w-10 px-3 py-2 sticky left-0 z-30 bg-zinc-950">
<input
type="checkbox"
checked={allOnPageSelected}
onChange={onToggleAll}
/>
</th>
{/* Sticky product col */}
<th className="px-3 py-2 sticky left-10 z-20 bg-zinc-950 shadow-[1px_0_0_0_rgba(255,255,255,0.08)] w-[480px] max-w-[480px]">
Product
</th>
{visibleCols.brand && <th className="px-3 py-2">Brand</th>}
{visibleCols.platform && <th className="px-3 py-2">Platform</th>}
{visibleCols.role && <th className="px-3 py-2">Role</th>}
{visibleCols.caliber && <th className="px-3 py-2">Caliber</th>}
{visibleCols.import && <th className="px-3 py-2">Import</th>}
{visibleCols.flags && <th className="px-3 py-2">Flags</th>}
{visibleCols.updated && <th className="px-3 py-2">Updated</th>}
</tr>
</thead>
<tbody>
{loading && (
<tr>
<td colSpan={colSpan} className="px-3 py-6 text-center opacity-70">
Loading
</td>
</tr>
)}
{!loading && rows.length === 0 && (
<tr>
<td colSpan={colSpan} className="px-3 py-6 text-center opacity-70">
No results.
</td>
</tr>
)}
{!loading &&
rows.map((row) => (
<tr
key={row.id}
className="border-t border-zinc-900 hover:bg-zinc-950/40"
>
{/* Sticky checkbox */}
<td className="px-3 py-2 sticky left-0 z-30 bg-black">
<input
type="checkbox"
checked={selected.has(row.id)}
onChange={() => onToggleOne(row.id)}
/>
</td>
{/* Sticky product */}
<td className="px-3 py-2 sticky left-10 z-20 bg-black shadow-[1px_0_0_0_rgba(255,255,255,0.08)] w-[480px] max-w-[480px]">
<div className="flex items-center gap-3">
{row.mainImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={row.mainImageUrl}
alt=""
className="h-10 w-10 rounded object-cover border border-zinc-800"
/>
) : (
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-950" />
)}
<div className="min-w-0">
<div className="font-medium whitespace-normal break-words leading-snug">
{row.name}
</div>
<div className="truncate text-xs opacity-60">#{row.id}</div>
{row.adminNote ? (
<div className="truncate text-xs opacity-60">
📝 {row.adminNote}
</div>
) : null}
</div>
</div>
</td>
{visibleCols.brand && (
<td className="px-3 py-2">{row.brandName ?? "—"}</td>
)}
{visibleCols.platform && (
<td className="px-3 py-2">{row.platform ?? "—"}</td>
)}
{visibleCols.role && (
<td className="px-3 py-2">{row.partRole ?? "—"}</td>
)}
{visibleCols.caliber && (
<td className="px-3 py-2">
<div className="flex flex-col">
<div className="text-sm">
{row.caliber ?? "—"}
{row.caliberLocked ? (
<span className="ml-2 rounded border border-zinc-700 px-1.5 py-0.5 text-[10px] opacity-80">
LOCK
</span>
) : null}
</div>
{row.caliberGroup ? (
<div className="text-xs opacity-60">
{row.caliberGroup}
</div>
) : null}
</div>
</td>
)}
{visibleCols.import && (
<td className="px-3 py-2">{row.importStatus ?? "—"}</td>
)}
{visibleCols.flags && (
<td className="px-3 py-2">
<div className="flex flex-wrap gap-1 text-xs">
{row.visibility === "HIDDEN" ? (
<span className="rounded border border-zinc-700 px-2 py-0.5">
HIDDEN
</span>
) : null}
{row.status !== "ACTIVE" ? (
<span className="rounded border border-zinc-700 px-2 py-0.5">
{row.status}
</span>
) : null}
{row.builderEligible === false ? (
<span className="rounded border border-zinc-700 px-2 py-0.5">
NO_BUILDER
</span>
) : null}
{row.adminLocked ? (
<span className="rounded border border-zinc-700 px-2 py-0.5">
LOCKED
</span>
) : null}
</div>
</td>
)}
{visibleCols.updated && (
<td className="px-3 py-2 text-xs opacity-70">
{row.updatedAt ?? "—"}
</td>
)}
</tr>
))}
</tbody>
</table>
</div>
);
}
+6
View File
@@ -0,0 +1,6 @@
export type PlatformDto = {
id: number;
key: string;
label: string;
is_active: boolean;
};
+31
View File
@@ -0,0 +1,31 @@
export function Field({
label,
children,
widthClass,
}: {
label: string;
children: React.ReactNode;
widthClass?: string;
}) {
return (
<div className={widthClass ?? "w-[180px]"}>
<div className="mb-1 text-[11px] uppercase tracking-[0.16em] text-zinc-500">
{label}
</div>
{children}
</div>
);
}
export function Chip({ text, onClear }: { text: string; onClear: () => void }) {
return (
<button
type="button"
onClick={onClear}
className="rounded-full border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 hover:bg-zinc-900"
title="Clear filter"
>
{text} <span className="ml-1 text-zinc-500">×</span>
</button>
);
}
+113
View File
@@ -0,0 +1,113 @@
import { API_BASE, getToken, qs } from "./auth";
import type {
AdminProductRow,
PageResponse,
PlatformDto,
ProductStatus,
ProductVisibility,
} from "./types";
import type { CaliberDto } from "./types";
async function authedFetch(url: string, init?: RequestInit) {
const token = getToken();
const res = await fetch(url, {
credentials: "include",
cache: "no-store",
...(init ?? {}),
headers: {
Accept: "application/json",
...(init?.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
} as any,
});
return res;
}
export async function fetchAdminRoles(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products/roles?${qs(params)}`
);
if (!res.ok) throw new Error(`Failed to load roles (${res.status}): ${await res.text()}`);
return (await res.json()) as string[];
}
// Facet/filtering can still use distinct values seen on products (optional)
export async function fetchProductCaliberFacet(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products/calibers?${qs(params)}`
);
if (!res.ok)
throw new Error(
`Failed to load caliber facet (${res.status}): ${await res.text()}`
);
return (await res.json()) as string[];
}
export async function fetchAdminProducts(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products?${qs(params)}`
);
if (!res.ok) throw new Error(`Failed to load admin products (${res.status}): ${await res.text()}`);
return (await res.json()) as PageResponse<AdminProductRow>;
}
export async function fetchPlatforms() {
const res = await fetch(`${API_BASE}/api/platforms`, {
method: "GET",
headers: { Accept: "application/json" },
cache: "no-store",
});
if (!res.ok) throw new Error(`Failed to load platforms (${res.status}): ${await res.text()}`);
const all = (await res.json()) as PlatformDto[];
return all.filter((p) => (p as any).is_active ?? (p as any).isActive);
}
export async function bulkUpdate(
productIds: number[],
set: Partial<{
visibility: ProductVisibility;
status: ProductStatus;
builderEligible: boolean;
adminLocked: boolean;
adminNote: string;
platform: string;
platformLocked: boolean;
caliber: string;
caliberGroup: string;
caliberLocked: boolean;
forceCaliberUpdate: boolean;
partRole: string;
partRoleLocked: boolean;
classificationReason: "Admin bulk override";
}>
) {
const token = getToken();
const res = await fetch(`${API_BASE}/api/v1/admin/products/bulk`, {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ productIds, ...set }),
});
if (!res.ok) throw new Error(`Bulk update failed (${res.status}): ${await res.text()}`);
return (await res.json()) as { updatedCount: number; skippedLockedCount: number };
}
export async function fetchAdminCalibers(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/calibers?${qs(params)}`
);
if (!res.ok) {
throw new Error(`Failed to load calibers (${res.status}): ${await res.text()}`);
}
return (await res.json()) as CaliberDto[];
}
+33
View File
@@ -0,0 +1,33 @@
export const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
export function getCookie(name: string): string | null {
if (typeof document === "undefined") return null;
const match = document.cookie.match(
new RegExp(
"(^| )" + name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") + "=([^;]+)"
)
);
return match ? decodeURIComponent(match[2]) : null;
}
export function getToken(): string | null {
if (typeof window === "undefined") return null;
const ls = localStorage.getItem("ballistic_auth_token");
if (ls && ls.trim().length > 0) return ls;
const ck = getCookie("bb_access_token");
if (ck && ck.trim().length > 0) return ck;
return null;
}
export function qs(params: Record<string, any>) {
const sp = new URLSearchParams();
Object.entries(params).forEach(([k, v]) => {
if (v === undefined || v === null || v === "") return;
sp.set(k, String(v));
});
return sp.toString();
}
+70
View File
@@ -0,0 +1,70 @@
export type ProductVisibility = "PUBLIC" | "HIDDEN";
export type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
export type AdminProductRow = {
id: number;
uuid: string;
name: string;
slug: string;
platform: string | null;
partRole: string | null;
brandName: string | null;
importStatus: string | null;
caliber: string | null;
caliberGroup: string | null;
caliberLocked: boolean;
visibility: ProductVisibility;
status: ProductStatus;
builderEligible: boolean;
adminLocked: boolean;
adminNote: string | null;
mainImageUrl: string | null;
createdAt: string | null;
updatedAt: string | null;
};
export type PageResponse<T> = {
content: T[];
totalElements: number;
totalPages: number;
number: number;
size: number;
};
export type AdminFilters = {
q: string;
platform: string;
partRole: string;
caliber: string;
visibility: ProductVisibility | "";
status: ProductStatus | "";
builderEligible: "" | "true" | "false";
adminLocked: "" | "true" | "false";
};
export type PlatformDto = {
id: number;
key: string;
label: string;
is_active: boolean;
};
export const initialFilters: AdminFilters = {
q: "",
platform: "",
partRole: "",
caliber: "",
visibility: "",
status: "",
builderEligible: "",
adminLocked: "",
};
export type CaliberDto = {
id: number;
key: string; // canonical key (ex: "5.56 NATO")
label: string; // human-readable label
isActive: boolean;
createdAt?: string;
updatedAt?: string;
};
+3 -409
View File
@@ -1,411 +1,5 @@
"use client"; import AdminProductsClient from "./_components/adminProductsClient";
import { useEffect, useMemo, useState } from "react"; export default function Page() {
return <AdminProductsClient />;
type ProductSummary = {
id: number;
name: string;
brand: string;
platform: string;
partRole: string;
price: number | null;
buyUrl: string | null;
imageUrl: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
type PlatformOption = {
key: string; // e.g. AR15, AR9
label: string; // e.g. "Armalite Rifle model 15"
};
// Backend currently filters products by canonical platform strings like "AR-15".
// DB platform keys are like "AR15" / "AR9". Translate when calling /api/products.
const platformKeyToApiPlatform = (key: string) => {
const k = (key ?? "").toUpperCase().trim();
if (!k) return "AR-15";
if (k === "AR15" || k === "AR-15") return "AR-15";
if (k === "AR10" || k === "AR-10") return "AR-10";
if (k === "AR9" || k === "AR-9") return "AR-9";
// Fallback: if it already contains a dash, assume it's canonical.
if (k.includes("-")) return k;
// Otherwise, best-effort: insert a dash after AR.
if (k.startsWith("AR") && k.length > 2) return `AR-${k.slice(2)}`;
return k;
};
const FALLBACK_PLATFORMS: PlatformOption[] = [
{ key: "AR15", label: "AR-15" },
{ key: "AR10", label: "AR-10" },
{ key: "AR9", label: "AR-9" },
];
export default function AdminProductsPage() {
const [platforms, setPlatforms] = useState<PlatformOption[]>(FALLBACK_PLATFORMS);
const [platform, setPlatform] = useState<string>("AR15");
const [loading, setLoading] = useState<boolean>(true);
const [error, setError] = useState<string | null>(null);
const [products, setProducts] = useState<ProductSummary[]>([]);
const [query, setQuery] = useState<string>("");
const [page, setPage] = useState<number>(1);
const [pageSize, setPageSize] = useState<number>(50);
// Load available platforms (dynamic if backend supports it; otherwise fallback)
useEffect(() => {
const controller = new AbortController();
async function loadPlatforms() {
try {
const res = await fetch(`${API_BASE_URL}/api/platforms`, {
signal: controller.signal,
});
if (!res.ok) return;
const data = (await res.json()) as unknown;
// Expected shape:
// [{ id, key, label, is_active, ... }]
const items = Array.isArray(data) ? data : [];
const cleaned: PlatformOption[] = items
.map((item) => {
if (!item || typeof item !== "object") return null;
const obj = item as Record<string, unknown>;
const key = typeof obj.key === "string" ? obj.key.trim() : "";
const label = typeof obj.label === "string" ? obj.label.trim() : "";
// Some payloads might use isActive; yours currently uses is_active
const activeRaw = obj.is_active ?? obj.isActive;
const isActive =
typeof activeRaw === "boolean"
? activeRaw
: typeof activeRaw === "number"
? activeRaw === 1
: true;
if (!key || !label || !isActive) return null;
return { key, label } as PlatformOption;
})
.filter((v): v is PlatformOption => v !== null);
if (cleaned.length === 0) return;
// Keep them stable and unique by key
const byKey = new Map<string, PlatformOption>();
for (const p of cleaned) byKey.set(p.key, p);
const unique = Array.from(byKey.values());
setPlatforms(unique);
setPlatform((prev) => (unique.some((p) => p.key === prev) ? prev : unique[0].key));
} catch (err: any) {
if (err?.name === "AbortError") return;
// ignore (fallback list stays)
}
}
loadPlatforms();
return () => controller.abort();
}, []);
// Load products for selected platform
useEffect(() => {
const controller = new AbortController();
async function loadProducts() {
try {
setLoading(true);
setError(null);
const apiPlatform = platformKeyToApiPlatform(platform);
// NOTE: backend endpoint expects `platform`, not `platformKey`.
const url = `${API_BASE_URL}/api/products?platform=${encodeURIComponent(
apiPlatform
)}`;
const res = await fetch(url, { signal: controller.signal });
if (!res.ok) {
throw new Error(`Failed to load products (${res.status})`);
}
const data = (await res.json()) as ProductSummary[];
setProducts(Array.isArray(data) ? data : []);
setPage(1);
} catch (err: any) {
if (err?.name === "AbortError") return;
setError(err?.message ?? "Failed to load products");
setProducts([]);
} finally {
setLoading(false);
}
}
loadProducts();
return () => controller.abort();
}, [platform]);
const filtered = useMemo(() => {
const q = query.trim().toLowerCase();
if (!q) return products;
return products.filter((p) => {
const name = (p.name ?? "").toLowerCase();
const brand = (p.brand ?? "").toLowerCase();
const role = (p.partRole ?? "").toLowerCase();
return name.includes(q) || brand.includes(q) || role.includes(q);
});
}, [products, query]);
// Reset to page 1 when the search query changes
useEffect(() => {
setPage(1);
}, [query, platform, pageSize]);
const totalPages = useMemo(() => {
const total = filtered.length;
return total === 0 ? 1 : Math.ceil(total / pageSize);
}, [filtered.length, pageSize]);
const clampedPage = useMemo(() => {
if (page < 1) return 1;
if (page > totalPages) return totalPages;
return page;
}, [page, totalPages]);
const pageStart = useMemo(() => (clampedPage - 1) * pageSize, [clampedPage, pageSize]);
const pageEndExclusive = useMemo(
() => Math.min(pageStart + pageSize, filtered.length),
[pageStart, pageSize, filtered.length]
);
const paginated = useMemo(() => {
return filtered.slice(pageStart, pageEndExclusive);
}, [filtered, pageStart, pageEndExclusive]);
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
<header className="mb-6">
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Admin
</p>
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
Products <span className="text-amber-300">Catalog</span>
</h1>
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
Read-only view of products coming from the Ballistic backend. Filter by
platform, search, and spot-check pricing + buy links.
</p>
</header>
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div className="flex flex-wrap items-center gap-3">
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
Platform
<select
value={platform}
onChange={(e) => setPlatform(e.target.value)}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{platforms.map((p) => (
<option key={p.key} value={p.key}>
{p.label}
</option>
))}
</select>
</label>
<div className="flex items-center gap-2">
<label htmlFor="q" className="text-xs text-zinc-500">
Search
</label>
<input
id="q"
value={query}
onChange={(e) => setQuery(e.target.value)}
placeholder="brand, name, part role…"
className="w-64 max-w-full rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
/>
</div>
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
Page size
<select
value={pageSize}
onChange={(e) => setPageSize(Number(e.target.value))}
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
>
{[25, 50, 100, 200].map((n) => (
<option key={n} value={n}>
{n}
</option>
))}
</select>
</label>
</div>
<div className="text-xs text-zinc-500">
{loading ? (
<span>Loading</span>
) : error ? (
<span className="text-red-400">{error}</span>
) : (
<span>
Showing{" "}
<span className="font-medium text-zinc-200">
{filtered.length === 0 ? 0 : pageStart + 1}-{pageEndExclusive}
</span>
{" "}of{" "}
<span className="font-medium text-zinc-200">{filtered.length}</span>
{" "}{filtered.length === 1 ? "product" : "products"}
</span>
)}
</div>
</div>
{!loading && !error && filtered.length > 0 && (
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
<div className="text-[0.7rem] text-zinc-500">
Page <span className="font-semibold text-zinc-200">{clampedPage}</span> of{" "}
<span className="font-semibold text-zinc-200">{totalPages}</span>
</div>
<div className="flex items-center gap-2">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={clampedPage === 1}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
>
Previous
</button>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={clampedPage === totalPages}
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
>
Next
</button>
</div>
</div>
)}
<div className="mt-4 overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
<table className="min-w-full text-xs md:text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Product</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Brand</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Platform</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Part Role</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Price</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Link</th>
</tr>
</thead>
<tbody>
{loading ? (
<tr>
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
Loading products
</td>
</tr>
) : error ? (
<tr>
<td className="px-3 py-6 text-center text-sm text-red-400" colSpan={6}>
{error}
</td>
</tr>
) : paginated.length === 0 ? (
<tr>
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
No products found.
</td>
</tr>
) : (
paginated.map((p) => (
<tr
key={p.id}
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
>
<td className="px-3 py-2 align-top">
<div className="font-medium text-zinc-100 line-clamp-2">{p.name}</div>
<div className="mt-0.5 text-[0.7rem] text-zinc-600">ID: {p.id}</div>
</td>
<td className="px-3 py-2 align-top text-zinc-300">{p.brand}</td>
<td className="px-3 py-2 align-top text-zinc-300">
{p.platform ?? platformKeyToApiPlatform(platform)}
</td>
<td className="px-3 py-2 align-top text-zinc-300">{p.partRole}</td>
<td className="px-3 py-2 align-top text-right text-amber-300">
{p.price == null ? "—" : `$${Number(p.price).toFixed(2)}`}
</td>
<td className="px-3 py-2 align-top text-right">
{p.buyUrl ? (
<a
href={p.buyUrl}
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center whitespace-nowrap rounded-md bg-amber-400 px-2.5 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
>
Open
</a>
) : (
<span className="text-zinc-600"></span>
)}
</td>
</tr>
))
)}
</tbody>
</table>
</div>
{!loading && !error && filtered.length > 0 && (
<div className="mt-3 flex items-center justify-center gap-3">
<button
type="button"
onClick={() => setPage((p) => Math.max(1, p - 1))}
disabled={clampedPage === 1}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
aria-label="Previous page"
>
&lt;
</button>
<span className="text-xs text-zinc-500">
Page{" "}
<span className="font-semibold text-zinc-200">
{clampedPage}
</span>{" "}
of{" "}
<span className="font-semibold text-zinc-200">
{totalPages}
</span>
</span>
<button
type="button"
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
disabled={clampedPage === totalPages}
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
aria-label="Next page"
>
&gt;
</button>
</div>
)}
<div className="mt-3 text-[0.7rem] text-zinc-500">
Backend: <span className="font-mono text-zinc-400">{API_BASE_URL}</span> · Endpoint: <span className="font-mono text-zinc-400">/api/products?platform=...</span> · Page size: <span className="font-mono text-zinc-400">{pageSize}</span>
</div>
</section>
</div>
</main>
);
} }
+1 -1
View File
@@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react";
import type { AdminUser, UserRole } from "@/types/user"; import type { AdminUser, UserRole } from "@/types/user";
const API_BASE_URL = const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"; process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
export default function AdminUsersPage() { export default function AdminUsersPage() {
const { getAuthHeaders } = useAuth(); const { getAuthHeaders } = useAuth();
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
export async function POST(req: Request) {
const token = cookies().get("session_token")?.value;
if (!token) {
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
}
const url = new URL(req.url);
const dryRun = url.searchParams.get("dryRun") ?? "true";
const limit = url.searchParams.get("limit") ?? "0";
const tokenMinutes = url.searchParams.get("tokenMinutes") ?? "30";
const upstream = `${API_BASE_URL}/api/v1/admin/beta/invites/send?dryRun=${dryRun}&limit=${limit}&tokenMinutes=${tokenMinutes}`;
const res = await fetch(upstream, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
});
}
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function POST(
_req: Request,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/beta/requests/${params.id}/invite`,
{
method: "POST",
headers,
cache: "no-store",
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function GET(req: Request) {
try {
const headers = await getAuthHeader();
const url = new URL(req.url);
const search = url.searchParams.toString();
const upstream = `${API_BASE_URL}/api/v1/admin/beta/requests${
search ? `?${search}` : ""
}`;
const res = await fetch(upstream, {
headers,
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
method: "DELETE",
headers,
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/email`, {
headers,
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/email`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/summary`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const { searchParams } = new URL(request.url);
const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/raw-categories?${searchParams.toString()}`,
{
headers,
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/import`,
{ method: 'POST', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/offer-sync`,
{ method: 'POST', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
{
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
headers,
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Forward to backend API
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return NextResponse.json(
{ error: text || 'Login failed' },
{ status: res.status }
);
}
const data = await res.json();
// Set HTTP-only cookie instead of returning token
cookies().set('session_token', data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
// Return user data without token
return NextResponse.json({
user: data.user,
});
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Login failed' },
{ status: 500 }
);
}
}
+7
View File
@@ -0,0 +1,7 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST() {
cookies().delete('session_token');
return NextResponse.json({ success: true });
}
+30
View File
@@ -0,0 +1,30 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
const token = cookies().get('session_token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
cookies().delete('session_token');
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch user' },
{ status: 500 }
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Forward to backend API
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return NextResponse.json(
{ error: text || 'Registration failed' },
{ status: res.status }
);
}
const data = await res.json();
// Set HTTP-only cookie on successful registration
if (data.token) {
cookies().set('session_token', data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
}
// Return user data without token
return NextResponse.json({
user: data.user,
});
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Registration failed' },
{ status: 500 }
);
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextResponse } from "next/server";
export async function POST(req: Request) {
const { token, role } = await req.json();
if (!token) {
return NextResponse.json({ ok: false }, { status: 400 });
}
const res = NextResponse.json({ ok: true });
// Server-readable, JS-unreadable
res.cookies.set("bb_access_token", token, {
httpOnly: true,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
// Optional: for quick middleware role checks (not “secure” by itself)
if (role) {
res.cookies.set("bb_role", role, {
httpOnly: false,
secure: process.env.NODE_ENV === "production",
sameSite: "lax",
path: "/",
});
}
return res;
}
export async function DELETE() {
const res = NextResponse.json({ ok: true });
res.cookies.set("bb_access_token", "", { path: "/", maxAge: 0 });
res.cookies.set("bb_role", "", { path: "/", maxAge: 0 });
return res;
}
+33 -44
View File
@@ -1,61 +1,50 @@
// app/api/beta-signup/route.ts
import { NextResponse } from "next/server"; import { NextResponse } from "next/server";
const BREVO_API_KEY = process.env.BREVO_API_KEY; const API_BASE_URL =
const BREVO_LIST_ID = process.env.BREVO_LIST_ID; // optional process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const TOS_VERSION = "2025-12-27";
type BetaSignupBody = {
email?: string;
useCase?: string;
acceptedTos?: boolean;
tosVersion?: string;
};
export async function POST(req: Request) { export async function POST(req: Request) {
if (!BREVO_API_KEY) {
console.error("BREVO_API_KEY is not set");
return NextResponse.json(
{ error: "Server not configured" },
{ status: 500 }
);
}
try { try {
const { email } = await req.json(); const body = (await req.json()) as BetaSignupBody;
if (!email || typeof email !== "string") { const email = (body.email ?? "").trim().toLowerCase();
return NextResponse.json( const useCase = (body.useCase ?? "").trim();
{ error: "Valid email is required" }, const acceptedTos = Boolean(body.acceptedTos);
{ status: 400 } const tosVersion = (body.tosVersion ?? TOS_VERSION).trim() || TOS_VERSION;
);
// Keep "no enumeration": always return ok:true, but silently refuse bad input
if (!email || !acceptedTos) {
return NextResponse.json({ ok: true });
} }
// Brevo contacts API // Forward only known fields
const res = await fetch("https://api.brevo.com/v3/contacts", { const payload = { email, useCase, acceptedTos, tosVersion };
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
method: "POST", method: "POST",
headers: { headers: { "Content-Type": "application/json" },
"Content-Type": "application/json", body: JSON.stringify(payload),
"api-key": BREVO_API_KEY, cache: "no-store",
},
body: JSON.stringify({
email,
updateEnabled: true, // update if already exists
...(BREVO_LIST_ID
? { listIds: [Number(BREVO_LIST_ID)] }
: {}),
attributes: {
SOURCE: "Battl Builders Beta",
},
}),
}); });
if (!res.ok) { if (!res.ok) {
const text = await res.text(); const text = await res.text().catch(() => "");
console.error("Brevo error:", res.status, text); console.error("beta-signup proxy failed:", res.status, text);
return NextResponse.json(
{ error: "Failed to subscribe" },
{ status: 500 }
);
} }
return NextResponse.json({ ok: true }); return NextResponse.json({ ok: true });
} catch (err) { } catch (e) {
console.error(err); console.error("beta-signup proxy error:", e);
return NextResponse.json( return NextResponse.json({ ok: true });
{ error: "Something went wrong" },
{ status: 500 }
);
} }
} }
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`,
{
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds/${params.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// Public build detail - no auth required
const res = await fetch(
`${API_BASE_URL}/api/v1/builds/${id}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch build' },
{ status: 500 }
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// Public builds endpoint - no auth required
const res = await fetch(
`${API_BASE_URL}/api/v1/builds?${searchParams}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch builds' },
{ status: 500 }
);
}
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const { searchParams } = new URL(request.url);
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds?${searchParams}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/gunbuilder/builds`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json(), { status: res.status });
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/options?${searchParams}`
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch catalog' },
{ status: 500 }
);
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products/${id}/offers`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch product offers' },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products/${id}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch product' },
{ status: 500 }
);
}
}
+33
View File
@@ -0,0 +1,33 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
{
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch products' },
{ status: 500 }
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products?${searchParams}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch products' },
{ status: 500 }
);
}
}
+25
View File
@@ -0,0 +1,25 @@
import { NextResponse } from "next/server";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
export async function POST(req: Request) {
const body = await req.text().catch(() => "");
const auth = req.headers.get("authorization") ?? "";
const upstream = await fetch(`${API_BASE_URL}/api/v1/users/me/password`, {
method: "POST",
headers: {
"content-type": req.headers.get("content-type") ?? "application/json",
...(auth ? { authorization: auth } : {}),
},
body,
cache: "no-store",
});
const text = await upstream.text().catch(() => "");
return new NextResponse(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
+43
View File
@@ -0,0 +1,43 @@
import { NextResponse } from "next/server";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
function passthroughHeaders(req: Request) {
const h = new Headers();
const auth = req.headers.get("authorization");
if (auth) h.set("authorization", auth);
h.set("content-type", req.headers.get("content-type") ?? "application/json");
return h;
}
export async function GET(req: Request) {
const upstream = await fetch(`${API_BASE_URL}/api/v1/users/me`, {
method: "GET",
headers: passthroughHeaders(req),
cache: "no-store",
});
const text = await upstream.text().catch(() => "");
return new NextResponse(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
export async function PATCH(req: Request) {
const body = await req.text().catch(() => "");
const upstream = await fetch(`${API_BASE_URL}/api/v1/users/me`, {
method: "PATCH",
headers: passthroughHeaders(req),
body,
cache: "no-store",
});
const text = await upstream.text().catch(() => "");
return new NextResponse(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
@@ -0,0 +1,27 @@
import { NextResponse } from "next/server";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
export async function GET(req: Request) {
const url = new URL(req.url);
const username = url.searchParams.get("username") ?? "";
const auth = req.headers.get("authorization") ?? "";
const upstream = await fetch(
`${API_BASE_URL}/api/v1/users/me/username-available?username=${encodeURIComponent(username)}`,
{
method: "GET",
headers: {
...(auth ? { authorization: auth } : {}),
},
cache: "no-store",
}
);
const text = await upstream.text().catch(() => "");
return new NextResponse(text, {
status: upstream.status,
headers: { "content-type": upstream.headers.get("content-type") ?? "application/json" },
});
}
+106
View File
@@ -0,0 +1,106 @@
// app/beta/confirm/page.tsx
"use client";
import { useEffect, useRef, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
function BetaConfirmPageContent() {
const params = useSearchParams();
const token = params.get("token");
const router = useRouter();
const { setSession } = useAuth();
const ran = useRef(false);
const [message, setMessage] = useState("Confirming your email…");
const [status, setStatus] = useState<"loading" | "success" | "error">(
"loading"
);
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("Missing token.");
return;
}
if (ran.current) return;
ran.current = true;
(async () => {
try {
const res = await fetch(`${API_BASE_URL}/api/auth/beta/confirm`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "Confirm link invalid/expired");
}
const data = await res.json();
const jwt = data.token ?? data.accessToken;
if (!jwt) throw new Error("No token returned from server");
const uuid = data.uuid ?? data.user?.uuid;
if (!uuid) throw new Error("No uuid returned from server");
const email = data.email ?? data.user?.email;
if (!email) throw new Error("No email returned from server");
setSession(jwt, {
uuid,
email,
displayName: data.displayName ?? data.user?.displayName ?? null,
role: data.role ?? data.user?.role ?? "USER",
});
setStatus("success");
setMessage("Confirmed. Signing you in…");
router.replace("/account?welcome=1");
} catch (e: any) {
setStatus("error");
setMessage(
e?.message ||
"That link is invalid or expired. Please request a new one."
);
}
})();
}, [token, router, setSession]);
return (
<main className="min-h-screen flex items-center justify-center bg-black text-white px-6">
<div className="max-w-md w-full rounded-xl border border-white/10 bg-white/5 p-6 text-center">
<h1 className="text-lg font-semibold">Battl Builders</h1>
<p className="mt-2 text-sm text-zinc-300">{message}</p>
{status === "error" && (
<p className="mt-4 text-xs text-zinc-400">
Go back and request a fresh sign-in link.
</p>
)}
</div>
</main>
);
}
export default function BetaConfirmPage() {
return (
<Suspense fallback={
<main className="min-h-screen flex items-center justify-center bg-black text-white px-6">
<div className="max-w-md w-full rounded-xl border border-white/10 bg-white/5 p-6 text-center">
<h1 className="text-lg font-semibold">Battl Builders</h1>
<p className="mt-2 text-sm text-zinc-300">Loading</p>
</div>
</main>
}>
<BetaConfirmPageContent />
</Suspense>
);
}
+93
View File
@@ -0,0 +1,93 @@
"use client";
import { useEffect, useRef, useState, Suspense } from "react";
import { useRouter, useSearchParams } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
function BetaMagicPageContent() {
const params = useSearchParams();
const token = params.get("token");
const router = useRouter();
const { setSession } = useAuth();
const ran = useRef(false);
const [status, setStatus] = useState<"loading" | "success" | "error">("loading");
const [message, setMessage] = useState("Signing you in…");
useEffect(() => {
if (!token) {
setStatus("error");
setMessage("Missing token.");
return;
}
// ✅ Prevent double-call (Strict Mode / rerenders)
if (ran.current) return;
ran.current = true;
(async () => {
try {
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://battlbuilder-api:8080"}/api/auth/magic/exchange`, {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ token }),
});
if (!res.ok) {
const text = await res.text().catch(() => "");
throw new Error(text || "Magic link failed");
}
const data = await res.json();
// Your AuthContext expects { token, email, displayName, role }
// and stores it in localStorage.
// We can simulate "login" by directly setting localStorage like AuthContext does,
// but easiest is just to store it here in the same shape.
setSession(data.token ?? data.accessToken, {
uuid: data.uuid ?? data.id,
email: data.email,
displayName: data.displayName ?? null,
role: data.role ?? "USER",
});
setStatus("success");
setMessage("Youre in. Redirecting…");
router.replace("/account?welcome=1");
} catch (e: any) {
setStatus("error");
setMessage(e?.message || "Magic link expired. Request a new one.");
}
})();
}, [token, router, setSession]);
return (
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
<div className="max-w-md w-full rounded-xl border border-white/10 bg-white/5 p-6">
<h1 className="text-lg font-semibold">Battl Builders</h1>
<p className="mt-2 text-sm text-zinc-300">{message}</p>
{status === "error" && (
<div className="mt-4 text-xs text-zinc-400">
Try going back to your inbox and requesting a fresh sign-in link.
</div>
)}
</div>
</main>
);
}
export default function BetaMagicPage() {
return (
<Suspense fallback={
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center px-4">
<div className="max-w-md w-full rounded-xl border border-white/10 bg-white/5 p-6">
<h1 className="text-lg font-semibold">Battl Builders</h1>
<p className="mt-2 text-sm text-zinc-300">Loading</p>
</div>
</main>
}>
<BetaMagicPageContent />
</Suspense>
);
}
+408
View File
@@ -0,0 +1,408 @@
// app/builds/[buildId]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
function formatMoney(n?: number | null) {
if (n == null) return "—";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(n);
}
function timeAgo(iso?: string | null) {
if (!iso) return "";
const d = new Date(iso);
const diff = Math.max(0, Date.now() - d.getTime());
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export default async function BuildBreakdownPage({
params,
}: {
params: { buildId: string };
}) {
// Public detail endpoint - use Next.js API route
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/builds/public/${params.buildId}`, {
cache: "no-store",
});
if (res.status === 404) return notFound();
const build = await res.json().catch(() => null);
if (!build) return notFound();
const items: any[] = build.items ?? [];
const total = items.reduce((sum, it) => {
const qty = Number(it.quantity ?? 1);
const price = it.bestPrice == null ? 0 : Number(it.bestPrice);
return sum + qty * price;
}, 0);
const images: any[] = build.images ?? build.photos ?? build.media ?? [];
return (
<main className="mx-auto max-w-6xl px-4 py-8">
{/* Header crumb */}
<div className="flex items-center justify-between">
<Link
href="/builds"
className="text-sm text-zinc-400 hover:text-zinc-200"
>
Back to Community Builds
</Link>
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<span className="rounded-full border border-zinc-800 bg-zinc-950 px-2 py-0.5">
Public Build
</span>
{build.updatedAt && (
<span className="hidden md:inline">
updated {timeAgo(build.updatedAt)}
</span>
)}
</div>
</div>
{/* Layout */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
{/* Post column */}
<div className="space-y-4">
{/* “Reddit-ish” Post Card */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60">
<div className="flex">
{/* Vote rail */}
<div className="flex w-12 flex-col items-center gap-2 border-r border-zinc-900 bg-black/20 py-4 text-zinc-500">
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
</button>
<div className="text-xs font-semibold text-zinc-400"></div>
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
</button>
</div>
{/* Post body */}
<div className="flex-1 p-5">
{/* Meta */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-500">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-amber-300">
r/BattlBuilders
</span>
<span></span>
<span>posted by</span>
<span className="text-zinc-300">u/anonymous</span>
{build.createdAt && (
<>
<span></span>
<span>{timeAgo(build.createdAt)}</span>
</>
)}
</div>
{/* Title */}
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-zinc-50">
{build.title ?? "Untitled Build"}
</h1>
{/* Description */}
{build.description ? (
<p className="mt-2 text-sm text-zinc-300/90">
{build.description}
</p>
) : (
<p className="mt-2 text-sm text-zinc-500">
{/* placeholder copy */}
Field notes coming soon. This build breakdown will include
purpose, tradeoffs, and why each part made the cut.
</p>
)}
{/* Action bar */}
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Comment
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Share
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Save
</button>
<div className="ml-auto flex items-center gap-2 text-zinc-500">
<span className="hidden sm:inline">Est. Total</span>
<span className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 text-zinc-200">
{formatMoney(total)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Parts card */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">Parts</h2>
<div className="text-xs text-zinc-500">{items.length} items</div>
</div>
<div className="mt-4 space-y-2">
{items.map((it) => (
<div
key={it.uuid ?? it.id}
className="flex items-center gap-3 rounded-lg border border-zinc-900 bg-black/30 px-3 py-3"
>
{/* Thumb */}
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
{it.productImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.productImageUrl}
alt={it.productName ?? "Part image"}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
IMG
</div>
)}
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-zinc-100">
{it.productName ?? "Part"}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-zinc-500">
<span className="rounded-md border border-zinc-800 bg-black/20 px-1.5 py-0.5">
{it.slot ?? "slot"}
</span>
{it.productBrand && <span>{it.productBrand}</span>}
<span className="text-zinc-600"></span>
<span>x{it.quantity ?? 1}</span>
</div>
</div>
{/* Price */}
<div className="flex flex-col items-end gap-1">
<div className="text-sm font-medium text-zinc-200">
{formatMoney(it.bestPrice)}
</div>
<button className="text-[11px] text-amber-300/90 hover:text-amber-200">
View part
</button>
</div>
</div>
))}
</div>
</div>
{/* Gallery */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">
Gallery
</h2>
<span className="text-xs text-zinc-500">user submitted</span>
</div>
{images.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4">
<p className="text-sm text-zinc-400">
No photos yet. Be the first to add a build pic.
</p>
<p className="mt-1 text-[11px] text-zinc-600">
(Upload UI coming soon well store and moderate images
per build.)
</p>
{/* Mock thumbnails */}
<div className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-md border border-zinc-800 bg-zinc-950 flex items-center justify-center text-[10px] text-zinc-600"
>
IMG
</div>
))}
</div>
<button
className="mt-4 rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
disabled
title="Upload UI coming soon"
>
Add Photo (coming soon)
</button>
</div>
) : (
<div className="mt-4">
{/* “Hero” preview */}
<div className="aspect-[16/9] overflow-hidden rounded-lg border border-zinc-900 bg-black/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images[0].url ?? images[0]}
alt="Build photo"
className="h-full w-full object-cover"
/>
</div>
{/* Thumbs */}
<div className="mt-3 grid grid-cols-4 gap-2 sm:grid-cols-6">
{images.slice(0, 12).map((img, idx) => (
<div
key={img.uuid ?? img.id ?? idx}
className="aspect-square overflow-hidden rounded-md border border-zinc-900 bg-black/30"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.thumbUrl ?? img.url ?? img}
alt={`Build photo ${idx + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
View all
</button>
<button
className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
disabled
title="Upload UI coming soon"
>
Add Photo
</button>
<span className="ml-auto text-[11px] text-zinc-600">
{images.length} photos
</span>
</div>
</div>
)}
</div>
{/* Comments (placeholder) */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">Comments</h2>
<span className="text-xs text-zinc-500">mocked</span>
</div>
<div className="mt-4 space-y-4">
{[
{
user: "u/gearhead",
body: "Solid parts list. Any reason you went with that upper over a BCM?",
},
{
user: "u/OP",
body: "Mostly availability + price. Ill probably swap once I track deals for a week.",
},
{
user: "u/boltcarrier",
body: "Would love to see this with a pinned/weld option for 14.5 builds.",
},
].map((c, idx) => (
<div key={idx} className="flex gap-3">
<div className="mt-1 h-6 w-6 rounded-full bg-zinc-900 text-[10px] text-zinc-300 flex items-center justify-center">
{c.user === "u/OP" ? "OP" : "u"}
</div>
<div className="flex-1">
<div className="text-[11px] text-zinc-500">
<span className="text-zinc-300">{c.user}</span> 1h ago
</div>
<div className="mt-1 text-sm text-zinc-300/90">
{c.body}
</div>
<div className="mt-2 flex items-center gap-3 text-[11px] text-zinc-500">
<button className="hover:text-amber-300">Reply</button>
<button className="hover:text-amber-300">Share</button>
<button className="hover:text-amber-300">Save</button>
</div>
</div>
</div>
))}
<div className="rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4 text-sm text-zinc-500">
Commenting is coming soon. This will become the breakdown
thread for the build (notes, tradeoffs, deal alerts, revisions).
</div>
</div>
</div>
</div>
{/* Sidebar */}
<aside className="space-y-4">
{/* Build stats */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
Build Summary
</h3>
<div className="mt-3 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-zinc-500">Build ID</span>
<span className="text-zinc-300">
{(build.uuid ?? params.buildId).slice(0, 8)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Visibility</span>
<span className="text-zinc-300">
{build.isPublic ? "Public" : "Private"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Items</span>
<span className="text-zinc-300">{items.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Est. Total</span>
<span className="text-zinc-50 font-medium">
{formatMoney(total)}
</span>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<button className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition">
Open in Builder
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
Copy share link
</button>
</div>
</div>
{/* Placeholder “OP notes” */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
OP Notes
</h3>
<ul className="mt-3 space-y-2 text-sm text-zinc-400">
<li> Purpose: home defense / range</li>
<li> Priority: reliability first</li>
<li> Next upgrades: optic, sling, light</li>
</ul>
</div>
</aside>
</div>
</main>
);
}
+3
View File
@@ -1,5 +1,6 @@
// app/(builder)/layout.tsx // app/(builder)/layout.tsx
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Suspense } from "react";
import { AuthProvider } from "@/context/AuthContext"; import { AuthProvider } from "@/context/AuthContext";
import { BuilderNav } from "@/components/BuilderNav"; import { BuilderNav } from "@/components/BuilderNav";
@@ -10,7 +11,9 @@ export default function BuilderLayout({ children }: { children: ReactNode }) {
<div className="min-h-screen bg-neutral-950 text-zinc-50"> <div className="min-h-screen bg-neutral-950 text-zinc-50">
<AuthProvider> <AuthProvider>
<TopNav /> <TopNav />
<Suspense fallback={<div className="h-[52px]" />}>
<BuilderNav /> <BuilderNav />
</Suspense>
<main className="min-h-screen">{children}</main> <main className="min-h-screen">{children}</main>
</AuthProvider> </AuthProvider>
</div> </div>

Some files were not shown because too many files have changed in this diff Show More