Compare commits
33 Commits
8d7809ccf1
...
bb-web-app
| Author | SHA1 | Date | |
|---|---|---|---|
| d9498f8aca | |||
| 5b73d59c2c | |||
| c4f69c0811 | |||
| df46788570 | |||
| 411c3bc05d | |||
| 2ea1f19863 | |||
| 7ad0b47819 | |||
| 3ab697b58d | |||
| 775a351425 | |||
| b0db3167c5 | |||
| 60d034d643 | |||
| ec6c88fa90 | |||
| b1e2b38506 | |||
| 9460c7df24 | |||
| 045e742362 | |||
| b55bd7041d | |||
| 19ec410a16 | |||
| 5c1c38f5e9 | |||
| c509cb6cd0 | |||
| ae86fe6867 | |||
| 72d8f559c9 | |||
| 1b348c3ee7 | |||
| 055ebb4cd9 | |||
| b7e3db8d34 | |||
| 228d08337f | |||
| 85d4629d3c | |||
| e69205e6f8 | |||
| ee91fa1353 | |||
| cc386d14f4 | |||
| 0dd89a751b | |||
| 2202abe89f | |||
| aa2dfb0407 | |||
| b8a5fe0e7a |
@@ -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
@@ -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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
|
||||||
|
|||||||
@@ -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"
|
||||||
@@ -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)
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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 don’t 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 || "Couldn’t 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 || "Couldn’t 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 || "Couldn’t 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 || "Couldn’t 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 don’t 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"
|
||||||
|
>
|
||||||
|
I’ll finish later
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,439 +1,5 @@
|
|||||||
// app/(account)/account/page.tsx
|
import { redirect } from "next/navigation";
|
||||||
"use client";
|
|
||||||
|
|
||||||
import { useMemo, useState } from "react";
|
export default function AccountIndex() {
|
||||||
import { useSearchParams, useRouter } from "next/navigation";
|
redirect("/account/profile");
|
||||||
import { useAuth } from "@/context/AuthContext";
|
}
|
||||||
|
|
||||||
const API_BASE_URL =
|
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
export default function AccountPage() {
|
|
||||||
const { user, loading, token, setSession, logout } = useAuth();
|
|
||||||
const searchParams = useSearchParams();
|
|
||||||
const router = useRouter();
|
|
||||||
|
|
||||||
const welcome = searchParams.get("welcome") === "1";
|
|
||||||
|
|
||||||
const needsDisplayName = useMemo(() => {
|
|
||||||
return !user?.displayName || user.displayName.trim().length === 0;
|
|
||||||
}, [user?.displayName]);
|
|
||||||
|
|
||||||
// Only show welcome panel if they actually need setup
|
|
||||||
const showWelcomeCard = welcome && needsDisplayName;
|
|
||||||
|
|
||||||
// ----------------------------
|
|
||||||
// Display Name state (shared)
|
|
||||||
// ----------------------------
|
|
||||||
const [displayName, setDisplayName] = useState(user?.displayName ?? "");
|
|
||||||
const [saving, setSaving] = useState(false);
|
|
||||||
const [msg, setMsg] = useState<string | null>(null);
|
|
||||||
|
|
||||||
// Inline profile editor
|
|
||||||
const [editingName, setEditingName] = useState(false);
|
|
||||||
|
|
||||||
// ----------------------------
|
|
||||||
// Password state
|
|
||||||
// ----------------------------
|
|
||||||
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 canSaveName = useMemo(() => {
|
|
||||||
return displayName.trim().length > 0 && !saving;
|
|
||||||
}, [displayName, saving]);
|
|
||||||
|
|
||||||
const pwMismatch = useMemo(
|
|
||||||
() => pw1.length > 0 && pw2.length > 0 && pw1 !== pw2,
|
|
||||||
[pw1, pw2]
|
|
||||||
);
|
|
||||||
const pwTooShort = useMemo(() => pw1.length > 0 && pw1.length < 8, [pw1]);
|
|
||||||
const canSavePassword = useMemo(() => {
|
|
||||||
return !pwSaving && pw1.length >= 8 && pw1 === pw2;
|
|
||||||
}, [pwSaving, pw1, pw2]);
|
|
||||||
|
|
||||||
if (loading) return <div className="text-sm opacity-70">Loading…</div>;
|
|
||||||
|
|
||||||
if (!user) {
|
|
||||||
return (
|
|
||||||
<div className="text-sm opacity-70">
|
|
||||||
You’re not logged in. Please log in to view your account.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
const authedUser = user; // user is non-null from here down
|
|
||||||
|
|
||||||
async function saveDisplayName() {
|
|
||||||
setSaving(true);
|
|
||||||
setMsg(null);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const nextName = displayName.trim();
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
|
||||||
method: "PATCH",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ displayName: nextName }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(text || "Failed to update profile");
|
|
||||||
}
|
|
||||||
|
|
||||||
const updated = await res.json().catch(() => null);
|
|
||||||
|
|
||||||
const nextUser = {
|
|
||||||
email: authedUser.email,
|
|
||||||
displayName: updated?.displayName ?? nextName,
|
|
||||||
role: authedUser.role,
|
|
||||||
};
|
|
||||||
|
|
||||||
if (token) setSession(token, nextUser);
|
|
||||||
|
|
||||||
setMsg("Saved ✅");
|
|
||||||
setEditingName(false);
|
|
||||||
|
|
||||||
// If we came from the welcome flow, clear the param
|
|
||||||
if (welcome) router.replace("/account");
|
|
||||||
} catch (e: any) {
|
|
||||||
setMsg(e?.message || "Couldn’t save. Try again.");
|
|
||||||
} finally {
|
|
||||||
setSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function savePassword() {
|
|
||||||
setPwMsg(null);
|
|
||||||
|
|
||||||
if (pw1.length < 8) {
|
|
||||||
setPwMsg("Password must be at least 8 characters.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
if (pw1 !== pw2) {
|
|
||||||
setPwMsg("Passwords don’t match.");
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setPwSaving(true);
|
|
||||||
|
|
||||||
try {
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/users/me/password`, {
|
|
||||||
method: "POST",
|
|
||||||
headers: {
|
|
||||||
"Content-Type": "application/json",
|
|
||||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
|
||||||
},
|
|
||||||
body: JSON.stringify({ password: pw1 }),
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const text = await res.text().catch(() => "");
|
|
||||||
throw new Error(text || "Failed to set password");
|
|
||||||
}
|
|
||||||
|
|
||||||
setPwMsg("Password set ✅ You can now use email + password login.");
|
|
||||||
setPw1("");
|
|
||||||
setPw2("");
|
|
||||||
setShowPassword(false);
|
|
||||||
|
|
||||||
if (welcome) router.replace("/account");
|
|
||||||
} catch (e: any) {
|
|
||||||
setPwMsg(e?.message || "Couldn’t set password. Try again.");
|
|
||||||
} finally {
|
|
||||||
setPwSaving(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="space-y-6">
|
|
||||||
{/* Welcome / Setup card (only if needed) */}
|
|
||||||
{showWelcomeCard && (
|
|
||||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-4">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-base font-semibold">Welcome to the beta 👋</h2>
|
|
||||||
<p className="text-sm text-zinc-300/80">
|
|
||||||
Quick setup and you’re ready to cook.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display name */}
|
|
||||||
<div className="space-y-2">
|
|
||||||
<label className="text-xs font-medium text-zinc-200">
|
|
||||||
Display name
|
|
||||||
</label>
|
|
||||||
<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 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
|
||||||
/>
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<button
|
|
||||||
onClick={saveDisplayName}
|
|
||||||
disabled={!canSaveName}
|
|
||||||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
{msg && (
|
|
||||||
<span
|
|
||||||
className={`text-xs ${
|
|
||||||
msg.toLowerCase().includes("couldn")
|
|
||||||
? "text-red-300"
|
|
||||||
: "text-zinc-200/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{msg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Password */}
|
|
||||||
<div className="pt-2 border-t border-white/10">
|
|
||||||
<p className="text-xs text-zinc-300/80">
|
|
||||||
Want password login later? Optional (but recommended).
|
|
||||||
</p>
|
|
||||||
|
|
||||||
{!showPassword ? (
|
|
||||||
<button
|
|
||||||
onClick={() => setShowPassword(true)}
|
|
||||||
className="mt-2 text-xs underline opacity-80 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Set a password
|
|
||||||
</button>
|
|
||||||
) : (
|
|
||||||
<div className="mt-3 space-y-2">
|
|
||||||
<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 don’t 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")
|
|
||||||
? "text-red-300"
|
|
||||||
: "text-zinc-200/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pwMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Profile header */}
|
|
||||||
<div>
|
|
||||||
<h2 className="text-lg font-semibold">Profile</h2>
|
|
||||||
<p className="text-sm opacity-70">Basic account info.</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Profile card */}
|
|
||||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="opacity-70">Email:</span>{" "}
|
|
||||||
<span className="font-medium">{user.email}</span>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Display name (inline editable) */}
|
|
||||||
<div className="text-sm">
|
|
||||||
<span className="opacity-70">Display name:</span>{" "}
|
|
||||||
{!editingName ? (
|
|
||||||
<>
|
|
||||||
<span className="font-medium">{user.displayName || "—"}</span>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingName(true);
|
|
||||||
setMsg(null);
|
|
||||||
setDisplayName(user.displayName ?? "");
|
|
||||||
}}
|
|
||||||
className="ml-3 text-xs underline opacity-70 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Edit
|
|
||||||
</button>
|
|
||||||
</>
|
|
||||||
) : (
|
|
||||||
<div className="mt-2 space-y-2">
|
|
||||||
<input
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
placeholder="Your display name"
|
|
||||||
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
|
|
||||||
onClick={saveDisplayName}
|
|
||||||
disabled={!canSaveName}
|
|
||||||
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
|
|
||||||
>
|
|
||||||
{saving ? "Saving…" : "Save"}
|
|
||||||
</button>
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setEditingName(false);
|
|
||||||
setDisplayName(user.displayName ?? "");
|
|
||||||
setMsg(null);
|
|
||||||
}}
|
|
||||||
className="text-xs opacity-80 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Cancel
|
|
||||||
</button>
|
|
||||||
{msg && (
|
|
||||||
<span
|
|
||||||
className={`text-xs ${
|
|
||||||
msg.toLowerCase().includes("couldn")
|
|
||||||
? "text-red-300"
|
|
||||||
: "text-zinc-200/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{msg}
|
|
||||||
</span>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</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>
|
|
||||||
|
|
||||||
{/* Optional: quick link to set password later */}
|
|
||||||
<button
|
|
||||||
onClick={() => {
|
|
||||||
setShowPassword(true);
|
|
||||||
// Scroll user’s attention to welcome panel area if it exists,
|
|
||||||
// otherwise just open the password UI in their mind.
|
|
||||||
// (Kept simple: you can add a dedicated card later if you want)
|
|
||||||
setPwMsg(null);
|
|
||||||
}}
|
|
||||||
className="text-xs underline opacity-70 hover:opacity-100"
|
|
||||||
>
|
|
||||||
Set / change password
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* If user clicks "Set/change password" outside welcome flow, show it here */}
|
|
||||||
{!showWelcomeCard && 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 don’t 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")
|
|
||||||
? "text-red-300"
|
|
||||||
: "text-zinc-200/80"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{pwMsg}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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 || "Couldn’t 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 don’t 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 || "Couldn’t 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 don’t 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
+24
-26
@@ -1,59 +1,57 @@
|
|||||||
// app/(account)/layout.tsx
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import AccountChrome from "./AccountChrome";
|
|
||||||
|
|
||||||
const nav = [
|
const nav = [
|
||||||
{ href: "/account", label: "My Account" },
|
{ name: "Profile", href: "/account/profile" },
|
||||||
{ href: "/account/settings", label: "Settings" },
|
{ name: "Security", href: "/account/security" },
|
||||||
{ href: "/vault", label: "My Vault" },
|
{ name: "My Vault", href: "/vault" },
|
||||||
|
{ name: "Favorite Parts", href: "/account/favorites" },
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AccountLayout({ children }: { children: React.ReactNode }) {
|
export default function AccountLayout({
|
||||||
|
children,
|
||||||
|
}: {
|
||||||
|
children: React.ReactNode;
|
||||||
|
}) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen">
|
<div className="min-h-screen bg-black text-zinc-50">
|
||||||
<AccountChrome />
|
<div className="mx-auto max-w-6xl px-4 py-10">
|
||||||
|
<div className="flex items-start justify-between gap-4">
|
||||||
<div className="mx-auto w-full max-w-6xl px-4 py-6">
|
|
||||||
{/* Header */}
|
|
||||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
|
||||||
<div>
|
<div>
|
||||||
<div className="text-[11px] uppercase tracking-[0.22em] text-white/50">
|
<div className="text-xs tracking-[0.25em] uppercase text-zinc-400">
|
||||||
Account
|
Account
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mt-1 text-2xl font-semibold text-white/90">
|
<h1 className="mt-2 text-3xl font-semibold">Settings & Profile</h1>
|
||||||
Settings & Profile
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
</h1>
|
|
||||||
<p className="mt-1 text-sm text-white/60">
|
|
||||||
Profile, password, and account settings.
|
Profile, password, and account settings.
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href="/builder"
|
href="/builder"
|
||||||
className="inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-white/80 hover:bg-white/10"
|
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm hover:bg-white/10"
|
||||||
>
|
>
|
||||||
← Back to Builder
|
← Back to Builder
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-6 lg:grid-cols-12">
|
<div className="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-12">
|
||||||
<aside className="lg:col-span-3 rounded-2xl border border-white/10 bg-white/5 p-4">
|
{/* Sidebar */}
|
||||||
<nav className="flex flex-col gap-1">
|
<aside className="lg:col-span-3">
|
||||||
|
<nav className="rounded-xl border border-white/10 bg-white/5 p-2">
|
||||||
{nav.map((item) => (
|
{nav.map((item) => (
|
||||||
<Link
|
<Link
|
||||||
key={item.href}
|
key={item.href}
|
||||||
href={item.href}
|
href={item.href}
|
||||||
className="rounded-lg px-3 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white"
|
className="block rounded-lg px-3 py-2 text-sm text-zinc-200 hover:bg-white/5"
|
||||||
>
|
>
|
||||||
{item.label}
|
{item.name}
|
||||||
</Link>
|
</Link>
|
||||||
))}
|
))}
|
||||||
</nav>
|
</nav>
|
||||||
</aside>
|
</aside>
|
||||||
|
|
||||||
<main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
|
{/* Content */}
|
||||||
{children}
|
<main className="lg:col-span-9">{children}</main>
|
||||||
</main>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
File diff suppressed because it is too large
Load Diff
@@ -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/v1/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, don’t 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 Builder
|
|
||||||
</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/v1/products/{productId}/offers
|
|
||||||
*/
|
|
||||||
export async function getRetailerOffers(
|
|
||||||
productId: string,
|
|
||||||
): Promise<RetailerOffer[]> {
|
|
||||||
const res = await fetch(
|
|
||||||
`${API_BASE_URL}/api/v1/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/v1/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 Builder
|
|
||||||
</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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -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)}`);
|
|
||||||
}
|
|
||||||
@@ -4,7 +4,7 @@ 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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -46,10 +46,10 @@ type GunbuilderProductFromApi = {
|
|||||||
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";
|
||||||
|
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
||||||
|
|
||||||
export default function Page({ params }: { params: { platform: string; partRole: string } }) {
|
export default async function Page(props: { params: Promise<{ platform: string; partRole: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
|
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
|
||||||
}
|
}
|
||||||
@@ -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,
|
||||||
|
high‑signal 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 that’s 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,15 +1,20 @@
|
|||||||
// app/(builder)/layout.tsx
|
// app/(builder)/layout.tsx
|
||||||
import type { ReactNode } from "react";
|
import type { ReactNode } from "react";
|
||||||
|
import { Suspense } from "react";
|
||||||
|
|
||||||
import { BuilderNav } from "@/components/BuilderNav";
|
import { BuilderNav } from "@/components/BuilderNav";
|
||||||
import { TopNav } from "@/components/TopNav";
|
import { TopNav } from "@/components/TopNav";
|
||||||
|
import { Footer } from "@/components/Footer";
|
||||||
|
|
||||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
|
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
|
||||||
<TopNav />
|
<TopNav />
|
||||||
<BuilderNav />
|
<Suspense fallback={<div className="h-[52px]" />}>
|
||||||
|
<BuilderNav />
|
||||||
|
</Suspense>
|
||||||
<main className="min-h-screen">{children}</main>
|
<main className="min-h-screen">{children}</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,441 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
import MailingAddressComponent from "@/components/MailingAddressComponent";
|
||||||
|
|
||||||
|
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 ("we," "us," "our," or "Company") 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 "Service").
|
||||||
|
</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
|
||||||
|
12–24 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'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'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 "Last Updated" 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>
|
||||||
|
<MailingAddressComponent/>
|
||||||
|
</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -5,6 +5,7 @@ import Link from "next/link";
|
|||||||
import { useParams, useRouter } from "next/navigation";
|
import { useParams, useRouter } from "next/navigation";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { useApi } from "@/lib/api";
|
import { useApi } from "@/lib/api";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
type BuildDto = {
|
type BuildDto = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
@@ -556,7 +557,7 @@ export default function VaultBuildEditPage() {
|
|||||||
✕
|
✕
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<img
|
<Image
|
||||||
src={p.url}
|
src={p.url}
|
||||||
alt="Pending upload"
|
alt="Pending upload"
|
||||||
className="h-32 w-full object-cover"
|
className="h-32 w-full object-cover"
|
||||||
@@ -573,7 +574,7 @@ export default function VaultBuildEditPage() {
|
|||||||
key={p.uuid}
|
key={p.uuid}
|
||||||
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||||||
>
|
>
|
||||||
<img
|
<Image
|
||||||
src={p.url}
|
src={p.url}
|
||||||
alt={p.caption ?? "Build photo"}
|
alt={p.caption ?? "Build photo"}
|
||||||
className="h-32 w-full object-cover"
|
className="h-32 w-full object-cover"
|
||||||
|
|||||||
@@ -19,7 +19,7 @@ type BuildDto = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
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 ?? "";
|
||||||
|
|
||||||
// UUID validator (defensive)
|
// UUID validator (defensive)
|
||||||
const isUuid = (v: string) =>
|
const isUuid = (v: string) =>
|
||||||
@@ -241,7 +241,7 @@ export default function VaultPage() {
|
|||||||
}`}
|
}`}
|
||||||
title={valid ? "Edit details / publish later" : "Invalid build id"}
|
title={valid ? "Edit details / publish later" : "Invalid build id"}
|
||||||
>
|
>
|
||||||
Edit Title
|
View / Edit
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</li>
|
</li>
|
||||||
|
|||||||
+429
-51
@@ -1,20 +1,123 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
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 = {},
|
||||||
|
authHeaders: HeadersInit = {}
|
||||||
|
) {
|
||||||
|
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
...authHeaders,
|
||||||
|
},
|
||||||
|
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() {
|
export default function AdminBetaInvitesPage() {
|
||||||
|
const { getAuthHeaders, user } = useAuth();
|
||||||
|
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
|
||||||
|
|
||||||
|
// ------------------------------
|
||||||
|
// Batch invites
|
||||||
|
// ------------------------------
|
||||||
const [dryRun, setDryRun] = useState(true);
|
const [dryRun, setDryRun] = useState(true);
|
||||||
const [limit, setLimit] = useState("1");
|
const [limit, setLimit] = useState("25");
|
||||||
const [tokenMinutes, setTokenMinutes] = useState("30");
|
const [tokenMinutes, setTokenMinutes] = useState("30");
|
||||||
const [loading, setLoading] = useState(false);
|
const [loadingBatch, setLoadingBatch] = useState(false);
|
||||||
const [result, setResult] = useState<any>(null);
|
const [batchResult, setBatchResult] = useState<any>(null);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [batchError, setBatchError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function runInvites() {
|
async function runInvites() {
|
||||||
setLoading(true);
|
setLoadingBatch(true);
|
||||||
setError(null);
|
setBatchError(null);
|
||||||
setResult(null);
|
setBatchResult(null);
|
||||||
|
|
||||||
try {
|
try {
|
||||||
const qs = new URLSearchParams({
|
const qs = new URLSearchParams({
|
||||||
@@ -23,70 +126,345 @@ export default function AdminBetaInvitesPage() {
|
|||||||
tokenMinutes: String(tokenMinutes || "30"),
|
tokenMinutes: String(tokenMinutes || "30"),
|
||||||
});
|
});
|
||||||
|
|
||||||
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
|
const data = await fetchJson(
|
||||||
method: "POST",
|
`/api/v1/admin/beta/invites/send?${qs.toString()}`,
|
||||||
});
|
{ method: "POST" },
|
||||||
|
authHeaders
|
||||||
|
);
|
||||||
|
|
||||||
const data = await res.json().catch(() => ({}));
|
setBatchResult(data);
|
||||||
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
|
await loadRequests();
|
||||||
|
|
||||||
setResult(data);
|
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
setError(e?.message ?? "Failed");
|
setBatchError(e?.message ?? "Failed");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
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/v1/admin/beta/requests?page=${page}&size=${size}`,
|
||||||
|
{ method: "GET" },
|
||||||
|
authHeaders
|
||||||
|
)) 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/v1/admin/beta/requests/${userId}/invite`,
|
||||||
|
{ method: "POST" },
|
||||||
|
authHeaders
|
||||||
|
)) 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]);
|
||||||
|
|
||||||
|
const isAdmin = (user?.role ?? "").toUpperCase() === "ADMIN";
|
||||||
|
|
||||||
|
if (!isAdmin) {
|
||||||
|
return (
|
||||||
|
<div className="space-y-2">
|
||||||
|
<h1 className="text-xl font-semibold">Admin</h1>
|
||||||
|
<p className="text-sm text-zinc-400">
|
||||||
|
You don’t have access to this page.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="space-y-6">
|
<div className="space-y-6">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
|
<h1 className="text-xl font-semibold">Beta Access</h1>
|
||||||
<p className="mt-1 text-sm text-zinc-400">
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
Runs the backend batch invite sender (uses your email templates + tracking).
|
Review pending beta requests and send invite links (batch or
|
||||||
|
per-user).
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
{/* Left: Batch Invites */}
|
||||||
<input
|
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||||
type="checkbox"
|
<div className="flex items-center justify-between gap-3">
|
||||||
checked={dryRun}
|
<div>
|
||||||
onChange={(e) => setDryRun(e.target.checked)}
|
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||||
/>
|
Batch Invites
|
||||||
Dry run (do everything except actually send)
|
</h2>
|
||||||
</label>
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
Runs the backend batch invite sender.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">
|
||||||
<Field label="Limit" htmlFor="limit">
|
{dryRun ? "Dry run" : "Live send"}
|
||||||
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
</span>
|
||||||
</Field>
|
</div>
|
||||||
|
|
||||||
<Field label="Token minutes" htmlFor="tokenMinutes">
|
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||||
<Input
|
<input
|
||||||
id="tokenMinutes"
|
type="checkbox"
|
||||||
value={tokenMinutes}
|
checked={dryRun}
|
||||||
onChange={(e) => setTokenMinutes(e.target.value)}
|
onChange={(e) => setDryRun(e.target.checked)}
|
||||||
/>
|
/>
|
||||||
</Field>
|
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>
|
</div>
|
||||||
|
|
||||||
<Button onClick={runInvites} disabled={loading}>
|
{/* Right: Requests List */}
|
||||||
{loading ? "Running…" : "Send Beta Invites"}
|
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||||
</Button>
|
<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>
|
||||||
|
|
||||||
{error && (
|
<Button
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
onClick={loadRequests}
|
||||||
{error}
|
disabled={loadingList}
|
||||||
|
className="w-auto px-6"
|
||||||
|
>
|
||||||
|
{loadingList ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
{result && (
|
{listError && (
|
||||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
{JSON.stringify(result, null, 2)}
|
{listError}
|
||||||
</pre>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
fetchEmailRequests,
|
fetchEmailRequests,
|
||||||
@@ -32,13 +32,13 @@ export default function AdminEmailPage() {
|
|||||||
|
|
||||||
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
|
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
|
||||||
|
|
||||||
const loadEmails = async () => {
|
const loadEmails = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchEmailRequests(token);
|
const data = await fetchEmailRequests(token);
|
||||||
setEmails(data.data);
|
setEmails(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -46,11 +46,11 @@ export default function AdminEmailPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEmails();
|
loadEmails();
|
||||||
}, [token]);
|
}, [loadEmails]);
|
||||||
|
|
||||||
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
|
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
fetchEmailRequests,
|
fetchEmailRequests,
|
||||||
@@ -27,13 +27,13 @@ export default function AdminEmailPage() {
|
|||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const loadEmails = async () => {
|
const loadEmails = useCallback(async () => {
|
||||||
if (!token) return;
|
if (!token) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchEmailRequests(token);
|
const data = await fetchEmailRequests(token);
|
||||||
setEmails(data.data);
|
setEmails(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -41,11 +41,11 @@ export default function AdminEmailPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, [token]);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEmails();
|
loadEmails();
|
||||||
}, [token]);
|
}, [loadEmails]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setModalMode("create");
|
setModalMode("create");
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, 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 {
|
||||||
|
|||||||
@@ -29,7 +29,7 @@ type QueueItem = {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE =
|
const API_BASE =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
function getAuthHeaders(): HeadersInit {
|
function getAuthHeaders(): HeadersInit {
|
||||||
if (typeof window === "undefined") return {};
|
if (typeof window === "undefined") return {};
|
||||||
|
|||||||
@@ -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();
|
||||||
|
|||||||
+109
-35
@@ -3,7 +3,9 @@
|
|||||||
import type React from "react";
|
import type React from "react";
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { usePathname, useRouter } from "next/navigation";
|
import { usePathname, useRouter } from "next/navigation";
|
||||||
import AdminLeftNavigation from "@/components/AdminLeftNavigation";
|
import AdminLeftNavigation, {
|
||||||
|
type AdminNavGroup,
|
||||||
|
} from "@/components/AdminLeftNavigation";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import {
|
import {
|
||||||
LayoutDashboard,
|
LayoutDashboard,
|
||||||
@@ -13,24 +15,103 @@ import {
|
|||||||
Store,
|
Store,
|
||||||
Users,
|
Users,
|
||||||
Settings,
|
Settings,
|
||||||
LucideMail,
|
Mail,
|
||||||
Wand2,
|
Wand2,
|
||||||
|
FolderKanban,
|
||||||
|
Shield,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navItems = [
|
const navGroups: AdminNavGroup[] = [
|
||||||
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
|
{
|
||||||
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
|
label: "Overview",
|
||||||
{ label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
|
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||||
{ label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
|
items: [
|
||||||
{ label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
|
{
|
||||||
{ label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
|
label: "Dashboard",
|
||||||
{ label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
|
href: "/admin",
|
||||||
{ label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
|
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||||
{ label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
|
},
|
||||||
{ label: "List Emails", href: "/admin/email", icon: <LucideMail className="h-4 w-4" /> },
|
],
|
||||||
{ label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
|
},
|
||||||
{ label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail 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",
|
||||||
|
href: "/admin/import-status",
|
||||||
|
icon: <Download className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Mappings",
|
||||||
|
href: "/admin/mapping",
|
||||||
|
icon: <Layers className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Enrichment",
|
||||||
|
href: "/admin/enrichment",
|
||||||
|
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",
|
||||||
|
href: "/admin/merchants",
|
||||||
|
icon: <Store className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
label: "Settings",
|
||||||
|
href: "/admin/settings",
|
||||||
|
icon: <Settings className="h-4 w-4" />,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
},
|
||||||
];
|
];
|
||||||
|
|
||||||
export default function AdminLayout({
|
export default function AdminLayout({
|
||||||
@@ -44,55 +125,48 @@ export default function AdminLayout({
|
|||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
|
||||||
// Where to send people back after login
|
|
||||||
const next = useMemo(
|
const next = useMemo(
|
||||||
() => encodeURIComponent(pathname || "/admin"),
|
() => encodeURIComponent(pathname || "/admin"),
|
||||||
[pathname]
|
[pathname]
|
||||||
);
|
);
|
||||||
|
|
||||||
/**
|
|
||||||
* ✅ AUTH GUARD
|
|
||||||
* Redirects happen in useEffect (NOT during render)
|
|
||||||
*/
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (loading) return;
|
if (loading) return;
|
||||||
|
|
||||||
// Not logged in
|
|
||||||
if (!user) {
|
if (!user) {
|
||||||
router.replace(`/login?next=${next}`);
|
router.replace(`/login?next=${next}`);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Logged in but not admin
|
|
||||||
if (user.role !== "ADMIN") {
|
if (user.role !== "ADMIN") {
|
||||||
router.replace("/");
|
router.replace("/");
|
||||||
}
|
}
|
||||||
}, [loading, user, router, next]);
|
}, [loading, user, router, next]);
|
||||||
|
|
||||||
// While loading OR redirecting, render nothing
|
|
||||||
if (loading) return null;
|
if (loading) return null;
|
||||||
if (!user) return null;
|
if (!user) return null;
|
||||||
if (user.role !== "ADMIN") 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="min-h-screen bg-black text-zinc-50 overflow-x-hidden pt-[52px]">
|
||||||
<AdminLeftNavigation
|
<AdminLeftNavigation
|
||||||
collapsed={collapsed}
|
collapsed={collapsed}
|
||||||
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||||
items={navItems}
|
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 min-h-screen 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">
|
||||||
@@ -105,8 +179,8 @@ export default function AdminLayout({
|
|||||||
</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 px-4 py-6">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+541
-85
@@ -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, it’s 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,83 @@ 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 {
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch(
|
// EXPECTED backend endpoint (new):
|
||||||
`${API_BASE_URL}/api/admin/mapping/pending-buckets`,
|
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
|
||||||
{ headers: { Accept: "application/json" } }
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
|
||||||
);
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// Don’t hard-fail the whole page if options aren’t 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);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
try {
|
||||||
|
if (tab === "roles") {
|
||||||
|
// Existing endpoint you already have:
|
||||||
|
// GET /api/admin/mapping/pending-buckets
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
@@ -100,40 +256,71 @@ export default function MappingAdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data: PendingBucket[] = await res.json();
|
const data: PendingBucket[] = await res.json();
|
||||||
setRows(data);
|
setRoleBuckets(data);
|
||||||
} catch (e: any) {
|
setRawRows([]);
|
||||||
console.error(e);
|
return;
|
||||||
setError(e.message ?? "Failed to load pending buckets");
|
|
||||||
} finally {
|
|
||||||
setLoading(false);
|
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
|
// 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_BASE_URL}/api/admin/mapping/raw-categories?${params.toString()}`,
|
||||||
|
{ headers: { Accept: "application/json" }, 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) {
|
||||||
|
console.error(e);
|
||||||
|
setError(e?.message ?? "Failed to load mapping data");
|
||||||
|
} finally {
|
||||||
|
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,11 +329,13 @@ 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);
|
||||||
|
|
||||||
|
// Existing endpoint you already have:
|
||||||
|
// POST /api/admin/mapping/apply
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
@@ -164,8 +353,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 +364,228 @@ 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_BASE_URL}/api/admin/mapping/upsert`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
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 server’s 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
|
||||||
</p>
|
blending worlds.
|
||||||
|
</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 +593,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 +611,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 +630,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 +645,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 (
|
||||||
|
|||||||
+457
-83
@@ -3,7 +3,7 @@
|
|||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
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 MerchantAdminDto = {
|
type MerchantAdminDto = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,13 +16,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 +68,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(() => {
|
||||||
@@ -81,6 +131,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);
|
||||||
@@ -124,6 +183,7 @@ 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);
|
||||||
@@ -151,6 +211,7 @@ 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);
|
||||||
@@ -179,9 +240,59 @@ export default function MerchantsAdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- create new merchant ---
|
||||||
|
const handleCreateMerchant = async () => {
|
||||||
|
try {
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
setBanner(null);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/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,135 +322,398 @@ 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 */}
|
||||||
<input
|
<td className="py-3 px-4">
|
||||||
type="text"
|
<div className="flex items-center gap-2">
|
||||||
value={m.feedUrl ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
|
||||||
<input
|
|
||||||
type="text"
|
|
||||||
value={m.offerFeedUrl ?? ""}
|
|
||||||
onChange={(e) =>
|
|
||||||
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"
|
|
||||||
/>
|
|
||||||
</td>
|
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
|
||||||
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
|
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="text"
|
||||||
checked={m.isActive}
|
value={m.feedUrl ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(m.id, "isActive", e.target.checked)
|
updateMerchantField(m.id, "feedUrl", e.target.value)
|
||||||
}
|
}
|
||||||
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
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"
|
||||||
/>
|
/>
|
||||||
Active
|
<button
|
||||||
</label>
|
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 text-xs text-zinc-400 whitespace-nowrap">
|
{/* Offer Feed URL with copy button */}
|
||||||
{formatDate(m.lastFullImportAt)}
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={m.offerFeedUrl ?? ""}
|
||||||
|
onChange={(e) =>
|
||||||
|
updateMerchantField(m.id, "offerFeedUrl", e.target.value || null)
|
||||||
|
}
|
||||||
|
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 text-xs text-zinc-400 whitespace-nowrap">
|
{/* Status Badge (replacing checkbox) */}
|
||||||
{formatDate(m.lastOfferSyncAt)}
|
<td className="py-3 px-4">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
updateMerchantField(m.id, "isActive", !m.isActive)
|
||||||
|
}
|
||||||
|
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"
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
{m.isActive ? "Active" : "Inactive"}
|
||||||
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 pl-3">
|
{/* Last Import with tooltip */}
|
||||||
<div className="flex flex-col items-end gap-1">
|
<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>
|
||||||
|
|
||||||
|
{/* Actions Dropdown */}
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{/* Primary Save Button */}
|
||||||
<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>
|
||||||
|
|
||||||
<button
|
{/* Dropdown Menu */}
|
||||||
type="button"
|
<div className="relative">
|
||||||
onClick={() => handleRunFullImport(m.id)}
|
<button
|
||||||
disabled={importingId === m.id}
|
type="button"
|
||||||
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 ${
|
onClick={() =>
|
||||||
importingId === m.id ? "opacity-60 cursor-wait" : ""
|
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"
|
||||||
{importingId === m.id ? "Importing…" : "Run Full Import"}
|
title="More actions"
|
||||||
</button>
|
>
|
||||||
|
<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>
|
||||||
|
|
||||||
<button
|
{openDropdown === m.id && (
|
||||||
type="button"
|
<>
|
||||||
onClick={() => handleRunOfferSync(m.id)}
|
<div
|
||||||
disabled={offerSyncId === m.id}
|
className="fixed inset-0 z-10"
|
||||||
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 ${
|
onClick={() => setOpenDropdown(null)}
|
||||||
offerSyncId === m.id ? "opacity-60 cursor-wait" : ""
|
/>
|
||||||
}`}
|
<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">
|
||||||
{offerSyncId === m.id ? "Syncing Offers…" : "Run Offer Sync"}
|
<button
|
||||||
</button>
|
type="button"
|
||||||
|
onClick={() => handleRunFullImport(m.id)}
|
||||||
|
disabled={importingId === m.id}
|
||||||
|
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 ? (
|
||||||
|
<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
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleRunOfferSync(m.id)}
|
||||||
|
disabled={offerSyncId === m.id}
|
||||||
|
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 ? (
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -352,4 +726,4 @@ export default function MerchantsAdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
+1
-1
@@ -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;
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -0,0 +1,366 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {JSX, 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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export type PlatformDto = {
|
||||||
|
id: number;
|
||||||
|
key: string;
|
||||||
|
label: string;
|
||||||
|
is_active: boolean;
|
||||||
|
};
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
@@ -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();
|
||||||
|
}
|
||||||
@@ -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
@@ -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/v1/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/v1/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"
|
|
||||||
>
|
|
||||||
<
|
|
||||||
</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"
|
|
||||||
>
|
|
||||||
>
|
|
||||||
</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/v1/products?platform=...</span> · Page size: <span className="font-mono text-zinc-400">{pageSize}</span>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
|
||||||
</main>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
@@ -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();
|
||||||
|
|||||||
@@ -2,11 +2,11 @@ import { NextResponse } from "next/server";
|
|||||||
import { cookies } from "next/headers";
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
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 ?? "";
|
||||||
|
|
||||||
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
||||||
export async function POST(req: Request) {
|
export async function POST(req: Request) {
|
||||||
const token = cookies().get("bb_access_token")?.value;
|
const token = (await cookies()).get("bb_access_token")?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
||||||
|
|||||||
@@ -1,22 +1,43 @@
|
|||||||
|
// app/api/beta-signup/route.ts
|
||||||
import { NextResponse } from "next/server";
|
import { NextResponse } from "next/server";
|
||||||
|
|
||||||
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 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) {
|
||||||
try {
|
try {
|
||||||
const body = await req.json();
|
const body = (await req.json()) as BetaSignupBody;
|
||||||
|
|
||||||
|
const email = (body.email ?? "").trim().toLowerCase();
|
||||||
|
const useCase = (body.useCase ?? "").trim();
|
||||||
|
const acceptedTos = Boolean(body.acceptedTos);
|
||||||
|
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 });
|
||||||
|
}
|
||||||
|
|
||||||
|
// Forward only known fields
|
||||||
|
const payload = { email, useCase, acceptedTos, tosVersion };
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify(body),
|
body: JSON.stringify(payload),
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
});
|
});
|
||||||
|
|
||||||
// Always return ok=true (matches your server behavior)
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
// Optional: log server response for debugging
|
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
console.error("beta-signup proxy failed:", res.status, text);
|
console.error("beta-signup proxy failed:", res.status, text);
|
||||||
}
|
}
|
||||||
@@ -24,6 +45,6 @@ export async function POST(req: Request) {
|
|||||||
return NextResponse.json({ ok: true });
|
return NextResponse.json({ ok: true });
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
console.error("beta-signup proxy error:", e);
|
console.error("beta-signup proxy error:", e);
|
||||||
return NextResponse.json({ ok: true }); // keep “no enumeration”
|
return NextResponse.json({ ok: true });
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -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" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,16 @@
|
|||||||
|
import { springProxy } from "@/lib/springProxy";
|
||||||
|
import type {
|
||||||
|
MerchantAdminResponse,
|
||||||
|
MerchantAdminUpdateRequest,
|
||||||
|
} from "@/types/merchant-admin";
|
||||||
|
|
||||||
|
export async function PUT(req: Request, props: { params: Promise<{ id: string }> }) {
|
||||||
|
const params = await props.params;
|
||||||
|
const body = (await req.json()) as MerchantAdminUpdateRequest;
|
||||||
|
|
||||||
|
return springProxy<MerchantAdminResponse, MerchantAdminUpdateRequest>(
|
||||||
|
req,
|
||||||
|
`/api/v1/admin/merchants/${params.id}`,
|
||||||
|
{ method: "PUT", body }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,19 @@
|
|||||||
|
import { springProxy } from "@/lib/springProxy";
|
||||||
|
import type {
|
||||||
|
MerchantAdminCreateRequest,
|
||||||
|
MerchantAdminResponse,
|
||||||
|
} from "@/types/merchant-admin";
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
return springProxy<MerchantAdminResponse[]>(req, "/api/v1/admin/merchants");
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const body = (await req.json()) as MerchantAdminCreateRequest;
|
||||||
|
|
||||||
|
return springProxy<MerchantAdminResponse, MerchantAdminCreateRequest>(
|
||||||
|
req,
|
||||||
|
"/api/v1/admin/merchants",
|
||||||
|
{ method: "POST", body }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -1,14 +1,14 @@
|
|||||||
// app/beta/confirm/page.tsx
|
// app/beta/confirm/page.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
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 BetaConfirmPage() {
|
function BetaConfirmPageContent() {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const token = params.get("token");
|
const token = params.get("token");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -44,13 +44,21 @@ export default function BetaConfirmPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data = await res.json();
|
const data = await res.json();
|
||||||
|
|
||||||
const jwt = data.token ?? data.accessToken;
|
const jwt = data.token ?? data.accessToken;
|
||||||
if (!jwt) throw new Error("No token returned from server");
|
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, {
|
setSession(jwt, {
|
||||||
email: data.email,
|
uuid,
|
||||||
displayName: data.displayName ?? null,
|
email,
|
||||||
role: data.role ?? "USER",
|
displayName: data.displayName ?? data.user?.displayName ?? null,
|
||||||
|
role: data.role ?? data.user?.role ?? "USER",
|
||||||
});
|
});
|
||||||
|
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
@@ -80,4 +88,19 @@ export default function BetaConfirmPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
+19
-3
@@ -1,10 +1,10 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
export default function BetaMagicPage() {
|
function BetaMagicPageContent() {
|
||||||
const params = useSearchParams();
|
const params = useSearchParams();
|
||||||
const token = params.get("token");
|
const token = params.get("token");
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
@@ -27,7 +27,7 @@ export default function BetaMagicPage() {
|
|||||||
|
|
||||||
(async () => {
|
(async () => {
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080"}/api/auth/magic/exchange`, {
|
const res = await fetch(`${process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://battlbuilder-api:8080"}/api/auth/magic/exchange`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ token }),
|
body: JSON.stringify({ token }),
|
||||||
@@ -45,6 +45,7 @@ export default function BetaMagicPage() {
|
|||||||
// We can simulate "login" by directly setting localStorage like AuthContext does,
|
// We can simulate "login" by directly setting localStorage like AuthContext does,
|
||||||
// but easiest is just to store it here in the same shape.
|
// but easiest is just to store it here in the same shape.
|
||||||
setSession(data.token ?? data.accessToken, {
|
setSession(data.token ?? data.accessToken, {
|
||||||
|
uuid: data.uuid ?? data.id,
|
||||||
email: data.email,
|
email: data.email,
|
||||||
displayName: data.displayName ?? null,
|
displayName: data.displayName ?? null,
|
||||||
role: data.role ?? "USER",
|
role: data.role ?? "USER",
|
||||||
@@ -74,4 +75,19 @@ export default function BetaMagicPage() {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</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>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,382 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import {
|
||||||
|
useCallback,
|
||||||
|
useEffect,
|
||||||
|
useMemo,
|
||||||
|
useState,
|
||||||
|
} from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||||
|
type Caliber = string;
|
||||||
|
|
||||||
|
type BuildCard = {
|
||||||
|
id: string;
|
||||||
|
title: string;
|
||||||
|
slug: string;
|
||||||
|
creator: string;
|
||||||
|
caliber: Caliber;
|
||||||
|
buildClass: BuildClass;
|
||||||
|
price: number;
|
||||||
|
votes: number;
|
||||||
|
tags: string[];
|
||||||
|
coverImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
|
||||||
|
|
||||||
|
const PRICE_FILTERS = [
|
||||||
|
{ id: "all", label: "All", min: 0, max: Infinity },
|
||||||
|
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
|
||||||
|
{ id: "1to2k", label: "$1k–$2k", min: 1000_00, max: 2000_00 },
|
||||||
|
{ id: "2to3k", label: "$2k–$3k", min: 2000_00, max: 3000_00 },
|
||||||
|
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
|
||||||
|
];
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
interface BuildsClientProps {
|
||||||
|
initialBuilds: BuildCard[];
|
||||||
|
initialError: string | null;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Client-side component that keeps all the previous interactivity:
|
||||||
|
* - filters
|
||||||
|
* - optimistic votes
|
||||||
|
*
|
||||||
|
* It receives the builds from the server but can optionally re-fetch on mount
|
||||||
|
* if you want truly live data (kept here as an example but can be removed).
|
||||||
|
*/
|
||||||
|
export default function BuildsClient({
|
||||||
|
initialBuilds,
|
||||||
|
initialError,
|
||||||
|
}: BuildsClientProps) {
|
||||||
|
// --- Remote data state (initialized from server) ---
|
||||||
|
const [loading, setLoading] = useState(false);
|
||||||
|
const [error, setError] = useState<string | null>(initialError);
|
||||||
|
const [builds, setBuilds] = useState<BuildCard[]>(initialBuilds);
|
||||||
|
|
||||||
|
// --- UI state (filters/votes) ---
|
||||||
|
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
||||||
|
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
||||||
|
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
||||||
|
|
||||||
|
const [votes, setVotes] = useState<Record<string, number>>(() =>
|
||||||
|
Object.fromEntries(initialBuilds.map((b) => [b.id, b.votes])),
|
||||||
|
);
|
||||||
|
|
||||||
|
const activePriceFilter =
|
||||||
|
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
|
||||||
|
|
||||||
|
// Optional: client re-fetch to keep data fresh (can be removed if not needed)
|
||||||
|
useEffect(() => {
|
||||||
|
// If you don't want client refetching, just return early here.
|
||||||
|
if (!API_BASE_URL) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "");
|
||||||
|
throw new Error(txt || `Failed to load builds (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as BuildCard[];
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setBuilds(data);
|
||||||
|
setVotes(Object.fromEntries(data.map((b) => [b.id, b.votes])));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message || "Failed to load builds feed");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comment this out if you want *only* server-side fetching:
|
||||||
|
// run();
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
|
||||||
|
const uniq = new Set<string>();
|
||||||
|
for (const b of builds) {
|
||||||
|
const c = (b.caliber ?? "").trim();
|
||||||
|
if (c && c !== "—") uniq.add(c);
|
||||||
|
}
|
||||||
|
return ["all", ...Array.from(uniq).sort()];
|
||||||
|
}, [builds]);
|
||||||
|
|
||||||
|
const filteredBuilds = useMemo(() => {
|
||||||
|
return builds.filter((build) => {
|
||||||
|
const matchesCaliber =
|
||||||
|
caliberFilter === "all" || build.caliber === caliberFilter;
|
||||||
|
|
||||||
|
const matchesClass =
|
||||||
|
classFilter === "all" || build.buildClass === classFilter;
|
||||||
|
|
||||||
|
const matchesPrice =
|
||||||
|
build.price >= activePriceFilter.min &&
|
||||||
|
build.price < activePriceFilter.max;
|
||||||
|
|
||||||
|
return matchesCaliber && matchesClass && matchesPrice;
|
||||||
|
});
|
||||||
|
}, [builds, caliberFilter, classFilter, activePriceFilter]);
|
||||||
|
|
||||||
|
const handleVote = useCallback((id: string, delta: 1 | -1) => {
|
||||||
|
setVotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: (prev[id] ?? 0) + delta,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
{/* Loading / Error (uses same UI as before) */}
|
||||||
|
{loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
||||||
|
Loading community builds…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
<div className="mt-2 text-xs text-red-200/80">
|
||||||
|
Dev tip: confirm backend route{" "}
|
||||||
|
<span className="font-mono">GET /api/v1/builds</span> is
|
||||||
|
implemented + CORS is configured.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
||||||
|
Filter Builds
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Filter by caliber, class, and price range. (Client-side for MVP.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">
|
||||||
|
{filteredBuilds.length}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
||||||
|
builds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{/* Caliber filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Caliber
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CALIBER_FILTERS.map((caliber) => (
|
||||||
|
<button
|
||||||
|
key={caliber}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
caliberFilter === caliber
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{caliber === "all" ? "All" : caliber}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Class
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CLASS_FILTERS.map((cls) => (
|
||||||
|
<button
|
||||||
|
key={cls}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setClassFilter(cls === "all" ? "all" : cls)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
classFilter === cls
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cls === "all" ? "All" : cls}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Price Range
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRICE_FILTERS.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriceFilterId(range.id)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
priceFilterId === range.id
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Build list */}
|
||||||
|
<section>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredBuilds.map((build) => (
|
||||||
|
<article
|
||||||
|
key={build.id}
|
||||||
|
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
||||||
|
>
|
||||||
|
{/* Votes column */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Upvote"
|
||||||
|
title="Upvote"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<div className="text-xs font-semibold text-amber-200">
|
||||||
|
{votes[build.id] ?? build.votes}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, -1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Downvote"
|
||||||
|
title="Downvote"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
build.coverImageUrl ||
|
||||||
|
"https://placehold.co/320x200/png?text=Build+Photo"
|
||||||
|
}
|
||||||
|
alt={`${build.title} cover`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
{build.buildClass} · {build.caliber}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
||||||
|
<Link
|
||||||
|
href={`/builds/${build.slug}`}
|
||||||
|
className="hover:text-amber-200 transition-colors"
|
||||||
|
>
|
||||||
|
{build.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Posted by{" "}
|
||||||
|
<span className="text-zinc-300">b/{build.creator}</span>{" "}
|
||||||
|
· Build detail page will show the parts list +
|
||||||
|
comments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right mt-1 md:mt-0">
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Est. Build Cost
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-amber-300">
|
||||||
|
{build.price > 0 ? (
|
||||||
|
<span>
|
||||||
|
{`$${Math.floor(build.price / 100).toLocaleString()}`}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{build.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
||||||
|
Comments + save coming soon.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
))}
|
||||||
|
|
||||||
|
{!loading && !error && filteredBuilds.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
||||||
|
No builds match those filters yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1 @@
|
|||||||
|
the page-use-client.tsx is the old client side page, this is a separation of the server side page and the client side
|
||||||
@@ -3,7 +3,7 @@ import Link from "next/link";
|
|||||||
import { notFound } from "next/navigation";
|
import { notFound } 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 ?? "";
|
||||||
|
|
||||||
function formatMoney(n?: number | null) {
|
function formatMoney(n?: number | null) {
|
||||||
if (n == null) return "—";
|
if (n == null) return "—";
|
||||||
@@ -26,11 +26,12 @@ function timeAgo(iso?: string | null) {
|
|||||||
return `${days}d ago`;
|
return `${days}d ago`;
|
||||||
}
|
}
|
||||||
|
|
||||||
export default async function BuildBreakdownPage({
|
export default async function BuildBreakdownPage(
|
||||||
params,
|
props: {
|
||||||
}: {
|
params: Promise<{ buildId: string }>;
|
||||||
params: { buildId: string };
|
}
|
||||||
}) {
|
) {
|
||||||
|
const params = await props.params;
|
||||||
// Public detail endpoint
|
// Public detail endpoint
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds/${params.buildId}`, {
|
||||||
cache: "no-store",
|
cache: "no-store",
|
||||||
@@ -73,7 +74,6 @@ export default async function BuildBreakdownPage({
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Layout */}
|
{/* Layout */}
|
||||||
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
|
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
|
||||||
{/* Post column */}
|
{/* Post column */}
|
||||||
@@ -168,11 +168,11 @@ export default async function BuildBreakdownPage({
|
|||||||
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
||||||
{it.productImageUrl ? (
|
{it.productImageUrl ? (
|
||||||
// eslint-disable-next-line @next/next/no-img-element
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
<img
|
(<img
|
||||||
src={it.productImageUrl}
|
src={it.productImageUrl}
|
||||||
alt={it.productName ?? "Part image"}
|
alt={it.productName ?? "Part image"}
|
||||||
className="h-full w-full object-cover"
|
className="h-full w-full object-cover"
|
||||||
/>
|
/>)
|
||||||
) : (
|
) : (
|
||||||
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
|
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
|
||||||
IMG
|
IMG
|
||||||
|
|||||||
@@ -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 />
|
||||||
<BuilderNav />
|
<Suspense fallback={<div className="h-[52px]" />}>
|
||||||
|
<BuilderNav />
|
||||||
|
</Suspense>
|
||||||
<main className="min-h-screen">{children}</main>
|
<main className="min-h-screen">{children}</main>
|
||||||
</AuthProvider>
|
</AuthProvider>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -0,0 +1,481 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* app/builds/page.tsx
|
||||||
|
* Community Builds feed (public)
|
||||||
|
*
|
||||||
|
* Dev Notes:
|
||||||
|
* - We are moving to Option B:
|
||||||
|
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
|
||||||
|
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
|
||||||
|
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
|
||||||
|
*
|
||||||
|
* Backend (target contract):
|
||||||
|
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
|
||||||
|
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
|
||||||
|
*/
|
||||||
|
|
||||||
|
import { useEffect, useMemo, useState, useCallback } from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
|
||||||
|
* We can tighten once the controlled vocab is finalized.
|
||||||
|
*/
|
||||||
|
type Caliber = string;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* This matches what the UI needs (card format).
|
||||||
|
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
|
||||||
|
*/
|
||||||
|
type BuildCard = {
|
||||||
|
id: string; // we use uuid here (public identifier)
|
||||||
|
title: string;
|
||||||
|
slug: string; // can be uuid for now; keep property so UI does not change
|
||||||
|
creator: string; // placeholder until user profiles are real
|
||||||
|
caliber: Caliber;
|
||||||
|
buildClass: BuildClass;
|
||||||
|
price: number; // optional server-side later; for now allow 0 fallback
|
||||||
|
votes: number;
|
||||||
|
tags: string[];
|
||||||
|
coverImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
type BuildFeedCardDto = {
|
||||||
|
uuid: string;
|
||||||
|
title?: string | null;
|
||||||
|
slug?: string | null; // optional (we can generate from title later)
|
||||||
|
creator?: string | null; // optional
|
||||||
|
caliber?: string | null;
|
||||||
|
buildClass?: BuildClass | null;
|
||||||
|
price?: number | null;
|
||||||
|
votes?: number | null;
|
||||||
|
tags?: string[] | null;
|
||||||
|
coverImageUrl?: string | null;
|
||||||
|
};
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
// --- UI Filter Sets (keep; we’ll generate options from data too) ---
|
||||||
|
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
|
||||||
|
|
||||||
|
const PRICE_FILTERS = [
|
||||||
|
{ id: "all", label: "All", min: 0, max: Infinity },
|
||||||
|
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
|
||||||
|
{ id: "1to2k", label: "$1k–$2k", min: 1000_00, max: 2000_00 },
|
||||||
|
{ id: "2to3k", label: "$2k–$3k", min: 2000_00, max: 3000_00 },
|
||||||
|
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
|
||||||
|
];
|
||||||
|
|
||||||
|
function safeArray(v: unknown): string[] {
|
||||||
|
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
||||||
|
}
|
||||||
|
|
||||||
|
function fallbackSlug(dto: BuildFeedCardDto) {
|
||||||
|
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
|
||||||
|
return dto.slug?.trim() || dto.uuid;
|
||||||
|
}
|
||||||
|
|
||||||
|
function normalizeCard(dto: BuildFeedCardDto): BuildCard {
|
||||||
|
const title = (dto.title ?? "Untitled Build").trim() || "Untitled Build";
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: dto.uuid,
|
||||||
|
title,
|
||||||
|
slug: fallbackSlug(dto),
|
||||||
|
|
||||||
|
// Until we wire real users: keep a placeholder creator string.
|
||||||
|
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
||||||
|
|
||||||
|
caliber: (dto.caliber ?? "—").trim() || "—",
|
||||||
|
|
||||||
|
// Default to Rifle for display if missing; backend should set this via profile.
|
||||||
|
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
||||||
|
|
||||||
|
// Price is optional (we can compute later from BuildItems + offers).
|
||||||
|
price: typeof dto.price === "number" ? dto.price : 0,
|
||||||
|
|
||||||
|
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
||||||
|
|
||||||
|
tags: safeArray(dto.tags),
|
||||||
|
|
||||||
|
coverImageUrl: dto.coverImageUrl ?? null,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function BuildsPage() {
|
||||||
|
// --- Remote data state ---
|
||||||
|
const [loading, setLoading] = useState(true);
|
||||||
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
const [builds, setBuilds] = useState<BuildCard[]>([]);
|
||||||
|
|
||||||
|
// --- UI state (filters/votes) ---
|
||||||
|
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
||||||
|
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
||||||
|
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
||||||
|
|
||||||
|
// Local vote state for optimistic UI
|
||||||
|
const [votes, setVotes] = useState<Record<string, number>>({});
|
||||||
|
|
||||||
|
const activePriceFilter =
|
||||||
|
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
|
||||||
|
|
||||||
|
// --- Fetch public builds feed ---
|
||||||
|
useEffect(() => {
|
||||||
|
let cancelled = false;
|
||||||
|
|
||||||
|
async function run() {
|
||||||
|
try {
|
||||||
|
setLoading(true);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
||||||
|
method: "GET",
|
||||||
|
credentials: "include",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const txt = await res.text().catch(() => "");
|
||||||
|
throw new Error(txt || `Failed to load builds (${res.status})`);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = (await res.json()) as BuildFeedCardDto[];
|
||||||
|
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
|
||||||
|
|
||||||
|
if (cancelled) return;
|
||||||
|
|
||||||
|
setBuilds(cards);
|
||||||
|
|
||||||
|
// Initialize vote UI from payload totals
|
||||||
|
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
|
||||||
|
} catch (e: any) {
|
||||||
|
if (!cancelled) setError(e?.message || "Failed to load builds feed");
|
||||||
|
} finally {
|
||||||
|
if (!cancelled) setLoading(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
run();
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
// Build the caliber filters dynamically from data, but keep "all" first.
|
||||||
|
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
|
||||||
|
const uniq = new Set<string>();
|
||||||
|
for (const b of builds) {
|
||||||
|
const c = (b.caliber ?? "").trim();
|
||||||
|
if (c && c !== "—") uniq.add(c);
|
||||||
|
}
|
||||||
|
return ["all", ...Array.from(uniq).sort()];
|
||||||
|
}, [builds]);
|
||||||
|
|
||||||
|
const filteredBuilds = useMemo(() => {
|
||||||
|
return builds.filter((build) => {
|
||||||
|
const matchesCaliber =
|
||||||
|
caliberFilter === "all" || build.caliber === caliberFilter;
|
||||||
|
|
||||||
|
const matchesClass =
|
||||||
|
classFilter === "all" || build.buildClass === classFilter;
|
||||||
|
|
||||||
|
const matchesPrice =
|
||||||
|
build.price >= activePriceFilter.min &&
|
||||||
|
build.price < activePriceFilter.max;
|
||||||
|
|
||||||
|
return matchesCaliber && matchesClass && matchesPrice;
|
||||||
|
});
|
||||||
|
}, [builds, caliberFilter, classFilter, activePriceFilter]);
|
||||||
|
|
||||||
|
const handleVote = useCallback((id: string, delta: 1 | -1) => {
|
||||||
|
// MVP: optimistic local vote only.
|
||||||
|
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
|
||||||
|
setVotes((prev) => ({
|
||||||
|
...prev,
|
||||||
|
[id]: (prev[id] ?? 0) + delta,
|
||||||
|
}));
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
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 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">
|
||||||
|
Battl Builder · Build Book
|
||||||
|
</p>
|
||||||
|
<h1
|
||||||
|
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
|
||||||
|
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
|
||||||
|
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
|
||||||
|
>
|
||||||
|
Community Builds
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
|
||||||
|
Browse community builds, vote them up or down, and steal good
|
||||||
|
ideas without shame. This feed is backed by public builds
|
||||||
|
(isPublic=true).
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex gap-2 md:gap-3">
|
||||||
|
<Link
|
||||||
|
href="/builder"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/60 px-4 py-2 text-xs md:text-sm font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
|
||||||
|
>
|
||||||
|
Open Builder
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
|
||||||
|
<Link
|
||||||
|
href="/vault"
|
||||||
|
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
||||||
|
>
|
||||||
|
+ Submit Build
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
{/* Loading / Error */}
|
||||||
|
{loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
||||||
|
Loading community builds…
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{error && !loading && (
|
||||||
|
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
<div className="mt-2 text-xs text-red-200/80">
|
||||||
|
Dev tip: confirm backend route{" "}
|
||||||
|
<span className="font-mono">GET /api/v1/builds</span> is
|
||||||
|
implemented + CORS is configured.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{/* Filters */}
|
||||||
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
||||||
|
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
||||||
|
Filter Builds
|
||||||
|
</h2>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Filter by caliber, class, and price range. (Client-side for
|
||||||
|
MVP.)
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
Showing{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">
|
||||||
|
{filteredBuilds.length}
|
||||||
|
</span>{" "}
|
||||||
|
of{" "}
|
||||||
|
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
||||||
|
builds
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid gap-3 md:grid-cols-3">
|
||||||
|
{/* Caliber filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Caliber
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CALIBER_FILTERS.map((caliber) => (
|
||||||
|
<button
|
||||||
|
key={caliber}
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
||||||
|
}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
caliberFilter === caliber
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{caliber === "all" ? "All" : caliber}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Class filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Class
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{CLASS_FILTERS.map((cls) => (
|
||||||
|
<button
|
||||||
|
key={cls}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
classFilter === cls
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{cls === "all" ? "All" : cls}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Price filter */}
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
Price Range
|
||||||
|
</div>
|
||||||
|
<div className="flex flex-wrap gap-2">
|
||||||
|
{PRICE_FILTERS.map((range) => (
|
||||||
|
<button
|
||||||
|
key={range.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => setPriceFilterId(range.id)}
|
||||||
|
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
||||||
|
priceFilterId === range.id
|
||||||
|
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{range.label}
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Build list */}
|
||||||
|
<section>
|
||||||
|
<div className="space-y-4">
|
||||||
|
{filteredBuilds.map((build) => {
|
||||||
|
const dollars = Math.floor((build.price ?? 0) / 100);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<article
|
||||||
|
key={build.id}
|
||||||
|
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
||||||
|
>
|
||||||
|
{/* Votes column */}
|
||||||
|
<div className="flex flex-col items-center justify-center gap-1">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, 1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Upvote"
|
||||||
|
title="Upvote"
|
||||||
|
>
|
||||||
|
▲
|
||||||
|
</button>
|
||||||
|
<div className="text-xs font-semibold text-amber-200">
|
||||||
|
{votes[build.id] ?? build.votes}
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleVote(build.id, -1)}
|
||||||
|
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
||||||
|
aria-label="Downvote"
|
||||||
|
title="Downvote"
|
||||||
|
>
|
||||||
|
▼
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Thumbnail */}
|
||||||
|
<div className="hidden sm:block">
|
||||||
|
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
||||||
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
|
<img
|
||||||
|
src={
|
||||||
|
build.coverImageUrl ||
|
||||||
|
"https://placehold.co/320x200/png?text=Build+Photo"
|
||||||
|
}
|
||||||
|
alt={`${build.title} cover`}
|
||||||
|
className="h-full w-full object-cover"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Main content */}
|
||||||
|
<div className="flex-1">
|
||||||
|
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
||||||
|
<div>
|
||||||
|
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
||||||
|
{build.buildClass} · {build.caliber}
|
||||||
|
</div>
|
||||||
|
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
||||||
|
<Link
|
||||||
|
href={`/builds/${build.slug}`}
|
||||||
|
className="hover:text-amber-200 transition-colors"
|
||||||
|
>
|
||||||
|
{build.title}
|
||||||
|
</Link>
|
||||||
|
</h3>
|
||||||
|
<p className="text-xs text-zinc-500 mt-1">
|
||||||
|
Posted by{" "}
|
||||||
|
<span className="text-zinc-300">
|
||||||
|
b/{build.creator}
|
||||||
|
</span>{" "}
|
||||||
|
· Build detail page will show the parts list +
|
||||||
|
comments.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="text-right mt-1 md:mt-0">
|
||||||
|
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||||||
|
Est. Build Cost
|
||||||
|
</div>
|
||||||
|
<div className="text-lg font-semibold text-amber-300">
|
||||||
|
{build.price > 0 ? (
|
||||||
|
<span>
|
||||||
|
${Math.floor(build.price / 100).toLocaleString()}
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"—"
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-3 flex flex-wrap items-center gap-2">
|
||||||
|
{build.tags.map((tag) => (
|
||||||
|
<span
|
||||||
|
key={tag}
|
||||||
|
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
||||||
|
>
|
||||||
|
{tag}
|
||||||
|
</span>
|
||||||
|
))}
|
||||||
|
|
||||||
|
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
||||||
|
Comments + save coming soon.
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</article>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
|
||||||
|
{!loading && !error && filteredBuilds.length === 0 && (
|
||||||
|
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
||||||
|
No builds match those filters yet.
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
+31
-382
@@ -1,43 +1,17 @@
|
|||||||
"use client";
|
// app/builds/page.tsx
|
||||||
|
|
||||||
/**
|
|
||||||
* app/builds/page.tsx
|
|
||||||
* Community Builds feed (public)
|
|
||||||
*
|
|
||||||
* Dev Notes:
|
|
||||||
* - We are moving to Option B:
|
|
||||||
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
|
|
||||||
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
|
|
||||||
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
|
|
||||||
*
|
|
||||||
* Backend (target contract):
|
|
||||||
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
|
|
||||||
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
|
|
||||||
*/
|
|
||||||
|
|
||||||
import { useEffect, useMemo, useState, useCallback } from "react";
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
type BuildClass = "Rifle" | "Pistol" | "NFA";
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
|
|
||||||
* We can tighten once the controlled vocab is finalized.
|
|
||||||
*/
|
|
||||||
type Caliber = string;
|
type Caliber = string;
|
||||||
|
|
||||||
/**
|
|
||||||
* This matches what the UI needs (card format).
|
|
||||||
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
|
|
||||||
*/
|
|
||||||
type BuildCard = {
|
type BuildCard = {
|
||||||
id: string; // we use uuid here (public identifier)
|
id: string;
|
||||||
title: string;
|
title: string;
|
||||||
slug: string; // can be uuid for now; keep property so UI does not change
|
slug: string;
|
||||||
creator: string; // placeholder until user profiles are real
|
creator: string;
|
||||||
caliber: Caliber;
|
caliber: Caliber;
|
||||||
buildClass: BuildClass;
|
buildClass: BuildClass;
|
||||||
price: number; // optional server-side later; for now allow 0 fallback
|
price: number;
|
||||||
votes: number;
|
votes: number;
|
||||||
tags: string[];
|
tags: string[];
|
||||||
coverImageUrl?: string | null;
|
coverImageUrl?: string | null;
|
||||||
@@ -46,8 +20,8 @@ type BuildCard = {
|
|||||||
type BuildFeedCardDto = {
|
type BuildFeedCardDto = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
title?: string | null;
|
title?: string | null;
|
||||||
slug?: string | null; // optional (we can generate from title later)
|
slug?: string | null;
|
||||||
creator?: string | null; // optional
|
creator?: string | null;
|
||||||
caliber?: string | null;
|
caliber?: string | null;
|
||||||
buildClass?: BuildClass | null;
|
buildClass?: BuildClass | null;
|
||||||
price?: number | null;
|
price?: number | null;
|
||||||
@@ -56,26 +30,13 @@ type BuildFeedCardDto = {
|
|||||||
coverImageUrl?: string | null;
|
coverImageUrl?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
// --- UI Filter Sets (keep; we’ll generate options from data too) ---
|
|
||||||
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
|
|
||||||
|
|
||||||
const PRICE_FILTERS = [
|
|
||||||
{ id: "all", label: "All", min: 0, max: Infinity },
|
|
||||||
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
|
|
||||||
{ id: "1to2k", label: "$1k–$2k", min: 1000_00, max: 2000_00 },
|
|
||||||
{ id: "2to3k", label: "$2k–$3k", min: 2000_00, max: 3000_00 },
|
|
||||||
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
|
|
||||||
];
|
|
||||||
|
|
||||||
function safeArray(v: unknown): string[] {
|
function safeArray(v: unknown): string[] {
|
||||||
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
|
||||||
}
|
}
|
||||||
|
|
||||||
function fallbackSlug(dto: BuildFeedCardDto) {
|
function fallbackSlug(dto: BuildFeedCardDto) {
|
||||||
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
|
|
||||||
return dto.slug?.trim() || dto.uuid;
|
return dto.slug?.trim() || dto.uuid;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -86,119 +47,41 @@ function normalizeCard(dto: BuildFeedCardDto): BuildCard {
|
|||||||
id: dto.uuid,
|
id: dto.uuid,
|
||||||
title,
|
title,
|
||||||
slug: fallbackSlug(dto),
|
slug: fallbackSlug(dto),
|
||||||
|
|
||||||
// Until we wire real users: keep a placeholder creator string.
|
|
||||||
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
|
||||||
|
|
||||||
caliber: (dto.caliber ?? "—").trim() || "—",
|
caliber: (dto.caliber ?? "—").trim() || "—",
|
||||||
|
|
||||||
// Default to Rifle for display if missing; backend should set this via profile.
|
|
||||||
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
|
||||||
|
|
||||||
// Price is optional (we can compute later from BuildItems + offers).
|
|
||||||
price: typeof dto.price === "number" ? dto.price : 0,
|
price: typeof dto.price === "number" ? dto.price : 0,
|
||||||
|
|
||||||
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
votes: typeof dto.votes === "number" ? dto.votes : 0,
|
||||||
|
|
||||||
tags: safeArray(dto.tags),
|
tags: safeArray(dto.tags),
|
||||||
|
|
||||||
coverImageUrl: dto.coverImageUrl ?? null,
|
coverImageUrl: dto.coverImageUrl ?? null,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export default function BuildsPage() {
|
// Import the client component that will handle filters & voting
|
||||||
// --- Remote data state ---
|
import BuildsClient from "./BuildsClient";
|
||||||
const [loading, setLoading] = useState(true);
|
|
||||||
const [error, setError] = useState<string | null>(null);
|
|
||||||
const [builds, setBuilds] = useState<BuildCard[]>([]);
|
|
||||||
|
|
||||||
// --- UI state (filters/votes) ---
|
export default async function BuildsPage() {
|
||||||
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
|
let cards: BuildCard[] = [];
|
||||||
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
|
let error: string | null = null;
|
||||||
const [priceFilterId, setPriceFilterId] = useState<string>("all");
|
|
||||||
|
|
||||||
// Local vote state for optimistic UI
|
try {
|
||||||
const [votes, setVotes] = useState<Record<string, number>>({});
|
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
||||||
|
method: "GET",
|
||||||
const activePriceFilter =
|
// Disable full-page static caching; you can adjust this per your needs:
|
||||||
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
|
cache: "no-store",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
// --- Fetch public builds feed ---
|
|
||||||
useEffect(() => {
|
|
||||||
let cancelled = false;
|
|
||||||
|
|
||||||
async function run() {
|
|
||||||
try {
|
|
||||||
setLoading(true);
|
|
||||||
setError(null);
|
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/v1/builds?limit=50`, {
|
|
||||||
method: "GET",
|
|
||||||
credentials: "include",
|
|
||||||
headers: { "Content-Type": "application/json" },
|
|
||||||
});
|
|
||||||
|
|
||||||
if (!res.ok) {
|
|
||||||
const txt = await res.text().catch(() => "");
|
|
||||||
throw new Error(txt || `Failed to load builds (${res.status})`);
|
|
||||||
}
|
|
||||||
|
|
||||||
const data = (await res.json()) as BuildFeedCardDto[];
|
|
||||||
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
|
|
||||||
|
|
||||||
if (cancelled) return;
|
|
||||||
|
|
||||||
setBuilds(cards);
|
|
||||||
|
|
||||||
// Initialize vote UI from payload totals
|
|
||||||
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
|
|
||||||
} catch (e: any) {
|
|
||||||
if (!cancelled) setError(e?.message || "Failed to load builds feed");
|
|
||||||
} finally {
|
|
||||||
if (!cancelled) setLoading(false);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
run();
|
|
||||||
return () => {
|
|
||||||
cancelled = true;
|
|
||||||
};
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
// Build the caliber filters dynamically from data, but keep "all" first.
|
|
||||||
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
|
|
||||||
const uniq = new Set<string>();
|
|
||||||
for (const b of builds) {
|
|
||||||
const c = (b.caliber ?? "").trim();
|
|
||||||
if (c && c !== "—") uniq.add(c);
|
|
||||||
}
|
|
||||||
return ["all", ...Array.from(uniq).sort()];
|
|
||||||
}, [builds]);
|
|
||||||
|
|
||||||
const filteredBuilds = useMemo(() => {
|
|
||||||
return builds.filter((build) => {
|
|
||||||
const matchesCaliber =
|
|
||||||
caliberFilter === "all" || build.caliber === caliberFilter;
|
|
||||||
|
|
||||||
const matchesClass =
|
|
||||||
classFilter === "all" || build.buildClass === classFilter;
|
|
||||||
|
|
||||||
const matchesPrice =
|
|
||||||
build.price >= activePriceFilter.min &&
|
|
||||||
build.price < activePriceFilter.max;
|
|
||||||
|
|
||||||
return matchesCaliber && matchesClass && matchesPrice;
|
|
||||||
});
|
});
|
||||||
}, [builds, caliberFilter, classFilter, activePriceFilter]);
|
|
||||||
|
|
||||||
const handleVote = useCallback((id: string, delta: 1 | -1) => {
|
if (!res.ok) {
|
||||||
// MVP: optimistic local vote only.
|
const txt = await res.text().catch(() => "");
|
||||||
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
|
throw new Error(txt || `Failed to load builds (${res.status})`);
|
||||||
setVotes((prev) => ({
|
}
|
||||||
...prev,
|
|
||||||
[id]: (prev[id] ?? 0) + delta,
|
const data = (await res.json()) as BuildFeedCardDto[];
|
||||||
}));
|
cards = (Array.isArray(data) ? data : []).map(normalizeCard);
|
||||||
}, []);
|
} catch (e: any) {
|
||||||
|
error = e?.message || "Failed to load builds feed";
|
||||||
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
@@ -230,8 +113,6 @@ export default function BuildsPage() {
|
|||||||
>
|
>
|
||||||
Open Builder
|
Open Builder
|
||||||
</Link>
|
</Link>
|
||||||
|
|
||||||
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
|
|
||||||
<Link
|
<Link
|
||||||
href="/vault"
|
href="/vault"
|
||||||
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
|
||||||
@@ -241,240 +122,8 @@ export default function BuildsPage() {
|
|||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Loading / Error */}
|
{/* Pass initial data + any load error into the client component */}
|
||||||
{loading && (
|
<BuildsClient initialBuilds={cards} initialError={error} />
|
||||||
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
|
|
||||||
Loading community builds…
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{error && !loading && (
|
|
||||||
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
|
|
||||||
{error}
|
|
||||||
<div className="mt-2 text-xs text-red-200/80">
|
|
||||||
Dev tip: confirm backend route{" "}
|
|
||||||
<span className="font-mono">GET /api/v1/builds</span> is
|
|
||||||
implemented + CORS is configured.
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Filters */}
|
|
||||||
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
|
|
||||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
|
||||||
<div>
|
|
||||||
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
|
|
||||||
Filter Builds
|
|
||||||
</h2>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
|
||||||
Filter by caliber, class, and price range. (Client-side for
|
|
||||||
MVP.)
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-xs text-zinc-500">
|
|
||||||
Showing{" "}
|
|
||||||
<span className="text-zinc-200 font-medium">
|
|
||||||
{filteredBuilds.length}
|
|
||||||
</span>{" "}
|
|
||||||
of{" "}
|
|
||||||
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
|
|
||||||
builds
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="grid gap-3 md:grid-cols-3">
|
|
||||||
{/* Caliber filter */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
Caliber
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CALIBER_FILTERS.map((caliber) => (
|
|
||||||
<button
|
|
||||||
key={caliber}
|
|
||||||
type="button"
|
|
||||||
onClick={() =>
|
|
||||||
setCaliberFilter(caliber === "all" ? "all" : caliber)
|
|
||||||
}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
||||||
caliberFilter === caliber
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
|
||||||
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{caliber === "all" ? "All" : caliber}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Class filter */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
Class
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{CLASS_FILTERS.map((cls) => (
|
|
||||||
<button
|
|
||||||
key={cls}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
||||||
classFilter === cls
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
|
||||||
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{cls === "all" ? "All" : cls}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price filter */}
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
Price Range
|
|
||||||
</div>
|
|
||||||
<div className="flex flex-wrap gap-2">
|
|
||||||
{PRICE_FILTERS.map((range) => (
|
|
||||||
<button
|
|
||||||
key={range.id}
|
|
||||||
type="button"
|
|
||||||
onClick={() => setPriceFilterId(range.id)}
|
|
||||||
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
|
|
||||||
priceFilterId === range.id
|
|
||||||
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
|
|
||||||
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
|
|
||||||
}`}
|
|
||||||
>
|
|
||||||
{range.label}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
|
|
||||||
{/* Build list */}
|
|
||||||
<section>
|
|
||||||
<div className="space-y-4">
|
|
||||||
{filteredBuilds.map((build) => {
|
|
||||||
const dollars = Math.floor((build.price ?? 0) / 100);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<article
|
|
||||||
key={build.id}
|
|
||||||
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
|
|
||||||
>
|
|
||||||
{/* Votes column */}
|
|
||||||
<div className="flex flex-col items-center justify-center gap-1">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleVote(build.id, 1)}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
|
||||||
aria-label="Upvote"
|
|
||||||
title="Upvote"
|
|
||||||
>
|
|
||||||
▲
|
|
||||||
</button>
|
|
||||||
<div className="text-xs font-semibold text-amber-200">
|
|
||||||
{votes[build.id] ?? build.votes}
|
|
||||||
</div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => handleVote(build.id, -1)}
|
|
||||||
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
|
|
||||||
aria-label="Downvote"
|
|
||||||
title="Downvote"
|
|
||||||
>
|
|
||||||
▼
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Thumbnail */}
|
|
||||||
<div className="hidden sm:block">
|
|
||||||
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
|
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
|
||||||
<img
|
|
||||||
src={
|
|
||||||
build.coverImageUrl ||
|
|
||||||
"https://placehold.co/320x200/png?text=Build+Photo"
|
|
||||||
}
|
|
||||||
alt={`${build.title} cover`}
|
|
||||||
className="h-full w-full object-cover"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Main content */}
|
|
||||||
<div className="flex-1">
|
|
||||||
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
|
|
||||||
<div>
|
|
||||||
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
|
|
||||||
{build.buildClass} · {build.caliber}
|
|
||||||
</div>
|
|
||||||
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
|
|
||||||
<Link
|
|
||||||
href={`/builds/${build.slug}`}
|
|
||||||
className="hover:text-amber-200 transition-colors"
|
|
||||||
>
|
|
||||||
{build.title}
|
|
||||||
</Link>
|
|
||||||
</h3>
|
|
||||||
<p className="text-xs text-zinc-500 mt-1">
|
|
||||||
Posted by{" "}
|
|
||||||
<span className="text-zinc-300">
|
|
||||||
b/{build.creator}
|
|
||||||
</span>{" "}
|
|
||||||
· Build detail page will show the parts list +
|
|
||||||
comments.
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="text-right mt-1 md:mt-0">
|
|
||||||
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
|
|
||||||
Est. Build Cost
|
|
||||||
</div>
|
|
||||||
<div className="text-lg font-semibold text-amber-300">
|
|
||||||
{build.price > 0 ? (
|
|
||||||
<span>
|
|
||||||
${Math.floor(build.price / 100).toLocaleString()}
|
|
||||||
</span>
|
|
||||||
) : (
|
|
||||||
"—"
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="mt-3 flex flex-wrap items-center gap-2">
|
|
||||||
{build.tags.map((tag) => (
|
|
||||||
<span
|
|
||||||
key={tag}
|
|
||||||
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
|
|
||||||
>
|
|
||||||
{tag}
|
|
||||||
</span>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<span className="ml-auto text-[0.7rem] text-zinc-500">
|
|
||||||
Comments + save coming soon.
|
|
||||||
</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</article>
|
|
||||||
);
|
|
||||||
})}
|
|
||||||
|
|
||||||
{!loading && !error && filteredBuilds.length === 0 && (
|
|
||||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
|
|
||||||
No builds match those filters yet.
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
+17
-3
@@ -1,16 +1,16 @@
|
|||||||
// app/login/page.tsx
|
// app/login/page.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useMemo, useState } from "react";
|
import { FormEvent, useMemo, useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
|
||||||
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 LoginPage() {
|
function LoginPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { login, loading } = useAuth();
|
const { login, loading } = useAuth();
|
||||||
@@ -186,3 +186,17 @@ export default function LoginPage() {
|
|||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export default function LoginPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
||||||
|
<div className="h-8 w-48 animate-pulse bg-zinc-800 rounded" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
}>
|
||||||
|
<LoginPageContent />
|
||||||
|
</Suspense>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|||||||
+231
-45
@@ -2,6 +2,15 @@
|
|||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState } from "react";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import Link from "next/link";
|
||||||
|
import {
|
||||||
|
WrenchScrewdriverIcon,
|
||||||
|
ShieldExclamationIcon,
|
||||||
|
CurrencyDollarIcon,
|
||||||
|
BookmarkSquareIcon,
|
||||||
|
BuildingStorefrontIcon,
|
||||||
|
ChatBubbleLeftRightIcon,
|
||||||
|
} from "@heroicons/react/20/solid";
|
||||||
|
|
||||||
export default function HomePage() {
|
export default function HomePage() {
|
||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
@@ -9,7 +18,10 @@ export default function HomePage() {
|
|||||||
const [status, setStatus] = useState<
|
const [status, setStatus] = useState<
|
||||||
"idle" | "loading" | "success" | "error"
|
"idle" | "loading" | "success" | "error"
|
||||||
>("idle");
|
>("idle");
|
||||||
|
|
||||||
const [message, setMessage] = useState<string | null>(null);
|
const [message, setMessage] = useState<string | null>(null);
|
||||||
|
const [accepted, setAccepted] = useState(false);
|
||||||
|
const TOS_VERSION = "2025-12-27";
|
||||||
|
|
||||||
async function handleSubmit(e: FormEvent) {
|
async function handleSubmit(e: FormEvent) {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
@@ -20,6 +32,14 @@ export default function HomePage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
setMessage(
|
||||||
|
"You must accept the Terms and confirm you’re responsible for safe/legal assembly."
|
||||||
|
);
|
||||||
|
setStatus("error");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setStatus("loading");
|
setStatus("loading");
|
||||||
setMessage(null);
|
setMessage(null);
|
||||||
@@ -27,7 +47,12 @@ export default function HomePage() {
|
|||||||
const res = await fetch("/api/beta-signup", {
|
const res = await fetch("/api/beta-signup", {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, useCase }),
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
useCase,
|
||||||
|
acceptedTos: accepted,
|
||||||
|
tosVersion: TOS_VERSION,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -36,7 +61,9 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
setStatus("success");
|
setStatus("success");
|
||||||
setMessage("✅ You’re on the list. Invites drop soon — we’ll email your access link when it’s go-time.");
|
setMessage(
|
||||||
|
"✅ You’re on the list. Invites drop soon — we’ll email your access link when it’s go-time."
|
||||||
|
);
|
||||||
setEmail("");
|
setEmail("");
|
||||||
setUseCase("");
|
setUseCase("");
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
@@ -46,6 +73,43 @@ export default function HomePage() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const features = [
|
||||||
|
{
|
||||||
|
name: "Guided part roles",
|
||||||
|
description: "Pick parts by role so you’re not guessing what fits where.",
|
||||||
|
icon: WrenchScrewdriverIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Basic compatibility warnings",
|
||||||
|
description:
|
||||||
|
"Early guardrails to catch obvious mismatches before you buy.",
|
||||||
|
icon: ShieldExclamationIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Live pricing totals",
|
||||||
|
description:
|
||||||
|
"See the running total as you build, with real retailer listings.",
|
||||||
|
icon: CurrencyDollarIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Save builds",
|
||||||
|
description: "Save a build to your account so you can finish it later.",
|
||||||
|
icon: BookmarkSquareIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Limited retailers (for now)",
|
||||||
|
description:
|
||||||
|
"We’re starting small with a couple merchants and expanding fast.",
|
||||||
|
icon: BuildingStorefrontIcon,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
name: "Feedback-driven roadmap",
|
||||||
|
description:
|
||||||
|
"Tell us what’s confusing, wrong, or missing — we’ll fix that first.",
|
||||||
|
icon: ChatBubbleLeftRightIcon,
|
||||||
|
},
|
||||||
|
];
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
{/* Abstract Brand Background */}
|
{/* Abstract Brand Background */}
|
||||||
@@ -98,48 +162,71 @@ export default function HomePage() {
|
|||||||
{/* Top Bar / Logo */}
|
{/* Top Bar / Logo */}
|
||||||
<header className="flex items-center justify-between gap-4">
|
<header className="flex items-center justify-between gap-4">
|
||||||
<div className="flex items-center gap-3">
|
<div className="flex items-center gap-3">
|
||||||
<div className="flex items-center">
|
<Image
|
||||||
<Image
|
src="/battl/battl-logo-mark-f.svg"
|
||||||
src="/battl/battl-logo-mark-f.svg"
|
alt="Battl Builders Logo"
|
||||||
alt="Battl Builders Logo"
|
width={260}
|
||||||
width={260} // adjust to taste
|
height={48}
|
||||||
height={48} // adjust to taste
|
className="block"
|
||||||
className="block"
|
/>
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<span className="rounded-full border border-amber-500/40 bg-amber-500/10 px-3 py-1 text-xs font-medium text-amber-300">
|
<nav className="flex items-center gap-2 sm:gap-3">
|
||||||
Private Beta • Coming Soon
|
<a
|
||||||
</span>
|
href="#request-access"
|
||||||
|
className="rounded-md border border-zinc-800 bg-zinc-950/60 px-3 py-2 text-xs font-medium text-zinc-200 hover:bg-zinc-900 hover:text-white"
|
||||||
|
>
|
||||||
|
Request Access
|
||||||
|
</a>
|
||||||
|
|
||||||
|
<Link
|
||||||
|
href="/login"
|
||||||
|
className="rounded-md border border-amber-500/60 bg-amber-500/15 px-3 py-2 text-xs font-medium text-amber-200 hover:bg-amber-500/20"
|
||||||
|
>
|
||||||
|
Sign In
|
||||||
|
</Link>
|
||||||
|
</nav>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Hero */}
|
{/* Hero */}
|
||||||
<section className="mt-16 grid gap-10 md:grid-cols-[minmax(0,1.3fr)_minmax(0,1fr)] md:items-center">
|
<section className="mt-16 grid gap-10 md:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] md:items-start">
|
||||||
<div>
|
<div>
|
||||||
<h1 className="text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl">
|
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1 text-xs uppercase tracking-[0.25em] text-white/70">
|
||||||
|
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
|
||||||
|
Private Beta
|
||||||
|
<span className="text-white/40">•</span>
|
||||||
|
<span className="text-white/60">AR-15 focus</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<h1 className="mt-6 text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl">
|
||||||
Stop Guessing.
|
Stop Guessing.
|
||||||
<span className="block text-amber-300 py-0.16em">
|
<span className="block text-amber-300">Start Building.</span>
|
||||||
Start Building.
|
|
||||||
</span>
|
|
||||||
</h1>
|
</h1>
|
||||||
|
|
||||||
<p className="mt-4 max-w-xl text-sm text-zinc-400 sm:text-base">
|
<p className="mt-4 max-w-xl text-sm text-zinc-400 sm:text-base">
|
||||||
From part comparisons to full-build matchups, Battl Builders helps
|
Battl Builders helps you plan an AR-15 build faster: choose parts
|
||||||
you find the right components, catch compatibility issues, and
|
by role, get basic compatibility guidance, and compare real
|
||||||
score the best deals — all without juggling tabs or spreadsheets.
|
listings—without juggling tabs or spreadsheets.
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
<ul className="mt-6 space-y-2 text-sm text-zinc-300">
|
<ul className="mt-6 space-y-2 text-sm text-zinc-300">
|
||||||
<li>• Side-by-side part & build comparisons</li>
|
<li>• Guided part roles (no “where does this go?”)</li>
|
||||||
<li>• Smart compatibility checks</li>
|
<li>• Basic compatibility warnings</li>
|
||||||
<li>• Live pricing from vetted merchants</li>
|
<li>• Live pricing from current retailers</li>
|
||||||
<li>• Save & share builds with your crew</li>
|
<li>• Save builds to your account</li>
|
||||||
<li>• Vote on the cleanest, meanest setups</li>
|
|
||||||
</ul>
|
</ul>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-white/50">
|
||||||
|
Early access beta. Data, pricing, and available parts are still
|
||||||
|
evolving.
|
||||||
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Beta Form */}
|
{/* Beta Form */}
|
||||||
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-5 shadow-[0_0_0_1px_rgba(24,24,27,0.9)]">
|
<div
|
||||||
|
id="request-access"
|
||||||
|
className="scroll-mt-24 rounded-lg border border-zinc-800 bg-zinc-950/70 p-5 shadow-[0_0_0_1px_rgba(24,24,27,0.9)]"
|
||||||
|
>
|
||||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||||
Join the Beta List
|
Join the Beta List
|
||||||
</h2>
|
</h2>
|
||||||
@@ -163,7 +250,7 @@ export default function HomePage() {
|
|||||||
onChange={(e) => setEmail(e.target.value)}
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
placeholder="you@gearjunkie.com"
|
placeholder="you@gearjunkie.com"
|
||||||
disabled={status === "loading" || status === "success"}
|
disabled={status === "loading" || status === "success"}
|
||||||
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
required
|
required
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
@@ -182,14 +269,48 @@ export default function HomePage() {
|
|||||||
onChange={(e) => setUseCase(e.target.value)}
|
onChange={(e) => setUseCase(e.target.value)}
|
||||||
rows={3}
|
rows={3}
|
||||||
disabled={status === "loading" || status === "success"}
|
disabled={status === "loading" || status === "success"}
|
||||||
placeholder="E.g. Comparing build costs, finding the best deals, sharing my builds, weird influencer shit..."
|
placeholder="E.g. Planning a build, comparing costs, sanity-checking compatibility..."
|
||||||
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:opacity-60 disabled:cursor-not-allowed"
|
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
|
||||||
|
<label className="flex items-start gap-2 text-[11px] text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={accepted}
|
||||||
|
onChange={(e) => setAccepted(e.target.checked)}
|
||||||
|
disabled={status === "loading" || status === "success"}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-zinc-700 bg-zinc-950 text-amber-400 focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
I agree to the{" "}
|
||||||
|
<Link
|
||||||
|
href="/tos"
|
||||||
|
className="text-amber-300 underline hover:text-amber-200"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-amber-300 underline hover:text-amber-200"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
. I understand Battl Builders is informational only and I’m
|
||||||
|
solely responsible for legality, compatibility, and safe
|
||||||
|
assembly/use of any firearm or components.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="submit"
|
type="submit"
|
||||||
disabled={status === "loading" || status === "success"}
|
disabled={
|
||||||
|
status === "loading" || status === "success" || !accepted
|
||||||
|
}
|
||||||
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
|
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
|
||||||
>
|
>
|
||||||
{status === "loading"
|
{status === "loading"
|
||||||
@@ -213,19 +334,6 @@ export default function HomePage() {
|
|||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{status === "success" && (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => {
|
|
||||||
setStatus("idle");
|
|
||||||
setMessage(null);
|
|
||||||
}}
|
|
||||||
className="w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 hover:bg-zinc-900"
|
|
||||||
>
|
|
||||||
Submit another email
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="mt-6 text-center text-[11px] text-zinc-500">
|
<div className="mt-6 text-center text-[11px] text-zinc-500">
|
||||||
Already invited?{" "}
|
Already invited?{" "}
|
||||||
<a href="/login" className="underline hover:text-zinc-300">
|
<a href="/login" className="underline hover:text-zinc-300">
|
||||||
@@ -236,6 +344,84 @@ export default function HomePage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
{/* Screen Recording Section */}
|
||||||
|
<section className="relative mt-24">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
|
||||||
|
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-black shadow-2xl">
|
||||||
|
{/* Screen Recording */}
|
||||||
|
<video
|
||||||
|
autoPlay
|
||||||
|
loop
|
||||||
|
muted
|
||||||
|
playsInline
|
||||||
|
className="w-full object-cover"
|
||||||
|
>
|
||||||
|
<source src="/builder-preview.mp4" type="video/mp4" />
|
||||||
|
Your browser does not support the video tag.
|
||||||
|
</video>
|
||||||
|
|
||||||
|
{/* Bottom fade */}
|
||||||
|
<div
|
||||||
|
aria-hidden="true"
|
||||||
|
className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Info Section */}
|
||||||
|
<section className="mt-14 rounded-2xl border border-white/10 bg-white/5 px-6 py-14 sm:px-8">
|
||||||
|
<div className="mx-auto max-w-2xl text-left sm:text-center">
|
||||||
|
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-amber-300/90">
|
||||||
|
Everything you need to test the core
|
||||||
|
</p>
|
||||||
|
<h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
|
||||||
|
Build smarter rifles — without the spreadsheet lifestyle.
|
||||||
|
</h2>
|
||||||
|
<p className="mt-4 text-sm leading-relaxed text-white/70 sm:text-base">
|
||||||
|
This private beta is focused on one thing: helping you plan an
|
||||||
|
AR-15 build faster and with fewer mistakes. It’s early, it’s
|
||||||
|
evolving, and your feedback will decide what we build next.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<dl className="mx-auto mt-10 grid max-w-2xl grid-cols-1 gap-x-8 gap-y-8 text-sm text-white/70 sm:grid-cols-2 lg:max-w-none lg:grid-cols-3">
|
||||||
|
{features.map((feature) => (
|
||||||
|
<div
|
||||||
|
key={feature.name}
|
||||||
|
className="relative rounded-xl border border-white/10 bg-black/30 p-5"
|
||||||
|
>
|
||||||
|
<dt className="flex items-center gap-2 text-sm font-semibold text-white">
|
||||||
|
<feature.icon
|
||||||
|
aria-hidden="true"
|
||||||
|
className="h-5 w-5 text-amber-300"
|
||||||
|
/>
|
||||||
|
{feature.name}
|
||||||
|
</dt>
|
||||||
|
<dd className="mt-2 text-sm text-white/70">
|
||||||
|
{feature.description}
|
||||||
|
</dd>
|
||||||
|
</div>
|
||||||
|
))}
|
||||||
|
</dl>
|
||||||
|
|
||||||
|
<div className="mx-auto mt-10 max-w-2xl rounded-xl border border-white/10 bg-black/30 p-5">
|
||||||
|
<div className="text-xs font-semibold uppercase tracking-[0.25em] text-white/60">
|
||||||
|
What feedback we want
|
||||||
|
</div>
|
||||||
|
<ul className="mt-3 space-y-2 text-sm text-white/80">
|
||||||
|
<li>• Where you got confused or stuck</li>
|
||||||
|
<li>• Warnings that felt wrong (or missing)</li>
|
||||||
|
<li>• Parts you expected to find but couldn’t</li>
|
||||||
|
<li>• If this beats your current build-planning process</li>
|
||||||
|
</ul>
|
||||||
|
<p className="mt-3 text-xs text-white/50">
|
||||||
|
By joining the private beta, you agree features, data, and pricing
|
||||||
|
may change.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
|
||||||
{/* Footer strip */}
|
{/* Footer strip */}
|
||||||
<footer className="mt-12 border-t border-zinc-900 pt-4 text-xs text-zinc-500">
|
<footer className="mt-12 border-t border-zinc-900 pt-4 text-xs text-zinc-500">
|
||||||
<div className="flex flex-wrap items-center justify-between gap-3">
|
<div className="flex flex-wrap items-center justify-between gap-3">
|
||||||
|
|||||||
+128
-62
@@ -1,13 +1,15 @@
|
|||||||
// app/register/page.tsx
|
// app/register/page.tsx
|
||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { FormEvent, useState } from "react";
|
import { FormEvent, useState, Suspense } from "react";
|
||||||
import { useRouter, useSearchParams } from "next/navigation";
|
import { useRouter, useSearchParams } from "next/navigation";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
|
||||||
export default function RegisterPage() {
|
const TOS_VERSION = "2025-12-27";
|
||||||
|
|
||||||
|
function RegisterPageContent() {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const searchParams = useSearchParams();
|
const searchParams = useSearchParams();
|
||||||
const { register, loading } = useAuth();
|
const { register, loading } = useAuth();
|
||||||
@@ -15,6 +17,7 @@ export default function RegisterPage() {
|
|||||||
const [email, setEmail] = useState("");
|
const [email, setEmail] = useState("");
|
||||||
const [displayName, setDisplayName] = useState("");
|
const [displayName, setDisplayName] = useState("");
|
||||||
const [password, setPassword] = useState("");
|
const [password, setPassword] = useState("");
|
||||||
|
const [accepted, setAccepted] = useState(false);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const next = searchParams.get("next") || "/builder";
|
const next = searchParams.get("next") || "/builder";
|
||||||
@@ -23,76 +26,139 @@ export default function RegisterPage() {
|
|||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
|
if (!accepted) {
|
||||||
|
setError(
|
||||||
|
"You must accept the Terms and confirm you’re responsible for safe/legal assembly."
|
||||||
|
);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await register({ email, password, displayName });
|
await register({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
acceptedTos: accepted,
|
||||||
|
tosVersion: TOS_VERSION,
|
||||||
|
});
|
||||||
|
|
||||||
router.push(next);
|
router.push(next);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
setError(err.message ?? "Failed to create account");
|
setError(
|
||||||
|
typeof err === "string"
|
||||||
|
? err
|
||||||
|
: err?.message ?? "Failed to create account"
|
||||||
|
);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
|
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
||||||
|
<h1 className="text-2xl font-semibold tracking-tight">
|
||||||
|
Join the <span className="text-amber-300">Battl Builder</span> Beta
|
||||||
|
</h1>
|
||||||
|
<p className="mt-2 text-sm text-zinc-400">
|
||||||
|
Create an account so we can save your builds, watch price drops, and
|
||||||
|
roll out new features to you first.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
||||||
|
{error && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{error}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<Field label="Display Name (optional)" htmlFor="displayName">
|
||||||
|
<Input
|
||||||
|
id="displayName"
|
||||||
|
type="text"
|
||||||
|
value={displayName}
|
||||||
|
onChange={(e) => setDisplayName(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Email" htmlFor="email">
|
||||||
|
<Input
|
||||||
|
id="email"
|
||||||
|
type="email"
|
||||||
|
autoComplete="email"
|
||||||
|
required
|
||||||
|
value={email}
|
||||||
|
onChange={(e) => setEmail(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<Field label="Password" htmlFor="password">
|
||||||
|
<Input
|
||||||
|
id="password"
|
||||||
|
type="password"
|
||||||
|
autoComplete="new-password"
|
||||||
|
required
|
||||||
|
value={password}
|
||||||
|
onChange={(e) => setPassword(e.target.value)}
|
||||||
|
/>
|
||||||
|
</Field>
|
||||||
|
|
||||||
|
<div className="rounded-md border border-zinc-800 bg-zinc-950/40 px-3 py-3">
|
||||||
|
<label className="flex items-start gap-2 text-[11px] text-zinc-400">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={accepted}
|
||||||
|
onChange={(e) => setAccepted(e.target.checked)}
|
||||||
|
className="mt-0.5 h-4 w-4 rounded border-zinc-700 bg-zinc-950 text-amber-400 focus:ring-2 focus:ring-amber-500/40"
|
||||||
|
required
|
||||||
|
/>
|
||||||
|
<span>
|
||||||
|
I agree to the{" "}
|
||||||
|
<Link
|
||||||
|
href="/tos"
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Terms of Service
|
||||||
|
</Link>{" "}
|
||||||
|
and{" "}
|
||||||
|
<Link
|
||||||
|
href="/privacy"
|
||||||
|
className="text-amber-300 hover:text-amber-200 underline"
|
||||||
|
>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
. I understand Battl Builders is informational only and I'm
|
||||||
|
solely responsible for legality, compatibility, and safe
|
||||||
|
assembly/use of any firearm or components.
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button type="submit" disabled={loading || !accepted}>
|
||||||
|
{loading ? "Creating account…" : "Join Beta"}
|
||||||
|
</Button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<p className="mt-4 text-xs text-zinc-500">
|
||||||
|
Already have an account?{" "}
|
||||||
|
<Link href="/login" className="text-amber-300 hover:text-amber-200">
|
||||||
|
Log in
|
||||||
|
</Link>
|
||||||
|
.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
export default function RegisterPage() {
|
||||||
|
return (
|
||||||
|
<Suspense fallback={
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
<div className="mx-auto flex max-w-md flex-col px-4 py-10">
|
||||||
<h1 className="text-2xl font-semibold tracking-tight">
|
<div className="h-8 w-64 animate-pulse bg-zinc-800 rounded" />
|
||||||
Join the <span className="text-amber-300">Battl Builder</span> Beta
|
|
||||||
</h1>
|
|
||||||
<p className="mt-2 text-sm text-zinc-400">
|
|
||||||
Create an account so we can save your builds, watch price drops, and
|
|
||||||
roll out new features to you first.
|
|
||||||
</p>
|
|
||||||
|
|
||||||
<form onSubmit={handleSubmit} className="mt-6 space-y-4">
|
|
||||||
{error && (
|
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
|
||||||
{error}
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<Field label="Display Name (optional)" htmlFor="displayName">
|
|
||||||
<Input
|
|
||||||
id="displayName"
|
|
||||||
type="text"
|
|
||||||
value={displayName}
|
|
||||||
onChange={(e) => setDisplayName(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Email" htmlFor="email">
|
|
||||||
<Input
|
|
||||||
id="email"
|
|
||||||
type="email"
|
|
||||||
autoComplete="email"
|
|
||||||
required
|
|
||||||
value={email}
|
|
||||||
onChange={(e) => setEmail(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Field label="Password" htmlFor="password">
|
|
||||||
<Input
|
|
||||||
id="password"
|
|
||||||
type="password"
|
|
||||||
autoComplete="new-password"
|
|
||||||
required
|
|
||||||
value={password}
|
|
||||||
onChange={(e) => setPassword(e.target.value)}
|
|
||||||
/>
|
|
||||||
</Field>
|
|
||||||
|
|
||||||
<Button type="submit" disabled={loading}>
|
|
||||||
{loading ? "Creating account…" : "Join Beta"}
|
|
||||||
</Button>
|
|
||||||
</form>
|
|
||||||
|
|
||||||
<p className="mt-4 text-xs text-zinc-500">
|
|
||||||
Already have an account?{" "}
|
|
||||||
<Link href="/login" className="text-amber-300 hover:text-amber-200">
|
|
||||||
Log in
|
|
||||||
</Link>
|
|
||||||
.
|
|
||||||
</p>
|
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
}>
|
||||||
|
<RegisterPageContent />
|
||||||
|
</Suspense>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -1,109 +1,200 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type React from "react";
|
import * as React from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import {
|
import { usePathname } from "next/navigation";
|
||||||
LayoutDashboard,
|
import { Disclosure, DisclosureButton, DisclosurePanel } from "@headlessui/react";
|
||||||
Download,
|
import { ChevronRight, PanelLeftClose, PanelLeftOpen } from "lucide-react";
|
||||||
Layers,
|
|
||||||
Boxes,
|
|
||||||
Store,
|
|
||||||
Users,
|
|
||||||
Settings,
|
|
||||||
LucideMail,
|
|
||||||
Wand2,
|
|
||||||
} from "lucide-react";
|
|
||||||
|
|
||||||
export type AdminNavItem = {
|
export type AdminNavItem = {
|
||||||
label: string;
|
label: string;
|
||||||
href: string;
|
href: string;
|
||||||
icon: React.ReactNode;
|
icon?: React.ReactNode;
|
||||||
};
|
};
|
||||||
|
|
||||||
type AdminLeftNavigationProps = {
|
export type AdminNavGroup = {
|
||||||
collapsed: boolean;
|
label: string;
|
||||||
onToggleCollapsed: () => void;
|
icon?: React.ReactNode;
|
||||||
items?: AdminNavItem[]; // ✅ NEW
|
items: AdminNavItem[];
|
||||||
};
|
};
|
||||||
|
|
||||||
const defaultNavItems: AdminNavItem[] = [
|
function cx(...classes: Array<string | false | undefined | null>) {
|
||||||
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
|
return classes.filter(Boolean).join(" ");
|
||||||
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
|
}
|
||||||
{ label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
|
|
||||||
{ label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
|
function isActiveRoute(pathname: string, href: string) {
|
||||||
{ label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
|
// Exact match for /admin, prefix match for nested pages
|
||||||
{ label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
|
if (href === "/admin") return pathname === "/admin";
|
||||||
{ label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
|
return pathname === href || pathname.startsWith(`${href}/`);
|
||||||
{ label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
|
}
|
||||||
{ label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
|
|
||||||
{ label: "Manage Emails", href: "/admin/email/manage", icon: <LucideMail className="h-4 w-4" /> },
|
function groupHasActiveItem(pathname: string, group: AdminNavGroup) {
|
||||||
{ label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
|
return group.items.some((i) => isActiveRoute(pathname, i.href));
|
||||||
{ label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" />,
|
}
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export default function AdminLeftNavigation({
|
export default function AdminLeftNavigation({
|
||||||
collapsed,
|
collapsed,
|
||||||
onToggleCollapsed,
|
onToggleCollapsed,
|
||||||
items = defaultNavItems, // ✅ NEW default
|
groups,
|
||||||
}: AdminLeftNavigationProps) {
|
}: {
|
||||||
|
collapsed: boolean;
|
||||||
|
onToggleCollapsed: () => void;
|
||||||
|
groups: AdminNavGroup[];
|
||||||
|
}) {
|
||||||
|
const pathname = usePathname() || "/admin";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<aside
|
<aside
|
||||||
className={`hidden border-r border-zinc-900 bg-zinc-950/80 px-3 py-6 md:flex md:flex-col transition-all duration-200 ${
|
className={cx(
|
||||||
collapsed ? "w-16" : "w-60"
|
"fixed left-0 flex shrink-0 flex-col border-r border-zinc-900 bg-zinc-950/60 backdrop-blur z-40",
|
||||||
}`}
|
collapsed ? "w-[68px]" : "w-[280px]"
|
||||||
|
)}
|
||||||
|
style={{ top: '52px', height: 'calc(100vh - 52px)' }}
|
||||||
>
|
>
|
||||||
<div className="mb-6 flex items-center gap-2">
|
{/* Header / Brand */}
|
||||||
|
<div className={cx("flex items-center justify-between px-3 py-3", collapsed && "justify-center")}>
|
||||||
|
<div className={cx("flex items-center gap-2", collapsed && "hidden")}>
|
||||||
|
<div className="h-8 w-8 rounded-lg bg-zinc-900/70 ring-1 ring-zinc-800" />
|
||||||
|
<div className="leading-tight">
|
||||||
|
<div className="text-xs uppercase tracking-[0.22em] text-zinc-500">
|
||||||
|
Battl
|
||||||
|
</div>
|
||||||
|
<div className="text-sm font-medium text-zinc-100">
|
||||||
|
Admin
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={onToggleCollapsed}
|
onClick={onToggleCollapsed}
|
||||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 text-zinc-400 transition hover:border-amber-400/60 hover:text-amber-300"
|
className={cx(
|
||||||
|
"inline-flex h-9 w-9 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900/40 text-zinc-200 hover:bg-zinc-900/70",
|
||||||
|
collapsed && "mx-auto"
|
||||||
|
)}
|
||||||
|
aria-label={collapsed ? "Expand sidebar" : "Collapse sidebar"}
|
||||||
|
title={collapsed ? "Expand" : "Collapse"}
|
||||||
>
|
>
|
||||||
<span className="sr-only">Toggle sidebar</span>
|
{collapsed ? (
|
||||||
<div className="space-y-0.5">
|
<PanelLeftOpen className="h-4 w-4" />
|
||||||
<span className="block h-[1px] w-3 bg-current" />
|
) : (
|
||||||
<span className="block h-[1px] w-3 bg-current" />
|
<PanelLeftClose className="h-4 w-4" />
|
||||||
<span className="block h-[1px] w-3 bg-current" />
|
)}
|
||||||
</div>
|
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
{!collapsed && (
|
|
||||||
<div>
|
|
||||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
|
||||||
<Link href="/" title="Back to Battl Dashboard">
|
|
||||||
Battl Builders
|
|
||||||
</Link>
|
|
||||||
</p>
|
|
||||||
<p className="text-[10px] text-zinc-600">Admin Command</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<nav className="flex-1 space-y-1 text-sm">
|
{/* Nav */}
|
||||||
{items.map((item) => (
|
<nav className="flex-1 overflow-y-auto px-2 pb-3">
|
||||||
<Link
|
<ul className="space-y-1">
|
||||||
key={item.href}
|
{groups.map((group) => {
|
||||||
href={item.href}
|
const defaultOpen = groupHasActiveItem(pathname, group);
|
||||||
className="flex items-center justify-between rounded-md px-2 py-1.5 text-zinc-300 transition hover:bg-zinc-900 hover:text-amber-300"
|
|
||||||
>
|
// In collapsed mode, we render items as a flat icon list (no accordion)
|
||||||
<div className="flex items-center gap-2">
|
if (collapsed) {
|
||||||
{item.icon}
|
return (
|
||||||
{!collapsed && <span>{item.label}</span>}
|
<li key={group.label} className="pt-1">
|
||||||
</div>
|
<div className="my-2 h-px bg-zinc-900" />
|
||||||
{!collapsed && <span className="text-[10px] text-zinc-600">›</span>}
|
<div className="flex flex-col gap-1">
|
||||||
</Link>
|
{group.items.map((item) => {
|
||||||
))}
|
const active = isActiveRoute(pathname, item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
title={`${group.label} → ${item.label}`}
|
||||||
|
className={cx(
|
||||||
|
"flex h-10 items-center justify-center rounded-md border border-transparent",
|
||||||
|
active
|
||||||
|
? "bg-white/5 text-white ring-1 ring-zinc-800"
|
||||||
|
: "text-zinc-400 hover:bg-white/5 hover:text-zinc-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex">{item.icon}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return (
|
||||||
|
<li key={group.label}>
|
||||||
|
<Disclosure defaultOpen={defaultOpen}>
|
||||||
|
{({ open }) => (
|
||||||
|
<>
|
||||||
|
<DisclosureButton
|
||||||
|
className={cx(
|
||||||
|
"group flex w-full items-center justify-between rounded-md px-2 py-2 text-left text-xs font-semibold uppercase tracking-[0.18em]",
|
||||||
|
open ? "bg-white/5 text-zinc-100" : "text-zinc-400 hover:bg-white/5 hover:text-zinc-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<span className="inline-flex text-zinc-400 group-hover:text-zinc-200">
|
||||||
|
{group.icon}
|
||||||
|
</span>
|
||||||
|
{group.label}
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<ChevronRight
|
||||||
|
className={cx(
|
||||||
|
"h-4 w-4 text-zinc-500 transition-transform",
|
||||||
|
open && "rotate-90"
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
</DisclosureButton>
|
||||||
|
|
||||||
|
<DisclosurePanel className="mt-1 space-y-1 pl-1">
|
||||||
|
{group.items.map((item) => {
|
||||||
|
const active = isActiveRoute(pathname, item.href);
|
||||||
|
return (
|
||||||
|
<Link
|
||||||
|
key={item.href}
|
||||||
|
href={item.href}
|
||||||
|
className={cx(
|
||||||
|
"flex items-center gap-2 rounded-md px-2 py-2 text-sm",
|
||||||
|
active
|
||||||
|
? "bg-white/5 text-white ring-1 ring-zinc-800"
|
||||||
|
: "text-zinc-300 hover:bg-white/5 hover:text-white"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="inline-flex h-5 w-5 items-center justify-center text-zinc-400">
|
||||||
|
{item.icon}
|
||||||
|
</span>
|
||||||
|
<span className="truncate">{item.label}</span>
|
||||||
|
</Link>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</DisclosurePanel>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</Disclosure>
|
||||||
|
</li>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</ul>
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{!collapsed && (
|
{/* Footer */}
|
||||||
<div className="mt-6 border-t border-zinc-900 pt-4 text-[11px] text-zinc-600">
|
<div className={cx("border-t border-zinc-900 p-3", collapsed && "px-2")}>
|
||||||
<p className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">Status</p>
|
<div
|
||||||
<p className="mt-1">
|
className={cx(
|
||||||
Import engine: <span className="text-emerald-400">online</span>
|
"flex items-center gap-3 rounded-md bg-zinc-900/30 px-3 py-2 ring-1 ring-zinc-800",
|
||||||
</p>
|
collapsed && "justify-center px-2"
|
||||||
<p className="text-zinc-500">Builder UI: in progress</p>
|
)}
|
||||||
|
title="Admin user"
|
||||||
|
>
|
||||||
|
<div className="h-8 w-8 rounded-full bg-zinc-900 ring-1 ring-zinc-800" />
|
||||||
|
{!collapsed && (
|
||||||
|
<div className="min-w-0">
|
||||||
|
<div className="text-xs uppercase tracking-[0.18em] text-zinc-500">
|
||||||
|
Internal
|
||||||
|
</div>
|
||||||
|
<div className="truncate text-sm text-zinc-200">ADMIN</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -2,9 +2,9 @@ import React from "react";
|
|||||||
|
|
||||||
export function Banner() {
|
export function Banner() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div className="fixed top-0 left-0 right-0 z-50">
|
||||||
{/* Early access bar */}
|
{/* Early access bar */}
|
||||||
<div className="border-b border-amber-500/20 bg-amber-500/5">
|
<div className="border-b border-amber-500/20 bg-amber-500/5 backdrop-blur-sm">
|
||||||
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
|
<div className="mx-auto flex max-w-6xl flex-col gap-1 px-4 py-2 text-[0.75rem] text-amber-100 md:flex-row md:items-center md:justify-between">
|
||||||
<div className="flex items-center gap-2">
|
<div className="flex items-center gap-2">
|
||||||
<span className="rounded-sm bg-amber-500/20 px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-amber-300">
|
<span className="rounded-sm bg-amber-500/20 px-2 py-0.5 text-[0.65rem] font-semibold uppercase tracking-[0.18em] text-amber-300">
|
||||||
|
|||||||
@@ -18,7 +18,7 @@
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { useSearchParams } from "next/navigation";
|
import { useSearchParams } from "next/navigation";
|
||||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||||
import type { Category } from "@/types/gunbuilder";
|
import type { Category } from "@/types/builderSlots";
|
||||||
|
|
||||||
type BuilderNavProps = {
|
type BuilderNavProps = {
|
||||||
// Optional override if a parent wants to force the active category
|
// Optional override if a parent wants to force the active category
|
||||||
@@ -75,7 +75,7 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
</button>
|
</button>
|
||||||
|
|
||||||
{/* Dropdown menu */}
|
{/* Dropdown menu */}
|
||||||
<div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all absolute left-0 mt-2 rounded-md border border-neutral-800 bg-black/95 shadow-xl z-40 min-w-[220px]">
|
<div className="invisible opacity-0 group-hover:visible group-hover:opacity-100 transition-all absolute left-0 mt-2 rounded-md border border-neutral-800 bg-black/95 shadow-xl z-100 min-w-[220px]">
|
||||||
<ul className="py-2 text-sm">
|
<ul className="py-2 text-sm">
|
||||||
{items.map((cat) => {
|
{items.map((cat) => {
|
||||||
const isActive = currentCategory === cat.id;
|
const isActive = currentCategory === cat.id;
|
||||||
@@ -114,7 +114,7 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
|
|||||||
* - Everything before the RIGHT cluster is "left side"
|
* - Everything before the RIGHT cluster is "left side"
|
||||||
* - Then a single `ml-auto` cluster pushes everything else to the right
|
* - Then a single `ml-auto` cluster pushes everything else to the right
|
||||||
*/}
|
*/}
|
||||||
<nav className="flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm backdrop-blur-sm">
|
<nav className="relative z-50 flex items-center gap-6 border-b border-neutral-900 px-6 py-3 text-sm backdrop-blur-sm">
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
{/* LEFT SIDE: primary nav */}
|
{/* LEFT SIDE: primary nav */}
|
||||||
{/* ------------------------------------------------------------------ */}
|
{/* ------------------------------------------------------------------ */}
|
||||||
|
|||||||
@@ -0,0 +1,108 @@
|
|||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export function Footer() {
|
||||||
|
const year = new Date().getFullYear();
|
||||||
|
|
||||||
|
const linkClass =
|
||||||
|
"text-zinc-400 hover:text-amber-300 transition-colors";
|
||||||
|
|
||||||
|
const sectionTitle =
|
||||||
|
"text-[11px] font-semibold uppercase tracking-[0.28em] text-zinc-500";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<footer className="border-t border-zinc-900 bg-black/60">
|
||||||
|
<div className="mx-auto max-w-6xl px-4 py-12">
|
||||||
|
<div className="grid gap-10 md:grid-cols-3">
|
||||||
|
{/* Brand */}
|
||||||
|
<div>
|
||||||
|
<p className="text-[11px] font-semibold uppercase tracking-[0.35em] text-zinc-500">
|
||||||
|
Battl Builders
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-3 text-sm text-zinc-300">
|
||||||
|
Build rifles smarter, not harder.
|
||||||
|
</p>
|
||||||
|
|
||||||
|
<p className="mt-2 text-[11px] text-zinc-500">
|
||||||
|
Early access platform · Data & pricing may change
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Explore */}
|
||||||
|
<div>
|
||||||
|
<p className={sectionTitle}>Explore</p>
|
||||||
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link href="/builder" className={linkClass}>
|
||||||
|
Builder
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/builds" className={linkClass}>
|
||||||
|
Community Builds
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/vault" className={linkClass}>
|
||||||
|
My Builds
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Platform */}
|
||||||
|
<div>
|
||||||
|
<p className={sectionTitle}>Platform</p>
|
||||||
|
<ul className="mt-4 space-y-2 text-sm">
|
||||||
|
<li>
|
||||||
|
<Link href="/about" className={linkClass}>
|
||||||
|
About
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/privacy" className={linkClass}>
|
||||||
|
Privacy Policy
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
<li>
|
||||||
|
<Link href="/tos" className={linkClass}>
|
||||||
|
Terms of Service
|
||||||
|
</Link>
|
||||||
|
</li>
|
||||||
|
|
||||||
|
{/* Optional, if you have it */}
|
||||||
|
{/* <li>
|
||||||
|
<Link href="/contact" className={linkClass}>
|
||||||
|
Contact
|
||||||
|
</Link>
|
||||||
|
</li> */}
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Bottom bar */}
|
||||||
|
<div className="mt-10 flex flex-col gap-3 border-t border-zinc-900 pt-6 text-[11px] text-zinc-500 md:flex-row md:items-center md:justify-between">
|
||||||
|
<span>© {year} Battl Builders</span>
|
||||||
|
|
||||||
|
<div className="flex flex-col items-center gap-2 md:flex-row md:gap-4">
|
||||||
|
<span className="text-zinc-600">
|
||||||
|
Built by people who actually build rifles
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<span className="hidden text-zinc-700 md:inline">•</span>
|
||||||
|
|
||||||
|
<span className="text-zinc-600">
|
||||||
|
<Link href="/tos" className="hover:text-amber-300 transition-colors">
|
||||||
|
Terms
|
||||||
|
</Link>{" "}
|
||||||
|
·{" "}
|
||||||
|
<Link href="/privacy" className="hover:text-amber-300 transition-colors">
|
||||||
|
Privacy
|
||||||
|
</Link>
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
export default function MailingAddressComponent() {
|
||||||
|
return <p>
|
||||||
|
<strong>Mailing Address:</strong>
|
||||||
|
<br/>
|
||||||
|
Battl Builder, LLC.
|
||||||
|
<br/>
|
||||||
|
[Your Address]
|
||||||
|
<br/>
|
||||||
|
[City, State, ZIP]
|
||||||
|
</p>;
|
||||||
|
}
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { Part } from "@/types/gunbuilder";
|
import type { Part } from "@/types/builderSlots";
|
||||||
|
|
||||||
interface PartCardProps {
|
interface PartCardProps {
|
||||||
part: Part;
|
part: Part;
|
||||||
|
|||||||
@@ -1,6 +1,9 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { PriceHistoryPoint } from "@/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data";
|
type PriceHistoryPoint = {
|
||||||
|
date: string;
|
||||||
|
price: number;
|
||||||
|
};
|
||||||
|
|
||||||
interface PricingHistoryGraphProps {
|
interface PricingHistoryGraphProps {
|
||||||
data: PriceHistoryPoint[];
|
data: PriceHistoryPoint[];
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import type { RetailerOffer } from "@/app/(app)/(builder)/parts/__DELETE[partRole]/_old_prod_details/data";
|
type RetailerOffer = {
|
||||||
|
id?: string | number;
|
||||||
|
retailer: string;
|
||||||
|
price: number;
|
||||||
|
originalPrice?: number;
|
||||||
|
url: string;
|
||||||
|
affiliateUrl?: string;
|
||||||
|
inStock: boolean;
|
||||||
|
};
|
||||||
|
|
||||||
interface RetailersListProps {
|
interface RetailersListProps {
|
||||||
retailers: RetailerOffer[];
|
retailers: RetailerOffer[];
|
||||||
@@ -46,7 +54,7 @@ export function RetailersList({ retailers }: RetailersListProps) {
|
|||||||
<div className="flex-1 min-w-0">
|
<div className="flex-1 min-w-0">
|
||||||
<div className="flex items-center gap-2 mb-1">
|
<div className="flex items-center gap-2 mb-1">
|
||||||
<span className="text-sm font-semibold text-zinc-50">
|
<span className="text-sm font-semibold text-zinc-50">
|
||||||
{retailer.retailerName}
|
{retailer.retailer}
|
||||||
</span>
|
</span>
|
||||||
{isLowestPrice && retailer.inStock && (
|
{isLowestPrice && retailer.inStock && (
|
||||||
<span className="text-[0.65rem] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-400/20 text-amber-300 border border-amber-400/30">
|
<span className="text-[0.65rem] font-semibold uppercase tracking-wide px-1.5 py-0.5 rounded bg-amber-400/20 text-amber-300 border border-amber-400/30">
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, 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 {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, 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 } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
import RichTextEditor from "@/components/ui/RichTextEditor";
|
import RichTextEditor from "@/components/ui/RichTextEditor";
|
||||||
|
|
||||||
|
|||||||
@@ -0,0 +1,401 @@
|
|||||||
|
"use client";
|
||||||
|
|
||||||
|
import { useMemo, useState } from "react";
|
||||||
|
|
||||||
|
export type BuildProgressHubCategoryGroup = {
|
||||||
|
id: string;
|
||||||
|
label: string;
|
||||||
|
categoryIds: string[];
|
||||||
|
};
|
||||||
|
|
||||||
|
export type BuildProgressHubProps = {
|
||||||
|
slots: any[];
|
||||||
|
selectedByCategory: Record<string, unknown>;
|
||||||
|
categoryGroups: BuildProgressHubCategoryGroup[];
|
||||||
|
isSlotSatisfiedFn: (slot: any, selectedByCategory: any) => boolean;
|
||||||
|
|
||||||
|
requiredSlotIds?: Set<string>;
|
||||||
|
upgradeSlotIds?: Set<string>;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Defaults (pragmatic UX for new users)
|
||||||
|
export const DEFAULT_REQUIRED_SLOT_IDS = new Set<string>([
|
||||||
|
"lower-receiver",
|
||||||
|
"complete-lower",
|
||||||
|
"upper-receiver",
|
||||||
|
"complete-upper",
|
||||||
|
|
||||||
|
"barrel",
|
||||||
|
"bcg",
|
||||||
|
"charging-handle",
|
||||||
|
"handguard",
|
||||||
|
"gas-block",
|
||||||
|
"gas-tube",
|
||||||
|
"muzzle-device",
|
||||||
|
|
||||||
|
"trigger",
|
||||||
|
"grip",
|
||||||
|
"safety-selector",
|
||||||
|
"buffer",
|
||||||
|
"stock",
|
||||||
|
]);
|
||||||
|
|
||||||
|
export const DEFAULT_UPGRADE_SLOT_IDS = new Set<string>([
|
||||||
|
"optic",
|
||||||
|
"sights",
|
||||||
|
"suppressor",
|
||||||
|
"weapon-light",
|
||||||
|
"foregrip",
|
||||||
|
"bipod",
|
||||||
|
"sling",
|
||||||
|
"rail-accessory",
|
||||||
|
]);
|
||||||
|
|
||||||
|
function clamp01(n: number) {
|
||||||
|
if (Number.isNaN(n)) return 0;
|
||||||
|
return Math.max(0, Math.min(1, n));
|
||||||
|
}
|
||||||
|
|
||||||
|
function CircularProgress({
|
||||||
|
value,
|
||||||
|
size = 64,
|
||||||
|
stroke = 8,
|
||||||
|
label,
|
||||||
|
sublabel,
|
||||||
|
}: {
|
||||||
|
value: number; // 0..1
|
||||||
|
size?: number;
|
||||||
|
stroke?: number;
|
||||||
|
label: string;
|
||||||
|
sublabel?: string;
|
||||||
|
}) {
|
||||||
|
const v = clamp01(value);
|
||||||
|
const r = (size - stroke) / 2;
|
||||||
|
const c = 2 * Math.PI * r;
|
||||||
|
const dash = c * v;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="relative" style={{ width: size, height: size }}>
|
||||||
|
<svg width={size} height={size} className="block">
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
className="text-zinc-800"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="transparent"
|
||||||
|
/>
|
||||||
|
<circle
|
||||||
|
cx={size / 2}
|
||||||
|
cy={size / 2}
|
||||||
|
r={r}
|
||||||
|
strokeWidth={stroke}
|
||||||
|
className="text-amber-400"
|
||||||
|
stroke="currentColor"
|
||||||
|
fill="transparent"
|
||||||
|
strokeLinecap="round"
|
||||||
|
strokeDasharray={`${dash} ${c - dash}`}
|
||||||
|
transform={`rotate(-90 ${size / 2} ${size / 2})`}
|
||||||
|
/>
|
||||||
|
</svg>
|
||||||
|
|
||||||
|
<div className="absolute inset-0 flex flex-col items-center justify-center text-center">
|
||||||
|
<div className="text-[0.65rem] font-semibold text-zinc-100 leading-none">
|
||||||
|
{label}
|
||||||
|
</div>
|
||||||
|
{sublabel ? (
|
||||||
|
<div className="mt-0.5 text-[0.6rem] text-zinc-400 leading-none">
|
||||||
|
{sublabel}
|
||||||
|
</div>
|
||||||
|
) : null}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function slotGroupLabel(slotId: string, categoryGroups: BuildProgressHubCategoryGroup[]) {
|
||||||
|
const g = categoryGroups.find((group) =>
|
||||||
|
group.categoryIds.includes(slotId)
|
||||||
|
);
|
||||||
|
return g?.label ?? "Other";
|
||||||
|
}
|
||||||
|
|
||||||
|
function scrollToSlot(slotId: string, categoryGroups: BuildProgressHubCategoryGroup[]) {
|
||||||
|
const el = document.getElementById(`slot-${slotId}`);
|
||||||
|
if (el) {
|
||||||
|
el.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
el.classList.add("ring-2", "ring-amber-400/40");
|
||||||
|
window.setTimeout(() => {
|
||||||
|
el.classList.remove("ring-2", "ring-amber-400/40");
|
||||||
|
}, 900);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const groupId =
|
||||||
|
categoryGroups.find((g) => g.categoryIds.includes(slotId))?.id ?? "";
|
||||||
|
const gEl = groupId ? document.getElementById(`group-${groupId}`) : null;
|
||||||
|
if (gEl) gEl.scrollIntoView({ behavior: "smooth", block: "start" });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function BuildProgressHub({
|
||||||
|
slots,
|
||||||
|
selectedByCategory,
|
||||||
|
categoryGroups,
|
||||||
|
isSlotSatisfiedFn,
|
||||||
|
requiredSlotIds,
|
||||||
|
upgradeSlotIds,
|
||||||
|
}: BuildProgressHubProps) {
|
||||||
|
const [open, setOpen] = useState(false);
|
||||||
|
|
||||||
|
const requiredIds = requiredSlotIds ?? DEFAULT_REQUIRED_SLOT_IDS;
|
||||||
|
const upgradeIds = upgradeSlotIds ?? DEFAULT_UPGRADE_SLOT_IDS;
|
||||||
|
|
||||||
|
const enriched = useMemo(() => {
|
||||||
|
return (slots ?? []).map((slot: any) => {
|
||||||
|
const slotId = String(slot.id ?? slot.key ?? slot.slotId ?? "");
|
||||||
|
const required = slot.required === true || requiredIds.has(slotId);
|
||||||
|
const satisfied = isSlotSatisfiedFn(slot, selectedByCategory as any);
|
||||||
|
|
||||||
|
return {
|
||||||
|
id: slotId,
|
||||||
|
label: String(slot.label ?? slotId),
|
||||||
|
required,
|
||||||
|
satisfied,
|
||||||
|
groupLabel: slotGroupLabel(slotId, categoryGroups),
|
||||||
|
};
|
||||||
|
});
|
||||||
|
}, [slots, selectedByCategory, categoryGroups, isSlotSatisfiedFn, requiredIds]);
|
||||||
|
|
||||||
|
const required = enriched.filter((s) => s.required);
|
||||||
|
const optional = enriched.filter((s) => !s.required);
|
||||||
|
|
||||||
|
const upgrades = optional.filter((s) => upgradeIds.has(s.id));
|
||||||
|
const accessories = optional.filter((s) => !upgradeIds.has(s.id));
|
||||||
|
|
||||||
|
const reqDone = required.filter((s) => s.satisfied).length;
|
||||||
|
const optDone = optional.filter((s) => s.satisfied).length;
|
||||||
|
|
||||||
|
const reqPct = required.length ? reqDone / required.length : 0;
|
||||||
|
const optPct = optional.length ? optDone / optional.length : 0;
|
||||||
|
|
||||||
|
const remainingRequired = Math.max(0, required.length - reqDone);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="fixed bottom-4 right-4 z-40">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen((v) => !v)}
|
||||||
|
className="group flex items-center gap-3 rounded-2xl border border-white/10 bg-zinc-950/80 px-3 py-3 shadow-xl backdrop-blur hover:bg-zinc-900/80"
|
||||||
|
aria-label="Build progress"
|
||||||
|
title="Build progress"
|
||||||
|
>
|
||||||
|
<CircularProgress
|
||||||
|
value={reqPct}
|
||||||
|
size={56}
|
||||||
|
stroke={7}
|
||||||
|
label={`${reqDone}/${required.length}`}
|
||||||
|
sublabel="Required"
|
||||||
|
/>
|
||||||
|
<div className="text-left">
|
||||||
|
<div className="text-xs font-semibold text-zinc-100">
|
||||||
|
Build Checklist
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[0.7rem] text-zinc-400">
|
||||||
|
{remainingRequired === 0
|
||||||
|
? "Core complete ✅"
|
||||||
|
: `${remainingRequired} required left`}
|
||||||
|
</div>
|
||||||
|
<div className="mt-1 text-[0.65rem] text-zinc-500">
|
||||||
|
Tap to {open ? "hide" : "view"} details
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{open && (
|
||||||
|
<div className="mt-3 w-[22rem] max-w-[92vw] overflow-hidden rounded-2xl border border-white/10 bg-zinc-950/90 shadow-2xl backdrop-blur">
|
||||||
|
<div className="flex items-center justify-between gap-3 border-b border-white/10 px-4 py-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-semibold text-zinc-100">
|
||||||
|
What you need (fast)
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[0.7rem] text-zinc-400">
|
||||||
|
Required vs optional — click anything to jump.
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setOpen(false)}
|
||||||
|
className="rounded-md border border-white/10 bg-white/5 px-2 py-1 text-xs text-zinc-200 hover:bg-white/10"
|
||||||
|
>
|
||||||
|
Close
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-2 gap-0 border-b border-white/10">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-amber-300">
|
||||||
|
Required
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.7rem] text-zinc-400">
|
||||||
|
{reqDone}/{required.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CircularProgress
|
||||||
|
value={reqPct}
|
||||||
|
size={64}
|
||||||
|
stroke={8}
|
||||||
|
label={`${Math.round(reqPct * 100)}%`}
|
||||||
|
sublabel="Core"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 py-3 border-l border-white/10">
|
||||||
|
<div className="flex items-center justify-between">
|
||||||
|
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-300">
|
||||||
|
Optional
|
||||||
|
</div>
|
||||||
|
<div className="text-[0.7rem] text-zinc-400">
|
||||||
|
{optDone}/{optional.length}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="mt-2">
|
||||||
|
<CircularProgress
|
||||||
|
value={optPct}
|
||||||
|
size={64}
|
||||||
|
stroke={8}
|
||||||
|
label={`${Math.round(optPct * 100)}%`}
|
||||||
|
sublabel="Extras"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="max-h-[55vh] overflow-auto">
|
||||||
|
<div className="px-4 py-3">
|
||||||
|
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-amber-300">
|
||||||
|
Required components
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{required.map((s) => (
|
||||||
|
<button
|
||||||
|
key={s.id}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
scrollToSlot(s.id, categoryGroups);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-left hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-zinc-100">
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[0.65rem] text-zinc-500">
|
||||||
|
{s.groupLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[0.65rem] border ${
|
||||||
|
s.satisfied
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/40 text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.satisfied ? "Added" : "Missing"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="px-4 pb-4">
|
||||||
|
<div className="text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-300">
|
||||||
|
Optional components
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-2 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-zinc-400">
|
||||||
|
Upgrades
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{upgrades.map((s) => (
|
||||||
|
<button
|
||||||
|
key={`upg-${s.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
scrollToSlot(s.id, categoryGroups);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-left hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-zinc-100">
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[0.65rem] text-zinc-500">
|
||||||
|
{s.groupLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[0.65rem] border ${
|
||||||
|
s.satisfied
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/40 text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.satisfied ? "Added" : "Open"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 text-[0.65rem] font-semibold uppercase tracking-[0.14em] text-zinc-400">
|
||||||
|
Accessories
|
||||||
|
</div>
|
||||||
|
<div className="mt-2 space-y-1">
|
||||||
|
{accessories.map((s) => (
|
||||||
|
<button
|
||||||
|
key={`acc-${s.id}`}
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setOpen(false);
|
||||||
|
scrollToSlot(s.id, categoryGroups);
|
||||||
|
}}
|
||||||
|
className="w-full rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-left hover:bg-white/10"
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<div className="text-xs font-medium text-zinc-100">
|
||||||
|
{s.label}
|
||||||
|
</div>
|
||||||
|
<div className="mt-0.5 text-[0.65rem] text-zinc-500">
|
||||||
|
{s.groupLabel}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<span
|
||||||
|
className={`rounded-full px-2 py-0.5 text-[0.65rem] border ${
|
||||||
|
s.satisfied
|
||||||
|
? "border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border-zinc-700 bg-zinc-900/40 text-zinc-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{s.satisfied ? "Added" : "Open"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</button>
|
||||||
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -8,7 +8,9 @@ export default function Filters(props: {
|
|||||||
availableBrands: string[];
|
availableBrands: string[];
|
||||||
brandFilter: string[];
|
brandFilter: string[];
|
||||||
setBrandFilter: (v: string[]) => void;
|
setBrandFilter: (v: string[]) => void;
|
||||||
|
availableCalibers: string[];
|
||||||
|
caliberFilter: string[];
|
||||||
|
setCaliberFilter: (next: string[]) => void;
|
||||||
priceBounds: { min: number | null; max: number | null };
|
priceBounds: { min: number | null; max: number | null };
|
||||||
priceRange: { min: number | null; max: number | null };
|
priceRange: { min: number | null; max: number | null };
|
||||||
setPriceRange: (v: { min: number | null; max: number | null }) => void;
|
setPriceRange: (v: { min: number | null; max: number | null }) => void;
|
||||||
@@ -20,6 +22,9 @@ export default function Filters(props: {
|
|||||||
availableBrands,
|
availableBrands,
|
||||||
brandFilter,
|
brandFilter,
|
||||||
setBrandFilter,
|
setBrandFilter,
|
||||||
|
availableCalibers,
|
||||||
|
caliberFilter,
|
||||||
|
setCaliberFilter,
|
||||||
priceBounds,
|
priceBounds,
|
||||||
priceRange,
|
priceRange,
|
||||||
setPriceRange,
|
setPriceRange,
|
||||||
@@ -88,7 +93,10 @@ export default function Filters(props: {
|
|||||||
onChange={(e) => {
|
onChange={(e) => {
|
||||||
const value = Number(e.target.value);
|
const value = Number(e.target.value);
|
||||||
setPriceRange({
|
setPriceRange({
|
||||||
min: Math.min(value, priceRange.max ?? priceBounds.max ?? value),
|
min: Math.min(
|
||||||
|
value,
|
||||||
|
priceRange.max ?? priceBounds.max ?? value
|
||||||
|
),
|
||||||
max: priceRange.max ?? priceBounds.max ?? value,
|
max: priceRange.max ?? priceBounds.max ?? value,
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
@@ -104,7 +112,10 @@ export default function Filters(props: {
|
|||||||
const value = Number(e.target.value);
|
const value = Number(e.target.value);
|
||||||
setPriceRange({
|
setPriceRange({
|
||||||
min: priceRange.min ?? priceBounds.min ?? value,
|
min: priceRange.min ?? priceBounds.min ?? value,
|
||||||
max: Math.max(value, priceRange.min ?? priceBounds.min ?? value),
|
max: Math.max(
|
||||||
|
value,
|
||||||
|
priceRange.min ?? priceBounds.min ?? value
|
||||||
|
),
|
||||||
});
|
});
|
||||||
}}
|
}}
|
||||||
className="w-full"
|
className="w-full"
|
||||||
@@ -168,6 +179,38 @@ export default function Filters(props: {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{availableCalibers?.length > 0 && (
|
||||||
|
<div className="mt-4">
|
||||||
|
<div className="mb-2 text-xs font-semibold tracking-wide text-zinc-300">
|
||||||
|
Caliber
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-2">
|
||||||
|
{availableCalibers.map((c) => {
|
||||||
|
const checked = caliberFilter.includes(c);
|
||||||
|
return (
|
||||||
|
<label
|
||||||
|
key={c}
|
||||||
|
className="flex items-center gap-2 text-sm text-zinc-300"
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={checked}
|
||||||
|
onChange={(e) => {
|
||||||
|
if (e.target.checked)
|
||||||
|
setCaliberFilter([...caliberFilter, c]);
|
||||||
|
else
|
||||||
|
setCaliberFilter(caliberFilter.filter((x) => x !== c));
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
<span>{c}</span>
|
||||||
|
</label>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{/* In-stock toggle */}
|
{/* In-stock toggle */}
|
||||||
<div className="border-t border-zinc-800 pt-3">
|
<div className="border-t border-zinc-800 pt-3">
|
||||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
|
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
|
||||||
@@ -187,4 +230,4 @@ export default function Filters(props: {
|
|||||||
</div>
|
</div>
|
||||||
</aside>
|
</aside>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -19,13 +19,30 @@ import SortBar from "@/components/parts/SortBar";
|
|||||||
import PartsGrid from "@/components/parts/PartsGrid";
|
import PartsGrid from "@/components/parts/PartsGrid";
|
||||||
import Pagination from "@/components/parts/Pagination";
|
import Pagination from "@/components/parts/Pagination";
|
||||||
import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
|
import PlatformSwitcher from "@/components/parts/PlatformSwitcher";
|
||||||
|
import { normalizePlatformKey } from "@/lib/platforms";
|
||||||
import type { CategoryId } from "@/types/gunbuilder";
|
import type { UiPart } from "@/types/uiPart";
|
||||||
|
import type { BuilderSlotKey, Category } from "@/types/builderSlots";
|
||||||
import {
|
import {
|
||||||
PART_ROLE_TO_CATEGORY,
|
PART_ROLE_TO_CATEGORY,
|
||||||
normalizePartRole,
|
normalizePartRole,
|
||||||
} from "@/lib/catalogMappings";
|
} from "@/lib/catalogMappings";
|
||||||
|
|
||||||
|
type PageResponse<T> = {
|
||||||
|
content: T[];
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
number: number; // 0-based
|
||||||
|
size: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
function unwrapContent<T>(json: any): { items: T[]; page?: PageResponse<T> } {
|
||||||
|
if (!json) return { items: [] };
|
||||||
|
if (Array.isArray(json)) return { items: json };
|
||||||
|
if (Array.isArray(json.content))
|
||||||
|
return { items: json.content, page: json as PageResponse<T> };
|
||||||
|
return { items: [] };
|
||||||
|
}
|
||||||
|
|
||||||
type ViewMode = "card" | "list";
|
type ViewMode = "card" | "list";
|
||||||
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||||
|
|
||||||
@@ -36,25 +53,13 @@ type GunbuilderProductFromApi = {
|
|||||||
platform: string;
|
platform: string;
|
||||||
partRole: string;
|
partRole: string;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
mainImageUrl: string | null;
|
imageUrl: string | null;
|
||||||
buyUrl: string | null;
|
buyUrl: string | null;
|
||||||
inStock?: boolean | null;
|
inStock?: boolean | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
type UiPart = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
brand: string;
|
|
||||||
platform: string;
|
|
||||||
partRole: string;
|
|
||||||
price: number;
|
|
||||||
imageUrl?: string;
|
|
||||||
buyUrl?: string;
|
|
||||||
inStock?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
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 PAGE_SIZE = 24;
|
const PAGE_SIZE = 24;
|
||||||
|
|
||||||
@@ -95,8 +100,9 @@ export default function PartsBrowseClient(props: {
|
|||||||
const isBuilderMode = pathname.startsWith("/parts/p/");
|
const isBuilderMode = pathname.startsWith("/parts/p/");
|
||||||
|
|
||||||
// ✅ Respect prop override first, else read query, else fallback
|
// ✅ Respect prop override first, else read query, else fallback
|
||||||
const effectivePlatform =
|
const effectivePlatform = normalizePlatformKey(
|
||||||
props.platform ?? searchParams.get("platform") ?? "AR-15";
|
props.platform ?? searchParams.get("platform") ?? "AR15"
|
||||||
|
);
|
||||||
|
|
||||||
const partRole = props.partRole;
|
const partRole = props.partRole;
|
||||||
|
|
||||||
@@ -106,6 +112,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
const [brandFilter, setBrandFilter] = useState<string[]>([]);
|
const [brandFilter, setBrandFilter] = useState<string[]>([]);
|
||||||
|
const [caliberFilter, setCaliberFilter] = useState<string[]>([]);
|
||||||
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
||||||
const [searchQuery, setSearchQuery] = useState("");
|
const [searchQuery, setSearchQuery] = useState("");
|
||||||
const [priceRange, setPriceRange] = useState<{
|
const [priceRange, setPriceRange] = useState<{
|
||||||
@@ -116,8 +123,9 @@ export default function PartsBrowseClient(props: {
|
|||||||
max: null,
|
max: null,
|
||||||
});
|
});
|
||||||
const [inStockOnly, setInStockOnly] = useState(false);
|
const [inStockOnly, setInStockOnly] = useState(false);
|
||||||
const [currentPage, setCurrentPage] = useState(1);
|
const [currentPage, setCurrentPage] = useState(1); // UI 1-based
|
||||||
|
const [serverTotalPages, setServerTotalPages] = useState(1);
|
||||||
|
const [serverTotalElements, setServerTotalElements] = useState(0);
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
// Fetch parts
|
// Fetch parts
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
@@ -132,31 +140,75 @@ export default function PartsBrowseClient(props: {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const search = new URLSearchParams();
|
const search = new URLSearchParams();
|
||||||
search.set("platform", effectivePlatform);
|
search.set("platform", normalizePlatformKey(effectivePlatform));
|
||||||
search.append("partRoles", partRole);
|
search.append("partRole", partRole);
|
||||||
|
|
||||||
|
// server paging (0-based)
|
||||||
|
search.set("page", String(currentPage - 1));
|
||||||
|
search.set("size", String(PAGE_SIZE));
|
||||||
|
|
||||||
|
// optional server search + sort (backend can ignore for now)
|
||||||
|
if (searchQuery.trim()) search.set("q", searchQuery.trim());
|
||||||
|
if (brandFilter.length)
|
||||||
|
brandFilter.forEach((b) => search.append("brand", b));
|
||||||
|
|
||||||
|
// server search caliber
|
||||||
|
if (caliberFilter.length)
|
||||||
|
caliberFilter.forEach((c) => search.append("caliber", c));
|
||||||
|
|
||||||
|
// sort mapping (Spring-style: sort=field,dir)
|
||||||
|
switch (sortBy) {
|
||||||
|
case "price-asc":
|
||||||
|
search.set("sort", "price,asc");
|
||||||
|
break;
|
||||||
|
case "price-desc":
|
||||||
|
search.set("sort", "price,desc");
|
||||||
|
break;
|
||||||
|
case "brand-asc":
|
||||||
|
search.set("sort", "brand,asc");
|
||||||
|
break;
|
||||||
|
case "relevance":
|
||||||
|
default:
|
||||||
|
// fallback (or whatever you consider "relevance")
|
||||||
|
search.set("sort", "updatedAt,desc");
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/v1/products?${search.toString()}`,
|
`${API_BASE_URL}/api/v1/catalog/options?${search.toString()}`,
|
||||||
{ signal: controller.signal }
|
{
|
||||||
|
signal: controller.signal,
|
||||||
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
if (!res.ok) throw new Error(`Failed to load products (${res.status})`);
|
||||||
|
|
||||||
const data: GunbuilderProductFromApi[] = await res.json();
|
const json = await res.json();
|
||||||
|
const { items, page } = unwrapContent<GunbuilderProductFromApi>(json);
|
||||||
|
|
||||||
setParts(
|
setParts(
|
||||||
data.map((p) => ({
|
items.map((p) => ({
|
||||||
id: normalizeId(p.id),
|
id: normalizeId(p.id),
|
||||||
name: p.name,
|
name: p.name,
|
||||||
brand: p.brand,
|
brand: p.brand,
|
||||||
platform: p.platform,
|
platform: p.platform,
|
||||||
partRole: p.partRole,
|
partRole: p.partRole,
|
||||||
price: p.price ?? 0,
|
price: p.price,
|
||||||
imageUrl: p.mainImageUrl ?? undefined,
|
imageUrl: p.imageUrl ?? undefined,
|
||||||
buyUrl: p.buyUrl ?? undefined,
|
buyUrl: p.buyUrl ?? undefined,
|
||||||
inStock: p.inStock ?? true,
|
inStock: p.inStock ?? undefined,
|
||||||
|
caliber: (p as any).caliber ?? null,
|
||||||
}))
|
}))
|
||||||
);
|
);
|
||||||
|
|
||||||
|
if (page) {
|
||||||
|
setServerTotalPages(page.totalPages ?? 1);
|
||||||
|
setServerTotalElements(page.totalElements ?? items.length);
|
||||||
|
} else {
|
||||||
|
// backward compat
|
||||||
|
setServerTotalPages(1);
|
||||||
|
setServerTotalElements(items.length);
|
||||||
|
}
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") return;
|
if (err?.name === "AbortError") return;
|
||||||
setError(err?.message ?? "Failed to load products");
|
setError(err?.message ?? "Failed to load products");
|
||||||
@@ -167,8 +219,15 @@ export default function PartsBrowseClient(props: {
|
|||||||
|
|
||||||
fetchParts();
|
fetchParts();
|
||||||
return () => controller.abort();
|
return () => controller.abort();
|
||||||
}, [partRole, effectivePlatform]);
|
}, [
|
||||||
|
partRole,
|
||||||
|
effectivePlatform,
|
||||||
|
currentPage,
|
||||||
|
searchQuery,
|
||||||
|
sortBy,
|
||||||
|
brandFilter,
|
||||||
|
caliberFilter,
|
||||||
|
]);
|
||||||
// Reset pagination on filters
|
// Reset pagination on filters
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
setCurrentPage(1);
|
setCurrentPage(1);
|
||||||
@@ -176,6 +235,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
partRole,
|
partRole,
|
||||||
effectivePlatform,
|
effectivePlatform,
|
||||||
brandFilter,
|
brandFilter,
|
||||||
|
caliberFilter,
|
||||||
sortBy,
|
sortBy,
|
||||||
searchQuery,
|
searchQuery,
|
||||||
priceRange,
|
priceRange,
|
||||||
@@ -198,13 +258,20 @@ export default function PartsBrowseClient(props: {
|
|||||||
[parts]
|
[parts]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const availableCalibers = useMemo(
|
||||||
|
() =>
|
||||||
|
Array.from(new Set(parts.map((p: any) => p.caliber)))
|
||||||
|
.filter(Boolean)
|
||||||
|
.sort((a, b) => String(a).localeCompare(String(b))),
|
||||||
|
[parts]
|
||||||
|
);
|
||||||
|
|
||||||
const priceBounds = useMemo(() => {
|
const priceBounds = useMemo(() => {
|
||||||
if (!parts.length)
|
const prices = parts
|
||||||
return { min: null as number | null, max: null as number | null };
|
.map((p) => p.price)
|
||||||
return {
|
.filter((v): v is number => typeof v === "number");
|
||||||
min: Math.min(...parts.map((p) => p.price)),
|
if (!prices.length) return { min: null, max: null };
|
||||||
max: Math.max(...parts.map((p) => p.price)),
|
return { min: Math.min(...prices), max: Math.max(...prices) };
|
||||||
};
|
|
||||||
}, [parts]);
|
}, [parts]);
|
||||||
|
|
||||||
// Keep priceRange clamped to bounds when bounds change
|
// Keep priceRange clamped to bounds when bounds change
|
||||||
@@ -227,77 +294,39 @@ export default function PartsBrowseClient(props: {
|
|||||||
});
|
});
|
||||||
}, [priceBounds.min, priceBounds.max]);
|
}, [priceBounds.min, priceBounds.max]);
|
||||||
|
|
||||||
const filteredParts = useMemo(() => {
|
const filteredParts = useMemo(() => parts, [parts]);
|
||||||
let result = [...parts];
|
const totalPages = Math.max(1, serverTotalPages);
|
||||||
|
const paginatedParts = filteredParts; // filteredParts should become just parts now (see below)
|
||||||
if (priceRange.min != null)
|
|
||||||
result = result.filter((p) => p.price >= priceRange.min!);
|
|
||||||
if (priceRange.max != null)
|
|
||||||
result = result.filter((p) => p.price <= priceRange.max!);
|
|
||||||
|
|
||||||
if (brandFilter.length)
|
|
||||||
result = result.filter((p) => brandFilter.includes(p.brand));
|
|
||||||
|
|
||||||
if (inStockOnly) result = result.filter((p) => p.inStock ?? true);
|
|
||||||
|
|
||||||
const q = searchQuery.toLowerCase().trim();
|
|
||||||
if (q)
|
|
||||||
result = result.filter(
|
|
||||||
(p) =>
|
|
||||||
p.name.toLowerCase().includes(q) || p.brand.toLowerCase().includes(q)
|
|
||||||
);
|
|
||||||
|
|
||||||
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:
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
|
|
||||||
return result;
|
|
||||||
}, [parts, brandFilter, sortBy, searchQuery, priceRange, inStockOnly]);
|
|
||||||
|
|
||||||
const totalPages = Math.max(1, Math.ceil(filteredParts.length / PAGE_SIZE));
|
|
||||||
const paginatedParts = filteredParts.slice(
|
|
||||||
(currentPage - 1) * PAGE_SIZE,
|
|
||||||
currentPage * PAGE_SIZE
|
|
||||||
);
|
|
||||||
|
|
||||||
const visibleRange = {
|
const visibleRange = {
|
||||||
start: filteredParts.length ? (currentPage - 1) * PAGE_SIZE + 1 : 0,
|
start: serverTotalElements ? (currentPage - 1) * PAGE_SIZE + 1 : 0,
|
||||||
end: Math.min(currentPage * PAGE_SIZE, filteredParts.length),
|
end: Math.min(currentPage * PAGE_SIZE, serverTotalElements),
|
||||||
};
|
};
|
||||||
|
|
||||||
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")} parts`;
|
const headingTitle = props.title ?? `${partRole.replaceAll("-", " ")}s`;
|
||||||
const headingSubtitle = props.subtitle ?? "Browse available parts.";
|
const headingSubtitle = props.subtitle ?? "Browse available parts.";
|
||||||
|
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
// Add → Builder handoff
|
// Add → Builder handoff
|
||||||
// ----------------------------
|
// ----------------------------
|
||||||
const handleAddToBuild = (p: UiPart) => {
|
const handleAddToBuild = (p: UiPart) => {
|
||||||
const normalizedRole = normalizePartRole(partRole);
|
// Always normalize first; our mappings may alias multiple roles to one canonical builder category
|
||||||
const categoryId: CategoryId | null =
|
const roleKey = normalizePartRole(partRole);
|
||||||
|
|
||||||
|
const categoryId: BuilderSlotKey | null =
|
||||||
|
PART_ROLE_TO_CATEGORY[roleKey] ??
|
||||||
PART_ROLE_TO_CATEGORY[partRole] ??
|
PART_ROLE_TO_CATEGORY[partRole] ??
|
||||||
PART_ROLE_TO_CATEGORY[normalizedRole] ??
|
|
||||||
null;
|
null;
|
||||||
|
|
||||||
if (!categoryId) {
|
if (!categoryId) {
|
||||||
alert(
|
alert(
|
||||||
`No CategoryId mapping found for role "${partRole}". Add it to catalogMappings.`
|
`No Category mapping found for role "${partRole}" (normalized: "${roleKey}"). Add it to catalogMappings.`
|
||||||
);
|
);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const qp = new URLSearchParams();
|
const qp = new URLSearchParams();
|
||||||
qp.set("platform", effectivePlatform);
|
qp.set("platform", normalizePlatformKey(effectivePlatform));
|
||||||
qp.set("select", `${categoryId}:${p.id}`);
|
qp.set("select", `${categoryId}:${p.id}`);
|
||||||
|
|
||||||
router.push(`/builder?${qp.toString()}`);
|
router.push(`/builder?${qp.toString()}`);
|
||||||
@@ -321,25 +350,25 @@ export default function PartsBrowseClient(props: {
|
|||||||
{headingSubtitle}
|
{headingSubtitle}
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
{!isBuilderMode && (
|
{/* {!isBuilderMode && (
|
||||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||||
<PlatformSwitcher
|
<PlatformSwitcher
|
||||||
currentPlatform={effectivePlatform}
|
currentPlatform={effectivePlatform}
|
||||||
partRole={partRole}
|
partRole={partRole}
|
||||||
preserveQuery
|
preserveQuery
|
||||||
mode="browse"
|
mode="browse"
|
||||||
/>
|
/> */}
|
||||||
|
|
||||||
<Link
|
<Link
|
||||||
href={`/builder?platform=${encodeURIComponent(
|
href={`/builder?platform=${encodeURIComponent(
|
||||||
effectivePlatform
|
effectivePlatform
|
||||||
)}`}
|
)}`}
|
||||||
className="inline-flex items-center rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
className="inline-flex items-center rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
>
|
>
|
||||||
Start New Build →
|
Start New Build →
|
||||||
</Link>
|
</Link>
|
||||||
</div>
|
{/* </div>
|
||||||
)}
|
)} */}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{isBuilderMode && (
|
{isBuilderMode && (
|
||||||
@@ -393,6 +422,9 @@ export default function PartsBrowseClient(props: {
|
|||||||
availableBrands={availableBrands}
|
availableBrands={availableBrands}
|
||||||
brandFilter={brandFilter}
|
brandFilter={brandFilter}
|
||||||
setBrandFilter={setBrandFilter}
|
setBrandFilter={setBrandFilter}
|
||||||
|
availableCalibers={availableCalibers}
|
||||||
|
caliberFilter={caliberFilter}
|
||||||
|
setCaliberFilter={setCaliberFilter}
|
||||||
priceBounds={priceBounds}
|
priceBounds={priceBounds}
|
||||||
priceRange={priceRange}
|
priceRange={priceRange}
|
||||||
setPriceRange={setPriceRange}
|
setPriceRange={setPriceRange}
|
||||||
@@ -406,7 +438,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
{!loading && !error && (
|
{!loading && !error && (
|
||||||
<SortBar
|
<SortBar
|
||||||
visibleRange={visibleRange}
|
visibleRange={visibleRange}
|
||||||
totalCount={filteredParts.length}
|
totalCount={serverTotalElements}
|
||||||
searchQuery={searchQuery}
|
searchQuery={searchQuery}
|
||||||
setSearchQuery={setSearchQuery}
|
setSearchQuery={setSearchQuery}
|
||||||
sortBy={sortBy}
|
sortBy={sortBy}
|
||||||
@@ -420,7 +452,7 @@ export default function PartsBrowseClient(props: {
|
|||||||
</p>
|
</p>
|
||||||
) : error ? (
|
) : error ? (
|
||||||
<p className="py-8 text-center text-sm text-red-400">{error}</p>
|
<p className="py-8 text-center text-sm text-red-400">{error}</p>
|
||||||
) : filteredParts.length === 0 ? (
|
) : serverTotalElements === 0 ? (
|
||||||
<p className="py-8 text-center text-sm text-zinc-500">
|
<p className="py-8 text-center text-sm text-zinc-500">
|
||||||
No parts found for this role yet.
|
No parts found for this role yet.
|
||||||
</p>
|
</p>
|
||||||
|
|||||||
+164
-125
@@ -2,19 +2,7 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import { Plus } from "lucide-react";
|
import { Plus } from "lucide-react";
|
||||||
|
import type { UiPart } from "@/types/uiPart";
|
||||||
type UiPart = {
|
|
||||||
id: string;
|
|
||||||
name: string;
|
|
||||||
brand: string;
|
|
||||||
caliber?: string;
|
|
||||||
platform: string;
|
|
||||||
partRole: string;
|
|
||||||
price: number;
|
|
||||||
imageUrl?: string;
|
|
||||||
buyUrl?: string;
|
|
||||||
inStock?: boolean;
|
|
||||||
};
|
|
||||||
|
|
||||||
export default function PartsGrid(props: {
|
export default function PartsGrid(props: {
|
||||||
viewMode: "card" | "list";
|
viewMode: "card" | "list";
|
||||||
@@ -27,63 +15,80 @@ export default function PartsGrid(props: {
|
|||||||
const { viewMode, parts, buildDetailHref, onAddToBuild } = props;
|
const { viewMode, parts, buildDetailHref, onAddToBuild } = props;
|
||||||
const addLabel = props.addLabel ?? "Add to Build";
|
const addLabel = props.addLabel ?? "Add to Build";
|
||||||
|
|
||||||
|
// ✅ Only show caliber column if we actually have caliber data
|
||||||
|
const showCaliber = parts.some((p) => !!p.caliber);
|
||||||
|
|
||||||
|
// ✅ Single source of truth for grid columns (prevents header/row drift)
|
||||||
|
// Columns: Image | Part | Brand | (Caliber?) | Price | Actions
|
||||||
|
const gridCols = showCaliber
|
||||||
|
? "md:grid-cols-[44px_minmax(0,1fr)_160px_120px_110px_120px]" // +Caliber
|
||||||
|
: "md:grid-cols-[44px_minmax(0,1fr)_160px_110px_120px]"; // no Caliber"md:grid-cols-[44px_minmax(0,1fr)_160px_110px_120px]";
|
||||||
|
|
||||||
|
const formatPrice = (price?: number | null) => {
|
||||||
|
if (price == null) return "—";
|
||||||
|
return `$${price.toFixed(2)}`;
|
||||||
|
};
|
||||||
|
|
||||||
if (viewMode === "card") {
|
if (viewMode === "card") {
|
||||||
return (
|
return (
|
||||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||||
{parts.map((part) => (
|
{parts.map((part) => {
|
||||||
<div
|
const buyHref = part.buyShortUrl ?? part.buyUrl;
|
||||||
key={part.id}
|
return (
|
||||||
className="group relative flex flex-col rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10"
|
<div
|
||||||
>
|
key={part.id}
|
||||||
<div className="mb-3 flex items-center justify-between gap-2">
|
className="group relative flex flex-col rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||||
<div className="min-w-0">
|
>
|
||||||
<div className="truncate text-sm font-semibold text-zinc-50">
|
<div className="mb-3 flex items-center justify-between gap-2">
|
||||||
{part.brand}{" "}
|
<div className="min-w-0">
|
||||||
<span className="font-normal">— {part.name}</span>
|
<div className="truncate text-sm font-semibold text-zinc-50">
|
||||||
|
{part.brand}{" "}
|
||||||
|
<span className="font-normal">— {part.name}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
|
||||||
|
{formatPrice(part.price)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
|
<div className="mt-2 flex gap-2">
|
||||||
${part.price.toFixed(2)}
|
<Link
|
||||||
|
href={buildDetailHref(part)}
|
||||||
|
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 hover:border-zinc-600 hover:bg-zinc-700"
|
||||||
|
>
|
||||||
|
View Details
|
||||||
|
</Link>
|
||||||
|
|
||||||
|
{onAddToBuild ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddToBuild(part)}
|
||||||
|
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300"
|
||||||
|
>
|
||||||
|
{addLabel}
|
||||||
|
</button>
|
||||||
|
) : buyHref ? (
|
||||||
|
<a
|
||||||
|
href={buyHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="flex-1 rounded-md bg-zinc-700 px-3 py-2 text-center text-xs font-semibold text-zinc-200 opacity-50"
|
||||||
|
>
|
||||||
|
No Price
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
);
|
||||||
<div className="mt-2 flex gap-2">
|
})}
|
||||||
<Link
|
|
||||||
href={buildDetailHref(part)}
|
|
||||||
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 hover:border-zinc-600 hover:bg-zinc-700"
|
|
||||||
>
|
|
||||||
View Details
|
|
||||||
</Link>
|
|
||||||
|
|
||||||
{onAddToBuild ? (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={() => onAddToBuild(part)}
|
|
||||||
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300"
|
|
||||||
>
|
|
||||||
{addLabel}
|
|
||||||
</button>
|
|
||||||
) : part.buyUrl ? (
|
|
||||||
<a
|
|
||||||
href={part.buyUrl}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="flex-1 rounded-md bg-amber-400 px-3 py-2 text-center text-xs font-semibold text-black hover:bg-amber-300"
|
|
||||||
>
|
|
||||||
Buy
|
|
||||||
</a>
|
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
disabled
|
|
||||||
className="flex-1 rounded-md bg-zinc-700 px-3 py-2 text-center text-xs font-semibold text-zinc-200 opacity-50"
|
|
||||||
>
|
|
||||||
No Action
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
))}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -92,81 +97,115 @@ export default function PartsGrid(props: {
|
|||||||
return (
|
return (
|
||||||
<>
|
<>
|
||||||
{/* Header (DESKTOP ONLY) */}
|
{/* Header (DESKTOP ONLY) */}
|
||||||
<div className="hidden md:grid md:grid-cols-[minmax(0,1fr)_120px_110px_120px] md:items-center md:gap-4 px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
|
<div
|
||||||
|
className={[
|
||||||
|
"hidden md:grid md:items-center md:gap-4 px-3 pb-2",
|
||||||
|
"text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500",
|
||||||
|
gridCols,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
<span className="text-left">Image</span>
|
||||||
<span className="min-w-0">Part</span>
|
<span className="min-w-0">Part</span>
|
||||||
<span className="text-right">Caliber</span>
|
<span className="text-right">Brand</span>
|
||||||
|
{showCaliber ? <span className="text-right">Caliber</span> : null}
|
||||||
<span className="text-right">Price</span>
|
<span className="text-right">Price</span>
|
||||||
<span className="text-right">Actions</span>
|
<span className="text-right">Actions</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Rows */}
|
{/* Rows */}
|
||||||
<div className="space-y-2">
|
<div className="space-y-2">
|
||||||
{parts.map((part) => (
|
{parts.map((part) => {
|
||||||
<div
|
const buyHref = part.buyShortUrl ?? part.buyUrl;
|
||||||
key={part.id}
|
|
||||||
className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10"
|
|
||||||
>
|
|
||||||
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,1fr)_120px_110px_120px] md:items-center md:gap-4">
|
|
||||||
{/* Part */}
|
|
||||||
<div className="min-w-0">
|
|
||||||
<Link
|
|
||||||
href={buildDetailHref(part)}
|
|
||||||
className="block text-sm font-semibold text-zinc-50 leading-snug line-clamp-2 break-words hover:text-amber-300 transition-colors"
|
|
||||||
title={part.name}
|
|
||||||
>
|
|
||||||
{part.name}
|
|
||||||
</Link>
|
|
||||||
{/* Dont think we need Brand in the grid */}
|
|
||||||
{/* <div className="mt-0.5 text-xs text-zinc-500 line-clamp-1">
|
|
||||||
{part.brand}
|
|
||||||
{part.caliber ? (
|
|
||||||
<span className="text-zinc-600"> • {part.caliber}</span>
|
|
||||||
) : null}
|
|
||||||
</div> */}
|
|
||||||
</div>
|
|
||||||
{/* Caliber (desktop) */}
|
|
||||||
<div className="hidden md:block text-sm text-zinc-300 text-right">
|
|
||||||
{part.caliber ?? "—"}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Price */}
|
return (
|
||||||
<div className="text-sm font-semibold text-amber-300 md:text-right">
|
<div
|
||||||
${part.price.toFixed(2)}
|
key={part.id}
|
||||||
</div>
|
className="group relative rounded-md border border-zinc-700 bg-zinc-900/50 p-3 transition-all hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
className={[
|
||||||
|
"flex flex-col gap-2 md:grid md:items-center md:gap-4",
|
||||||
|
gridCols,
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{/* Image */}
|
||||||
|
<div className="hidden md:block justify-self-start">
|
||||||
|
{part.imageUrl ? (
|
||||||
|
// eslint-disable-next-line @next/next/no-img-element
|
||||||
|
<img
|
||||||
|
src={part.imageUrl}
|
||||||
|
alt=""
|
||||||
|
className="h-10 w-10 rounded object-cover border border-zinc-800 bg-zinc-950"
|
||||||
|
loading="lazy"
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-950" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
{/* Actions */}
|
{/* Part */}
|
||||||
<div className="flex justify-end gap-2">
|
<div className="min-w-0">
|
||||||
{onAddToBuild ? (
|
<Link
|
||||||
<button
|
href={buildDetailHref(part)}
|
||||||
type="button"
|
className="block text-sm font-semibold text-zinc-50 leading-snug line-clamp-2 break-words hover:text-amber-300 transition-colors"
|
||||||
onClick={() => onAddToBuild(part)}
|
title={part.name}
|
||||||
className="inline-flex items-center justify-center h-8 w-8 rounded-md bg-amber-400 text-black hover:bg-amber-300 transition-colors"
|
|
||||||
aria-label="Add to Build"
|
|
||||||
title="Add to Build"
|
|
||||||
>
|
>
|
||||||
<Plus className="h-4 w-4" />
|
{part.name}
|
||||||
</button>
|
</Link>
|
||||||
) : part.buyUrl ? (
|
</div>
|
||||||
<a
|
|
||||||
href={part.buyUrl}
|
{/* Brand */}
|
||||||
target="_blank"
|
<div className="hidden md:block text-sm text-zinc-300 text-right">
|
||||||
rel="noreferrer"
|
{part.brand || "—"}
|
||||||
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
</div>
|
||||||
>
|
|
||||||
Buy
|
{/* Caliber (optional) */}
|
||||||
</a>
|
{showCaliber ? (
|
||||||
) : (
|
<div className="hidden md:block text-sm text-zinc-300 text-right">
|
||||||
<button
|
{part.caliber ?? "—"}
|
||||||
disabled
|
</div>
|
||||||
className="whitespace-nowrap rounded-md bg-zinc-700 px-3 py-1.5 text-xs font-semibold text-zinc-200 opacity-50"
|
) : null}
|
||||||
>
|
|
||||||
No Action
|
{/* Price */}
|
||||||
</button>
|
<div className="text-sm font-semibold text-amber-300 md:text-right">
|
||||||
)}
|
{formatPrice(part.price)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Actions */}
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
{onAddToBuild ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => onAddToBuild(part)}
|
||||||
|
className="inline-flex items-center justify-center h-8 w-8 rounded-md bg-amber-400 text-black hover:bg-amber-300 transition-colors"
|
||||||
|
aria-label="Add to Build"
|
||||||
|
title="Add to Build"
|
||||||
|
>
|
||||||
|
<Plus className="h-4 w-4" />
|
||||||
|
</button>
|
||||||
|
) : buyHref ? (
|
||||||
|
<a
|
||||||
|
href={buyHref}
|
||||||
|
target="_blank"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="whitespace-nowrap rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
||||||
|
>
|
||||||
|
Buy
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<button
|
||||||
|
disabled
|
||||||
|
className="whitespace-nowrap rounded-md bg-zinc-700 px-3 py-1.5 text-xs font-semibold text-zinc-200 opacity-50"
|
||||||
|
title="No buy link available"
|
||||||
|
>
|
||||||
|
No Action
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
);
|
||||||
))}
|
})}
|
||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -3,8 +3,9 @@
|
|||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import PlatformSwitcher from "./PlatformSwitcher";
|
import PlatformSwitcher from "./PlatformSwitcher";
|
||||||
|
import Image from "next/image";
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type ProductListDto = {
|
type ProductListDto = {
|
||||||
id: string; // your API returns strings sometimes; keep it flexible
|
id: string; // your API returns strings sometimes; keep it flexible
|
||||||
@@ -138,10 +139,16 @@ export default function PartsListPageClient(props: {
|
|||||||
className="group rounded-lg border border-zinc-800 bg-black/30 p-3 hover:border-zinc-700"
|
className="group rounded-lg border border-zinc-800 bg-black/30 p-3 hover:border-zinc-700"
|
||||||
>
|
>
|
||||||
<div className="flex gap-3">
|
<div className="flex gap-3">
|
||||||
<div className="h-16 w-16 overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
<div className="relative h-16 w-16 overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
|
||||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||||
{p.imageUrl ? (
|
{p.imageUrl ? (
|
||||||
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
|
<Image
|
||||||
|
src={p.imageUrl}
|
||||||
|
alt={p.name}
|
||||||
|
fill
|
||||||
|
sizes="64px"
|
||||||
|
className="object-cover"
|
||||||
|
/>
|
||||||
) : (
|
) : (
|
||||||
<div className="h-full w-full grid place-items-center text-xs text-zinc-600">
|
<div className="h-full w-full grid place-items-center text-xs text-zinc-600">
|
||||||
—
|
—
|
||||||
@@ -175,4 +182,4 @@ export default function PartsListPageClient(props: {
|
|||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -4,7 +4,7 @@ import { useEffect, useMemo, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import PlatformSwitcher from "./PlatformSwitcher";
|
import PlatformSwitcher from "./PlatformSwitcher";
|
||||||
|
|
||||||
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type ProductDetailDto = {
|
type ProductDetailDto = {
|
||||||
id: string;
|
id: string;
|
||||||
|
|||||||
@@ -1,11 +1,16 @@
|
|||||||
"use client";
|
"use client";
|
||||||
import QuillImageResize from "quill-image-resize-module";
|
|
||||||
Quill.register("modules/imageResize", QuillImageResize);
|
|
||||||
import dynamic from "next/dynamic";
|
import dynamic from "next/dynamic";
|
||||||
import type React from "react";
|
import type React from "react";
|
||||||
|
|
||||||
import "react-quill/dist/quill.snow.css";
|
import "react-quill/dist/quill.snow.css";
|
||||||
|
|
||||||
|
// Import Quill modules after React Quill is loaded
|
||||||
|
if (typeof window !== 'undefined') {
|
||||||
|
const Quill = require('quill');
|
||||||
|
const QuillImageResize = require('quill-image-resize-module').default;
|
||||||
|
Quill.register("modules/imageResize", QuillImageResize);
|
||||||
|
}
|
||||||
|
|
||||||
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
|
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
|
||||||
|
|
||||||
type RichTextEditorProps = {
|
type RichTextEditorProps = {
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import {JSX, 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 } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
import RichTextEditor from "@/components/ui/RichTextEditor";
|
import RichTextEditor from "@/components/ui/RichTextEditor";
|
||||||
|
|
||||||
|
|||||||
+71
-29
@@ -10,33 +10,37 @@ import {
|
|||||||
} from "react";
|
} from "react";
|
||||||
|
|
||||||
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 AuthUser = {
|
type AuthUser = {
|
||||||
uuid: string;
|
uuid: string;
|
||||||
email: string;
|
email: string;
|
||||||
|
username?: string | null;
|
||||||
|
passwordSetAt?: string | null;
|
||||||
displayName?: string | null;
|
displayName?: string | null;
|
||||||
role: string;
|
role: string;
|
||||||
} | null;
|
} | null;
|
||||||
|
|
||||||
|
export type RegisterParams = {
|
||||||
|
email: string;
|
||||||
|
password: string;
|
||||||
|
displayName?: string;
|
||||||
|
acceptedTos: boolean;
|
||||||
|
tosVersion: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
// 1) update the type
|
||||||
type AuthContextValue = {
|
type AuthContextValue = {
|
||||||
user: AuthUser;
|
user: AuthUser;
|
||||||
token: string | null;
|
token: string | null;
|
||||||
loading: boolean;
|
loading: boolean;
|
||||||
login: (params: { email: string; password: string }) => Promise<void>;
|
login: (params: { email: string; password: string }) => Promise<void>;
|
||||||
register: (params: {
|
register: (params: RegisterParams) => Promise<void>;
|
||||||
email: string;
|
|
||||||
password: string;
|
|
||||||
displayName?: string;
|
|
||||||
}) => Promise<void>;
|
|
||||||
logout: () => void;
|
logout: () => void;
|
||||||
getAuthHeaders: () => HeadersInit;
|
getAuthHeaders: () => HeadersInit;
|
||||||
|
setSession: (token: string, user: NonNullable<AuthUser>) => Promise<void>;
|
||||||
|
refreshMeAndSession: () => Promise<void>;
|
||||||
|
|
||||||
/**
|
|
||||||
* Used for non-password auth flows (ex: magic link).
|
|
||||||
* Persists session exactly like login/register.
|
|
||||||
*/
|
|
||||||
setSession: (token: string, user: NonNullable<AuthUser>) => void;
|
|
||||||
};
|
};
|
||||||
|
|
||||||
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
const AuthContext = createContext<AuthContextValue | undefined>(undefined);
|
||||||
@@ -110,24 +114,55 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
* Used by login/register/magic-link
|
* Used by login/register/magic-link
|
||||||
*/
|
*/
|
||||||
const setSession = useCallback(
|
const setSession = useCallback(
|
||||||
(nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
async (nextToken: string, nextUser: NonNullable<AuthUser>) => {
|
||||||
setToken(nextToken);
|
setToken(nextToken);
|
||||||
setUser(nextUser);
|
setUser(nextUser);
|
||||||
persistAuth(nextToken, nextUser);
|
persistAuth(nextToken, nextUser);
|
||||||
|
|
||||||
// ✅ Make middleware / server aware of the session
|
// ✅ await so middleware sees cookie before navigation
|
||||||
fetch("/api/auth/session", {
|
try {
|
||||||
method: "POST",
|
await fetch("/api/auth/session", {
|
||||||
headers: { "Content-Type": "application/json" },
|
method: "POST",
|
||||||
body: JSON.stringify({
|
headers: { "Content-Type": "application/json" },
|
||||||
token: nextToken,
|
body: JSON.stringify({
|
||||||
role: nextUser.role,
|
token: nextToken,
|
||||||
}),
|
role: nextUser.role,
|
||||||
}).catch(() => {});
|
}),
|
||||||
|
});
|
||||||
|
} catch {
|
||||||
|
// ignore
|
||||||
|
}
|
||||||
},
|
},
|
||||||
[persistAuth]
|
[persistAuth]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
const refreshMeAndSession = useCallback(async () => {
|
||||||
|
if (!token) return;
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||||
|
headers: { Authorization: `Bearer ${token}` },
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => res.statusText);
|
||||||
|
throw new Error(text || "Failed to refresh session");
|
||||||
|
}
|
||||||
|
|
||||||
|
const me = await res.json();
|
||||||
|
|
||||||
|
const nextUser = {
|
||||||
|
uuid: me.uuid,
|
||||||
|
email: me.email,
|
||||||
|
username: me.username || null,
|
||||||
|
displayName: me.displayName || null,
|
||||||
|
role: me.role || "USER",
|
||||||
|
passwordSetAt: me.passwordSetAt || null,
|
||||||
|
} as NonNullable<AuthUser>;
|
||||||
|
|
||||||
|
await setSession(token, nextUser);
|
||||||
|
}, [token, setSession]);
|
||||||
|
|
||||||
const login = useCallback(
|
const login = useCallback(
|
||||||
async ({ email, password }: { email: string; password: string }) => {
|
async ({ email, password }: { email: string; password: string }) => {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
@@ -152,10 +187,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
uuid: data.uuid,
|
uuid: data.uuid,
|
||||||
email: data.email ?? email,
|
email: data.email ?? email,
|
||||||
displayName: data.displayName ?? null,
|
displayName: data.displayName ?? null,
|
||||||
|
passwordSetAt: data.passwordSetAt ?? null,
|
||||||
role: data.role ?? "USER",
|
role: data.role ?? "USER",
|
||||||
} as AuthUser);
|
} as AuthUser);
|
||||||
|
|
||||||
setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -168,17 +204,21 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
email,
|
email,
|
||||||
password,
|
password,
|
||||||
displayName,
|
displayName,
|
||||||
}: {
|
acceptedTos,
|
||||||
email: string;
|
tosVersion,
|
||||||
password: string;
|
}: RegisterParams) => {
|
||||||
displayName?: string;
|
|
||||||
}) => {
|
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
try {
|
try {
|
||||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({ email, password, displayName }),
|
body: JSON.stringify({
|
||||||
|
email,
|
||||||
|
password,
|
||||||
|
displayName,
|
||||||
|
acceptedTos,
|
||||||
|
tosVersion,
|
||||||
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
@@ -195,10 +235,11 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
uuid: data.uuid,
|
uuid: data.uuid,
|
||||||
email: data.email ?? email,
|
email: data.email ?? email,
|
||||||
displayName: data.displayName ?? displayName ?? null,
|
displayName: data.displayName ?? displayName ?? null,
|
||||||
|
passwordSetAt: data.passwordSetAt ?? null,
|
||||||
role: data.role ?? "USER",
|
role: data.role ?? "USER",
|
||||||
} as AuthUser);
|
} as AuthUser);
|
||||||
|
|
||||||
setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
await setSession(nextToken, nextUser as NonNullable<AuthUser>);
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
@@ -228,6 +269,7 @@ export function AuthProvider({ children }: { children: ReactNode }) {
|
|||||||
logout,
|
logout,
|
||||||
getAuthHeaders,
|
getAuthHeaders,
|
||||||
setSession,
|
setSession,
|
||||||
|
refreshMeAndSession
|
||||||
};
|
};
|
||||||
|
|
||||||
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
return <AuthContext.Provider value={value}>{children}</AuthContext.Provider>;
|
||||||
|
|||||||
+29
-54
@@ -1,62 +1,37 @@
|
|||||||
import type { Category } from "@/types/gunbuilder";
|
// data/gunbuilderParts.ts
|
||||||
import { CATEGORY_SLUGS } from "@/types/gunbuilder";
|
import type { Category } from "@/types/builderSlots";
|
||||||
|
|
||||||
export const CATEGORIES: Category[] = [
|
export const CATEGORIES: Category[] = [
|
||||||
// Lower group
|
// Lower group
|
||||||
{
|
{ id: "lower-receiver", name: "Stripped Lower", group: "lower" },
|
||||||
id: "lower-receiver",
|
{ id: "complete-lower", name: "Complete Lower", group: "lower" },
|
||||||
name: "Stripped Lower",
|
{ id: "lower-parts-kit", name: "Lower Parts Kit", group: "lower" },
|
||||||
group: "lower",
|
{ id: "trigger", name: "Trigger / Fire Control", group: "lower" },
|
||||||
slug: CATEGORY_SLUGS["lower-receiver"],
|
{ id: "grip", name: "Grips", group: "lower" },
|
||||||
},
|
{ id: "safety-selector", name: "Safety / Selector", group: "lower" },
|
||||||
{
|
{ id: "buffer", name: "Buffer System", group: "lower" },
|
||||||
id: "complete-lower",
|
{ id: "stock", name: "Stock / Brace", group: "lower" },
|
||||||
name: "Complete Lower",
|
|
||||||
group: "lower",
|
|
||||||
slug: CATEGORY_SLUGS["complete-lower"],
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "lower-parts",
|
|
||||||
name: "Lower Parts Kit",
|
|
||||||
group: "lower",
|
|
||||||
slug: CATEGORY_SLUGS["lower-parts"],
|
|
||||||
},
|
|
||||||
{ id: "trigger", name: "Trigger / Fire Control", group: "lower", slug: CATEGORY_SLUGS.trigger },
|
|
||||||
{ id: "grip", name: "Pistol Grip", group: "lower", slug: CATEGORY_SLUGS.grip },
|
|
||||||
{ id: "safety", name: "Safety / Selector", group: "lower", slug: CATEGORY_SLUGS.safety },
|
|
||||||
{ id: "buffer", name: "Buffer System", group: "lower", slug: CATEGORY_SLUGS.buffer },
|
|
||||||
{ id: "stock", name: "Stock / Brace", group: "lower", slug: CATEGORY_SLUGS.stock },
|
|
||||||
|
|
||||||
// Upper group
|
// Upper group
|
||||||
{
|
{ id: "upper-receiver", name: "Stripped Upper", group: "upper" },
|
||||||
id: "upper-receiver",
|
{ id: "complete-upper", name: "Complete Upper", group: "upper" },
|
||||||
name: "Stripped Upper",
|
{ id: "barrel", name: "Barrels", group: "upper" },
|
||||||
group: "upper",
|
{ id: "handguard", name: "Handguards / Rails", group: "upper" },
|
||||||
slug: CATEGORY_SLUGS["upper-receiver"],
|
{ id: "gas-block", name: "Gas Block", group: "upper" },
|
||||||
},
|
{ id: "gas-tube", name: "Gas Tube", group: "upper" },
|
||||||
{
|
{ id: "muzzle-device", name: "Muzzle Device", group: "upper" },
|
||||||
id: "complete-upper",
|
{ id: "sights", name: "Iron Sights", group: "upper" },
|
||||||
name: "Complete Upper",
|
{ id: "bcg", name: "Bolt Carrier Group", group: "upper" },
|
||||||
group: "upper",
|
{ id: "charging-handle", name: "Charging Handle", group: "upper" },
|
||||||
slug: CATEGORY_SLUGS["complete-upper"],
|
{ id: "suppressor", name: "Suppressors", group: "upper" },
|
||||||
},
|
{ id: "optic", name: "Optics", group: "upper" },
|
||||||
{ id: "barrel", name: "Barrels", group: "upper", slug: CATEGORY_SLUGS.barrel },
|
|
||||||
{ id: "handguard", name: "Handguards / Rails", group: "upper", slug: CATEGORY_SLUGS.handguard },
|
|
||||||
{ id: "gas-block", name: "Gas Block", group: "upper", slug: CATEGORY_SLUGS["gas-block"] },
|
|
||||||
{ id: "gas-tube", name: "Gas Tube", group: "upper", slug: CATEGORY_SLUGS["gas-tube"] },
|
|
||||||
{ id: "muzzle-device", name: "Muzzle Device", group: "upper", slug: CATEGORY_SLUGS["muzzle-device"] },
|
|
||||||
{ id: "sights", name: "Iron Sights", group: "upper", slug: CATEGORY_SLUGS.sights },
|
|
||||||
{ id: "bcg", name: "Bolt Carrier Group", group: "upper", slug: CATEGORY_SLUGS.bcg },
|
|
||||||
{ id: "charging-handle", name: "Charging Handle", group: "upper", slug: CATEGORY_SLUGS["charging-handle"] },
|
|
||||||
{ id: "suppressor", name: "Suppressors", group: "upper", slug: CATEGORY_SLUGS.suppressor },
|
|
||||||
{ id: "optic", name: "Optics", group: "upper", slug: CATEGORY_SLUGS.optic },
|
|
||||||
|
|
||||||
// Accessories (platform-agnostic)
|
// Accessories (platform-agnostic)
|
||||||
{ id: "magazine", name: "Magazines", group: "accessories", slug: CATEGORY_SLUGS.magazine },
|
{ id: "magazine", name: "Magazines", group: "accessories" },
|
||||||
{ id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories", slug: CATEGORY_SLUGS["weapon-light"] },
|
{ id: "weapon-light", name: "Weapon Lights & Lasers", group: "accessories" },
|
||||||
{ id: "foregrip", name: "Vertical / Angled Grips", group: "accessories", slug: CATEGORY_SLUGS.foregrip },
|
{ id: "foregrip", name: "Vertical / Angled Grips", group: "accessories" },
|
||||||
{ id: "bipod", name: "Bipods", group: "accessories", slug: CATEGORY_SLUGS.bipod },
|
{ id: "bipod", name: "Bipods", group: "accessories" },
|
||||||
{ id: "sling", name: "Slings & Mounts", group: "accessories", slug: CATEGORY_SLUGS.sling },
|
{ id: "sling", name: "Slings & Mounts", group: "accessories" },
|
||||||
{ id: "rail-accessory", name: "Rail Accessories", group: "accessories", slug: CATEGORY_SLUGS["rail-accessory"] },
|
{ id: "rail-accessory", name: "Rail Accessories", group: "accessories" },
|
||||||
{ id: "tools", name: "Tools & Maintenance", group: "accessories", slug: CATEGORY_SLUGS.tools },
|
{ id: "tools", name: "Tools & Maintenance", group: "accessories" },
|
||||||
];
|
];
|
||||||
@@ -0,0 +1,11 @@
|
|||||||
|
import { defineConfig } from "eslint/config";
|
||||||
|
import nextCoreWebVitals from "eslint-config-next/core-web-vitals";
|
||||||
|
import path from "node:path";
|
||||||
|
import { fileURLToPath } from "node:url";
|
||||||
|
|
||||||
|
const __filename = fileURLToPath(import.meta.url);
|
||||||
|
const __dirname = path.dirname(__filename);
|
||||||
|
|
||||||
|
export default defineConfig([{
|
||||||
|
extends: [...nextCoreWebVitals],
|
||||||
|
}]);
|
||||||
@@ -0,0 +1,33 @@
|
|||||||
|
# ---------- deps ----------
|
||||||
|
FROM node:20-alpine AS deps
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
|
||||||
|
# ---------- build ----------
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY . .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
# Build-time public env (baked into client bundle). You can override by rebuilding.
|
||||||
|
ARG NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=$NEXT_PUBLIC_API_BASE_URL
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
# ---------- runner ----------
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
|
||||||
|
# If you want slightly better security:
|
||||||
|
RUN addgroup -S nextjs && adduser -S nextjs -G nextjs
|
||||||
|
|
||||||
|
# Standalone output includes server + minimal node_modules
|
||||||
|
COPY --from=builder /app/.next/standalone ./
|
||||||
|
COPY --from=builder /app/.next/static ./.next/static
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
|
||||||
|
USER nextjs
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["node", "server.js"]
|
||||||
@@ -0,0 +1,24 @@
|
|||||||
|
FROM node:20-alpine AS deps
|
||||||
|
RUN apk add curl
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci --only=production
|
||||||
|
|
||||||
|
FROM node:20-alpine AS builder
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json ./
|
||||||
|
RUN npm ci
|
||||||
|
COPY . .
|
||||||
|
ENV NODE_ENV=production
|
||||||
|
ENV NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
||||||
|
RUN npm run build
|
||||||
|
|
||||||
|
FROM node:20-alpine AS runner
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY --from=deps /app/node_modules ./node_modules
|
||||||
|
COPY --from=builder /app/.next ./.next
|
||||||
|
COPY --from=builder /app/public ./public
|
||||||
|
COPY --from=builder /app/package.json ./package.json
|
||||||
|
EXPOSE 3000
|
||||||
|
CMD ["npm", "start"]
|
||||||
+1
-1
@@ -4,7 +4,7 @@ import { useMemo } from "react";
|
|||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
|
||||||
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 JsonValue = any;
|
type JsonValue = any;
|
||||||
|
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
// lib/api/adminCategoryMappings.ts
|
// lib/api/adminCategoryMappings.ts
|
||||||
|
|
||||||
const API_BASE =
|
const API_BASE =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
export interface AdminCategory {
|
export interface AdminCategory {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
const API_BASE =
|
const API_BASE =
|
||||||
process.env.NEXT_PUBLIC_API_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_URL ?? "";
|
||||||
|
|
||||||
export interface AdminMerchantCategoryMapping {
|
export interface AdminMerchantCategoryMapping {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -17,7 +17,7 @@ export interface UpdateEmailRequestDto {
|
|||||||
status?: string;
|
status?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || 'http://localhost:8080';
|
const API_BASE = process.env.NEXT_PUBLIC_API_BASE_URL || '';
|
||||||
|
|
||||||
export async function fetchEmailRequests(token: string): Promise<EmailRequest[]> {
|
export async function fetchEmailRequests(token: string): Promise<EmailRequest[]> {
|
||||||
const response = await fetch(`${API_BASE}/api/email`, {
|
const response = await fetch(`${API_BASE}/api/email`, {
|
||||||
|
|||||||
@@ -15,7 +15,7 @@ export type CreatePlatformDto = {
|
|||||||
export type UpdatePlatformDto = Partial<CreatePlatformDto>;
|
export type UpdatePlatformDto = Partial<CreatePlatformDto>;
|
||||||
|
|
||||||
const API_BASE =
|
const API_BASE =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
function normalizePlatform(raw: any): Platform {
|
function normalizePlatform(raw: any): Platform {
|
||||||
const id = Number(raw?.id);
|
const id = Number(raw?.id);
|
||||||
@@ -74,12 +74,12 @@ export async function updatePlatform(
|
|||||||
dto: UpdatePlatformDto
|
dto: UpdatePlatformDto
|
||||||
): Promise<Platform> {
|
): Promise<Platform> {
|
||||||
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
|
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
|
||||||
method: "PUT",
|
method: "PATCH",
|
||||||
headers: {
|
headers: {
|
||||||
"Content-Type": "application/json",
|
"Content-Type": "application/json",
|
||||||
...tokenHeaders,
|
...tokenHeaders,
|
||||||
},
|
},
|
||||||
body: JSON.stringify(dto),
|
body: JSON.stringify({ isActive: dto.isActive }),
|
||||||
});
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
|
|||||||
+1
-1
@@ -1,7 +1,7 @@
|
|||||||
// lib/auth-client.ts
|
// lib/auth-client.ts
|
||||||
|
|
||||||
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 type AuthUser = {
|
export type AuthUser = {
|
||||||
id: number;
|
id: number;
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
// /lib/buildOverlaps.ts
|
// /lib/buildOverlaps.ts
|
||||||
import type { CategoryId } from "@/types/gunbuilder";
|
import type { BuilderSlotKey } from "@/types/builderSlots";
|
||||||
|
|
||||||
|
type CategoryId = BuilderSlotKey;
|
||||||
|
|
||||||
export type OverlapChipModel = {
|
export type OverlapChipModel = {
|
||||||
key: string;
|
key: string;
|
||||||
@@ -25,10 +27,10 @@ export const UPPER_OVERLAP_CATEGORIES = new Set<CategoryId>([
|
|||||||
|
|
||||||
export const LOWER_OVERLAP_CATEGORIES = new Set<CategoryId>([
|
export const LOWER_OVERLAP_CATEGORIES = new Set<CategoryId>([
|
||||||
"lower-receiver",
|
"lower-receiver",
|
||||||
"lower-parts",
|
"lower-parts-kit",
|
||||||
"trigger",
|
"trigger",
|
||||||
"grip",
|
"grip",
|
||||||
"safety",
|
"safety-selector",
|
||||||
"buffer",
|
"buffer",
|
||||||
"stock",
|
"stock",
|
||||||
]);
|
]);
|
||||||
|
|||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user