Compare commits
48 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| 140b83bd65 | |||
| 775ee0f691 | |||
| 4989bf6de4 | |||
| 4267c34399 | |||
| 6d1697e672 | |||
| 45270f9b18 | |||
| a13687635b | |||
| c2600b6a68 | |||
| b45493f5bd | |||
| cb46430ce4 | |||
| 77c31ae4f9 | |||
| fb2effca47 | |||
| b09ccc542e | |||
| 3aee0e6755 | |||
| e49dc96522 | |||
| 50ef395a38 | |||
| 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
|
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)
|
||||||
Generated
+6
@@ -0,0 +1,6 @@
|
|||||||
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
|
<project version="4">
|
||||||
|
<component name="db-tree-configuration">
|
||||||
|
<option name="data" value="" />
|
||||||
|
</component>
|
||||||
|
</project>
|
||||||
@@ -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";
|
||||||
|
|
||||||
/**
|
/**
|
||||||
@@ -18,6 +18,7 @@ type OfferFromApi = {
|
|||||||
price?: number | null;
|
price?: number | null;
|
||||||
originalPrice?: number | null;
|
originalPrice?: number | null;
|
||||||
buyUrl?: string | null;
|
buyUrl?: string | null;
|
||||||
|
buyShortUrl?: string | null;
|
||||||
inStock?: boolean | null;
|
inStock?: boolean | null;
|
||||||
lastUpdated?: string | null;
|
lastUpdated?: string | null;
|
||||||
};
|
};
|
||||||
@@ -29,6 +30,7 @@ type GunbuilderProductFromApi = {
|
|||||||
platform: string;
|
platform: string;
|
||||||
partRole: string;
|
partRole: string;
|
||||||
price: number | null;
|
price: number | null;
|
||||||
|
caliber?: string | null;
|
||||||
|
|
||||||
// Optional (legacy fallback label if product.offers is missing)
|
// Optional (legacy fallback label if product.offers is missing)
|
||||||
merchantName?: string | null;
|
merchantName?: string | null;
|
||||||
@@ -37,6 +39,7 @@ type GunbuilderProductFromApi = {
|
|||||||
mainImageUrl?: string | null;
|
mainImageUrl?: string | null;
|
||||||
|
|
||||||
buyUrl?: string | null;
|
buyUrl?: string | null;
|
||||||
|
buyShortUrl?: string | null;
|
||||||
inStock?: boolean | null;
|
inStock?: boolean | null;
|
||||||
|
|
||||||
shortDescription?: string | null;
|
shortDescription?: string | null;
|
||||||
@@ -46,10 +49,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";
|
||||||
|
|
||||||
@@ -238,14 +241,23 @@ export default function ProductDetailsPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
|
const url = `/api/catalog/products/${numericId}`;
|
||||||
const res = await fetch(url, { signal: controller.signal });
|
const res = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load product (${res.status})`);
|
throw new Error(`Failed to load product (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: GunbuilderProductFromApi = await res.json();
|
const data: GunbuilderProductFromApi = await res.json();
|
||||||
|
console.log('[DEBUG] Product from API:', {
|
||||||
|
id: data.id,
|
||||||
|
name: data.name,
|
||||||
|
buyUrl: data.buyUrl,
|
||||||
|
buyShortUrl: data.buyShortUrl
|
||||||
|
});
|
||||||
setProduct(data);
|
setProduct(data);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") return;
|
if (err?.name === "AbortError") return;
|
||||||
@@ -272,14 +284,18 @@ export default function ProductDetailsPage() {
|
|||||||
try {
|
try {
|
||||||
setOffersLoading(true);
|
setOffersLoading(true);
|
||||||
|
|
||||||
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`;
|
const url = `/api/catalog/products/${numericId}/offers`;
|
||||||
const res = await fetch(url, { signal: controller.signal });
|
const res = await fetch(url, {
|
||||||
|
signal: controller.signal,
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load offers (${res.status})`);
|
throw new Error(`Failed to load offers (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|
||||||
const data: OfferFromApi[] = await res.json();
|
const data: OfferFromApi[] = await res.json();
|
||||||
|
console.log('[DEBUG] Offers from API:', data);
|
||||||
setOffersFromApi(Array.isArray(data) ? data : []);
|
setOffersFromApi(Array.isArray(data) ? data : []);
|
||||||
} catch (err: any) {
|
} catch (err: any) {
|
||||||
if (err?.name === "AbortError") return;
|
if (err?.name === "AbortError") return;
|
||||||
@@ -330,14 +346,23 @@ export default function ProductDetailsPage() {
|
|||||||
* - Fallback to legacy single-offer derived from product if needed
|
* - Fallback to legacy single-offer derived from product if needed
|
||||||
*/
|
*/
|
||||||
const offers = useMemo(() => {
|
const offers = useMemo(() => {
|
||||||
if (offersFromApi.length > 0) return sortOffers(offersFromApi);
|
if (offersFromApi.length > 0) {
|
||||||
|
console.log('[DEBUG] Using offers from API, first offer:', offersFromApi[0]);
|
||||||
|
const fallbackShortUrl = product?.buyShortUrl ?? null;
|
||||||
|
const normalized = offersFromApi.map((offer) => ({
|
||||||
|
...offer,
|
||||||
|
buyShortUrl: offer.buyShortUrl || fallbackShortUrl,
|
||||||
|
}));
|
||||||
|
return sortOffers(normalized);
|
||||||
|
}
|
||||||
|
|
||||||
if (product?.buyUrl) {
|
if (product?.buyUrl || product?.buyShortUrl) {
|
||||||
|
console.log('[DEBUG] Using fallback from product, buyShortUrl:', product.buyShortUrl, 'buyUrl:', product.buyUrl);
|
||||||
return sortOffers([
|
return sortOffers([
|
||||||
{
|
{
|
||||||
merchantName: product.merchantName ?? "Retailer",
|
merchantName: product.merchantName ?? "Retailer",
|
||||||
price: product.price,
|
price: product.price,
|
||||||
buyUrl: product.buyUrl,
|
buyUrl: product.buyShortUrl || product.buyUrl,
|
||||||
inStock: product.inStock ?? true,
|
inStock: product.inStock ?? true,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
@@ -559,6 +584,14 @@ export default function ProductDetailsPage() {
|
|||||||
{product.platform || platform}
|
{product.platform || platform}
|
||||||
</span>
|
</span>
|
||||||
</span>
|
</span>
|
||||||
|
{product.caliber && (
|
||||||
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
|
Caliber:{" "}
|
||||||
|
<span className="font-semibold text-zinc-100">
|
||||||
|
{product.caliber}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
)}
|
||||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||||
Role:{" "}
|
Role:{" "}
|
||||||
<span className="font-semibold text-zinc-100">
|
<span className="font-semibold text-zinc-100">
|
||||||
@@ -660,9 +693,9 @@ export default function ProductDetailsPage() {
|
|||||||
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
||||||
</td>
|
</td>
|
||||||
<td className="px-3 py-2 text-right">
|
<td className="px-3 py-2 text-right">
|
||||||
{o.buyUrl ? (
|
{(o.buyShortUrl || o.buyUrl) ? (
|
||||||
<a
|
<a
|
||||||
href={o.buyUrl}
|
href={o.buyShortUrl || o.buyUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
||||||
@@ -686,10 +719,10 @@ export default function ProductDetailsPage() {
|
|||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Best Offer CTA */}
|
{/* Best Offer CTA */}
|
||||||
{bestOffer?.buyUrl ? (
|
{(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
|
||||||
<div className="mt-3 flex justify-end">
|
<div className="mt-3 flex justify-end">
|
||||||
<a
|
<a
|
||||||
href={bestOffer.buyUrl}
|
href={bestOffer.buyShortUrl || bestOffer.buyUrl}
|
||||||
target="_blank"
|
target="_blank"
|
||||||
rel="noreferrer"
|
rel="noreferrer"
|
||||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||||
|
|||||||
@@ -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 />
|
||||||
|
<Suspense fallback={<div className="h-[52px]" />}>
|
||||||
<BuilderNav />
|
<BuilderNav />
|
||||||
|
</Suspense>
|
||||||
<main className="min-h-screen">{children}</main>
|
<main className="min-h-screen">{children}</main>
|
||||||
|
<Footer />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
@@ -0,0 +1,445 @@
|
|||||||
|
import React from "react";
|
||||||
|
import Link from "next/link";
|
||||||
|
|
||||||
|
export const metadata = {
|
||||||
|
title: "Privacy Policy | Battl Builder",
|
||||||
|
description:
|
||||||
|
"Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.",
|
||||||
|
};
|
||||||
|
|
||||||
|
export default function PrivacyPolicy() {
|
||||||
|
const lastUpdated = "January 9, 2026";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="mx-auto max-w-4xl px-4 py-12">
|
||||||
|
<div className="mb-10">
|
||||||
|
<h1 className="text-4xl font-bold tracking-tight">Privacy Policy</h1>
|
||||||
|
<p className="mt-2 text-sm opacity-60">Last Updated: {lastUpdated}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="space-y-12 text-sm leading-relaxed text-white/90">
|
||||||
|
{/* 1. Introduction */}
|
||||||
|
<section>
|
||||||
|
<h2 className="mb-4 text-xl font-semibold">1. Introduction</h2>
|
||||||
|
<p>
|
||||||
|
Battl Builder ("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>
|
||||||
|
<p>
|
||||||
|
<strong>Mailing Address:</strong>
|
||||||
|
<br />
|
||||||
|
Battl Builder, LLC.
|
||||||
|
<br />
|
||||||
|
{/* [Your Address]
|
||||||
|
<br />
|
||||||
|
[City, State, ZIP] */}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
<p className="mt-4 text-sm">
|
||||||
|
We will respond to all privacy inquiries within 30 days of receipt.
|
||||||
|
</p>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div className="space-y-3 pt-10">
|
||||||
|
<p className="text-xs opacity-50">
|
||||||
|
This Privacy Policy is aligned with GDPR, CCPA, and other major
|
||||||
|
privacy regulations.
|
||||||
|
</p>
|
||||||
|
<Link
|
||||||
|
href="/"
|
||||||
|
className="block text-sm text-amber-400 hover:text-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
← Return to Home
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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"
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ type BuildDto = {
|
|||||||
updatedAt?: string | null;
|
updatedAt?: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
const API_BASE_URL =
|
// API routes now handled by Next.js /api routes (server-side)
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
// UUID validator (defensive)
|
// UUID validator (defensive)
|
||||||
const isUuid = (v: string) =>
|
const isUuid = (v: string) =>
|
||||||
@@ -40,14 +39,14 @@ export default function VaultPage() {
|
|||||||
|
|
||||||
// NOTE:
|
// NOTE:
|
||||||
// - `loading` here is AuthContext hydration, not network.
|
// - `loading` here is AuthContext hydration, not network.
|
||||||
// - `token` is the real gate for /me endpoints.
|
// - Auth is handled by HTTP-only cookies automatically.
|
||||||
const { user, token, loading: authLoading } = useAuth();
|
const { user, loading: authLoading } = useAuth();
|
||||||
|
|
||||||
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
const [busy, setBusy] = useState(false);
|
const [busy, setBusy] = useState(false);
|
||||||
|
|
||||||
const authed = !!user && !!token;
|
const authed = !!user;
|
||||||
|
|
||||||
// Redirect if not logged in (after auth hydrates)
|
// Redirect if not logged in (after auth hydrates)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -56,12 +55,12 @@ export default function VaultPage() {
|
|||||||
}
|
}
|
||||||
}, [authLoading, authed, router]);
|
}, [authLoading, authed, router]);
|
||||||
|
|
||||||
// When auth identity changes, clear stale list to avoid “ghost builds”
|
// When auth identity changes, clear stale list to avoid "ghost builds"
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (authLoading) return;
|
if (authLoading) return;
|
||||||
setBuilds([]);
|
setBuilds([]);
|
||||||
setError(null);
|
setError(null);
|
||||||
}, [authLoading, user?.uuid, token]);
|
}, [authLoading, user?.uuid]);
|
||||||
|
|
||||||
// Fetch user builds
|
// Fetch user builds
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -75,8 +74,8 @@ export default function VaultPage() {
|
|||||||
|
|
||||||
try {
|
try {
|
||||||
// IMPORTANT:
|
// IMPORTANT:
|
||||||
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
|
// Use api wrapper which includes credentials (cookies) automatically.
|
||||||
const res = await api.get("/api/v1/builds/me");
|
const res = await api.get("/api/builds");
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
@@ -241,7 +240,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>
|
||||||
|
|||||||
+373
-25
@@ -1,20 +1,111 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Button, Field, Input } from "@/components/ui/form";
|
import { Button, Field, Input } from "@/components/ui/form";
|
||||||
|
|
||||||
|
type AdminBetaRequestDto = {
|
||||||
|
id: number;
|
||||||
|
uuid: string;
|
||||||
|
email: string;
|
||||||
|
displayName?: string | null;
|
||||||
|
invited: boolean;
|
||||||
|
verified: boolean;
|
||||||
|
active: boolean;
|
||||||
|
createdAt?: string;
|
||||||
|
updatedAt?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
type PageResponse<T> = {
|
||||||
|
content: T[];
|
||||||
|
number: number;
|
||||||
|
size: number;
|
||||||
|
totalElements: number;
|
||||||
|
totalPages: number;
|
||||||
|
};
|
||||||
|
|
||||||
|
// Expected invite response shape (adjust fields if your DTO differs)
|
||||||
|
type AdminInviteResponse = {
|
||||||
|
ok?: boolean;
|
||||||
|
email?: string;
|
||||||
|
|
||||||
|
// backend returns this
|
||||||
|
inviteUrl?: string;
|
||||||
|
|
||||||
|
// keep these optional if you ever add them later
|
||||||
|
magicUrl?: string;
|
||||||
|
token?: string;
|
||||||
|
};
|
||||||
|
|
||||||
|
async function fetchJson(path: string, init: RequestInit = {}) {
|
||||||
|
const res = await fetch(path, {
|
||||||
|
...init,
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
...(init.headers ?? {}),
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
const data = text
|
||||||
|
? (() => {
|
||||||
|
try {
|
||||||
|
return JSON.parse(text);
|
||||||
|
} catch {
|
||||||
|
return text;
|
||||||
|
}
|
||||||
|
})()
|
||||||
|
: null;
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const msg =
|
||||||
|
typeof data === "string"
|
||||||
|
? data
|
||||||
|
: data?.message || data?.error || `Request failed (${res.status})`;
|
||||||
|
throw new Error(msg);
|
||||||
|
}
|
||||||
|
|
||||||
|
return data;
|
||||||
|
}
|
||||||
|
|
||||||
|
function Badge({
|
||||||
|
ok,
|
||||||
|
yes = "Yes",
|
||||||
|
no = "No",
|
||||||
|
}: {
|
||||||
|
ok: boolean;
|
||||||
|
yes?: string;
|
||||||
|
no?: string;
|
||||||
|
}) {
|
||||||
|
return (
|
||||||
|
<span
|
||||||
|
className={[
|
||||||
|
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||||
|
ok
|
||||||
|
? "border border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||||
|
: "border border-zinc-700 bg-zinc-950/40 text-zinc-400",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
{ok ? yes : no}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export default function AdminBetaInvitesPage() {
|
export default function AdminBetaInvitesPage() {
|
||||||
|
// ------------------------------
|
||||||
|
// 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,31 +114,144 @@ 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(`/api/admin/beta-invites?${qs.toString()}`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
||||||
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/admin/beta/requests?page=${page}&size=${size}`,
|
||||||
|
{ method: "GET" }
|
||||||
|
)) as PageResponse<AdminBetaRequestDto>;
|
||||||
|
|
||||||
|
setRequests(data);
|
||||||
|
} catch (e: any) {
|
||||||
|
setListError(e?.message ?? "Failed to load beta requests");
|
||||||
|
} finally {
|
||||||
|
setLoadingList(false);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
|
||||||
|
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
|
||||||
|
return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
|
||||||
|
method: "POST",
|
||||||
|
})) as AdminInviteResponse;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onSendInviteEmail(userId: number) {
|
||||||
|
setListError(null);
|
||||||
|
setLastInvite(null);
|
||||||
|
setRowBusy((m) => ({ ...m, [userId]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await inviteSingle(userId);
|
||||||
|
setLastInvite(resp);
|
||||||
|
await loadRequests();
|
||||||
|
} catch (e: any) {
|
||||||
|
setListError(e?.message ?? "Failed to send invite");
|
||||||
|
} finally {
|
||||||
|
setRowBusy((m) => ({ ...m, [userId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function onCopyInviteLink(userId: number) {
|
||||||
|
setListError(null);
|
||||||
|
setLastInvite(null);
|
||||||
|
setRowBusy((m) => ({ ...m, [userId]: true }));
|
||||||
|
|
||||||
|
try {
|
||||||
|
const resp = await inviteSingle(userId);
|
||||||
|
setLastInvite(resp);
|
||||||
|
await loadRequests();
|
||||||
|
|
||||||
|
// build URL
|
||||||
|
const url =
|
||||||
|
resp.inviteUrl || // what your backend returns
|
||||||
|
resp.magicUrl ||
|
||||||
|
(resp.token
|
||||||
|
? `${window.location.origin}/beta/magic?token=${resp.token}`
|
||||||
|
: null);
|
||||||
|
|
||||||
|
if (!url) {
|
||||||
|
throw new Error(
|
||||||
|
`Invite response missing inviteUrl. Backend should return {"inviteUrl": "..."}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
await navigator.clipboard.writeText(url);
|
||||||
|
} catch (e: any) {
|
||||||
|
setListError(e?.message ?? "Failed to copy invite link");
|
||||||
|
} finally {
|
||||||
|
setRowBusy((m) => ({ ...m, [userId]: false }));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadRequests();
|
||||||
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [page]);
|
||||||
|
|
||||||
return (
|
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="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||||
|
{/* Left: Batch Invites */}
|
||||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||||
|
<div className="flex items-center justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||||
|
Batch Invites
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
Runs the backend batch invite sender.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">
|
||||||
|
{dryRun ? "Dry run" : "Live send"}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||||
<input
|
<input
|
||||||
type="checkbox"
|
type="checkbox"
|
||||||
@@ -57,9 +261,13 @@ export default function AdminBetaInvitesPage() {
|
|||||||
Dry run (do everything except actually send)
|
Dry run (do everything except actually send)
|
||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div className="grid gap-4 md:grid-cols-3">
|
<div className="grid gap-4 md:grid-cols-2">
|
||||||
<Field label="Limit" htmlFor="limit">
|
<Field label="Limit" htmlFor="limit">
|
||||||
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
<Input
|
||||||
|
id="limit"
|
||||||
|
value={limit}
|
||||||
|
onChange={(e) => setLimit(e.target.value)}
|
||||||
|
/>
|
||||||
</Field>
|
</Field>
|
||||||
|
|
||||||
<Field label="Token minutes" htmlFor="tokenMinutes">
|
<Field label="Token minutes" htmlFor="tokenMinutes">
|
||||||
@@ -71,22 +279,162 @@ export default function AdminBetaInvitesPage() {
|
|||||||
</Field>
|
</Field>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<Button onClick={runInvites} disabled={loading}>
|
<Button onClick={runInvites} disabled={loadingBatch}>
|
||||||
{loading ? "Running…" : "Send Beta Invites"}
|
{loadingBatch ? "Running…" : "Send Beta Invites"}
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{error && (
|
{batchError && (
|
||||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
{error}
|
{batchError}
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{result && (
|
{batchResult && (
|
||||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||||
{JSON.stringify(result, null, 2)}
|
{JSON.stringify(batchResult, null, 2)}
|
||||||
|
</pre>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Right: Requests List */}
|
||||||
|
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||||
|
<div className="flex items-start justify-between gap-3">
|
||||||
|
<div>
|
||||||
|
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||||
|
Beta Requests
|
||||||
|
</h2>
|
||||||
|
<p className="mt-1 text-sm text-zinc-400">
|
||||||
|
Users with role <span className="text-zinc-200">BETA</span> and{" "}
|
||||||
|
<span className="text-zinc-200">is_active=false</span>.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<Button
|
||||||
|
onClick={loadRequests}
|
||||||
|
disabled={loadingList}
|
||||||
|
className="w-auto px-6"
|
||||||
|
>
|
||||||
|
{loadingList ? "Refreshing…" : "Refresh"}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{listError && (
|
||||||
|
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||||
|
{listError}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<div className="overflow-x-auto rounded-md border border-zinc-800">
|
||||||
|
{" "}
|
||||||
|
<table className="min-w-[680px] w-full text-left text-sm">
|
||||||
|
<thead className="bg-black/30 text-xs text-zinc-500">
|
||||||
|
<tr className="border-b border-zinc-800">
|
||||||
|
<th className="px-3 py-2">Email</th>
|
||||||
|
<th className="px-3 py-2">Invited</th>
|
||||||
|
<th className="px-3 py-2">Verified</th>
|
||||||
|
<th className="px-3 py-2">Active</th>
|
||||||
|
<th className="px-3 py-2 text-right w-[190px] whitespace-nowrap">
|
||||||
|
Actions
|
||||||
|
</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody className="text-zinc-200">
|
||||||
|
{requests?.content?.length ? (
|
||||||
|
requests.content.map((u) => {
|
||||||
|
const busy = !!rowBusy[u.id];
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr key={u.id} className="border-b border-zinc-900">
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<div className="font-medium">{u.email}</div>
|
||||||
|
<div className="text-xs text-zinc-500">
|
||||||
|
{u.displayName ?? "—"} • #{u.id}
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge ok={u.invited} yes="Invited" no="—" />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge ok={u.verified} yes="Verified" no="—" />
|
||||||
|
</td>
|
||||||
|
<td className="px-3 py-2">
|
||||||
|
<Badge ok={u.active} yes="Active" no="Inactive" />
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-3 py-2 w-[190px]">
|
||||||
|
<div className="flex justify-end gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => onCopyInviteLink(u.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-900 disabled:opacity-60"
|
||||||
|
title="Dev helper: generate invite and copy URL (also triggers invite generation server-side)"
|
||||||
|
>
|
||||||
|
{busy ? "Working…" : "Copy link (dev)"}
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => onSendInviteEmail(u.id)}
|
||||||
|
disabled={busy}
|
||||||
|
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 hover:bg-amber-500/15 disabled:opacity-60"
|
||||||
|
title="Send invite email to this user"
|
||||||
|
>
|
||||||
|
{busy ? "Working…" : "Send invite email"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})
|
||||||
|
) : (
|
||||||
|
<tr>
|
||||||
|
<td className="px-3 py-6 text-sm text-zinc-400" colSpan={5}>
|
||||||
|
{loadingList ? "Loading…" : "No pending beta requests."}
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
)}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* pager */}
|
||||||
|
<div className="flex items-center justify-between text-xs text-zinc-500">
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
|
||||||
|
disabled={!requests || requests.number <= 0 || loadingList}
|
||||||
|
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||||
|
>
|
||||||
|
Prev
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span>
|
||||||
|
Page {requests ? requests.number + 1 : 1} of{" "}
|
||||||
|
{requests ? requests.totalPages : 1} •{" "}
|
||||||
|
{requests ? requests.totalElements : 0} total
|
||||||
|
</span>
|
||||||
|
|
||||||
|
<button
|
||||||
|
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
|
||||||
|
disabled={
|
||||||
|
!requests ||
|
||||||
|
loadingList ||
|
||||||
|
requests.number >= requests.totalPages - 1
|
||||||
|
}
|
||||||
|
onClick={() => setPage((p) => p + 1)}
|
||||||
|
>
|
||||||
|
Next
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* optional debug panel */}
|
||||||
|
{lastInvite && (
|
||||||
|
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||||
|
{JSON.stringify(lastInvite, null, 2)}
|
||||||
</pre>
|
</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,7 +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 {
|
import {
|
||||||
fetchEmailRequests,
|
fetchEmailRequests,
|
||||||
createEmailRequest,
|
createEmailRequest,
|
||||||
@@ -17,7 +16,6 @@ import { formatDate } from "@/lib/dateFormattingUtility";
|
|||||||
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
|
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
|
||||||
|
|
||||||
export default function AdminEmailPage() {
|
export default function AdminEmailPage() {
|
||||||
const { token } = useAuth();
|
|
||||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -32,13 +30,11 @@ 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;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchEmailRequests(token);
|
const data = await fetchEmailRequests();
|
||||||
setEmails(data.data);
|
setEmails(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -46,11 +42,11 @@ export default function AdminEmailPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEmails();
|
loadEmails();
|
||||||
}, [token]);
|
}, [loadEmails]);
|
||||||
|
|
||||||
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
|
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
|
||||||
|
|
||||||
@@ -108,11 +104,10 @@ export default function AdminEmailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!token) return;
|
|
||||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteEmailRequest(token, id);
|
await deleteEmailRequest(id);
|
||||||
await loadEmails();
|
await loadEmails();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e?.message ?? "Failed to delete email request");
|
alert(e?.message ?? "Failed to delete email request");
|
||||||
@@ -121,17 +116,15 @@ export default function AdminEmailPage() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
if (modalMode === "create") {
|
if (modalMode === "create") {
|
||||||
await createEmailRequest(token, formData);
|
await createEmailRequest(formData);
|
||||||
} else if (selectedEmail) {
|
} else if (selectedEmail) {
|
||||||
const updateData: UpdateEmailRequestDto = {};
|
const updateData: UpdateEmailRequestDto = {};
|
||||||
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
||||||
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
||||||
await updateEmailRequest(token, selectedEmail.id, updateData);
|
await updateEmailRequest(selectedEmail.id, updateData);
|
||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
await loadEmails();
|
await loadEmails();
|
||||||
@@ -142,14 +135,6 @@ export default function AdminEmailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 text-sm text-red-500">
|
|
||||||
You must be logged in to view this page.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -1,7 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
|
||||||
import {
|
import {
|
||||||
fetchEmailRequests,
|
fetchEmailRequests,
|
||||||
createEmailRequest,
|
createEmailRequest,
|
||||||
@@ -14,7 +13,6 @@ import {
|
|||||||
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
||||||
import { formatDate } from "@/lib/dateFormattingUtility";
|
import { formatDate } from "@/lib/dateFormattingUtility";
|
||||||
export default function AdminEmailPage() {
|
export default function AdminEmailPage() {
|
||||||
const { token } = useAuth();
|
|
||||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
@@ -27,13 +25,11 @@ export default function AdminEmailPage() {
|
|||||||
});
|
});
|
||||||
const [submitting, setSubmitting] = useState(false);
|
const [submitting, setSubmitting] = useState(false);
|
||||||
|
|
||||||
const loadEmails = async () => {
|
const loadEmails = useCallback(async () => {
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
const data = await fetchEmailRequests(token);
|
const data = await fetchEmailRequests();
|
||||||
setEmails(data.data);
|
setEmails(data);
|
||||||
setError(null);
|
setError(null);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -41,11 +37,11 @@ export default function AdminEmailPage() {
|
|||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
};
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
loadEmails();
|
loadEmails();
|
||||||
}, [token]);
|
}, [loadEmails]);
|
||||||
|
|
||||||
const handleCreate = () => {
|
const handleCreate = () => {
|
||||||
setModalMode("create");
|
setModalMode("create");
|
||||||
@@ -62,11 +58,10 @@ export default function AdminEmailPage() {
|
|||||||
};
|
};
|
||||||
|
|
||||||
const handleDelete = async (id: number) => {
|
const handleDelete = async (id: number) => {
|
||||||
if (!token) return;
|
|
||||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
await deleteEmailRequest(token, id);
|
await deleteEmailRequest(id);
|
||||||
await loadEmails();
|
await loadEmails();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
alert(e?.message ?? "Failed to delete email request");
|
alert(e?.message ?? "Failed to delete email request");
|
||||||
@@ -75,17 +70,15 @@ export default function AdminEmailPage() {
|
|||||||
|
|
||||||
const handleSubmit = async (e: React.FormEvent) => {
|
const handleSubmit = async (e: React.FormEvent) => {
|
||||||
e.preventDefault();
|
e.preventDefault();
|
||||||
if (!token) return;
|
|
||||||
|
|
||||||
try {
|
try {
|
||||||
setSubmitting(true);
|
setSubmitting(true);
|
||||||
if (modalMode === "create") {
|
if (modalMode === "create") {
|
||||||
await createEmailRequest(token, formData);
|
await createEmailRequest(formData);
|
||||||
} else if (selectedEmail) {
|
} else if (selectedEmail) {
|
||||||
const updateData: UpdateEmailRequestDto = {};
|
const updateData: UpdateEmailRequestDto = {};
|
||||||
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
||||||
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
||||||
await updateEmailRequest(token, selectedEmail.id, updateData);
|
await updateEmailRequest(selectedEmail.id, updateData);
|
||||||
}
|
}
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
await loadEmails();
|
await loadEmails();
|
||||||
@@ -96,14 +89,6 @@ export default function AdminEmailPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
if (!token) {
|
|
||||||
return (
|
|
||||||
<div className="p-6 text-sm text-red-500">
|
|
||||||
You must be logged in to view this page.
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="p-6 space-y-4">
|
<div className="p-6 space-y-4">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useRef, useState } from "react";
|
import { useEffect, useRef, useState } from "react";
|
||||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
|
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||||
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
||||||
|
|
||||||
export default function SendEmailForm(): JSX.Element {
|
export default function SendEmailForm(): JSX.Element {
|
||||||
|
|||||||
@@ -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();
|
||||||
@@ -46,8 +46,8 @@ export default function ImportStatusPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const [summaryRes, byMerchantRes] = await Promise.all([
|
const [summaryRes, byMerchantRes] = await Promise.all([
|
||||||
fetch(`${API_BASE_URL}/api/admin/import-status/summary`),
|
fetch('/api/admin/import-status/summary', { credentials: 'include' }),
|
||||||
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`),
|
fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
|
||||||
]);
|
]);
|
||||||
|
|
||||||
if (!summaryRes.ok || !byMerchantRes.ok) {
|
if (!summaryRes.ok || !byMerchantRes.ok) {
|
||||||
@@ -108,9 +108,10 @@ export default function ImportStatusPage() {
|
|||||||
try {
|
try {
|
||||||
setImportingId(merchantId);
|
setImportingId(merchantId);
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`,
|
`/api/admin/merchants/${merchantId}/import`,
|
||||||
{
|
{
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
credentials: 'include',
|
||||||
}
|
}
|
||||||
);
|
);
|
||||||
|
|
||||||
|
|||||||
+109
-44
@@ -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,69 +125,53 @@ 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="h-screen bg-black text-zinc-50 overflow-hidden">
|
||||||
<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 h-full flex-1 min-w-0 flex-col transition-all duration-300"
|
||||||
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
|
style={{ marginLeft: collapsed ? '68px' : '280px' }}
|
||||||
|
>
|
||||||
|
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3 sticky top-0 z-30 backdrop-blur-sm">
|
||||||
<div>
|
<div>
|
||||||
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
|
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
|
||||||
Admin
|
Admin
|
||||||
</p>
|
</p>
|
||||||
<p className="text-sm text-zinc-200">
|
<p className="text-sm text-zinc-200">Battl Control Panel</p>
|
||||||
Battl Builders Control Panel
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="flex items-center gap-3">
|
|
||||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
|
|
||||||
Internal • v0.1
|
|
||||||
</span>
|
|
||||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
|
|
||||||
ADMIN
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
|
||||||
{/* Content */}
|
{/* ✅ min-w-0 here prevents table min-width from widening the page */}
|
||||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
|
<main className="flex w-full flex-1 min-w-0 flex-col overflow-y-auto px-4 py-6">
|
||||||
{children}
|
{children}
|
||||||
</main>
|
</main>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
+538
-78
@@ -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,85 @@ export default function MappingAdminPage() {
|
|||||||
[]
|
[]
|
||||||
);
|
);
|
||||||
|
|
||||||
|
// Keep URL in sync (so reconcile can deep-link)
|
||||||
|
function syncUrl(next: Partial<{ tab: TabKey; merchantId: string; platform: string; q: string }>) {
|
||||||
|
const params = new URLSearchParams(searchParams.toString());
|
||||||
|
if (next.tab) params.set("tab", next.tab);
|
||||||
|
if (next.merchantId !== undefined) {
|
||||||
|
if (next.merchantId) params.set("merchantId", next.merchantId);
|
||||||
|
else params.delete("merchantId");
|
||||||
|
}
|
||||||
|
if (next.platform !== undefined) {
|
||||||
|
if (next.platform) params.set("platform", next.platform);
|
||||||
|
else params.delete("platform");
|
||||||
|
}
|
||||||
|
if (next.q !== undefined) {
|
||||||
|
if (next.q) params.set("q", next.q);
|
||||||
|
else params.delete("q");
|
||||||
|
}
|
||||||
|
router.replace(`/admin/mapping?${params.toString()}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Load merchants + canonical categories (needed for catalog tab, but also nice for filtering everywhere)
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function load() {
|
async function loadOptions() {
|
||||||
|
setOptionsLoading(true);
|
||||||
try {
|
try {
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// EXPECTED backend endpoint (new):
|
||||||
|
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
|
||||||
|
const res = await fetch('/api/admin/mapping/options', {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
credentials: 'include',
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
// 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);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch(
|
try {
|
||||||
`${API_BASE_URL}/api/admin/mapping/pending-buckets`,
|
if (tab === "roles") {
|
||||||
{ headers: { Accept: "application/json" } }
|
// Existing endpoint you already have:
|
||||||
);
|
// GET /api/admin/mapping/pending-buckets
|
||||||
|
const res = await fetch('/api/admin/mapping/pending-buckets', {
|
||||||
|
headers: { Accept: "application/json" },
|
||||||
|
credentials: 'include',
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
const text = await res.text().catch(() => "");
|
const text = await res.text().catch(() => "");
|
||||||
@@ -100,40 +258,71 @@ export default function MappingAdminPage() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
const data: PendingBucket[] = await res.json();
|
const data: PendingBucket[] = await res.json();
|
||||||
setRows(data);
|
setRoleBuckets(data);
|
||||||
|
setRawRows([]);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
// tab === "catalog"
|
||||||
|
// EXPECTED new endpoint:
|
||||||
|
// GET /api/admin/mapping/raw-categories?merchantId=4&platform=AR-15&q=...
|
||||||
|
// returns rows with productCount + mapping state including canonicalCategoryId
|
||||||
|
if (!merchantId) {
|
||||||
|
setRawRows([]);
|
||||||
|
setError("Pick a merchant to view catalog mappings.");
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const params = new URLSearchParams();
|
||||||
|
params.set("merchantId", merchantId);
|
||||||
|
if (platform?.trim()) params.set("platform", platform.trim());
|
||||||
|
if (q?.trim()) params.set("q", q.trim());
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/mapping/raw-categories?${params.toString()}`,
|
||||||
|
{ headers: { Accept: "application/json" }, credentials: 'include', cache: "no-store" }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Catalog mapping endpoint not ready (${res.status})${text ? `: ${text}` : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data: RawCategoryRow[] = await res.json();
|
||||||
|
setRawRows(data);
|
||||||
|
setRoleBuckets([]);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(e.message ?? "Failed to load pending buckets");
|
setError(e?.message ?? "Failed to load mapping data");
|
||||||
} finally {
|
} finally {
|
||||||
setLoading(false);
|
setLoading(false);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Load on mount + when tab changes
|
||||||
|
useEffect(() => {
|
||||||
load();
|
load();
|
||||||
}, []);
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
|
}, [tab]);
|
||||||
|
|
||||||
|
// ===== Roles tab actions =====
|
||||||
|
|
||||||
const handleChangeRole = (idx: number, value: string) => {
|
const handleChangeRole = (idx: number, value: string) => {
|
||||||
// Always store normalized kebab-case (defensive)
|
const normalized = value ? normalizeRole(value) : "";
|
||||||
const normalized = value
|
setRoleBuckets((prev) => {
|
||||||
? value.trim().toLowerCase().replace(/_/g, "-")
|
|
||||||
: "";
|
|
||||||
|
|
||||||
setRows((prev) => {
|
|
||||||
const next = [...prev];
|
const next = [...prev];
|
||||||
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
|
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
};
|
};
|
||||||
|
|
||||||
const handleSave = async (row: PendingBucket) => {
|
const handleSaveRole = async (row: PendingBucket) => {
|
||||||
if (!row.mappedPartRole) return;
|
if (!row.mappedPartRole) return;
|
||||||
|
|
||||||
const mapped = row.mappedPartRole
|
const mapped = normalizeRole(row.mappedPartRole);
|
||||||
.trim()
|
|
||||||
.toLowerCase()
|
|
||||||
.replace(/_/g, "-");
|
|
||||||
|
|
||||||
// Basic guard: prevent saving random roles from stale data
|
|
||||||
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
|
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
|
||||||
if (!allowed.has(mapped)) {
|
if (!allowed.has(mapped)) {
|
||||||
setError(
|
setError(
|
||||||
@@ -142,14 +331,17 @@ export default function MappingAdminPage() {
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const key = `${row.merchantId}-${row.rawCategoryKey}`;
|
const key = `roles-${row.merchantId}-${row.rawCategoryKey}`;
|
||||||
try {
|
try {
|
||||||
setSavingKey(key);
|
setSavingKey(key);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
// Existing endpoint you already have:
|
||||||
|
// POST /api/admin/mapping/apply
|
||||||
|
const res = await fetch('/api/admin/mapping/apply', {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: 'include',
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
merchantId: row.merchantId,
|
merchantId: row.merchantId,
|
||||||
rawCategoryKey: row.rawCategoryKey,
|
rawCategoryKey: row.rawCategoryKey,
|
||||||
@@ -164,8 +356,7 @@ export default function MappingAdminPage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
// After save, remove this bucket from the list
|
setRoleBuckets((prev) =>
|
||||||
setRows((prev) =>
|
|
||||||
prev.filter(
|
prev.filter(
|
||||||
(r) =>
|
(r) =>
|
||||||
!(
|
!(
|
||||||
@@ -176,46 +367,229 @@ export default function MappingAdminPage() {
|
|||||||
);
|
);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
setError(e.message ?? "Failed to save mapping");
|
setError(e?.message ?? "Failed to save mapping");
|
||||||
} finally {
|
} finally {
|
||||||
setSavingKey(null);
|
setSavingKey(null);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// ===== Catalog tab actions =====
|
||||||
|
|
||||||
|
const updateRawRow = (idx: number, patch: Partial<RawCategoryRow>) => {
|
||||||
|
setRawRows((prev) => {
|
||||||
|
const next = [...prev];
|
||||||
|
next[idx] = { ...next[idx], ...patch };
|
||||||
|
return next;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleSaveCatalog = async (row: RawCategoryRow) => {
|
||||||
|
if (!merchantId) return;
|
||||||
|
|
||||||
|
const key = `catalog-${row.merchantId}-${row.rawCategoryKey}`;
|
||||||
|
try {
|
||||||
|
setSavingKey(key);
|
||||||
|
setError(null);
|
||||||
|
|
||||||
|
// EXPECTED new endpoint:
|
||||||
|
// POST /api/admin/mapping/upsert
|
||||||
|
const res = await fetch('/api/admin/mapping/upsert', {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
credentials: 'include',
|
||||||
|
body: JSON.stringify({
|
||||||
|
merchantId: row.merchantId,
|
||||||
|
platform: row.platform ?? platform ?? null,
|
||||||
|
rawCategory: row.rawCategoryKey,
|
||||||
|
enabled: row.enabled ?? true,
|
||||||
|
canonicalCategoryId: row.canonicalCategoryId ?? null,
|
||||||
|
// do NOT overwrite part role here; backend should merge/partial update
|
||||||
|
canonicalPartRole: null,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
throw new Error(
|
||||||
|
`Failed to save catalog mapping (${res.status})${text ? `: ${text}` : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reload after save so we reflect the 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
|
||||||
|
blending worlds.
|
||||||
</p>
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => load()}
|
||||||
|
className="rounded-md border border-zinc-700 bg-zinc-950/60 px-3 py-1.5 text-xs font-semibold text-zinc-100 hover:bg-zinc-900/60"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Tabs */}
|
||||||
|
<div className="mt-4 flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTab("roles");
|
||||||
|
syncUrl({ tab: "roles" });
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"rounded-md px-3 py-1.5 text-xs font-semibold",
|
||||||
|
tab === "roles"
|
||||||
|
? "bg-amber-400 text-black"
|
||||||
|
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
Builder Part Roles
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<button
|
||||||
|
onClick={() => {
|
||||||
|
setTab("catalog");
|
||||||
|
syncUrl({ tab: "catalog" });
|
||||||
|
}}
|
||||||
|
className={[
|
||||||
|
"rounded-md px-3 py-1.5 text-xs font-semibold",
|
||||||
|
tab === "catalog"
|
||||||
|
? "bg-amber-400 text-black"
|
||||||
|
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
|
||||||
|
].join(" ")}
|
||||||
|
>
|
||||||
|
Catalog Categories
|
||||||
|
</button>
|
||||||
|
|
||||||
|
<span className="ml-auto text-xs text-zinc-400">
|
||||||
|
{totals.buckets} rows • {totals.products} products
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{/* Filters (catalog tab only) */}
|
||||||
|
{tab === "catalog" && (
|
||||||
|
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
|
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||||
|
<div>
|
||||||
|
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
Merchant
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||||
|
value={merchantId}
|
||||||
|
onChange={(e) => {
|
||||||
|
setMerchantId(e.target.value);
|
||||||
|
syncUrl({ merchantId: e.target.value });
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select merchant…</option>
|
||||||
|
{merchants.map((m) => (
|
||||||
|
<option key={m.id} value={String(m.id)}>
|
||||||
|
{m.name} (#{m.id})
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
{optionsLoading && (
|
||||||
|
<p className="mt-1 text-[0.7rem] text-zinc-500">Loading merchants…</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
Platform
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||||
|
value={platform}
|
||||||
|
onChange={(e) => {
|
||||||
|
setPlatform(e.target.value);
|
||||||
|
syncUrl({ platform: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="AR-15"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
Search raw categories
|
||||||
|
</label>
|
||||||
|
<div className="mt-1 flex gap-2">
|
||||||
|
<input
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||||
|
value={q}
|
||||||
|
onChange={(e) => {
|
||||||
|
setQ(e.target.value);
|
||||||
|
syncUrl({ q: e.target.value });
|
||||||
|
}}
|
||||||
|
placeholder="e.g. Charging Handles"
|
||||||
|
/>
|
||||||
|
<button
|
||||||
|
onClick={() => load()}
|
||||||
|
className="rounded-md bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
|
||||||
|
>
|
||||||
|
Search
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
|
|
||||||
{loading && (
|
{loading && (
|
||||||
<p className="mt-4 text-sm text-zinc-400">Loading pending buckets…</p>
|
<p className="mt-4 text-sm text-zinc-400">Loading…</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{error && (
|
{error && (
|
||||||
<p className="mt-4 text-sm text-red-400">Error: {error}</p>
|
<p className="mt-4 text-sm text-red-400">Error: {error}</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && rows.length === 0 && !error && (
|
{/* ROLES TAB TABLE */}
|
||||||
|
{!loading && tab === "roles" && !error && roleBuckets.length === 0 && (
|
||||||
<p className="mt-4 text-sm text-emerald-400">
|
<p className="mt-4 text-sm text-emerald-400">
|
||||||
No pending mapping buckets 🎉
|
No pending role buckets 🎉
|
||||||
</p>
|
</p>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{!loading && rows.length > 0 && (
|
{!loading && tab === "roles" && roleBuckets.length > 0 && (
|
||||||
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
|
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
<div className="mb-2 flex items-center justify-between">
|
<div className="mb-2 flex items-center justify-between">
|
||||||
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
||||||
Pending Buckets
|
Pending Part Role Buckets
|
||||||
</h2>
|
</h2>
|
||||||
<span className="text-xs text-zinc-400">
|
<span className="text-xs text-zinc-400">
|
||||||
{rows.length} buckets •{" "}
|
{totals.buckets} buckets • {totals.products} products
|
||||||
{rows.reduce((s, r) => s + r.productCount, 0)} products
|
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
@@ -223,26 +597,17 @@ export default function MappingAdminPage() {
|
|||||||
<table className="min-w-full text-xs">
|
<table className="min-w-full text-xs">
|
||||||
<thead>
|
<thead>
|
||||||
<tr className="border-b border-zinc-800 bg-zinc-900/60">
|
<tr className="border-b border-zinc-800 bg-zinc-900/60">
|
||||||
<th className="px-2 py-1 text-left text-zinc-400">
|
<th className="px-2 py-1 text-left text-zinc-400">Merchant</th>
|
||||||
Merchant
|
<th className="px-2 py-1 text-left text-zinc-400">Raw Category</th>
|
||||||
</th>
|
<th className="px-2 py-1 text-left text-zinc-400">Products</th>
|
||||||
<th className="px-2 py-1 text-left text-zinc-400">
|
<th className="px-2 py-1 text-left text-zinc-400">Part Role</th>
|
||||||
Raw Category
|
<th className="px-2 py-1 text-right text-zinc-400">Action</th>
|
||||||
</th>
|
|
||||||
<th className="px-2 py-1 text-left text-zinc-400">
|
|
||||||
Products
|
|
||||||
</th>
|
|
||||||
<th className="px-2 py-1 text-left text-zinc-400">
|
|
||||||
Part Role
|
|
||||||
</th>
|
|
||||||
<th className="px-2 py-1 text-right text-zinc-400">
|
|
||||||
Action
|
|
||||||
</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody>
|
<tbody>
|
||||||
{rows.map((row, idx) => {
|
{roleBuckets.map((row, idx) => {
|
||||||
const rowKey = `${row.merchantId}-${row.rawCategoryKey}`;
|
const rowKey = `roles-${row.merchantId}-${row.rawCategoryKey}`;
|
||||||
const isSaving = savingKey === rowKey;
|
const isSaving = savingKey === rowKey;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
@@ -250,25 +615,17 @@ export default function MappingAdminPage() {
|
|||||||
key={rowKey}
|
key={rowKey}
|
||||||
className="border-b border-zinc-900 hover:bg-zinc-900/40"
|
className="border-b border-zinc-900 hover:bg-zinc-900/40"
|
||||||
>
|
>
|
||||||
<td className="px-2 py-1 text-zinc-100">
|
<td className="px-2 py-1 text-zinc-100">{row.merchantName}</td>
|
||||||
{row.merchantName}
|
<td className="px-2 py-1 text-zinc-300">{row.rawCategoryKey}</td>
|
||||||
</td>
|
<td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
|
||||||
<td className="px-2 py-1 text-zinc-300">
|
|
||||||
{row.rawCategoryKey}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1 text-zinc-200">
|
|
||||||
{row.productCount}
|
|
||||||
</td>
|
|
||||||
<td className="px-2 py-1 text-zinc-100">
|
<td className="px-2 py-1 text-zinc-100">
|
||||||
<select
|
<select
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||||
value={row.mappedPartRole ?? ""}
|
value={row.mappedPartRole ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) => handleChangeRole(idx, e.target.value)}
|
||||||
handleChangeRole(idx, e.target.value)
|
|
||||||
}
|
|
||||||
>
|
>
|
||||||
<option value="">Select part role…</option>
|
<option value="">Select part role…</option>
|
||||||
{options.map((opt) => (
|
{roleOptions.map((opt) => (
|
||||||
<option key={opt.value} value={opt.value}>
|
<option key={opt.value} value={opt.value}>
|
||||||
{opt.label} ({opt.value})
|
{opt.label} ({opt.value})
|
||||||
</option>
|
</option>
|
||||||
@@ -277,7 +634,7 @@ export default function MappingAdminPage() {
|
|||||||
</td>
|
</td>
|
||||||
<td className="px-2 py-1 text-right">
|
<td className="px-2 py-1 text-right">
|
||||||
<button
|
<button
|
||||||
onClick={() => handleSave(row)}
|
onClick={() => handleSaveRole(row)}
|
||||||
disabled={!row.mappedPartRole || isSaving}
|
disabled={!row.mappedPartRole || isSaving}
|
||||||
className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700"
|
className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700"
|
||||||
>
|
>
|
||||||
@@ -292,6 +649,109 @@ export default function MappingAdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
</section>
|
</section>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
{/* CATALOG TAB TABLE */}
|
||||||
|
{!loading && tab === "catalog" && !error && rawRows.length === 0 && (
|
||||||
|
<p className="mt-4 text-sm text-zinc-400">
|
||||||
|
No rows loaded. Pick a merchant + click Search.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
|
||||||
|
{!loading && tab === "catalog" && rawRows.length > 0 && (
|
||||||
|
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
|
||||||
|
<div className="mb-2 flex items-center justify-between">
|
||||||
|
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
||||||
|
Catalog Category Mapping
|
||||||
|
</h2>
|
||||||
|
<span className="text-xs text-zinc-400">
|
||||||
|
{totals.buckets} rows • {totals.products} products
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-1 overflow-x-auto">
|
||||||
|
<table className="min-w-full text-xs">
|
||||||
|
<thead>
|
||||||
|
<tr className="border-b border-zinc-800 bg-zinc-900/60">
|
||||||
|
<th className="px-2 py-1 text-left text-zinc-400">Raw Category</th>
|
||||||
|
<th className="px-2 py-1 text-left text-zinc-400">Products</th>
|
||||||
|
<th className="px-2 py-1 text-left text-zinc-400">Canonical Category</th>
|
||||||
|
<th className="px-2 py-1 text-left text-zinc-400">Enabled</th>
|
||||||
|
<th className="px-2 py-1 text-right text-zinc-400">Action</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
|
||||||
|
<tbody>
|
||||||
|
{rawRows.map((row, idx) => {
|
||||||
|
const rowKey = `catalog-${row.merchantId}-${row.rawCategoryKey}`;
|
||||||
|
const isSaving = savingKey === rowKey;
|
||||||
|
|
||||||
|
const currentCategoryId =
|
||||||
|
row.canonicalCategoryId != null ? String(row.canonicalCategoryId) : "";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<tr
|
||||||
|
key={rowKey}
|
||||||
|
className="border-b border-zinc-900 hover:bg-zinc-900/40"
|
||||||
|
>
|
||||||
|
<td className="px-2 py-1 text-zinc-300">{row.rawCategoryKey}</td>
|
||||||
|
<td className="px-2 py-1 text-zinc-200">{row.productCount}</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-1 text-zinc-100">
|
||||||
|
<select
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-1 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||||
|
value={currentCategoryId}
|
||||||
|
onChange={(e) => {
|
||||||
|
const id = safeNum(e.target.value);
|
||||||
|
const opt = canonicalCategories.find((c) => c.id === id);
|
||||||
|
updateRawRow(idx, {
|
||||||
|
canonicalCategoryId: id,
|
||||||
|
canonicalCategoryName: opt?.name ?? null,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<option value="">Select canonical category…</option>
|
||||||
|
{canonicalCategories.map((c) => (
|
||||||
|
<option key={c.id} value={String(c.id)}>
|
||||||
|
{c.name}
|
||||||
|
</option>
|
||||||
|
))}
|
||||||
|
</select>
|
||||||
|
|
||||||
|
{canonicalCategories.length === 0 && (
|
||||||
|
<p className="mt-1 text-[0.7rem] text-zinc-500">
|
||||||
|
No canonical categories loaded (options endpoint may not be wired yet).
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-1 text-zinc-100">
|
||||||
|
<label className="inline-flex items-center gap-2">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={row.enabled ?? true}
|
||||||
|
onChange={(e) => updateRawRow(idx, { enabled: e.target.checked })}
|
||||||
|
/>
|
||||||
|
<span className="text-zinc-300">Enabled</span>
|
||||||
|
</label>
|
||||||
|
</td>
|
||||||
|
|
||||||
|
<td className="px-2 py-1 text-right">
|
||||||
|
<button
|
||||||
|
onClick={() => handleSaveCatalog(row)}
|
||||||
|
disabled={isSaving}
|
||||||
|
className="rounded-md bg-emerald-500 px-3 py-1 text-[0.7rem] font-semibold text-black disabled:cursor-not-allowed disabled:bg-zinc-700"
|
||||||
|
>
|
||||||
|
{isSaving ? "Saving…" : "Save"}
|
||||||
|
</button>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
);
|
||||||
|
})}
|
||||||
|
</tbody>
|
||||||
|
</table>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
"use client";
|
"use client";
|
||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useCallback, useEffect, useState } from "react";
|
||||||
import { useApi } from "@/lib/useApi";
|
import { useApi } from "@/lib/useApi";
|
||||||
|
|
||||||
type Merchant = {
|
type Merchant = {
|
||||||
@@ -91,7 +91,7 @@ export default function MerchantCategoryMappingPage() {
|
|||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||||
}, []);
|
}, []);
|
||||||
// 2) Load mappings when merchant changes (THIS sends merchantId)
|
// 2) Load mappings when merchant changes (THIS sends merchantId)
|
||||||
useEffect(() => {
|
const loadMappings = useCallback(() => {
|
||||||
if (selectedMerchantId == null) return;
|
if (selectedMerchantId == null) return;
|
||||||
|
|
||||||
setLoadingMappings(true);
|
setLoadingMappings(true);
|
||||||
@@ -112,7 +112,11 @@ export default function MerchantCategoryMappingPage() {
|
|||||||
);
|
);
|
||||||
})
|
})
|
||||||
.finally(() => setLoadingMappings(false));
|
.finally(() => setLoadingMappings(false));
|
||||||
}, [selectedMerchantId]);
|
}, [selectedMerchantId, get]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
loadMappings();
|
||||||
|
}, [loadMappings]);
|
||||||
|
|
||||||
// 3) Save a single mapping row (adjust endpoint if yours differs)
|
// 3) Save a single mapping row (adjust endpoint if yours differs)
|
||||||
const handleSaveMapping = async (
|
const handleSaveMapping = async (
|
||||||
|
|||||||
+435
-62
@@ -2,8 +2,7 @@
|
|||||||
|
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
|
|
||||||
const API_BASE_URL =
|
// API routes now handled by Next.js /api routes (server-side)
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
|
||||||
|
|
||||||
type MerchantAdminDto = {
|
type MerchantAdminDto = {
|
||||||
id: number;
|
id: number;
|
||||||
@@ -16,13 +15,50 @@ type MerchantAdminDto = {
|
|||||||
lastOfferSyncAt: string | null;
|
lastOfferSyncAt: string | null;
|
||||||
};
|
};
|
||||||
|
|
||||||
function formatDate(value: string | null) {
|
// Helper: relative time formatting
|
||||||
if (!value) return "—";
|
function formatRelativeTime(value: string | null): string {
|
||||||
|
if (!value) return "Never";
|
||||||
|
const d = new Date(value);
|
||||||
|
if (Number.isNaN(d.getTime())) return "Invalid date";
|
||||||
|
|
||||||
|
const now = Date.now();
|
||||||
|
const diff = now - d.getTime();
|
||||||
|
const minutes = Math.floor(diff / 60000);
|
||||||
|
const hours = Math.floor(diff / 3600000);
|
||||||
|
const days = Math.floor(diff / 86400000);
|
||||||
|
|
||||||
|
if (minutes < 1) return "Just now";
|
||||||
|
if (minutes < 60) return `${minutes}m ago`;
|
||||||
|
if (hours < 24) return `${hours}h ago`;
|
||||||
|
if (days < 30) return `${days}d ago`;
|
||||||
|
return d.toLocaleDateString();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: full date formatting for tooltips
|
||||||
|
function formatFullDate(value: string | null): string {
|
||||||
|
if (!value) return "Never";
|
||||||
const d = new Date(value);
|
const d = new Date(value);
|
||||||
if (Number.isNaN(d.getTime())) return value;
|
if (Number.isNaN(d.getTime())) return value;
|
||||||
return d.toLocaleString();
|
return d.toLocaleString();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Helper: truncate URL for display
|
||||||
|
function truncateUrl(url: string | null, maxLength = 40): string {
|
||||||
|
if (!url) return "—";
|
||||||
|
if (url.length <= maxLength) return url;
|
||||||
|
return url.substring(0, maxLength) + "...";
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper: copy to clipboard
|
||||||
|
async function copyToClipboard(text: string) {
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export default function MerchantsAdminPage() {
|
export default function MerchantsAdminPage() {
|
||||||
const [merchants, setMerchants] = useState<MerchantAdminDto[]>([]);
|
const [merchants, setMerchants] = useState<MerchantAdminDto[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -31,6 +67,19 @@ export default function MerchantsAdminPage() {
|
|||||||
const [importingId, setImportingId] = useState<number | null>(null);
|
const [importingId, setImportingId] = useState<number | null>(null);
|
||||||
const [offerSyncId, setOfferSyncId] = useState<number | null>(null);
|
const [offerSyncId, setOfferSyncId] = useState<number | null>(null);
|
||||||
const [banner, setBanner] = useState<string | null>(null);
|
const [banner, setBanner] = useState<string | null>(null);
|
||||||
|
const [copiedUrl, setCopiedUrl] = useState<string | null>(null);
|
||||||
|
const [openDropdown, setOpenDropdown] = useState<number | null>(null);
|
||||||
|
|
||||||
|
// --- new merchant form state ---
|
||||||
|
const [showNewMerchantForm, setShowNewMerchantForm] = useState(false);
|
||||||
|
const [creating, setCreating] = useState(false);
|
||||||
|
const [newMerchant, setNewMerchant] = useState({
|
||||||
|
name: "",
|
||||||
|
avantlinkMid: "",
|
||||||
|
feedUrl: "",
|
||||||
|
offerFeedUrl: "",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
|
||||||
// --- load merchants ---
|
// --- load merchants ---
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -41,7 +90,7 @@ export default function MerchantsAdminPage() {
|
|||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const url = `${API_BASE_URL}/api/admin/merchants`;
|
const url = `/api/admin/merchants`;
|
||||||
console.log("Loading merchants from:", url);
|
console.log("Loading merchants from:", url);
|
||||||
|
|
||||||
const res = await fetch(url, {
|
const res = await fetch(url, {
|
||||||
@@ -81,6 +130,15 @@ export default function MerchantsAdminPage() {
|
|||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- copy URL handler ---
|
||||||
|
const handleCopyUrl = async (url: string, id: number) => {
|
||||||
|
const success = await copyToClipboard(url);
|
||||||
|
if (success) {
|
||||||
|
setCopiedUrl(`${id}-${url}`);
|
||||||
|
setTimeout(() => setCopiedUrl(null), 2000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
// --- save merchant (name/feed URLs/isActive) ---
|
// --- save merchant (name/feed URLs/isActive) ---
|
||||||
const handleSave = async (id: number) => {
|
const handleSave = async (id: number) => {
|
||||||
const merchant = merchants.find((m) => m.id === id);
|
const merchant = merchants.find((m) => m.id === id);
|
||||||
@@ -91,7 +149,7 @@ export default function MerchantsAdminPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, {
|
const res = await fetch(`/api/admin/merchants/${id}`, {
|
||||||
method: "PUT",
|
method: "PUT",
|
||||||
headers: { "Content-Type": "application/json" },
|
headers: { "Content-Type": "application/json" },
|
||||||
body: JSON.stringify({
|
body: JSON.stringify({
|
||||||
@@ -124,12 +182,13 @@ export default function MerchantsAdminPage() {
|
|||||||
|
|
||||||
// --- run full import ---
|
// --- run full import ---
|
||||||
const handleRunFullImport = async (id: number) => {
|
const handleRunFullImport = async (id: number) => {
|
||||||
|
setOpenDropdown(null);
|
||||||
try {
|
try {
|
||||||
setImportingId(id);
|
setImportingId(id);
|
||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, {
|
const res = await fetch(`/api/admin/merchants/${id}/import`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -151,13 +210,14 @@ export default function MerchantsAdminPage() {
|
|||||||
|
|
||||||
// --- run offer sync ---
|
// --- run offer sync ---
|
||||||
const handleRunOfferSync = async (id: number) => {
|
const handleRunOfferSync = async (id: number) => {
|
||||||
|
setOpenDropdown(null);
|
||||||
try {
|
try {
|
||||||
setOfferSyncId(id);
|
setOfferSyncId(id);
|
||||||
setError(null);
|
setError(null);
|
||||||
setBanner(null);
|
setBanner(null);
|
||||||
|
|
||||||
const res = await fetch(
|
const res = await fetch(
|
||||||
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`,
|
`/api/admin/merchants/${id}/offer-sync`,
|
||||||
{ method: "POST" }
|
{ method: "POST" }
|
||||||
);
|
);
|
||||||
|
|
||||||
@@ -179,9 +239,59 @@ export default function MerchantsAdminPage() {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
// --- create new merchant ---
|
||||||
|
const handleCreateMerchant = async () => {
|
||||||
|
try {
|
||||||
|
setCreating(true);
|
||||||
|
setError(null);
|
||||||
|
setBanner(null);
|
||||||
|
|
||||||
|
const res = await fetch(`/api/admin/merchants`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify({
|
||||||
|
name: newMerchant.name,
|
||||||
|
avantlinkMid: newMerchant.avantlinkMid,
|
||||||
|
feedUrl: newMerchant.feedUrl,
|
||||||
|
offerFeedUrl: newMerchant.offerFeedUrl || null,
|
||||||
|
isActive: newMerchant.isActive,
|
||||||
|
}),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
const text = await res.text().catch(() => "");
|
||||||
|
console.error("Merchant creation failed", res.status, text);
|
||||||
|
throw new Error(
|
||||||
|
`Creation failed (${res.status})${text ? `: ${text}` : ""}`
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const created: MerchantAdminDto = await res.json();
|
||||||
|
setMerchants((prev) => [...prev, created]);
|
||||||
|
|
||||||
|
// Reset form
|
||||||
|
setNewMerchant({
|
||||||
|
name: "",
|
||||||
|
avantlinkMid: "",
|
||||||
|
feedUrl: "",
|
||||||
|
offerFeedUrl: "",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
setShowNewMerchantForm(false);
|
||||||
|
|
||||||
|
setBanner("Merchant created successfully.");
|
||||||
|
} catch (e: any) {
|
||||||
|
console.error("Error creating merchant", e);
|
||||||
|
setError(e?.message ?? "Failed to create merchant");
|
||||||
|
} finally {
|
||||||
|
setCreating(false);
|
||||||
|
setTimeout(() => setBanner(null), 3000);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<main className="min-h-screen bg-black text-zinc-50">
|
<main className="min-h-screen bg-black text-zinc-50">
|
||||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
<div className="mx-auto max-w-7xl px-4 py-6 lg:py-10">
|
||||||
{/* Header */}
|
{/* Header */}
|
||||||
<header className="mb-6 flex items-center justify-between gap-4">
|
<header className="mb-6 flex items-center justify-between gap-4">
|
||||||
<div>
|
<div>
|
||||||
@@ -211,136 +321,399 @@ export default function MerchantsAdminPage() {
|
|||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
{/* Table */}
|
{/* Add New Merchant Section */}
|
||||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-3 md:p-4">
|
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4">
|
||||||
|
{!showNewMerchantForm ? (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => setShowNewMerchantForm(true)}
|
||||||
|
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-4 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||||
|
>
|
||||||
|
<span className="text-lg">+</span>
|
||||||
|
Add New Merchant
|
||||||
|
</button>
|
||||||
|
) : (
|
||||||
|
<div>
|
||||||
|
<div className="flex items-center justify-between mb-4">
|
||||||
|
<h2 className="text-lg font-semibold text-zinc-100">
|
||||||
|
Add New Merchant
|
||||||
|
</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => {
|
||||||
|
setShowNewMerchantForm(false);
|
||||||
|
setNewMerchant({
|
||||||
|
name: "",
|
||||||
|
avantlinkMid: "",
|
||||||
|
feedUrl: "",
|
||||||
|
offerFeedUrl: "",
|
||||||
|
isActive: true,
|
||||||
|
});
|
||||||
|
}}
|
||||||
|
className="text-zinc-400 hover:text-zinc-200 text-sm"
|
||||||
|
>
|
||||||
|
Cancel
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
||||||
|
Merchant Name *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMerchant.name}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewMerchant((prev) => ({ ...prev, name: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="e.g., Brownells"
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
||||||
|
AvantLink MID *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMerchant.avantlinkMid}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewMerchant((prev) => ({
|
||||||
|
...prev,
|
||||||
|
avantlinkMid: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="e.g., 12345"
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
||||||
|
Feed URL *
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMerchant.feedUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewMerchant((prev) => ({ ...prev, feedUrl: e.target.value }))
|
||||||
|
}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="block text-xs font-medium text-zinc-400 mb-1">
|
||||||
|
Offer Feed URL (optional)
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
value={newMerchant.offerFeedUrl}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewMerchant((prev) => ({
|
||||||
|
...prev,
|
||||||
|
offerFeedUrl: e.target.value,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
placeholder="https://..."
|
||||||
|
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-3 py-2 text-sm text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="md:col-span-2">
|
||||||
|
<label className="inline-flex items-center gap-2 text-sm text-zinc-300">
|
||||||
|
<input
|
||||||
|
type="checkbox"
|
||||||
|
checked={newMerchant.isActive}
|
||||||
|
onChange={(e) =>
|
||||||
|
setNewMerchant((prev) => ({
|
||||||
|
...prev,
|
||||||
|
isActive: e.target.checked,
|
||||||
|
}))
|
||||||
|
}
|
||||||
|
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||||
|
/>
|
||||||
|
Active
|
||||||
|
</label>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div className="mt-4 flex gap-3">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={handleCreateMerchant}
|
||||||
|
disabled={
|
||||||
|
creating ||
|
||||||
|
!newMerchant.name ||
|
||||||
|
!newMerchant.avantlinkMid ||
|
||||||
|
!newMerchant.feedUrl
|
||||||
|
}
|
||||||
|
className={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||||
|
creating ||
|
||||||
|
!newMerchant.name ||
|
||||||
|
!newMerchant.avantlinkMid ||
|
||||||
|
!newMerchant.feedUrl
|
||||||
|
? "bg-amber-400/20 text-amber-200 cursor-not-allowed"
|
||||||
|
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
{creating ? "Creating…" : "Create Merchant"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</section>
|
||||||
|
|
||||||
|
{/* Improved Table */}
|
||||||
|
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 overflow-hidden">
|
||||||
{loading ? (
|
{loading ? (
|
||||||
<p className="text-sm text-zinc-500">Loading merchants…</p>
|
<p className="text-sm text-zinc-500 p-4">Loading merchants…</p>
|
||||||
) : merchants.length === 0 ? (
|
) : merchants.length === 0 ? (
|
||||||
<p className="text-sm text-zinc-500">
|
<p className="text-sm text-zinc-500 p-4">
|
||||||
No merchants found. Seed some merchants in the backend.
|
No merchants found. Use the "Add New Merchant" button above.
|
||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<div className="overflow-x-auto">
|
<div className="overflow-x-auto">
|
||||||
<table className="min-w-full text-left text-sm">
|
<table className="min-w-full text-left text-sm">
|
||||||
<thead>
|
<thead className="sticky top-0 bg-zinc-950/95 backdrop-blur-sm">
|
||||||
<tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500">
|
<tr className="border-b border-zinc-800 text-xs uppercase tracking-[0.16em] text-zinc-500">
|
||||||
<th className="py-2 pr-3">Merchant</th>
|
<th className="py-3 px-4 font-medium">Merchant</th>
|
||||||
<th className="py-2 px-3">AvantLink MID</th>
|
<th className="py-3 px-4 font-medium">MID</th>
|
||||||
<th className="py-2 px-3">Feed URL</th>
|
<th className="py-3 px-4 font-medium">Feed URL</th>
|
||||||
<th className="py-2 px-3">Offer Feed URL</th>
|
<th className="py-3 px-4 font-medium">Offer URL</th>
|
||||||
<th className="py-2 px-3">Active</th>
|
<th className="py-3 px-4 font-medium">Status</th>
|
||||||
<th className="py-2 px-3">Last Full Import</th>
|
<th className="py-3 px-4 font-medium">Last Import</th>
|
||||||
<th className="py-2 px-3">Last Offer Sync</th>
|
<th className="py-3 px-4 font-medium text-right">Actions</th>
|
||||||
<th className="py-2 pl-3 text-right">Actions</th>
|
|
||||||
</tr>
|
</tr>
|
||||||
</thead>
|
</thead>
|
||||||
|
|
||||||
<tbody className="divide-y divide-zinc-800">
|
<tbody className="divide-y divide-zinc-800/50">
|
||||||
{merchants.map((m) => (
|
{merchants.map((m) => (
|
||||||
<tr key={m.id} className="align-top">
|
<tr key={m.id} className="hover:bg-zinc-900/30 transition-colors">
|
||||||
<td className="py-2 pr-3">
|
{/* Merchant Name */}
|
||||||
|
<td className="py-3 px-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.name}
|
value={m.name}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(m.id, "name", e.target.value)
|
updateMerchantField(m.id, "name", e.target.value)
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full min-w-[140px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-sm font-medium text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
{/* AvantLink MID */}
|
||||||
|
<td className="py-3 px-4">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.avantlinkMid}
|
value={m.avantlinkMid}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(m.id, "avantlinkMid", e.target.value)
|
updateMerchantField(m.id, "avantlinkMid", e.target.value)
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
className="w-full min-w-[80px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
{/* Feed URL with copy button */}
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.feedUrl ?? ""}
|
value={m.feedUrl ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(m.id, "feedUrl", e.target.value)
|
updateMerchantField(m.id, "feedUrl", e.target.value)
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
title={m.feedUrl ?? ""}
|
||||||
|
className="flex-1 min-w-[180px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyUrl(m.feedUrl, m.id)}
|
||||||
|
className="flex-shrink-0 p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
|
||||||
|
title="Copy URL"
|
||||||
|
>
|
||||||
|
{copiedUrl === `${m.id}-${m.feedUrl}` ? (
|
||||||
|
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
{/* Offer Feed URL with copy button */}
|
||||||
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
<input
|
<input
|
||||||
type="text"
|
type="text"
|
||||||
value={m.offerFeedUrl ?? ""}
|
value={m.offerFeedUrl ?? ""}
|
||||||
onChange={(e) =>
|
onChange={(e) =>
|
||||||
updateMerchantField(m.id, "offerFeedUrl", e.target.value || null)
|
updateMerchantField(m.id, "offerFeedUrl", e.target.value || null)
|
||||||
}
|
}
|
||||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
placeholder="—"
|
||||||
|
title={m.offerFeedUrl ?? "None"}
|
||||||
|
className="flex-1 min-w-[180px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||||
/>
|
/>
|
||||||
|
{m.offerFeedUrl && (
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() => handleCopyUrl(m.offerFeedUrl!, m.id)}
|
||||||
|
className="flex-shrink-0 p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
|
||||||
|
title="Copy URL"
|
||||||
|
>
|
||||||
|
{copiedUrl === `${m.id}-${m.offerFeedUrl}` ? (
|
||||||
|
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||||
|
</svg>
|
||||||
|
) : (
|
||||||
|
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||||
|
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||||
|
</svg>
|
||||||
|
)}
|
||||||
|
</button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3">
|
{/* Status Badge (replacing checkbox) */}
|
||||||
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
|
<td className="py-3 px-4">
|
||||||
<input
|
<button
|
||||||
type="checkbox"
|
type="button"
|
||||||
checked={m.isActive}
|
onClick={() =>
|
||||||
onChange={(e) =>
|
updateMerchantField(m.id, "isActive", !m.isActive)
|
||||||
updateMerchantField(m.id, "isActive", e.target.checked)
|
|
||||||
}
|
}
|
||||||
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||||
|
m.isActive
|
||||||
|
? "bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20"
|
||||||
|
: "bg-zinc-700/50 text-zinc-400 hover:bg-zinc-700"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<span
|
||||||
|
className={`w-1.5 h-1.5 rounded-full ${
|
||||||
|
m.isActive ? "bg-emerald-400" : "bg-zinc-500"
|
||||||
|
}`}
|
||||||
/>
|
/>
|
||||||
Active
|
{m.isActive ? "Active" : "Inactive"}
|
||||||
</label>
|
</button>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
{/* Last Import with tooltip */}
|
||||||
{formatDate(m.lastFullImportAt)}
|
<td className="py-3 px-4">
|
||||||
|
<div className="flex flex-col gap-0.5">
|
||||||
|
<span
|
||||||
|
className="text-xs text-zinc-400"
|
||||||
|
title={`Full: ${formatFullDate(m.lastFullImportAt)}`}
|
||||||
|
>
|
||||||
|
{formatRelativeTime(m.lastFullImportAt)}
|
||||||
|
</span>
|
||||||
|
<span
|
||||||
|
className="text-[10px] text-zinc-500"
|
||||||
|
title={`Offers: ${formatFullDate(m.lastOfferSyncAt)}`}
|
||||||
|
>
|
||||||
|
Offers: {formatRelativeTime(m.lastOfferSyncAt)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
|
|
||||||
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
{/* Actions Dropdown */}
|
||||||
{formatDate(m.lastOfferSyncAt)}
|
<td className="py-3 px-4">
|
||||||
</td>
|
<div className="flex items-center justify-end gap-2">
|
||||||
|
{/* Primary Save Button */}
|
||||||
<td className="py-2 pl-3">
|
|
||||||
<div className="flex flex-col items-end gap-1">
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleSave(m.id)}
|
onClick={() => handleSave(m.id)}
|
||||||
disabled={savingId === m.id}
|
disabled={savingId === m.id}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-medium transition-colors ${
|
className={`rounded px-3 py-1.5 text-xs font-medium transition-colors ${
|
||||||
savingId === m.id
|
savingId === m.id
|
||||||
? "bg-amber-400/20 text-amber-200 cursor-wait"
|
? "bg-amber-400/20 text-amber-200 cursor-wait"
|
||||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
{savingId === m.id ? "Saving…" : "Save"}
|
{savingId === m.id ? (
|
||||||
|
<span className="flex items-center gap-1">
|
||||||
|
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Saving
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Save"
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
|
{/* Dropdown Menu */}
|
||||||
|
<div className="relative">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
onClick={() =>
|
||||||
|
setOpenDropdown(openDropdown === m.id ? null : m.id)
|
||||||
|
}
|
||||||
|
className="p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
|
||||||
|
title="More actions"
|
||||||
|
>
|
||||||
|
<svg className="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<path d="M12 8c1.1 0 2-.9 2-2s-.9-2-2-2-2 .9-2 2 .9 2 2 2zm0 2c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2zm0 6c-1.1 0-2 .9-2 2s.9 2 2 2 2-.9 2-2-.9-2-2-2z" />
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
|
|
||||||
|
{openDropdown === m.id && (
|
||||||
|
<>
|
||||||
|
<div
|
||||||
|
className="fixed inset-0 z-10"
|
||||||
|
onClick={() => setOpenDropdown(null)}
|
||||||
|
/>
|
||||||
|
<div className="absolute right-0 mt-1 w-48 rounded-md border border-zinc-700 bg-zinc-900 shadow-lg z-20">
|
||||||
|
<div className="py-1">
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRunFullImport(m.id)}
|
onClick={() => handleRunFullImport(m.id)}
|
||||||
disabled={importingId === m.id}
|
disabled={importingId === m.id}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-wait"
|
||||||
importingId === m.id ? "opacity-60 cursor-wait" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{importingId === m.id ? "Importing…" : "Run Full Import"}
|
{importingId === m.id ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Importing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Run Full Import"
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
|
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
onClick={() => handleRunOfferSync(m.id)}
|
onClick={() => handleRunOfferSync(m.id)}
|
||||||
disabled={offerSyncId === m.id}
|
disabled={offerSyncId === m.id}
|
||||||
className={`rounded-md px-3 py-1.5 text-xs font-medium border border-zinc-700 bg-zinc-900 text-zinc-200 hover:bg-zinc-800 transition-colors ${
|
className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-wait"
|
||||||
offerSyncId === m.id ? "opacity-60 cursor-wait" : ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{offerSyncId === m.id ? "Syncing Offers…" : "Run Offer Sync"}
|
{offerSyncId === m.id ? (
|
||||||
|
<span className="flex items-center gap-2">
|
||||||
|
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||||
|
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||||
|
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||||
|
</svg>
|
||||||
|
Syncing...
|
||||||
|
</span>
|
||||||
|
) : (
|
||||||
|
"Run Offer Sync"
|
||||||
|
)}
|
||||||
</button>
|
</button>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
</td>
|
</td>
|
||||||
</tr>
|
</tr>
|
||||||
))}
|
))}
|
||||||
|
|||||||
+4
-2
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
|||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
|
|
||||||
const API_BASE_URL =
|
const API_BASE_URL =
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
type AdminOverview = {
|
type AdminOverview = {
|
||||||
totalProducts: number;
|
totalProducts: number;
|
||||||
@@ -24,7 +24,9 @@ export default function AdminLandingPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`);
|
const res = await fetch('/api/admin/dashboard/overview', {
|
||||||
|
credentials: 'include',
|
||||||
|
});
|
||||||
if (!res.ok) {
|
if (!res.ok) {
|
||||||
throw new Error(`Failed to load dashboard (${res.status})`);
|
throw new Error(`Failed to load dashboard (${res.status})`);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -3,7 +3,6 @@
|
|||||||
|
|
||||||
import { useEffect, useMemo, useState } from "react";
|
import { useEffect, useMemo, useState } from "react";
|
||||||
import { Plus, Pencil, Trash2, X } from "lucide-react";
|
import { Plus, Pencil, Trash2, X } from "lucide-react";
|
||||||
import { useAuth } from "@/context/AuthContext";
|
|
||||||
import {
|
import {
|
||||||
createPlatform,
|
createPlatform,
|
||||||
deletePlatform,
|
deletePlatform,
|
||||||
@@ -15,7 +14,6 @@ import {
|
|||||||
} from "@/lib/api/platforms";
|
} from "@/lib/api/platforms";
|
||||||
|
|
||||||
export default function AdminPlatformsPage() {
|
export default function AdminPlatformsPage() {
|
||||||
const { getAuthHeaders } = useAuth();
|
|
||||||
|
|
||||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
@@ -36,7 +34,7 @@ export default function AdminPlatformsPage() {
|
|||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
const data = await fetchPlatforms(getAuthHeaders());
|
const data = await fetchPlatforms({});
|
||||||
setPlatforms(data);
|
setPlatforms(data);
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -71,12 +69,13 @@ 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);
|
||||||
setError(null);
|
setError(null);
|
||||||
await deletePlatform(getAuthHeaders(), p.id);
|
await deletePlatform({}, p.id);
|
||||||
await load();
|
await load();
|
||||||
} catch (e: any) {
|
} catch (e: any) {
|
||||||
console.error(e);
|
console.error(e);
|
||||||
@@ -102,14 +101,15 @@ export default function AdminPlatformsPage() {
|
|||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
if (modalMode === "create") {
|
if (modalMode === "create") {
|
||||||
await createPlatform(getAuthHeaders(), { ...form, key, label });
|
await createPlatform({}, { ...form, key, label });
|
||||||
} else if (selected) {
|
} else if (selected) {
|
||||||
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({}, selected.id, update);
|
||||||
}
|
}
|
||||||
|
|
||||||
setShowModal(false);
|
setShowModal(false);
|
||||||
@@ -164,7 +164,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 +247,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 +268,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 +281,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 +303,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 { 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,114 @@
|
|||||||
|
import { API_BASE, qs } from "./auth";
|
||||||
|
import type {
|
||||||
|
AdminProductRow,
|
||||||
|
PageResponse,
|
||||||
|
PlatformDto,
|
||||||
|
ProductStatus,
|
||||||
|
ProductVisibility,
|
||||||
|
} from "./types";
|
||||||
|
|
||||||
|
import type { CaliberDto } from "./types";
|
||||||
|
|
||||||
|
export async function fetchAdminRoles(params: Record<string, any>) {
|
||||||
|
const queryString = qs(params);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/products/roles${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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 queryString = qs(params);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/products/calibers${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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 queryString = qs(params);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/products${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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 res = await fetch(`/api/admin/products/bulk`, {
|
||||||
|
method: "PATCH",
|
||||||
|
credentials: "include",
|
||||||
|
headers: {
|
||||||
|
"Content-Type": "application/json",
|
||||||
|
},
|
||||||
|
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 queryString = qs(params);
|
||||||
|
const res = await fetch(
|
||||||
|
`/api/admin/calibers${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
credentials: "include",
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
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();
|
||||||
@@ -17,11 +17,12 @@ export default function AdminUsersPage() {
|
|||||||
const [error, setError] = useState<string | null>(null);
|
const [error, setError] = useState<string | null>(null);
|
||||||
|
|
||||||
async function fetchUsers() {
|
async function fetchUsers() {
|
||||||
|
console.log("in the users fetch");
|
||||||
try {
|
try {
|
||||||
setLoading(true);
|
setLoading(true);
|
||||||
setError(null);
|
setError(null);
|
||||||
|
|
||||||
const res = await fetch(`${API_BASE_URL}/admin/users`, {
|
const res = await fetch(`/api/admin/users`, {
|
||||||
headers: {
|
headers: {
|
||||||
...getAuthHeaders(),
|
...getAuthHeaders(),
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -2,11 +2,13 @@ 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 cookieStore = await cookies();
|
||||||
|
|
||||||
|
const token = cookieStore.get("session_token")?.value;
|
||||||
|
|
||||||
if (!token) {
|
if (!token) {
|
||||||
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
||||||
|
|||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||||
|
|
||||||
|
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
||||||
|
export async function POST(req: Request) {
|
||||||
|
const token = cookies().get("session_token")?.value;
|
||||||
|
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const dryRun = url.searchParams.get("dryRun") ?? "true";
|
||||||
|
const limit = url.searchParams.get("limit") ?? "0";
|
||||||
|
const tokenMinutes = url.searchParams.get("tokenMinutes") ?? "30";
|
||||||
|
|
||||||
|
const upstream = `${API_BASE_URL}/api/v1/admin/beta/invites/send?dryRun=${dryRun}&limit=${limit}&tokenMinutes=${tokenMinutes}`;
|
||||||
|
|
||||||
|
const res = await fetch(upstream, {
|
||||||
|
method: "POST",
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
},
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
const text = await res.text();
|
||||||
|
return new NextResponse(text, {
|
||||||
|
status: res.status,
|
||||||
|
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
|
||||||
|
});
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get("session_token")?.value;
|
||||||
|
if (!token) throw new Error("Unauthorized");
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/beta/requests/${resolvedParams.id}/invite`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get("session_token")?.value;
|
||||||
|
if (!token) throw new Error("Unauthorized");
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
_req: Request,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/beta/requests/${params.id}/invite`,
|
||||||
|
{
|
||||||
|
method: "POST",
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get("session_token")?.value;
|
||||||
|
if (!token) throw new Error("Unauthorized");
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const search = url.searchParams.toString();
|
||||||
|
|
||||||
|
const upstream = `${API_BASE_URL}/api/v1/admin/beta/requests${
|
||||||
|
search ? `?${search}` : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const res = await fetch(upstream, {
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get("session_token")?.value;
|
||||||
|
if (!token) throw new Error("Unauthorized");
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(req: Request) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const url = new URL(req.url);
|
||||||
|
const search = url.searchParams.toString();
|
||||||
|
|
||||||
|
const upstream = `${API_BASE_URL}/api/v1/admin/beta/requests${
|
||||||
|
search ? `?${search}` : ""
|
||||||
|
}`;
|
||||||
|
|
||||||
|
const res = await fetch(upstream, {
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,42 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/calibers${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/calibers${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,66 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get("session_token")?.value;
|
||||||
|
if (!token) throw new Error("Unauthorized");
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/email/${resolvedParams.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/email/${resolvedParams.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,63 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get("session_token")?.value;
|
||||||
|
if (!token) throw new Error("Unauthorized");
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
|
||||||
|
method: "PUT",
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
|
||||||
|
method: "DELETE",
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,57 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get("session_token")?.value;
|
||||||
|
if (!token) throw new Error("Unauthorized");
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,56 @@
|
|||||||
|
import { NextRequest, NextResponse } from "next/server";
|
||||||
|
import { cookies } from "next/headers";
|
||||||
|
|
||||||
|
const API_BASE_URL =
|
||||||
|
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get("session_token")?.value;
|
||||||
|
if (!token) throw new Error("Unauthorized");
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
||||||
|
headers,
|
||||||
|
cache: "no-store",
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
||||||
|
method: "POST",
|
||||||
|
headers: { ...headers, "Content-Type": "application/json" },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,52 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string; action: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const resolvedParams = await params;
|
||||||
|
|
||||||
|
const { id, action } = resolvedParams;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/enrichment/${id}/${action}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string; action: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const { id, action } = params;
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/enrichment/${id}/${action}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/enrichment/ai/run${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/enrichment/ai/run${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { fixImageUrls } from '@/lib/server/image-utils';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/enrichment/queue2${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Fix mixed content warnings: upgrade HTTP image URLs to HTTPS
|
||||||
|
return NextResponse.json(fixImageUrls(data));
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
import { fixImageUrls } from '@/lib/server/image-utils';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/enrichment/queue2${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const data = await res.json();
|
||||||
|
|
||||||
|
// Fix mixed content warnings: upgrade HTTP image URLs to HTTPS
|
||||||
|
return NextResponse.json(fixImageUrls(data));
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,48 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/enrichment/run${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,46 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/enrichment/run${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
Authorization: `Bearer ${token}`,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
const contentType = res.headers.get('content-type');
|
||||||
|
if (contentType?.includes('application/json')) {
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ success: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Internal server error' },
|
||||||
|
{ status: 500 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/summary`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/summary`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,36 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/mapping/raw-categories?${searchParams.toString()}`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,39 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const { searchParams } = new URL(request.url);
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/admin/mapping/raw-categories?${searchParams.toString()}`,
|
||||||
|
{
|
||||||
|
headers,
|
||||||
|
cache: 'no-store',
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,41 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,40 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}/import`,
|
||||||
|
{ method: 'POST', headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/import`,
|
||||||
|
{ method: 'POST', headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}/offer-sync`,
|
||||||
|
{ method: 'POST', headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,35 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/offer-sync`,
|
||||||
|
{ method: 'POST', headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,94 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
|
||||||
|
{ method: 'DELETE', headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,90 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
|
||||||
|
{ headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PUT(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
|
||||||
|
{
|
||||||
|
method: 'PUT',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
|
||||||
|
{ method: 'DELETE', headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,55 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,54 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET() {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||||
|
headers,
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,71 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: Promise<{ id: string }> }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const resolvedParams = await params;
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
|
||||||
|
{ method: 'DELETE', headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,68 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function PATCH(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
|
||||||
|
{
|
||||||
|
method: 'PATCH',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function DELETE(
|
||||||
|
request: NextRequest,
|
||||||
|
{ params }: { params: { id: string } }
|
||||||
|
) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
|
||||||
|
{ method: 'DELETE', headers }
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json({ ok: true });
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,73 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const token = cookieStore.get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/platforms${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/admin/platforms`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,72 @@
|
|||||||
|
import { NextRequest, NextResponse } from 'next/server';
|
||||||
|
import { cookies } from 'next/headers';
|
||||||
|
|
||||||
|
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||||
|
|
||||||
|
async function getAuthHeader() {
|
||||||
|
const token = cookies().get('session_token')?.value;
|
||||||
|
if (!token) throw new Error('Unauthorized');
|
||||||
|
return { Authorization: `Bearer ${token}` };
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function GET(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const queryString = searchParams.toString();
|
||||||
|
|
||||||
|
const res = await fetch(
|
||||||
|
`${API_BASE_URL}/api/v1/admin/platforms${queryString ? `?${queryString}` : ''}`,
|
||||||
|
{
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
Accept: 'application/json',
|
||||||
|
},
|
||||||
|
}
|
||||||
|
);
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function POST(request: NextRequest) {
|
||||||
|
try {
|
||||||
|
const headers = await getAuthHeader();
|
||||||
|
const body = await request.json();
|
||||||
|
|
||||||
|
const res = await fetch(`${API_BASE_URL}/api/v1/admin/platforms`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: {
|
||||||
|
...headers,
|
||||||
|
'Content-Type': 'application/json',
|
||||||
|
},
|
||||||
|
body: JSON.stringify(body),
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!res.ok) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: await res.text() },
|
||||||
|
{ status: res.status }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return NextResponse.json(await res.json());
|
||||||
|
} catch (err: any) {
|
||||||
|
return NextResponse.json(
|
||||||
|
{ error: err?.message || 'Unauthorized' },
|
||||||
|
{ status: 401 }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user