Compare commits
117 Commits
it-works
...
47d5d17d29
| Author | SHA1 | Date | |
|---|---|---|---|
| 47d5d17d29 | |||
| c686da0c03 | |||
| 680d2b21a1 | |||
| c86d49f317 | |||
| b8a0fd1963 | |||
| 00613ebc9f | |||
| 05a4ad2720 | |||
| eb1dfef7b7 | |||
| 10148acaa9 | |||
| 019e1892de | |||
| 668398e66a | |||
| f12e2ffee7 | |||
| 3a345b6c26 | |||
| 754a378782 | |||
| 37921469d5 | |||
| 5526920ff8 | |||
| fb52521495 | |||
| f8265063d5 | |||
| 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 | |||
| 8d7809ccf1 | |||
| e202d811ed | |||
| c8bf032f7a | |||
| 5401f6464d | |||
| 57034eefc3 | |||
| bb8ddb6823 | |||
| 70fac2857e | |||
| c01aabb003 | |||
| 061d737c6c | |||
| 34e915f904 | |||
| b1a8dae8ed | |||
| 13edcdce69 | |||
| 856b99b933 | |||
| 008936ff98 | |||
| 47d5cf239d | |||
| 8d9013d0dd | |||
| c06696e66d | |||
| 54ea3243fd | |||
| 61935982b3 | |||
| b224e2d0e0 | |||
| f81974cc0b | |||
| b76ced45f1 | |||
| c6d1bce771 | |||
| 7f3818f795 | |||
| 463fd06a12 | |||
| 4c2d767d09 | |||
| 10c0bd953b | |||
| 607939c468 | |||
| 0f10ff4e09 | |||
| 978b9df96c | |||
| 3ddb48fe58 | |||
| e200667611 | |||
| 4e74a38287 | |||
| 7f59e8fb27 | |||
| e16271663e | |||
| e2d0fb8003 | |||
| 1757aba3e7 | |||
| 777618f684 | |||
| b26dcb947e | |||
| 54c30b1d8a | |||
| 1c4fbed34a | |||
| 8b6d9a015e | |||
| 7c2206ffc3 | |||
| 6b0e0334b0 | |||
| 2cd871b529 | |||
| ce05593127 | |||
| be03424e26 | |||
| fa710d35a1 | |||
| 6863b3618c | |||
| b4177883f8 | |||
| 48e677bcfb |
@@ -1 +0,0 @@
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
BACKEND_BASE_URL=http://localhost:8080
|
||||
|
||||
# Middleware to limited site to only root page
|
||||
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
|
||||
|
||||
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
|
||||
|
||||
@@ -0,0 +1,9 @@
|
||||
|
||||
NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
||||
|
||||
# Middleware to limited site to only root page
|
||||
LAUNCH_ONLY_ROOT=false
|
||||
|
||||
NEXT_PUBLIC_SHORTLINK_BASE_URL=https://battl.build
|
||||
|
||||
|
||||
@@ -0,0 +1,20 @@
|
||||
name: CI
|
||||
|
||||
on:
|
||||
push:
|
||||
branches:
|
||||
- develop
|
||||
|
||||
jobs:
|
||||
deploy:
|
||||
runs-on: ubuntu
|
||||
steps:
|
||||
- uses: actions/checkout@v4
|
||||
|
||||
- name: Pull latest images
|
||||
run: |
|
||||
docker pull gitea.gofwd.group/forward_group/ballistic-builder-spring/spring-api:latest
|
||||
docker pull gitea.gofwd.group/sean/shadow-gunbuilder-ai-proto/webui:latest
|
||||
|
||||
- name: Recreate containers with new images
|
||||
run: docker compose up -d
|
||||
@@ -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
+8
@@ -0,0 +1,8 @@
|
||||
# Default ignored files
|
||||
/shelf/
|
||||
/workspace.xml
|
||||
# Editor-based HTTP Client requests
|
||||
/httpRequests/
|
||||
# Datasource local storage ignored files
|
||||
/dataSources/
|
||||
/dataSources.local.xml
|
||||
Generated
+17
@@ -0,0 +1,17 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourceManagerImpl" format="xml" multifile-model="true">
|
||||
<data-source source="LOCAL" name="postgres@r710.dev.gofwd.group" uuid="54ed6f82-1373-4766-8855-7dd5f698a5ec">
|
||||
<driver-ref>postgresql</driver-ref>
|
||||
<synchronize>true</synchronize>
|
||||
<jdbc-driver>org.postgresql.Driver</jdbc-driver>
|
||||
<jdbc-url>jdbc:postgresql://r710.dev.gofwd.group:5433/postgres</jdbc-url>
|
||||
<jdbc-additional-properties>
|
||||
<property name="com.intellij.clouds.kubernetes.db.host.port" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.enabled" value="false" />
|
||||
<property name="com.intellij.clouds.kubernetes.db.container.port" />
|
||||
</jdbc-additional-properties>
|
||||
<working-dir>$ProjectFileDir$</working-dir>
|
||||
</data-source>
|
||||
</component>
|
||||
</project>
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="DataSourcePerFileMappings">
|
||||
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" value="54ed6f82-1373-4766-8855-7dd5f698a5ec" />
|
||||
</component>
|
||||
</project>
|
||||
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>
|
||||
+6
@@ -0,0 +1,6 @@
|
||||
<component name="InspectionProjectProfileManager">
|
||||
<profile version="1.0">
|
||||
<option name="myName" value="Project Default" />
|
||||
<inspection_tool class="Eslint" enabled="true" level="WARNING" enabled_by_default="true" />
|
||||
</profile>
|
||||
</component>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="ProjectModuleManager">
|
||||
<modules>
|
||||
<module fileurl="file://$PROJECT_DIR$/.idea/shadow-gunbuilder-ai-proto.iml" filepath="$PROJECT_DIR$/.idea/shadow-gunbuilder-ai-proto.iml" />
|
||||
</modules>
|
||||
</component>
|
||||
</project>
|
||||
Generated
Generated
+9
@@ -0,0 +1,9 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<module type="JAVA_MODULE" version="4">
|
||||
<component name="NewModuleRootManager" inherit-compiler-output="true">
|
||||
<exclude-output />
|
||||
<content url="file://$MODULE_DIR$" />
|
||||
<orderEntry type="inheritedJdk" />
|
||||
<orderEntry type="sourceFolder" forTests="false" />
|
||||
</component>
|
||||
</module>
|
||||
Generated
+8
@@ -0,0 +1,8 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="SqlDialectMappings">
|
||||
<file url="file://$PROJECT_DIR$/.idea/queries/Query.sql" dialect="PostgreSQL" />
|
||||
<file url="file://$PROJECT_DIR$/schema-example.sql" dialect="PostgreSQL" />
|
||||
<file url="PROJECT" dialect="PostgreSQL" />
|
||||
</component>
|
||||
</project>
|
||||
Generated
+1
-1
@@ -3,4 +3,4 @@
|
||||
<component name="VcsDirectoryMappings">
|
||||
<mapping directory="$PROJECT_DIR$" vcs="Git" />
|
||||
</component>
|
||||
</project>
|
||||
</project>
|
||||
|
||||
Generated
-93
@@ -1,93 +0,0 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="AutoImportSettings">
|
||||
<option name="autoReloadType" value="SELECTIVE" />
|
||||
</component>
|
||||
<component name="ChangeListManager">
|
||||
<list default="true" id="261566a2-3672-4898-b754-916bbdb2063b" name="Changes" comment="">
|
||||
<change beforePath="$PROJECT_DIR$/next-env.d.ts" beforeDir="false" afterPath="$PROJECT_DIR$/next-env.d.ts" afterDir="false" />
|
||||
<change beforePath="$PROJECT_DIR$/tsconfig.json" beforeDir="false" afterPath="$PROJECT_DIR$/tsconfig.json" afterDir="false" />
|
||||
</list>
|
||||
<option name="SHOW_DIALOG" value="false" />
|
||||
<option name="HIGHLIGHT_CONFLICTS" value="true" />
|
||||
<option name="HIGHLIGHT_NON_ACTIVE_CHANGELIST" value="false" />
|
||||
<option name="LAST_RESOLUTION" value="IGNORE" />
|
||||
</component>
|
||||
<component name="Git.Settings">
|
||||
<option name="RECENT_GIT_ROOT_PATH" value="$PROJECT_DIR$" />
|
||||
</component>
|
||||
<component name="ProjectColorInfo">{
|
||||
"associatedIndex": 1
|
||||
}</component>
|
||||
<component name="ProjectId" id="361l08WqyxLtDro87xvcOWn3FhH" />
|
||||
<component name="ProjectViewState">
|
||||
<option name="hideEmptyMiddlePackages" value="true" />
|
||||
<option name="showLibraryContents" value="true" />
|
||||
</component>
|
||||
<component name="PropertiesComponent">{
|
||||
"keyToString": {
|
||||
"ModuleVcsDetector.initialDetectionPerformed": "true",
|
||||
"RunOnceActivity.ShowReadmeOnStart": "true",
|
||||
"RunOnceActivity.TerminalTabsStorage.copyFrom.TerminalArrangementManager.252": "true",
|
||||
"RunOnceActivity.git.unshallow": "true",
|
||||
"git-widget-placeholder": "main",
|
||||
"ignore.virus.scanning.warn.message": "true",
|
||||
"js.debugger.nextJs.config.created.client": "true",
|
||||
"js.debugger.nextJs.config.created.server": "true",
|
||||
"kotlin-language-version-configured": "true",
|
||||
"last_opened_file_path": "//wsl.localhost/Arch/home/don/code/shadow-gunbuilder-ai-proto",
|
||||
"node.js.detected.package.eslint": "true",
|
||||
"node.js.detected.package.tslint": "true",
|
||||
"node.js.selected.package.eslint": "(autodetect)",
|
||||
"node.js.selected.package.tslint": "(autodetect)",
|
||||
"nodejs_package_manager_path": "npm",
|
||||
"vue.rearranger.settings.migration": "true"
|
||||
}
|
||||
}</component>
|
||||
<component name="RunManager" selected="npm.Next.js: server-side">
|
||||
<configuration name="Next.js: debug client-side" type="JavascriptDebugType" uri="http://localhost:3000/">
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
<configuration name="Next.js: server-side" type="js.build_tools.npm">
|
||||
<package-json value="$PROJECT_DIR$/package.json" />
|
||||
<command value="run" />
|
||||
<scripts>
|
||||
<script value="dev" />
|
||||
</scripts>
|
||||
<node-interpreter value="project" />
|
||||
<envs />
|
||||
<method v="2" />
|
||||
</configuration>
|
||||
</component>
|
||||
<component name="SharedIndexes">
|
||||
<attachedChunks>
|
||||
<set>
|
||||
<option value="bundled-jdk-9823dce3aa75-bf35d07a577b-intellij.indexing.shared.core-IU-252.27397.103" />
|
||||
<option value="bundled-js-predefined-d6986cc7102b-3aa1da707db6-JavaScript-IU-252.27397.103" />
|
||||
</set>
|
||||
</attachedChunks>
|
||||
</component>
|
||||
<component name="TaskManager">
|
||||
<task active="true" id="Default" summary="Default task">
|
||||
<changelist id="261566a2-3672-4898-b754-916bbdb2063b" name="Changes" comment="" />
|
||||
<created>1764181733358</created>
|
||||
<option name="number" value="Default" />
|
||||
<option name="presentableId" value="Default" />
|
||||
<updated>1764181733358</updated>
|
||||
<workItem from="1764181734592" duration="78000" />
|
||||
<workItem from="1764181824336" duration="9367000" />
|
||||
</task>
|
||||
<servers />
|
||||
</component>
|
||||
<component name="TypeScriptGeneratedFilesManager">
|
||||
<option name="version" value="3" />
|
||||
</component>
|
||||
<component name="github-copilot-workspace">
|
||||
<instructionFileLocations>
|
||||
<option value=".github/instructions" />
|
||||
</instructionFileLocations>
|
||||
<promptFileLocations>
|
||||
<option value=".github/prompts" />
|
||||
</promptFileLocations>
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1,68 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server at localhost:3000
|
||||
npm run build # Production build (TS errors and ESLint are suppressed — see next.config.mjs)
|
||||
npm run lint # ESLint check
|
||||
```
|
||||
|
||||
No test framework is configured.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
`.env.local` for development:
|
||||
```
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
BACKEND_BASE_URL=http://localhost:8080
|
||||
LAUNCH_ONLY_ROOT=false # Set to "true" to restrict unauthenticated users to root page only
|
||||
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
In Docker, the Spring Boot service is resolved as `http://battlbuilder-api:8080` (the default in `lib/springProxy.ts` when `SPRING_BASE_URL` is unset).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two backends
|
||||
1. **Spring Boot** — primary data backend. All product catalog, builds, auth, merchant/admin data lives here.
|
||||
2. **Sanity CMS** — content only (guides/blog posts). Project `zal102rv`, dataset `production`. Queries in `lib/sanity.ts`.
|
||||
|
||||
### API proxy pattern
|
||||
Next.js API routes (`app/api/`) act as a thin proxy to Spring Boot. Client code never calls Spring Boot directly. The helper `lib/springProxy.ts` forwards cookies and auth headers to Spring Boot and handles response serialization.
|
||||
|
||||
### Authentication
|
||||
- **Session cookie**: `session_token` HTTP-only cookie set by Spring Boot, forwarded by the proxy.
|
||||
- **Client state**: `AuthContext` (`context/AuthContext.tsx`) hydrates from `localStorage` on mount, then verifies against `/api/auth/me`.
|
||||
- **Middleware**: `middleware.ts` enforces route protection. Public paths are whitelisted; `/admin/*` requires login; `/builder`, `/builds`, `/vault` are gated only when `LAUNCH_ONLY_ROOT=true`.
|
||||
|
||||
### Route groups
|
||||
- `app/(app)/` — main consumer app layout (TopNav + Footer)
|
||||
- `app/(app)/(builder)/` — builder + parts browser (BuilderNav layout)
|
||||
- `app/(account)/` — account settings pages (AccountChrome sidebar layout)
|
||||
- `app/admin/` — internal admin tools (AdminLeftNavigation layout)
|
||||
- `app/api/` — 50 server-side route handlers proxying to Spring Boot
|
||||
|
||||
### Builder domain model
|
||||
The builder is AR-platform-centric. Key concepts:
|
||||
|
||||
- **`BuilderSlotKey`** (`types/builderSlots.ts`) — the canonical kebab-case category ID (e.g., `"complete-upper"`, `"barrel"`). This is the source of truth for localStorage keys, URL state, and backend part role mapping.
|
||||
- **`CATEGORY_TO_PART_ROLES` / `PART_ROLE_TO_CATEGORY`** (`lib/catalogMappings.ts`) — bidirectional mapping between `BuilderSlotKey` and backend `partRole` strings. Update here when the backend adds new part roles.
|
||||
- **`BUILDER_SLOTS`** (`data/builderSlots.ts`) — defines slot groups (LOWER, UPPER, SYSTEM, ACCESSORIES) and satisfaction patterns (e.g., a complete lower satisfies the lower assembly slot).
|
||||
- **`BuildState`** — `Partial<Record<BuilderSlotKey, string>>` stored in `localStorage`. Product IDs are the values.
|
||||
- **Overlap detection** (`lib/buildOverlaps.ts`) — warns users when they select both a complete assembly and individual sub-parts.
|
||||
|
||||
### Legacy key aliases
|
||||
`types/builderSlots.ts` exports `normalizeSlotKey()` which maps camelCase legacy keys (e.g., `completeUpper`) to current kebab-case keys. This is needed for backwards-compatible URL/localStorage deserialization.
|
||||
|
||||
### API client pattern
|
||||
- Client components use `useApi()` hook (`lib/api.ts`) for fetch calls with `credentials: 'include'`.
|
||||
- Admin-specific API calls live in `lib/api/` (e.g., `adminCategoryMappings.ts`, `adminMerchantMappings.ts`).
|
||||
- Product catalog helpers are in `lib/catalog.ts`.
|
||||
|
||||
### Sanity content
|
||||
- `lib/sanity.ts` — Sanity client + GROQ queries for posts.
|
||||
- `lib/sanityFetch.ts` — low-level HTTPS fetch to Sanity IP (bypasses CDN DNS issues in server/Docker environments).
|
||||
- Guides at `/guides/[slug]` are the only public Sanity-driven pages.
|
||||
@@ -11,7 +11,7 @@ This is an internal tool built for speed, clarity, and capability.
|
||||
# 🚀 What This Admin UI Does
|
||||
|
||||
## Merchant Feeds
|
||||
Path: `/gunbuilder/admin/merchants`
|
||||
Path: `//admin/merchants`
|
||||
|
||||
A clean dashboard for all merchants feeding the system.
|
||||
|
||||
@@ -26,7 +26,7 @@ Purpose: keep the catalog alive, current, and trustworthy.
|
||||
---
|
||||
|
||||
## Category Mapping
|
||||
Path: `/gunbuilder/admin/category-mapping`
|
||||
Path: `/builder/admin/category-mapping`
|
||||
|
||||
This is where raw merchant chaos gets cleaned up.
|
||||
|
||||
@@ -42,7 +42,7 @@ Once mapped, future imports classify parts automatically.
|
||||
|
||||
## Builder Integration
|
||||
|
||||
The admin UI powers the consumer-facing Builder at `/gunbuilder`.
|
||||
The admin UI powers the consumer-facing Builder at `/builder`.
|
||||
|
||||
Today:
|
||||
- Load compatible parts
|
||||
@@ -80,13 +80,13 @@ pnpm install
|
||||
Create `.env.local`:
|
||||
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_ADMIN_BASE_PATH=/gunbuilder/admin
|
||||
NEXT_PUBLIC_ADMIN_BASE_PATH=/builder/admin
|
||||
|
||||
## Start Dev Server
|
||||
npm run dev
|
||||
|
||||
Then visit:
|
||||
http://localhost:3000/gunbuilder/admin
|
||||
http://localhost:3000/builder/admin
|
||||
|
||||
---
|
||||
|
||||
@@ -103,7 +103,7 @@ If CORS fails, the admin cannot communicate with the API.
|
||||
# 🧭 Project Structure
|
||||
|
||||
app/
|
||||
gunbuilder/
|
||||
builder/
|
||||
admin/
|
||||
merchants/
|
||||
category-mapping/
|
||||
|
||||
@@ -0,0 +1,14 @@
|
||||
// app/(account)/AccountChrome.tsx
|
||||
"use client";
|
||||
|
||||
import TopNav from "@/components/TopNav";
|
||||
import BuilderNav from "@/components/BuilderNav";
|
||||
|
||||
export default function AccountChrome() {
|
||||
return (
|
||||
<>
|
||||
<TopNav />
|
||||
<BuilderNav />
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default function AccountIndex() {
|
||||
redirect("/account/profile");
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,59 @@
|
||||
import Link from "next/link";
|
||||
|
||||
const nav = [
|
||||
{ name: "Profile", href: "/account/profile" },
|
||||
{ name: "Security", href: "/account/security" },
|
||||
{ name: "My Vault", href: "/vault" },
|
||||
{ name: "Favorite Parts", href: "/account/favorites" },
|
||||
];
|
||||
|
||||
export default function AccountLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-10">
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<div className="text-xs tracking-[0.25em] uppercase text-zinc-400">
|
||||
Account
|
||||
</div>
|
||||
<h1 className="mt-2 text-3xl font-semibold">Settings & Profile</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
Profile, password, and account settings.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Link
|
||||
href="/builder"
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm hover:bg-white/10"
|
||||
>
|
||||
← Back to Builder
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="mt-10 grid grid-cols-1 gap-8 lg:grid-cols-12">
|
||||
{/* Sidebar */}
|
||||
<aside className="lg:col-span-3">
|
||||
<nav className="rounded-xl border border-white/10 bg-white/5 p-2">
|
||||
{nav.map((item) => (
|
||||
<Link
|
||||
key={item.href}
|
||||
href={item.href}
|
||||
className="block rounded-lg px-3 py-2 text-sm text-zinc-200 hover:bg-white/5"
|
||||
>
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
{/* Content */}
|
||||
<main className="lg:col-span-9">{children}</main>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { Suspense } from "react";
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
function BuildLoader() {
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
useEffect(() => {
|
||||
const encoded = searchParams.get("build");
|
||||
|
||||
if (encoded) {
|
||||
try {
|
||||
const decoded = JSON.parse(atob(encoded));
|
||||
if (decoded && typeof decoded === "object") {
|
||||
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
|
||||
}
|
||||
} catch {
|
||||
// Bad payload — just go to builder anyway, it'll load fresh
|
||||
}
|
||||
}
|
||||
|
||||
router.replace("/builder");
|
||||
}, [searchParams, router]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center">
|
||||
<p className="text-sm text-zinc-400">Loading build…</p>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
export default function BuildPage() {
|
||||
return (
|
||||
<Suspense>
|
||||
<BuildLoader />
|
||||
</Suspense>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
// app/(builder)/builder/layout.tsx
|
||||
import type { ReactNode } from "react";
|
||||
|
||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||
return <>{children}</>;
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -0,0 +1,16 @@
|
||||
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
||||
|
||||
export default async function PartsBrowsePage({
|
||||
params,
|
||||
}: {
|
||||
params: Promise<{ partRole: string }>;
|
||||
}) {
|
||||
const { partRole } = await params;
|
||||
|
||||
return (
|
||||
<PartsBrowseClient
|
||||
partRole={decodeURIComponent(partRole)}
|
||||
platform={null} // let client read ?platform=... or default
|
||||
/>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,24 @@
|
||||
// app/(builder)/parts/_components/buildDetailHref.ts
|
||||
|
||||
const toSlug = (str: string) =>
|
||||
str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
|
||||
export type BuildDetailInput = {
|
||||
id: string;
|
||||
name: string;
|
||||
brand?: string;
|
||||
};
|
||||
|
||||
export function buildDetailHref(
|
||||
platform: string,
|
||||
partRole: string,
|
||||
p: BuildDetailInput
|
||||
) {
|
||||
const slug = toSlug(`${p.brand ?? ""} ${p.name ?? ""}`);
|
||||
return `/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRole
|
||||
)}/${p.id}-${slug}`;
|
||||
}
|
||||
@@ -0,0 +1,784 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import type { BuilderSlotKey } from "@/types/builderSlots";
|
||||
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
|
||||
import { trackAffiliateLinkClicked } from "@/lib/analytics";
|
||||
import { ProductImage } from "@/components/parts/ProductImage";
|
||||
|
||||
/**
|
||||
* API Shapes
|
||||
* - OfferFromApi: what /api/v1/products/:id/offers returns
|
||||
* - GunbuilderProductFromApi: product detail contract (v1)
|
||||
*/
|
||||
type OfferFromApi = {
|
||||
id?: string | number;
|
||||
merchantName?: string | null;
|
||||
price?: number | null;
|
||||
originalPrice?: number | null;
|
||||
buyUrl?: string | null;
|
||||
buyShortUrl?: string | null;
|
||||
inStock?: boolean | null;
|
||||
lastUpdated?: string | null;
|
||||
};
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
caliber?: string | null;
|
||||
|
||||
// Optional (legacy fallback label if product.offers is missing)
|
||||
merchantName?: string | null;
|
||||
|
||||
imageUrl?: string | null;
|
||||
mainImageUrl?: string | null;
|
||||
|
||||
buyUrl?: string | null;
|
||||
buyShortUrl?: string | null;
|
||||
inStock?: boolean | null;
|
||||
|
||||
shortDescription?: string | null;
|
||||
description?: string | null;
|
||||
|
||||
// (May be empty depending on endpoint; we now fetch offers separately)
|
||||
offers?: OfferFromApi[] | null;
|
||||
};
|
||||
|
||||
type BuildState = Partial<Record<BuilderSlotKey, string>>;
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||
|
||||
/**
|
||||
* Extract numeric id from slug like: "1217-radian-ar-15..."
|
||||
* This keeps URLs pretty while still letting us fetch by ID.
|
||||
*/
|
||||
function extractNumericId(productSlug: string): number | null {
|
||||
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)}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Derive a friendly merchant label.
|
||||
* Priority:
|
||||
* 1) offer.merchantName (best)
|
||||
* 2) hostname from offer.buyUrl (fallback)
|
||||
* 3) "Retailer"
|
||||
*/
|
||||
function getHostFromUrl(url?: string | null): string | null {
|
||||
if (!url) return null;
|
||||
try {
|
||||
const u = new URL(url);
|
||||
return u.host.replace(/^www\./, "");
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
function toTitleCase(s: string): string {
|
||||
return s
|
||||
.replace(/[-_]+/g, " ")
|
||||
.replace(/\b\w/g, (c) => c.toUpperCase());
|
||||
}
|
||||
|
||||
function merchantLabel(offer: OfferFromApi): string {
|
||||
const name = offer.merchantName?.trim();
|
||||
if (name) return name;
|
||||
|
||||
const host = getHostFromUrl(offer.buyUrl);
|
||||
if (host) return toTitleCase(host.split(".")[0]);
|
||||
|
||||
return "Retailer";
|
||||
}
|
||||
|
||||
/**
|
||||
* Sort offers:
|
||||
* 1) lowest price first
|
||||
* 2) in-stock before out-of-stock
|
||||
* 3) merchant label tiebreaker
|
||||
*/
|
||||
function sortOffers(offers: OfferFromApi[]): OfferFromApi[] {
|
||||
return [...offers].sort((a, b) => {
|
||||
const ap = a.price ?? Number.POSITIVE_INFINITY;
|
||||
const bp = b.price ?? Number.POSITIVE_INFINITY;
|
||||
if (ap !== bp) return ap - bp;
|
||||
|
||||
// in-stock first (false sorts later)
|
||||
const aStock = a.inStock === false ? 1 : 0;
|
||||
const bStock = b.inStock === false ? 1 : 0;
|
||||
if (aStock !== bStock) return aStock - bStock;
|
||||
|
||||
return merchantLabel(a).localeCompare(merchantLabel(b));
|
||||
});
|
||||
}
|
||||
|
||||
export default function ProductDetailsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// 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]
|
||||
);
|
||||
|
||||
/**
|
||||
* categoryId drives builder state selection/removal
|
||||
* (based on partRole -> category mapping).
|
||||
*/
|
||||
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, setBuild] = useState<BuildState>(() => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as BuildState) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
// Keep build in sync if user changes build elsewhere
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
setBuild(stored ? (JSON.parse(stored) as BuildState) : {});
|
||||
} catch {
|
||||
// ignore
|
||||
}
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||
const isSelected =
|
||||
!!categoryId &&
|
||||
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||
|
||||
// Product fetch state
|
||||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Offers fetch state (from /products/:id/offers)
|
||||
const [offersFromApi, setOffersFromApi] = useState<OfferFromApi[]>([]);
|
||||
const [offersLoading, setOffersLoading] = useState(false);
|
||||
|
||||
// Image modal state
|
||||
const [isImageOpen, setIsImageOpen] = useState(false);
|
||||
|
||||
/**
|
||||
* Route normalization:
|
||||
* If platform segment is missing/empty, rewrite to canonical /parts/p route.
|
||||
*/
|
||||
useEffect(() => {
|
||||
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 details from API:
|
||||
* GET /api/v1/products/:id
|
||||
*/
|
||||
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/catalog/products/${numericId}`;
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load product (${res.status})`);
|
||||
}
|
||||
|
||||
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);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
setError(err?.message ?? "Failed to load product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct();
|
||||
return () => controller.abort();
|
||||
}, [numericId]);
|
||||
|
||||
/**
|
||||
* Fetch offers (rich offer data)
|
||||
* GET /api/v1/products/:id/offers
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (!numericId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchOffers() {
|
||||
try {
|
||||
setOffersLoading(true);
|
||||
|
||||
const url = `/api/catalog/products/${numericId}/offers`;
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load offers (${res.status})`);
|
||||
}
|
||||
|
||||
const data: OfferFromApi[] = await res.json();
|
||||
console.log('[DEBUG] Offers from API:', data);
|
||||
setOffersFromApi(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
// offers failing shouldn't kill the page
|
||||
setOffersFromApi([]);
|
||||
} finally {
|
||||
setOffersLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchOffers();
|
||||
return () => controller.abort();
|
||||
}, [numericId]);
|
||||
|
||||
/**
|
||||
* Builder selection toggle:
|
||||
* - If selected -> remove from build
|
||||
* - Else -> add to build
|
||||
*/
|
||||
const handleTogglePart = () => {
|
||||
if (!numericId) return;
|
||||
|
||||
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()}`);
|
||||
};
|
||||
|
||||
const imageUrl = product?.imageUrl ?? product?.mainImageUrl ?? null;
|
||||
|
||||
/**
|
||||
* Offers normalization:
|
||||
* - Prefer offers endpoint data
|
||||
* - Fallback to legacy single-offer derived from product if needed
|
||||
*/
|
||||
const offers = useMemo(() => {
|
||||
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 || product?.buyShortUrl) {
|
||||
console.log('[DEBUG] Using fallback from product, buyShortUrl:', product.buyShortUrl, 'buyUrl:', product.buyUrl);
|
||||
return sortOffers([
|
||||
{
|
||||
merchantName: product.merchantName ?? "Retailer",
|
||||
price: product.price,
|
||||
buyUrl: product.buyShortUrl || product.buyUrl,
|
||||
inStock: product.inStock ?? true,
|
||||
},
|
||||
]);
|
||||
}
|
||||
|
||||
return [];
|
||||
}, [offersFromApi, product]);
|
||||
|
||||
/**
|
||||
* Best offer (sorted offers[0])
|
||||
*/
|
||||
const bestOffer = offers[0] ?? null;
|
||||
|
||||
/**
|
||||
* Helper: identify the best offer row
|
||||
*/
|
||||
const isBestOffer = (offer: OfferFromApi) =>
|
||||
!!bestOffer &&
|
||||
offer.id != null &&
|
||||
bestOffer.id != null &&
|
||||
String(offer.id) === String(bestOffer.id);
|
||||
|
||||
useEffect(() => {
|
||||
console.log("OFFERS", offers);
|
||||
console.log("BEST", bestOffer);
|
||||
}, [offers, bestOffer]);
|
||||
|
||||
// Use best offer for “truth” when available
|
||||
const stockValue = bestOffer?.inStock ?? product?.inStock ?? null;
|
||||
const currentPrice = bestOffer?.price ?? product?.price ?? null;
|
||||
|
||||
if (!categoryId) {
|
||||
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"
|
||||
>
|
||||
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">
|
||||
Offers, pricing placeholders, and builder actions — all on the
|
||||
canonical /parts/p route.
|
||||
</p>
|
||||
</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 */}
|
||||
<div className="space-y-3">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||
{imageUrl ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsImageOpen(true)}
|
||||
className="h-full w-full"
|
||||
title="Click to enlarge"
|
||||
>
|
||||
<ProductImage
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-contain p-2"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
<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 ${
|
||||
stockValue === false
|
||||
? "text-red-300"
|
||||
: "text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
{stockValue === 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" : "Add to Build"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Price history placeholder */}
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
Price History
|
||||
</h3>
|
||||
<span className="text-[10px] text-zinc-600">placeholder</span>
|
||||
</div>
|
||||
<div className="mt-2 h-28 rounded border border-zinc-800 bg-zinc-950 flex items-center justify-center text-xs text-zinc-600">
|
||||
Pricing graph will go here
|
||||
</div>
|
||||
<p className="mt-2 text-[11px] text-zinc-500">
|
||||
We’ll wire this to price snapshots once the offer model is
|
||||
stable.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right */}
|
||||
<div className="min-w-0 space-y-4">
|
||||
{/* Product meta */}
|
||||
<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>
|
||||
{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">
|
||||
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(currentPrice)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(product.shortDescription || product.description) && (
|
||||
<div className="mt-4 border-t border-zinc-800 pt-4">
|
||||
{product.shortDescription ? (
|
||||
<p className="text-sm text-zinc-300">
|
||||
{product.shortDescription}
|
||||
</p>
|
||||
) : null}
|
||||
{product.description ? (
|
||||
<p className="mt-2 whitespace-pre-wrap text-sm text-zinc-400">
|
||||
{product.description}
|
||||
</p>
|
||||
) : null}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Offers */}
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
Merchant Offers
|
||||
</h3>
|
||||
<span className="text-[10px] text-zinc-600">
|
||||
{offersLoading
|
||||
? "loading…"
|
||||
: offers.length
|
||||
? `${offers.length} offers`
|
||||
: "no offers"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{offers.length ? (
|
||||
<div className="mt-3 overflow-x-auto rounded border border-zinc-800">
|
||||
<table className="min-w-full text-sm">
|
||||
<thead className="bg-zinc-900/60">
|
||||
<tr className="text-xs uppercase tracking-[0.14em] text-zinc-500">
|
||||
<th className="px-3 py-2 text-left">Merchant</th>
|
||||
<th className="px-3 py-2 text-left">Stock</th>
|
||||
<th className="px-3 py-2 text-right">Price</th>
|
||||
<th className="px-3 py-2 text-right">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
{offers.map((o, idx) => (
|
||||
<tr
|
||||
key={String(o.id ?? idx)}
|
||||
className={`hover:bg-zinc-900/40 ${
|
||||
isBestOffer(o)
|
||||
? "bg-amber-400/10 border-l-2 border-amber-400"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
<td className="px-3 py-2 text-zinc-200 flex items-center">
|
||||
<span>{merchantLabel(o)}</span>
|
||||
|
||||
{isBestOffer(o) && (
|
||||
<span className="ml-2 inline-flex items-center rounded bg-amber-400 px-2 py-0.5 text-[10px] font-semibold text-black">
|
||||
BEST DEAL
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-xs">
|
||||
{o.inStock === false ? (
|
||||
<span className="text-red-300">
|
||||
Out of stock
|
||||
</span>
|
||||
) : (
|
||||
<span className="text-emerald-300">
|
||||
In stock
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right font-semibold text-amber-300">
|
||||
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{(o.buyShortUrl || o.buyUrl) ? (
|
||||
<a
|
||||
href={o.buyShortUrl || o.buyUrl}
|
||||
target="_blank"
|
||||
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"
|
||||
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(o))}
|
||||
>
|
||||
Buy
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-xs text-zinc-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
) : (
|
||||
<p className="mt-3 text-sm text-zinc-500">
|
||||
No offers attached yet. Once offers are available, this
|
||||
becomes the “where to buy” table.
|
||||
</p>
|
||||
)}
|
||||
|
||||
{/* Best Offer CTA */}
|
||||
{(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<a
|
||||
href={bestOffer.buyShortUrl || bestOffer.buyUrl}
|
||||
target="_blank"
|
||||
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"
|
||||
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(bestOffer))}
|
||||
>
|
||||
Buy from {merchantLabel(bestOffer)} →
|
||||
</a>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{/* Footer tips */}
|
||||
<div className="flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||
{/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||
Tip: Add this part, then go back and compare alternates.
|
||||
</span> */}
|
||||
{/* <span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||
Canonical route:{" "}
|
||||
<span className="text-zinc-300">
|
||||
/parts/p/{platform}/{partRoleParam}/{productSlug}
|
||||
</span>
|
||||
</span> */}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
{/* Image modal */}
|
||||
{isImageOpen && imageUrl ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 flex items-center justify-center bg-black/80 p-4"
|
||||
onClick={() => setIsImageOpen(false)}
|
||||
>
|
||||
<div
|
||||
className="relative max-h-[90vh] max-w-[90vw]"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsImageOpen(false)}
|
||||
className="absolute -top-3 -right-3 rounded-full border border-zinc-700 bg-zinc-900 px-3 py-2 text-xs font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
|
||||
<ProductImage
|
||||
src={imageUrl}
|
||||
alt={product?.name ?? "Product image"}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
|
||||
|
||||
export default function Page({ params }: { params: { platform: string; partRole: string } }) {
|
||||
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client";
|
||||
|
||||
import React from "react";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
<h1 className="text-3xl font-bold tracking-tight mb-4">
|
||||
About Ballistic Builder
|
||||
</h1>
|
||||
|
||||
<p className="text-sm text-muted-foreground mb-8">
|
||||
Last updated: {new Date().getFullYear()}
|
||||
</p>
|
||||
|
||||
<section className="space-y-4">
|
||||
<p className="text-base leading-relaxed">
|
||||
Ballistic Builder is a tooling and catalog platform designed to make
|
||||
it easier to explore, compare, and assemble builds from across
|
||||
multiple merchants. Our goal is to give builders fast, clear,
|
||||
high‑signal information so they can focus on decisions, not data
|
||||
wrangling.
|
||||
</p>
|
||||
|
||||
<p className="text-base leading-relaxed">
|
||||
Behind the scenes, we normalize merchant feeds, track offers, and
|
||||
surface structured parts data. On the front end, we present that data
|
||||
in a way that’s optimized for building, not just browsing.
|
||||
</p>
|
||||
|
||||
<p className="text-base leading-relaxed">
|
||||
This project is under active development. Expect frequent iteration,
|
||||
new features, and occasional sharp edges as we ship and refine.
|
||||
</p>
|
||||
</section>
|
||||
|
||||
<section className="mt-10 space-y-3">
|
||||
<h2 className="text-xl font-semibold">Contact</h2>
|
||||
<p className="text-base leading-relaxed">
|
||||
For questions, feedback, or bug reports, reach out to us at{" "}
|
||||
<a
|
||||
href="mailto:support@example.com"
|
||||
className="underline underline-offset-4"
|
||||
>
|
||||
support@example.com
|
||||
</a>
|
||||
.
|
||||
</p>
|
||||
</section>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,151 @@
|
||||
import { postBySlugQuery, postSlugsQuery, urlFor } from '@/lib/sanity'
|
||||
import { sanityFetch } from '@/lib/sanityFetch'
|
||||
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const revalidate = 60
|
||||
|
||||
type Params = { slug: string }
|
||||
|
||||
type Post = {
|
||||
_id: string
|
||||
title: string
|
||||
slug: { current: string }
|
||||
publishedAt: string
|
||||
excerpt: string | null
|
||||
mainImage: { asset: object; alt?: string } | null
|
||||
body: unknown[]
|
||||
seoTitle: string | null
|
||||
seoDescription: string | null
|
||||
author: { name: string; image?: object; bio?: string } | null
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const slugs: { slug: string }[] = await sanityFetch<{ slug: string }[]>(postSlugsQuery)
|
||||
return slugs.map((s) => ({ slug: s.slug }))
|
||||
} catch (err) {
|
||||
console.error('[Guides] generateStaticParams fetch failed:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
let post: Post | null = null
|
||||
try {
|
||||
post = await sanityFetch<Post | null>(postBySlugQuery, { slug })
|
||||
} catch (err) {
|
||||
console.error('[Guides] generateMetadata fetch failed:', err)
|
||||
}
|
||||
if (!post) return {}
|
||||
|
||||
return {
|
||||
title: post.seoTitle ?? post.title,
|
||||
description: post.seoDescription ?? post.excerpt ?? undefined,
|
||||
openGraph: {
|
||||
title: post.seoTitle ?? post.title,
|
||||
description: post.seoDescription ?? post.excerpt ?? undefined,
|
||||
images: post.mainImage?.asset
|
||||
? [{ url: urlFor(post.mainImage).width(1200).height(630).url() }]
|
||||
: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: { params: Promise<Params> }) {
|
||||
const { slug } = await params
|
||||
let post: Post | null = null
|
||||
try {
|
||||
post = await sanityFetch<Post | null>(postBySlugQuery, { slug })
|
||||
} catch (err) {
|
||||
console.error('[Guides] PostPage fetch failed:', err)
|
||||
}
|
||||
|
||||
if (!post) notFound()
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6 text-xs text-zinc-500">
|
||||
<Link href="/guides" className="hover:text-zinc-300">
|
||||
← Guides
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-8">
|
||||
<div className="mb-3 flex items-center gap-2 text-[0.7rem] text-zinc-500">
|
||||
{post.author?.name && <span>{post.author.name}</span>}
|
||||
{post.author?.name && <span>·</span>}
|
||||
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-zinc-100 md:text-4xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="mt-4 text-base text-zinc-400 leading-relaxed">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Hero image */}
|
||||
{post.mainImage?.asset && (
|
||||
<div className="relative mb-10 h-64 w-full overflow-hidden rounded-xl md:h-80">
|
||||
<Image
|
||||
src={urlFor(post.mainImage).width(1200).height(630).url()}
|
||||
alt={post.mainImage.alt ?? post.title}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{post.body && (
|
||||
<article className="border-b border-zinc-800 pb-10">
|
||||
<PortableTextRenderer value={post.body} />
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* CTA footer */}
|
||||
<div className="mt-10 rounded-xl border border-zinc-800 bg-zinc-950/60 p-6 text-center">
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
Ready to put this into practice?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Use the Battl Builder to price out your parts and track your total.
|
||||
</p>
|
||||
<Link
|
||||
href="/builder"
|
||||
className="mt-4 inline-block rounded-md bg-amber-400 px-5 py-2.5 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Start Your Build →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="mt-8 text-center">
|
||||
<Link href="/guides" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← Back to all guides
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { postsQuery, urlFor } from '@/lib/sanity'
|
||||
import { sanityFetch } from '@/lib/sanityFetch'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Build Guides',
|
||||
description: 'AR-15 build guides, part breakdowns, and buying advice from Battl Builders.',
|
||||
}
|
||||
|
||||
export const revalidate = 60 // ISR — regenerate every 60s
|
||||
|
||||
type Post = {
|
||||
_id: string
|
||||
title: string
|
||||
slug: { current: string }
|
||||
publishedAt: string
|
||||
excerpt: string | null
|
||||
mainImage: { asset: object; alt?: string } | null
|
||||
author: { name: string; image?: object } | null
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export default async function GuidesPage() {
|
||||
let posts: Post[] = []
|
||||
try {
|
||||
posts = await sanityFetch<Post[]>(postsQuery)
|
||||
} catch (err) {
|
||||
console.error('[Guides] Sanity fetch failed:', err)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-10">
|
||||
<header className="mb-10">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
|
||||
Battl Builders
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">
|
||||
Build Guides
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-zinc-400 max-w-xl">
|
||||
Part breakdowns, buying advice, and build walkthroughs to help you
|
||||
make smarter decisions before you spend a dollar.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">No guides published yet — check back soon.</p>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{posts.map((post) => (
|
||||
<article
|
||||
key={post._id}
|
||||
className="group flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-950/60 p-5 transition-colors hover:border-zinc-700 sm:flex-row"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{post.mainImage?.asset && (
|
||||
<div className="relative h-44 w-full shrink-0 overflow-hidden rounded-lg sm:h-32 sm:w-48">
|
||||
<Image
|
||||
src={urlFor(post.mainImage).width(400).height(256).url()}
|
||||
alt={post.mainImage.alt ?? post.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
sizes="(max-width: 640px) 100vw, 192px"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex flex-col justify-center gap-2">
|
||||
<div className="flex items-center gap-2 text-[0.7rem] text-zinc-500">
|
||||
{post.author?.name && <span>{post.author.name}</span>}
|
||||
{post.author?.name && <span>·</span>}
|
||||
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold leading-snug text-zinc-100 group-hover:text-amber-300 transition-colors">
|
||||
<Link href={`/guides/${post.slug.current}`} className="stretched-link">
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="text-sm text-zinc-400 leading-relaxed line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/guides/${post.slug.current}`}
|
||||
className="mt-1 self-start text-xs font-medium text-amber-400 hover:text-amber-300"
|
||||
>
|
||||
Read guide →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-12 rounded-lg border border-zinc-800 bg-zinc-950/60 p-5 text-center">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Ready to start building?{' '}
|
||||
<Link href="/builder" className="font-semibold text-amber-400 hover:text-amber-300">
|
||||
Open the Builder →
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
// app/(builder)/layout.tsx
|
||||
import type { ReactNode } from "react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { BuilderNavConditional } from "@/components/BuilderNavConditional";
|
||||
import { TopNav } from "@/components/TopNav";
|
||||
import { Footer } from "@/components/Footer";
|
||||
|
||||
export default function BuilderLayout({ children }: { children: ReactNode }) {
|
||||
return (
|
||||
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
|
||||
<TopNav />
|
||||
<Suspense fallback={<div className="h-[52px]" />}>
|
||||
<BuilderNavConditional />
|
||||
</Suspense>
|
||||
<main className="min-h-screen">{children}</main>
|
||||
<Footer />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,617 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useApi } from "@/lib/api";
|
||||
import Image from "next/image";
|
||||
|
||||
type BuildDto = {
|
||||
uuid: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
items?: Array<{
|
||||
slot: string;
|
||||
productId: number;
|
||||
productName?: string | null;
|
||||
productBrand?: string | null;
|
||||
bestPrice?: number | string | null; // backend may serialize BigDecimal as string
|
||||
}>;
|
||||
photos?: BuildPhotoDto[];
|
||||
};
|
||||
|
||||
type BuildPhotoDto = {
|
||||
uuid: string;
|
||||
url: string;
|
||||
caption?: string | null;
|
||||
sortOrder?: number | null;
|
||||
createdAt?: string | null;
|
||||
};
|
||||
|
||||
type LocalPreview = {
|
||||
id: string;
|
||||
file: File;
|
||||
url: string; // object URL
|
||||
};
|
||||
|
||||
function fmt(d?: string | null) {
|
||||
if (!d) return "—";
|
||||
const dt = new Date(d);
|
||||
return Number.isNaN(dt.getTime()) ? d : dt.toLocaleString();
|
||||
}
|
||||
|
||||
export default function VaultBuildEditPage() {
|
||||
const { uuid } = useParams<{ uuid: string }>();
|
||||
const router = useRouter();
|
||||
const api = useApi();
|
||||
const { token, loading: authLoading } = useAuth();
|
||||
|
||||
const [build, setBuild] = useState<BuildDto | null>(null);
|
||||
const [form, setForm] = useState({
|
||||
title: "",
|
||||
description: "",
|
||||
isPublic: false,
|
||||
});
|
||||
|
||||
const [busy, setBusy] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [savedMsg, setSavedMsg] = useState<string | null>(null);
|
||||
|
||||
// photo upload UI state
|
||||
const [previews, setPreviews] = useState<LocalPreview[]>([]);
|
||||
const [uploading, setUploading] = useState(false);
|
||||
|
||||
// avoid object URL leaks
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
previews.forEach((p) => URL.revokeObjectURL(p.url));
|
||||
};
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const authed = !!token;
|
||||
|
||||
// Load build once auth is ready + token exists
|
||||
useEffect(() => {
|
||||
if (!uuid) return;
|
||||
if (authLoading) return;
|
||||
|
||||
if (!authed) {
|
||||
setError("Please log in to edit builds.");
|
||||
return;
|
||||
}
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
try {
|
||||
setError(null);
|
||||
const res = await api.get(
|
||||
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(txt || `Failed to load build (${res.status})`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BuildDto;
|
||||
|
||||
if (cancelled) return;
|
||||
setBuild(data);
|
||||
setForm({
|
||||
title: data.title ?? "",
|
||||
description: data.description ?? "",
|
||||
isPublic: Boolean(data.isPublic),
|
||||
});
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message ?? "Failed to load build");
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [uuid, authLoading, authed, api]);
|
||||
|
||||
const dirty = useMemo(() => {
|
||||
if (!build) return false;
|
||||
return (
|
||||
form.title !== (build.title ?? "") ||
|
||||
form.description !== (build.description ?? "") ||
|
||||
form.isPublic !== Boolean(build.isPublic)
|
||||
);
|
||||
}, [build, form]);
|
||||
|
||||
async function onSave() {
|
||||
if (!uuid) return;
|
||||
setBusy(true);
|
||||
setSavedMsg(null);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const payload = {
|
||||
title: form.title.trim() || "Untitled Build",
|
||||
description: form.description.trim() || null,
|
||||
isPublic: form.isPublic,
|
||||
};
|
||||
|
||||
const res = await api.put(
|
||||
`/api/v1/builds/me/${encodeURIComponent(uuid)}`,
|
||||
payload
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(txt || `Save failed (${res.status})`);
|
||||
}
|
||||
|
||||
const updated = (await res.json()) as BuildDto;
|
||||
setBuild(updated);
|
||||
setForm({
|
||||
title: updated.title ?? "",
|
||||
description: updated.description ?? "",
|
||||
isPublic: Boolean(updated.isPublic),
|
||||
});
|
||||
setSavedMsg("Saved.");
|
||||
setTimeout(() => setSavedMsg(null), 2000);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Save failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
function isImage(file: File) {
|
||||
return file.type.startsWith("image/");
|
||||
}
|
||||
|
||||
async function fileToUpload(file: File, buildUuid: string) {
|
||||
// 1) ask backend for presigned upload URL
|
||||
const res = await api.post(
|
||||
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos/upload-url`,
|
||||
{
|
||||
fileName: file.name,
|
||||
contentType: file.type,
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(await res.text().catch(() => "Failed to get upload URL"));
|
||||
}
|
||||
|
||||
const { uploadUrl, publicUrl } = await res.json();
|
||||
|
||||
// 2) upload directly to storage
|
||||
const put = await fetch(uploadUrl, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": file.type },
|
||||
body: file,
|
||||
});
|
||||
|
||||
if (!put.ok) throw new Error("Upload failed");
|
||||
|
||||
// 3) confirm + create photo record
|
||||
const create = await api.post(
|
||||
`/api/v1/builds/me/${encodeURIComponent(buildUuid)}/photos`,
|
||||
{
|
||||
url: publicUrl,
|
||||
caption: null,
|
||||
sortOrder: 0,
|
||||
}
|
||||
);
|
||||
|
||||
if (!create.ok) {
|
||||
throw new Error(await create.text().catch(() => "Failed to save photo"));
|
||||
}
|
||||
|
||||
return (await create.json()) as BuildPhotoDto;
|
||||
}
|
||||
|
||||
async function onDelete() {
|
||||
if (!uuid) return;
|
||||
|
||||
const ok = window.confirm("Delete this build?\n\nThis cannot be undone.");
|
||||
if (!ok) return;
|
||||
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
const res = await api.del(
|
||||
`/api/v1/builds/me/${encodeURIComponent(uuid)}`
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const txt = await res.text().catch(() => "");
|
||||
throw new Error(txt || `Delete failed (${res.status})`);
|
||||
}
|
||||
|
||||
router.replace("/vault");
|
||||
router.refresh();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Delete failed");
|
||||
} finally {
|
||||
setBusy(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl p-6">
|
||||
<div className="mb-4 flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold">Edit Build</h1>
|
||||
<div className="mt-1 text-xs text-zinc-500">
|
||||
UUID: <span className="font-mono">{uuid}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<Link
|
||||
href="/vault"
|
||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-xs hover:bg-white/10"
|
||||
>
|
||||
← Vault
|
||||
</Link>
|
||||
|
||||
<button
|
||||
className="rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
||||
disabled={!uuid}
|
||||
onClick={() =>
|
||||
router.push(`/builder?load=${encodeURIComponent(uuid)}`)
|
||||
}
|
||||
title="Open this build in the builder to swap parts"
|
||||
>
|
||||
Open in Builder
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{authLoading && <div className="text-sm text-zinc-400">Loading…</div>}
|
||||
|
||||
{!authLoading && error && (
|
||||
<pre className="mb-4 whitespace-pre-wrap rounded-md border border-red-500/30 bg-red-500/10 p-3 text-xs text-red-200">
|
||||
{error}
|
||||
</pre>
|
||||
)}
|
||||
|
||||
{!authLoading && build && (
|
||||
<div className="space-y-4">
|
||||
{/* meta */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="flex flex-wrap items-center justify-between gap-2">
|
||||
<div className="text-xs text-zinc-400">
|
||||
Created: {fmt(build.createdAt)} • Updated:{" "}
|
||||
{fmt(build.updatedAt)}
|
||||
</div>
|
||||
|
||||
<span
|
||||
className={`rounded-full border px-2 py-0.5 text-[11px] ${
|
||||
form.isPublic
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-white/10 bg-white/5 text-white/70"
|
||||
}`}
|
||||
>
|
||||
{form.isPublic ? "Published" : "Draft"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* parts */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-3 flex items-center justify-between">
|
||||
<div className="text-sm font-semibold">Parts in this build</div>
|
||||
<div className="text-xs text-zinc-400">
|
||||
{build.items?.length ? `${build.items.length} items` : "—"}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!build.items?.length ? (
|
||||
<div className="text-sm opacity-70">No parts saved yet.</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-lg border border-white/10 bg-black/20">
|
||||
{/* header */}
|
||||
<div className="grid grid-cols-12 gap-3 border-b border-white/10 px-3 py-2 text-[11px] font-semibold uppercase tracking-wider text-zinc-400">
|
||||
<div className="col-span-5">Product</div>
|
||||
<div className="col-span-4">Component (Role)</div>
|
||||
<div className="col-span-2">Brand</div>
|
||||
<div className="col-span-1 text-right">Price</div>
|
||||
</div>
|
||||
|
||||
{/* rows */}
|
||||
{build.items
|
||||
.slice()
|
||||
.sort((a, b) => a.slot.localeCompare(b.slot))
|
||||
.map((it, idx) => {
|
||||
const price =
|
||||
typeof it.bestPrice === "number"
|
||||
? it.bestPrice
|
||||
: it.bestPrice
|
||||
? Number(it.bestPrice)
|
||||
: null;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={`${it.slot}-${it.productId}-${idx}`}
|
||||
className="grid grid-cols-12 gap-2 border-b border-white/5 px-3 py-2 text-sm last:border-b-0"
|
||||
>
|
||||
{/* Product */}
|
||||
<div className="col-span-5 min-w-0">
|
||||
<div className="truncate text-zinc-100">
|
||||
{it.productName ?? `Product #${it.productId}`}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
ID:{" "}
|
||||
<span className="font-mono">{it.productId}</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Component (Role) */}
|
||||
<div className="col-span-4 truncate text-zinc-200">
|
||||
{it.slot}
|
||||
</div>
|
||||
|
||||
{/* Brand */}
|
||||
<div className="col-span-2 truncate text-zinc-300">
|
||||
{it.productBrand ?? "—"}
|
||||
</div>
|
||||
|
||||
{/* Price */}
|
||||
<div className="col-span-1 text-right font-semibold text-zinc-100">
|
||||
{price != null && !Number.isNaN(price)
|
||||
? `$${price.toFixed(2)}`
|
||||
: "—"}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* form */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<label className="block text-xs font-medium text-zinc-300">
|
||||
Title
|
||||
</label>
|
||||
<input
|
||||
value={form.title}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, title: e.target.value }))
|
||||
}
|
||||
className="mt-2 w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
|
||||
placeholder="e.g. Suppressed MK2"
|
||||
maxLength={120}
|
||||
/>
|
||||
|
||||
<label className="mt-4 block text-xs font-medium text-zinc-300">
|
||||
Notes / Description
|
||||
</label>
|
||||
<textarea
|
||||
value={form.description}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, description: e.target.value }))
|
||||
}
|
||||
className="mt-2 min-h-[120px] w-full rounded-md border border-white/10 bg-black/30 px-3 py-2 text-sm outline-none focus:border-amber-400/50"
|
||||
placeholder="Why this build? Goals, suppressor setup, ammo, intended use, etc."
|
||||
maxLength={2000}
|
||||
/>
|
||||
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<label className="flex items-center gap-2 text-sm">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={form.isPublic}
|
||||
onChange={(e) =>
|
||||
setForm((f) => ({ ...f, isPublic: e.target.checked }))
|
||||
}
|
||||
/>
|
||||
<span className="text-zinc-200">Publish to community</span>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{savedMsg && (
|
||||
<span className="text-xs text-emerald-300">{savedMsg}</span>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={onSave}
|
||||
disabled={busy || !dirty}
|
||||
className="rounded-md bg-white px-3 py-1.5 text-xs font-semibold text-black hover:bg-zinc-200 disabled:opacity-50"
|
||||
>
|
||||
{busy ? "Working…" : dirty ? "Save" : "Saved"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Build Photos */}
|
||||
<div className="mt-8 rounded-xl border border-white/10 bg-white/5 p-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<div className="text-sm font-semibold">Build Photos</div>
|
||||
<div className="text-xs opacity-70">
|
||||
Select photos, remove any you don’t want, then upload.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
{/* Select (no upload) */}
|
||||
<label
|
||||
className={`cursor-pointer rounded-md border border-white/10 bg-white/5 px-3 py-2 text-xs hover:bg-white/10 ${
|
||||
uploading ? "opacity-60 pointer-events-none" : ""
|
||||
}`}
|
||||
title={uploading ? "Uploading…" : "Select photos"}
|
||||
>
|
||||
Select Photos
|
||||
<input
|
||||
type="file"
|
||||
accept="image/*"
|
||||
multiple
|
||||
className="hidden"
|
||||
onChange={(e) => {
|
||||
const files = Array.from(e.target.files ?? []).filter(
|
||||
isImage
|
||||
);
|
||||
if (!files.length) return;
|
||||
|
||||
const local: LocalPreview[] = files.map((f) => ({
|
||||
id:
|
||||
typeof crypto !== "undefined" &&
|
||||
"randomUUID" in crypto
|
||||
? crypto.randomUUID()
|
||||
: `${Date.now()}-${Math.random()}`,
|
||||
file: f,
|
||||
url: URL.createObjectURL(f),
|
||||
}));
|
||||
|
||||
setPreviews((prev) => [...local, ...prev]);
|
||||
e.target.value = ""; // reset input so re-selecting same file works
|
||||
}}
|
||||
/>
|
||||
</label>
|
||||
|
||||
{/* Upload (real upload) */}
|
||||
<button
|
||||
type="button"
|
||||
disabled={uploading || previews.length === 0 || !build?.uuid}
|
||||
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
||||
onClick={async () => {
|
||||
if (!build?.uuid || previews.length === 0) return;
|
||||
|
||||
setError(null);
|
||||
setUploading(true);
|
||||
|
||||
// snapshot so user can keep selecting more while current batch uploads
|
||||
const batch = [...previews];
|
||||
|
||||
try {
|
||||
for (const p of batch) {
|
||||
// if user removed it before we got to it, skip
|
||||
const stillThere = previews.some((x) => x.id === p.id);
|
||||
if (!stillThere) continue;
|
||||
|
||||
const created = await fileToUpload(p.file, build.uuid);
|
||||
|
||||
setBuild((prev) =>
|
||||
prev
|
||||
? {
|
||||
...prev,
|
||||
photos: [created, ...(prev.photos ?? [])],
|
||||
}
|
||||
: prev
|
||||
);
|
||||
|
||||
// remove preview after success
|
||||
setPreviews((prev) => {
|
||||
const hit = prev.find((x) => x.id === p.id);
|
||||
if (hit) URL.revokeObjectURL(hit.url);
|
||||
return prev.filter((x) => x.id !== p.id);
|
||||
});
|
||||
}
|
||||
} catch (err: any) {
|
||||
setError(err?.message ?? "Photo upload failed");
|
||||
} finally {
|
||||
setUploading(false);
|
||||
}
|
||||
}}
|
||||
>
|
||||
{uploading
|
||||
? "Uploading…"
|
||||
: `Upload${previews.length ? ` (${previews.length})` : ""}`}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Gallery */}
|
||||
<div className="mt-4 grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||
{/* Local previews (pending upload) */}
|
||||
{previews.map((p) => (
|
||||
<div
|
||||
key={p.id}
|
||||
className="relative overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||||
>
|
||||
{/* remove X */}
|
||||
<button
|
||||
type="button"
|
||||
aria-label="Remove"
|
||||
className="absolute right-2 top-2 rounded-full bg-black/60 px-2 py-1 text-xs text-white hover:bg-black/80"
|
||||
onClick={() => {
|
||||
setPreviews((prev) => {
|
||||
const hit = prev.find((x) => x.id === p.id);
|
||||
if (hit) URL.revokeObjectURL(hit.url);
|
||||
return prev.filter((x) => x.id !== p.id);
|
||||
});
|
||||
}}
|
||||
disabled={uploading}
|
||||
title={
|
||||
uploading
|
||||
? "Wait for upload to finish"
|
||||
: "Remove from upload list"
|
||||
}
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
|
||||
<Image
|
||||
src={p.url}
|
||||
alt="Pending upload"
|
||||
className="h-32 w-full object-cover"
|
||||
/>
|
||||
<div className="p-2 text-xs opacity-80">
|
||||
{uploading ? "Uploading…" : "Pending upload"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{/* Uploaded photos */}
|
||||
{(build?.photos ?? []).map((p) => (
|
||||
<div
|
||||
key={p.uuid}
|
||||
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||||
>
|
||||
<Image
|
||||
src={p.url}
|
||||
alt={p.caption ?? "Build photo"}
|
||||
className="h-32 w-full object-cover"
|
||||
/>
|
||||
<div className="p-2 text-xs opacity-80 line-clamp-2">
|
||||
{p.caption ?? "—"}
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{previews.length === 0 && (build?.photos?.length ?? 0) === 0 && (
|
||||
<div className="col-span-full text-xs opacity-70">
|
||||
No photos yet. Select a few and upload when ready.
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* danger zone */}
|
||||
<div className="rounded-xl border border-red-500/20 bg-red-500/5 p-4">
|
||||
<div className="text-sm font-semibold text-red-200">
|
||||
Danger zone
|
||||
</div>
|
||||
<div className="mt-1 text-xs text-red-200/70">
|
||||
Deleting a build is permanent.
|
||||
</div>
|
||||
|
||||
<button
|
||||
onClick={onDelete}
|
||||
disabled={busy}
|
||||
className="mt-3 rounded-md border border-red-500/30 bg-red-500/10 px-3 py-1.5 text-xs font-semibold text-red-100 hover:bg-red-500/15 disabled:opacity-50"
|
||||
>
|
||||
Delete build
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,254 @@
|
||||
// app/vault/page.tsx
|
||||
"use client";
|
||||
|
||||
import Link from "next/link";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useApi } from "@/lib/api";
|
||||
|
||||
type BuildDto = {
|
||||
id: string; // internal numeric id (not used by UI)
|
||||
uuid: string; // public identifier
|
||||
title: string;
|
||||
description?: string | null;
|
||||
isPublic?: boolean | null;
|
||||
createdAt?: string | null;
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
// API routes now handled by Next.js /api routes (server-side)
|
||||
|
||||
// UUID validator (defensive)
|
||||
const isUuid = (v: string) =>
|
||||
/^[0-9a-f]{8}-[0-9a-f]{4}-[1-5][0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$/i.test(
|
||||
v
|
||||
);
|
||||
|
||||
function fmt(d?: string | null) {
|
||||
if (!d) return "—";
|
||||
const dt = new Date(d);
|
||||
if (Number.isNaN(dt.getTime())) return d;
|
||||
return dt.toLocaleString();
|
||||
}
|
||||
|
||||
export default function VaultPage() {
|
||||
const router = useRouter();
|
||||
const api = useApi();
|
||||
|
||||
// NOTE:
|
||||
// - `loading` here is AuthContext hydration, not network.
|
||||
// - Auth is handled by HTTP-only cookies automatically.
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
|
||||
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const authed = !!user;
|
||||
|
||||
// Redirect if not logged in (after auth hydrates)
|
||||
useEffect(() => {
|
||||
if (!authLoading && !authed) {
|
||||
router.replace("/login");
|
||||
}
|
||||
}, [authLoading, authed, router]);
|
||||
|
||||
// When auth identity changes, clear stale list to avoid "ghost builds"
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
setBuilds([]);
|
||||
setError(null);
|
||||
}, [authLoading, user?.uuid]);
|
||||
|
||||
// Fetch user builds
|
||||
useEffect(() => {
|
||||
if (authLoading || !authed) return;
|
||||
|
||||
let cancelled = false;
|
||||
|
||||
(async () => {
|
||||
setBusy(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// IMPORTANT:
|
||||
// Use api wrapper which includes credentials (cookies) automatically.
|
||||
const res = await api.get("/api/builds");
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as BuildDto[];
|
||||
if (!cancelled) setBuilds(Array.isArray(data) ? data : []);
|
||||
} catch (e: any) {
|
||||
if (!cancelled) setError(e?.message || "Failed to load builds");
|
||||
} finally {
|
||||
if (!cancelled) setBusy(false);
|
||||
}
|
||||
})();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
}, [authLoading, authed, api]);
|
||||
|
||||
if (authLoading) {
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<div className="text-sm opacity-70">Loading…</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// Already redirected
|
||||
if (!authed) return null;
|
||||
|
||||
return (
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<h1 className="text-2xl font-semibold">Vault</h1>
|
||||
<p className="text-sm opacity-70">
|
||||
Your saved builds. Open one to keep tweaking parts, or add details
|
||||
to submit it to the community.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<Link
|
||||
href="/builder"
|
||||
className="rounded-md border border-white/10 bg-white/5 px-3 py-1.5 text-sm hover:bg-white/10"
|
||||
title="Create a new build in the builder"
|
||||
>
|
||||
New Build
|
||||
</Link>
|
||||
<Link href="/account" className="text-sm opacity-80 hover:underline" title={"Your account info"}>
|
||||
Account →
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="mb-4 rounded-xl border border-red-500/30 bg-red-500/10 p-3 text-sm">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Builds list */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5">
|
||||
<div className="flex items-center justify-between border-b border-white/10 px-4 py-3">
|
||||
<div className="text-sm font-medium">My Builds</div>
|
||||
<div className="text-xs opacity-70">
|
||||
{busy ? "Refreshing…" : `${builds.length} total`}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{builds.length === 0 ? (
|
||||
<div className="p-4 text-sm opacity-70">
|
||||
No builds yet. Create one in the builder and hit “Save As…”
|
||||
</div>
|
||||
) : (
|
||||
<ul className="divide-y divide-white/10">
|
||||
{builds.map((b) => {
|
||||
const valid = !!b?.uuid && isUuid(String(b.uuid));
|
||||
const published = Boolean(b.isPublic);
|
||||
|
||||
const openHref = valid
|
||||
? `/builder?load=${encodeURIComponent(b.uuid)}`
|
||||
: "#";
|
||||
|
||||
const detailsHref = valid
|
||||
? `/vault/${encodeURIComponent(b.uuid)}/edit`
|
||||
: "#";
|
||||
|
||||
return (
|
||||
<li
|
||||
key={b.uuid}
|
||||
className="p-4 flex items-start justify-between gap-4"
|
||||
>
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-center gap-2">
|
||||
<div className="font-medium truncate">{b.title}</div>
|
||||
|
||||
{/* Status chip */}
|
||||
<span
|
||||
className={`shrink-0 rounded-full border px-2 py-0.5 text-[11px] ${
|
||||
published
|
||||
? "border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||
: "border-white/10 bg-white/5 text-white/70"
|
||||
}`}
|
||||
title={
|
||||
published
|
||||
? "This build is public (community-visible)."
|
||||
: "Draft (only visible in your Vault)."
|
||||
}
|
||||
>
|
||||
{published ? "Published" : "Draft"}
|
||||
</span>
|
||||
|
||||
{!valid && (
|
||||
<span
|
||||
className="shrink-0 rounded-full border border-red-500/30 bg-red-500/10 px-2 py-0.5 text-[11px] text-red-200"
|
||||
title="This build record has an invalid UUID."
|
||||
>
|
||||
Invalid UUID
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-1 text-xs opacity-70">
|
||||
Updated: {fmt(b.updatedAt)} • UUID:{" "}
|
||||
<span className="font-mono">{b.uuid}</span>
|
||||
</div>
|
||||
|
||||
{b.description && (
|
||||
<div className="mt-2 text-sm opacity-80 line-clamp-2">
|
||||
{b.description}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="flex shrink-0 items-center gap-2">
|
||||
<Link
|
||||
href={openHref}
|
||||
aria-disabled={!valid}
|
||||
tabIndex={!valid ? -1 : 0}
|
||||
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||
valid
|
||||
? "border-white/10 bg-white/5 hover:bg-white/10"
|
||||
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
|
||||
}`}
|
||||
title={valid ? "Load this build into the builder" : "Invalid build id"}
|
||||
>
|
||||
View in Builder
|
||||
</Link>
|
||||
|
||||
<Link
|
||||
href={detailsHref}
|
||||
aria-disabled={!valid}
|
||||
tabIndex={!valid ? -1 : 0}
|
||||
className={`rounded-md border px-3 py-1.5 text-xs transition-colors ${
|
||||
valid
|
||||
? "border-white/10 bg-white/5 hover:bg-white/10"
|
||||
: "border-white/10 bg-white/5 opacity-40 pointer-events-none"
|
||||
}`}
|
||||
title={valid ? "Edit details / publish later" : "Invalid build id"}
|
||||
>
|
||||
View / Edit
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
"use server";
|
||||
|
||||
export type SendEmailPayload = {
|
||||
recipient: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
};
|
||||
|
||||
export type ApiResponse<T> = {
|
||||
success?: boolean;
|
||||
message?: string;
|
||||
data?: T;
|
||||
};
|
||||
|
||||
export type EmailRequest = {
|
||||
id: number;
|
||||
recipient: string;
|
||||
subject: string;
|
||||
body: string;
|
||||
sentAt: string | null;
|
||||
status: "PENDING" | "SENT" | "FAILED" | string;
|
||||
errorMessage: string | null;
|
||||
createdAt: string;
|
||||
};
|
||||
|
||||
export async function sendEmailAction(
|
||||
payload: SendEmailPayload
|
||||
): Promise<ApiResponse<EmailRequest>> {
|
||||
const baseUrl = process.env.BACKEND_BASE_URL;
|
||||
if (!baseUrl) {
|
||||
throw new Error("BACKEND_BASE_URL is not set");
|
||||
}
|
||||
|
||||
const res = await fetch(`${baseUrl}/api/email/send`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
// Authorization: `Bearer ${process.env.BACKEND_JWT}`, // if needed
|
||||
},
|
||||
body: JSON.stringify(payload),
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const data: ApiResponse<EmailRequest> | null = await res
|
||||
.json()
|
||||
.catch(() => null);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data?.message ?? `Request failed with ${res.status}`);
|
||||
}
|
||||
|
||||
if (!data) {
|
||||
throw new Error("Empty response body");
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Field, Input } from "@/components/ui/form";
|
||||
|
||||
type AdminBetaRequestDto = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
email: string;
|
||||
displayName?: string | null;
|
||||
invited: boolean;
|
||||
verified: boolean;
|
||||
active: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
|
||||
type PageResponse<T> = {
|
||||
content: T[];
|
||||
number: number;
|
||||
size: number;
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
};
|
||||
|
||||
// Expected invite response shape (adjust fields if your DTO differs)
|
||||
type AdminInviteResponse = {
|
||||
ok?: boolean;
|
||||
email?: string;
|
||||
|
||||
// backend returns this
|
||||
inviteUrl?: string;
|
||||
|
||||
// keep these optional if you ever add them later
|
||||
magicUrl?: string;
|
||||
token?: string;
|
||||
};
|
||||
|
||||
async function fetchJson(path: string, init: RequestInit = {}) {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const text = await res.text().catch(() => "");
|
||||
const data = text
|
||||
? (() => {
|
||||
try {
|
||||
return JSON.parse(text);
|
||||
} catch {
|
||||
return text;
|
||||
}
|
||||
})()
|
||||
: null;
|
||||
|
||||
if (!res.ok) {
|
||||
const msg =
|
||||
typeof data === "string"
|
||||
? data
|
||||
: data?.message || data?.error || `Request failed (${res.status})`;
|
||||
throw new Error(msg);
|
||||
}
|
||||
|
||||
return data;
|
||||
}
|
||||
|
||||
function Badge({
|
||||
ok,
|
||||
yes = "Yes",
|
||||
no = "No",
|
||||
}: {
|
||||
ok: boolean;
|
||||
yes?: string;
|
||||
no?: string;
|
||||
}) {
|
||||
return (
|
||||
<span
|
||||
className={[
|
||||
"inline-flex items-center rounded-full px-2 py-0.5 text-[11px] font-medium",
|
||||
ok
|
||||
? "border border-emerald-500/30 bg-emerald-500/10 text-emerald-200"
|
||||
: "border border-zinc-700 bg-zinc-950/40 text-zinc-400",
|
||||
].join(" ")}
|
||||
>
|
||||
{ok ? yes : no}
|
||||
</span>
|
||||
);
|
||||
}
|
||||
|
||||
export default function AdminBetaInvitesPage() {
|
||||
// ------------------------------
|
||||
// Batch invites
|
||||
// ------------------------------
|
||||
const [dryRun, setDryRun] = useState(true);
|
||||
const [limit, setLimit] = useState("25");
|
||||
const [tokenMinutes, setTokenMinutes] = useState("30");
|
||||
const [loadingBatch, setLoadingBatch] = useState(false);
|
||||
const [batchResult, setBatchResult] = useState<any>(null);
|
||||
const [batchError, setBatchError] = useState<string | null>(null);
|
||||
|
||||
async function runInvites() {
|
||||
setLoadingBatch(true);
|
||||
setBatchError(null);
|
||||
setBatchResult(null);
|
||||
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
dryRun: String(dryRun),
|
||||
limit: String(limit || "0"),
|
||||
tokenMinutes: String(tokenMinutes || "30"),
|
||||
});
|
||||
|
||||
const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
setBatchResult(data);
|
||||
await loadRequests();
|
||||
} catch (e: any) {
|
||||
setBatchError(e?.message ?? "Failed");
|
||||
} finally {
|
||||
setLoadingBatch(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Requests table
|
||||
// ------------------------------
|
||||
const [page, setPage] = useState(0);
|
||||
const size = 25;
|
||||
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [requests, setRequests] =
|
||||
useState<PageResponse<AdminBetaRequestDto> | null>(null);
|
||||
|
||||
// per-row loading state
|
||||
const [rowBusy, setRowBusy] = useState<Record<number, boolean>>({});
|
||||
|
||||
// last invite response (for visibility / debug)
|
||||
const [lastInvite, setLastInvite] = useState<AdminInviteResponse | null>(
|
||||
null
|
||||
);
|
||||
|
||||
async function loadRequests() {
|
||||
setLoadingList(true);
|
||||
setListError(null);
|
||||
|
||||
try {
|
||||
const data = (await fetchJson(
|
||||
`/api/admin/beta/requests?page=${page}&size=${size}`,
|
||||
{ method: "GET" }
|
||||
)) as PageResponse<AdminBetaRequestDto>;
|
||||
|
||||
setRequests(data);
|
||||
} catch (e: any) {
|
||||
setListError(e?.message ?? "Failed to load beta requests");
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
|
||||
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
|
||||
return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
|
||||
method: "POST",
|
||||
})) as AdminInviteResponse;
|
||||
}
|
||||
|
||||
async function onSendInviteEmail(userId: number) {
|
||||
setListError(null);
|
||||
setLastInvite(null);
|
||||
setRowBusy((m) => ({ ...m, [userId]: true }));
|
||||
|
||||
try {
|
||||
const resp = await inviteSingle(userId);
|
||||
setLastInvite(resp);
|
||||
await loadRequests();
|
||||
} catch (e: any) {
|
||||
setListError(e?.message ?? "Failed to send invite");
|
||||
} finally {
|
||||
setRowBusy((m) => ({ ...m, [userId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopyInviteLink(userId: number) {
|
||||
setListError(null);
|
||||
setLastInvite(null);
|
||||
setRowBusy((m) => ({ ...m, [userId]: true }));
|
||||
|
||||
try {
|
||||
const resp = await inviteSingle(userId);
|
||||
setLastInvite(resp);
|
||||
await loadRequests();
|
||||
|
||||
// build URL
|
||||
const url =
|
||||
resp.inviteUrl || // what your backend returns
|
||||
resp.magicUrl ||
|
||||
(resp.token
|
||||
? `${window.location.origin}/beta/magic?token=${resp.token}`
|
||||
: null);
|
||||
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
`Invite response missing inviteUrl. Backend should return {"inviteUrl": "..."}`
|
||||
);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch (e: any) {
|
||||
setListError(e?.message ?? "Failed to copy invite link");
|
||||
} finally {
|
||||
setRowBusy((m) => ({ ...m, [userId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Beta Access</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Review pending beta requests and send invite links (batch or
|
||||
per-user).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-[280px_minmax(0,1fr)] xl:grid-cols-[320px_minmax(0,1fr)]">
|
||||
{/* Left: Batch Invites */}
|
||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||
Batch Invites
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Runs the backend batch invite sender.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<span className="shrink-0 rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-xs text-amber-200">
|
||||
{dryRun ? "Dry run" : "Live send"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dryRun}
|
||||
onChange={(e) => setDryRun(e.target.checked)}
|
||||
/>
|
||||
Dry run (do everything except actually send)
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Limit" htmlFor="limit">
|
||||
<Input
|
||||
id="limit"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Token minutes" htmlFor="tokenMinutes">
|
||||
<Input
|
||||
id="tokenMinutes"
|
||||
value={tokenMinutes}
|
||||
onChange={(e) => setTokenMinutes(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Button onClick={runInvites} disabled={loadingBatch}>
|
||||
{loadingBatch ? "Running…" : "Send Beta Invites"}
|
||||
</Button>
|
||||
|
||||
{batchError && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{batchError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchResult && (
|
||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||
{JSON.stringify(batchResult, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Right: Requests List */}
|
||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div>
|
||||
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
|
||||
Beta Requests
|
||||
</h2>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Users with role <span className="text-zinc-200">BETA</span> and{" "}
|
||||
<span className="text-zinc-200">is_active=false</span>.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<Button
|
||||
onClick={loadRequests}
|
||||
disabled={loadingList}
|
||||
className="w-auto px-6"
|
||||
>
|
||||
{loadingList ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{listError && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{listError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-zinc-800">
|
||||
{" "}
|
||||
<table className="min-w-[680px] w-full text-left text-sm">
|
||||
<thead className="bg-black/30 text-xs text-zinc-500">
|
||||
<tr className="border-b border-zinc-800">
|
||||
<th className="px-3 py-2">Email</th>
|
||||
<th className="px-3 py-2">Invited</th>
|
||||
<th className="px-3 py-2">Verified</th>
|
||||
<th className="px-3 py-2">Active</th>
|
||||
<th className="px-3 py-2 text-right w-[190px] whitespace-nowrap">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody className="text-zinc-200">
|
||||
{requests?.content?.length ? (
|
||||
requests.content.map((u) => {
|
||||
const busy = !!rowBusy[u.id];
|
||||
|
||||
return (
|
||||
<tr key={u.id} className="border-b border-zinc-900">
|
||||
<td className="px-3 py-2">
|
||||
<div className="font-medium">{u.email}</div>
|
||||
<div className="text-xs text-zinc-500">
|
||||
{u.displayName ?? "—"} • #{u.id}
|
||||
</div>
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-2">
|
||||
<Badge ok={u.invited} yes="Invited" no="—" />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge ok={u.verified} yes="Verified" no="—" />
|
||||
</td>
|
||||
<td className="px-3 py-2">
|
||||
<Badge ok={u.active} yes="Active" no="Inactive" />
|
||||
</td>
|
||||
|
||||
<td className="px-3 py-2 w-[190px]">
|
||||
<div className="flex justify-end gap-2">
|
||||
<button
|
||||
onClick={() => onCopyInviteLink(u.id)}
|
||||
disabled={busy}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-200 hover:bg-zinc-900 disabled:opacity-60"
|
||||
title="Dev helper: generate invite and copy URL (also triggers invite generation server-side)"
|
||||
>
|
||||
{busy ? "Working…" : "Copy link (dev)"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => onSendInviteEmail(u.id)}
|
||||
disabled={busy}
|
||||
className="rounded-md border border-amber-500/40 bg-amber-500/10 px-3 py-1.5 text-xs text-amber-200 hover:bg-amber-500/15 disabled:opacity-60"
|
||||
title="Send invite email to this user"
|
||||
>
|
||||
{busy ? "Working…" : "Send invite email"}
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
);
|
||||
})
|
||||
) : (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-sm text-zinc-400" colSpan={5}>
|
||||
{loadingList ? "Loading…" : "No pending beta requests."}
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{/* pager */}
|
||||
<div className="flex items-center justify-between text-xs text-zinc-500">
|
||||
<button
|
||||
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
|
||||
disabled={!requests || requests.number <= 0 || loadingList}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<span>
|
||||
Page {requests ? requests.number + 1 : 1} of{" "}
|
||||
{requests ? requests.totalPages : 1} •{" "}
|
||||
{requests ? requests.totalElements : 0} total
|
||||
</span>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 disabled:opacity-50"
|
||||
disabled={
|
||||
!requests ||
|
||||
loadingList ||
|
||||
requests.number >= requests.totalPages - 1
|
||||
}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* optional debug panel */}
|
||||
{lastInvite && (
|
||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||
{JSON.stringify(lastInvite, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,331 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
fetchEmailRequests,
|
||||
createEmailRequest,
|
||||
updateEmailRequest,
|
||||
deleteEmailRequest,
|
||||
type EmailRequest,
|
||||
type CreateEmailRequestDto,
|
||||
type UpdateEmailRequestDto,
|
||||
} from "@/lib/api/emailRequests";
|
||||
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
||||
import { formatDate } from "@/lib/dateFormattingUtility";
|
||||
|
||||
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
|
||||
|
||||
export default function AdminEmailPage() {
|
||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||
const [selectedEmail, setSelectedEmail] = useState<EmailRequest | null>(null);
|
||||
const [formData, setFormData] = useState<CreateEmailRequestDto>({
|
||||
email: "",
|
||||
status: "",
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
|
||||
|
||||
const loadEmails = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchEmailRequests();
|
||||
setEmails(data);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to load email requests");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
}, [loadEmails]);
|
||||
|
||||
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
|
||||
|
||||
const counts = useMemo(() => {
|
||||
const c = { ALL: 0, PENDING: 0, SENT: 0, FAILED: 0, OTHER: 0 } as Record<
|
||||
EmailStatusTab,
|
||||
number
|
||||
>;
|
||||
|
||||
c.ALL = emails.length;
|
||||
|
||||
for (const e of emails) {
|
||||
const st = normalizeStatus(e.status);
|
||||
if (st === "PENDING") c.PENDING += 1;
|
||||
else if (st === "SENT") c.SENT += 1;
|
||||
else if (st === "FAILED") c.FAILED += 1;
|
||||
else c.OTHER += 1;
|
||||
}
|
||||
|
||||
return c;
|
||||
}, [emails]);
|
||||
|
||||
const filteredEmails = useMemo(() => {
|
||||
if (statusTab === "ALL") return emails;
|
||||
|
||||
return emails.filter((e) => {
|
||||
const st = normalizeStatus(e.status);
|
||||
if (statusTab === "OTHER") {
|
||||
return st !== "PENDING" && st !== "SENT" && st !== "FAILED";
|
||||
}
|
||||
return st === statusTab;
|
||||
});
|
||||
}, [emails, statusTab]);
|
||||
|
||||
const tabs: { key: EmailStatusTab; label: string }[] = [
|
||||
{ key: "ALL", label: "All" },
|
||||
{ key: "PENDING", label: "Pending" },
|
||||
{ key: "SENT", label: "Sent" },
|
||||
{ key: "FAILED", label: "Failed" },
|
||||
{ key: "OTHER", label: "Other" },
|
||||
];
|
||||
|
||||
const handleCreate = () => {
|
||||
setModalMode("create");
|
||||
setFormData({ email: "", status: "" });
|
||||
setSelectedEmail(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (email: EmailRequest) => {
|
||||
setModalMode("edit");
|
||||
setFormData({ email: email.email, status: email.status });
|
||||
setSelectedEmail(email);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||
|
||||
try {
|
||||
await deleteEmailRequest(id);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to delete email request");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (modalMode === "create") {
|
||||
await createEmailRequest(formData);
|
||||
} else if (selectedEmail) {
|
||||
const updateData: UpdateEmailRequestDto = {};
|
||||
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
||||
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
||||
await updateEmailRequest(selectedEmail.id, updateData);
|
||||
}
|
||||
setShowModal(false);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to save email request");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">Emails</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
Manage Email and their statuses.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Email
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Tabs */}
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
{tabs.map((t) => {
|
||||
const active = statusTab === t.key;
|
||||
return (
|
||||
<button
|
||||
key={t.key}
|
||||
type="button"
|
||||
onClick={() => setStatusTab(t.key)}
|
||||
className={`inline-flex items-center gap-2 rounded-md border px-3 py-1.5 text-xs transition ${
|
||||
active
|
||||
? "border-amber-500/60 bg-amber-500/10 text-amber-200"
|
||||
: "border-zinc-800 bg-zinc-950/60 text-zinc-300 hover:border-amber-400/60 hover:text-amber-300"
|
||||
}`}
|
||||
>
|
||||
<span>{t.label}</span>
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] ${
|
||||
active ? "bg-amber-500/15 text-amber-200" : "bg-zinc-900 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{counts[t.key]}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="text-sm text-zinc-400">Loading…</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
|
||||
<table className="min-w-full text-left text-xs">
|
||||
<thead className="bg-zinc-900/80 text-zinc-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">ID</th>
|
||||
<th className="px-3 py-2 font-medium">Email</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Created At</th>
|
||||
<th className="px-3 py-2 font-medium">Updated At</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{filteredEmails.map((email) => (
|
||||
<tr
|
||||
key={email.id}
|
||||
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
|
||||
>
|
||||
<td className="px-3 py-2 text-zinc-400">{email.id}</td>
|
||||
<td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
|
||||
{email.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||
{new Date(email.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||
{email.updatedAt ? formatDate(email.updatedAt) : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(email)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(email.id)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{filteredEmails.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-3 py-4 text-center text-xs text-zinc-500"
|
||||
>
|
||||
No email requests found for{" "}
|
||||
<span className="text-zinc-300">{tabs.find((t) => t.key === statusTab)?.label}</span>.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">
|
||||
{modalMode === "create" ? "Create Email Request" : "Edit Email Request"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||
Status
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="pending"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Saving..." : modalMode === "create" ? "Create" : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,258 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
fetchEmailRequests,
|
||||
createEmailRequest,
|
||||
updateEmailRequest,
|
||||
deleteEmailRequest,
|
||||
type EmailRequest,
|
||||
type CreateEmailRequestDto,
|
||||
type UpdateEmailRequestDto,
|
||||
} from "@/lib/api/emailRequests";
|
||||
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
||||
import { formatDate } from "@/lib/dateFormattingUtility";
|
||||
export default function AdminEmailPage() {
|
||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||
const [selectedEmail, setSelectedEmail] = useState<EmailRequest | null>(null);
|
||||
const [formData, setFormData] = useState<CreateEmailRequestDto>({
|
||||
email: "",
|
||||
status: "",
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadEmails = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchEmailRequests();
|
||||
setEmails(data);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to load email requests");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
}, [loadEmails]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setModalMode("create");
|
||||
setFormData({ email: "", status: "" });
|
||||
setSelectedEmail(null);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleEdit = (email: EmailRequest) => {
|
||||
setModalMode("edit");
|
||||
setFormData({ email: email.email, status: email.status });
|
||||
setSelectedEmail(email);
|
||||
setShowModal(true);
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||
|
||||
try {
|
||||
await deleteEmailRequest(id);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to delete email request");
|
||||
}
|
||||
};
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (modalMode === "create") {
|
||||
await createEmailRequest(formData);
|
||||
} else if (selectedEmail) {
|
||||
const updateData: UpdateEmailRequestDto = {};
|
||||
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
||||
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
||||
await updateEmailRequest(selectedEmail.id, updateData);
|
||||
}
|
||||
setShowModal(false);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to save email request");
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
<div className="flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-lg font-semibold tracking-tight">
|
||||
Processed E-mail
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-zinc-400">
|
||||
Manage email requests and their statuses.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={handleCreate}
|
||||
className="flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500"
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Email Request
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Error */}
|
||||
{error && (
|
||||
<div className="rounded border border-red-500/60 bg-red-950/30 px-3 py-2 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
{loading ? (
|
||||
<div className="text-sm text-zinc-400">Loading…</div>
|
||||
) : (
|
||||
<div className="overflow-x-auto rounded-lg border border-zinc-800 bg-zinc-950/60">
|
||||
<table className="min-w-full text-left text-xs">
|
||||
<thead className="bg-zinc-900/80 text-zinc-400">
|
||||
<tr>
|
||||
<th className="px-3 py-2 font-medium">ID</th>
|
||||
<th className="px-3 py-2 font-medium">Email</th>
|
||||
<th className="px-3 py-2 font-medium">Status</th>
|
||||
<th className="px-3 py-2 font-medium">Created At</th>
|
||||
<th className="px-3 py-2 font-medium">Updated At</th>
|
||||
<th className="px-3 py-2 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{emails.map((email) => (
|
||||
<tr
|
||||
key={email.id}
|
||||
className="border-t border-zinc-800/70 hover:bg-zinc-900/60"
|
||||
>
|
||||
<td className="px-3 py-2 text-zinc-400">{email.id}</td>
|
||||
<td className="px-3 py-2 text-zinc-100">{email.recipient}</td>
|
||||
<td className="px-3 py-2">
|
||||
<span className="inline-flex items-center rounded-full bg-zinc-800 px-2 py-0.5 text-[11px] text-zinc-300">
|
||||
{email.status}
|
||||
</span>
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||
{new Date(email.createdAt).toLocaleString()}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-[11px] text-zinc-400">
|
||||
{formatDate(email.updatedAt)}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
<button
|
||||
onClick={() => handleEdit(email)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
title="Edit"
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
onClick={() => handleDelete(email.id)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
|
||||
title="Delete"
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
|
||||
{emails.length === 0 && (
|
||||
<tr>
|
||||
<td
|
||||
colSpan={6}
|
||||
className="px-3 py-4 text-center text-xs text-zinc-500"
|
||||
>
|
||||
No email requests found.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Modal */}
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">
|
||||
{modalMode === "create" ? "Create Email Request" : "Edit Email Request"}
|
||||
</h2>
|
||||
<button
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={handleSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||
Email
|
||||
</label>
|
||||
<input
|
||||
type="email"
|
||||
value={formData.email}
|
||||
onChange={(e) => setFormData({ ...formData, email: e.target.value })}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="user@example.com"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||
Status
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
value={formData.status}
|
||||
onChange={(e) => setFormData({ ...formData, status: e.target.value })}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-emerald-500 focus:outline-none focus:ring-1 focus:ring-emerald-500"
|
||||
placeholder="pending"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
disabled={submitting}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
disabled={submitting}
|
||||
>
|
||||
{submitting ? "Saving..." : modalMode === "create" ? "Create" : "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,116 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
||||
|
||||
export default function SendEmailForm(): JSX.Element {
|
||||
const [result, setResult] = useState<
|
||||
ApiResponse<EmailRequest> | { error: string } | null
|
||||
>(null);
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const [showSuccess, setShowSuccess] = useState(false);
|
||||
const successTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||
|
||||
useEffect(() => {
|
||||
return () => {
|
||||
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||
};
|
||||
}, []);
|
||||
|
||||
async function onSubmit(e: React.FormEvent<HTMLFormElement>): Promise<void> {
|
||||
e.preventDefault();
|
||||
setResult(null);
|
||||
|
||||
setShowSuccess(false);
|
||||
if (successTimerRef.current) clearTimeout(successTimerRef.current);
|
||||
|
||||
const form = new FormData(e.currentTarget);
|
||||
|
||||
const recipient = String(form.get("recipient") ?? "");
|
||||
const subject = String(form.get("subject") ?? "");
|
||||
const body = String(form.get("body") ?? "");
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
const data = await sendEmailAction({ recipient, subject, body });
|
||||
setResult(data);
|
||||
|
||||
if (data?.success === true) {
|
||||
setShowSuccess(true);
|
||||
|
||||
successTimerRef.current = setTimeout(() => {
|
||||
setShowSuccess(false);
|
||||
}, 2000);
|
||||
}
|
||||
} catch (err) {
|
||||
const message = err instanceof Error ? err.message : "Unknown error";
|
||||
setResult({ error: message });
|
||||
} finally {
|
||||
setSubmitting(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-xl px-4 py-10 text-zinc-50">
|
||||
<h1 className="text-2xl font-semibold tracking-tight">
|
||||
Send <span className="text-amber-300">Email</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
Compose a message and send it from the admin panel.
|
||||
</p>
|
||||
|
||||
<form onSubmit={onSubmit} className="mt-6 space-y-4">
|
||||
{result && "error" in result && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{result.error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<Field label="Recipient" htmlFor="recipient">
|
||||
<Input
|
||||
id="recipient"
|
||||
name="recipient"
|
||||
type="email"
|
||||
placeholder="user@example.com"
|
||||
defaultValue="user@example.com"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Subject" htmlFor="subject">
|
||||
<Input
|
||||
id="subject"
|
||||
name="subject"
|
||||
placeholder="Test subject"
|
||||
defaultValue="Test subject"
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Body" htmlFor="body">
|
||||
<Textarea
|
||||
id="body"
|
||||
name="body"
|
||||
placeholder="Write your message…"
|
||||
defaultValue="Test body"
|
||||
rows={6}
|
||||
required
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Button type="submit" disabled={submitting}>
|
||||
{submitting ? "Sending…" : showSuccess ? "Success" : "Send"}
|
||||
</Button>
|
||||
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/60 p-3">
|
||||
<div className="text-xs font-medium text-zinc-400">Response</div>
|
||||
<pre className="mt-2 overflow-x-auto text-xs text-zinc-200">
|
||||
{result ? JSON.stringify(result, null, 2) : "No response yet."}
|
||||
</pre>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import SendEmailForm from "@/components/SendEmail";
|
||||
|
||||
export default function SendEmailPage() {
|
||||
return <SendEmailForm />;
|
||||
}
|
||||
@@ -0,0 +1,585 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Check, X, Wand2, RefreshCw } from "lucide-react";
|
||||
|
||||
type EnrichmentStatus = "PENDING_REVIEW" | "APPROVED" | "REJECTED" | "APPLIED";
|
||||
type EnrichmentType = "CALIBER" | "CALIBER_GROUP";
|
||||
type EnrichmentSource = "RULES" | "AI";
|
||||
|
||||
type QueueItem = {
|
||||
id: number;
|
||||
productId: number;
|
||||
productName?: string;
|
||||
productSlug?: string;
|
||||
mainImageUrl?: string;
|
||||
brandName?: string;
|
||||
enrichmentType: EnrichmentType;
|
||||
source: EnrichmentSource;
|
||||
status: EnrichmentStatus;
|
||||
attributes: Record<string, any>;
|
||||
confidence: number;
|
||||
rationale?: string;
|
||||
createdAt: string;
|
||||
|
||||
// NEW: current value on products table (so we can prevent bad applies)
|
||||
productCaliber?: string | null;
|
||||
productCaliberGroup?: string | null;
|
||||
|
||||
};
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
if (typeof window === "undefined") return {};
|
||||
const token =
|
||||
localStorage.getItem("token") ||
|
||||
localStorage.getItem("jwt") ||
|
||||
localStorage.getItem("accessToken");
|
||||
|
||||
return token ? { Authorization: `Bearer ${token}` } : {};
|
||||
}
|
||||
|
||||
async function apiFetch(path: string, init?: RequestInit) {
|
||||
const res = await fetch(`${API_BASE}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
...(init?.headers ?? {}),
|
||||
},
|
||||
// If you're using cookie auth instead of bearer, flip this on:
|
||||
// credentials: "include",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || `Request failed (${res.status})`);
|
||||
}
|
||||
|
||||
const ct = res.headers.get("content-type") ?? "";
|
||||
if (ct.includes("application/json")) return res.json();
|
||||
return res.text();
|
||||
}
|
||||
|
||||
function hasProductCaliber(it: QueueItem) {
|
||||
const c = it.productCaliber;
|
||||
return typeof c === "string" && c.trim().length > 0;
|
||||
}
|
||||
|
||||
function hasAlreadySet(it: QueueItem) {
|
||||
if (it.enrichmentType === "CALIBER") {
|
||||
return hasProductCaliber(it);
|
||||
}
|
||||
const g = it.productCaliberGroup;
|
||||
return typeof g === "string" && g.trim().length > 0;
|
||||
}
|
||||
|
||||
// ✅ ADD THIS: text for the “Already set:” pill
|
||||
function alreadySetLabel(it: QueueItem) {
|
||||
return it.enrichmentType === "CALIBER" ? it.productCaliber : it.productCaliberGroup;
|
||||
}
|
||||
|
||||
export default function EnrichmentQueueClient() {
|
||||
const [type, setType] = useState<EnrichmentType>("CALIBER");
|
||||
const [status, setStatus] = useState<EnrichmentStatus>("PENDING_REVIEW");
|
||||
const [limit, setLimit] = useState<number>(50);
|
||||
|
||||
const [items, setItems] = useState<QueueItem[]>([]);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [busyId, setBusyId] = useState<number | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// Bulk selection
|
||||
const [selected, setSelected] = useState<Record<number, boolean>>({});
|
||||
|
||||
// “Fancy” per-row UI: treat an item as “locally approved/rejected” immediately
|
||||
// so the row swaps buttons without waiting for a reload.
|
||||
const [localStatus, setLocalStatus] = useState<
|
||||
Record<number, EnrichmentStatus>
|
||||
>({});
|
||||
|
||||
const count = useMemo(() => items.length, [items]);
|
||||
|
||||
const selectedIds = useMemo(
|
||||
() =>
|
||||
Object.entries(selected)
|
||||
.filter(([, v]) => v)
|
||||
.map(([k]) => Number(k)),
|
||||
[selected]
|
||||
);
|
||||
|
||||
|
||||
const allOnPageSelected = useMemo(() => {
|
||||
if (items.length === 0) return false;
|
||||
return items.every((it) => selected[it.id]);
|
||||
}, [items, selected]);
|
||||
|
||||
function toggleAllOnPage() {
|
||||
const next = { ...selected };
|
||||
const nextValue = !allOnPageSelected;
|
||||
for (const it of items) next[it.id] = nextValue;
|
||||
setSelected(next);
|
||||
}
|
||||
|
||||
function toggleOne(id: number) {
|
||||
setSelected((prev) => ({ ...prev, [id]: !prev[id] }));
|
||||
}
|
||||
|
||||
function effectiveStatus(it: QueueItem): EnrichmentStatus {
|
||||
return localStatus[it.id] ?? it.status;
|
||||
}
|
||||
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const data = (await apiFetch(
|
||||
`/api/admin/enrichment/queue2?type=${encodeURIComponent(
|
||||
type
|
||||
)}&status=${encodeURIComponent(status)}&limit=${limit}`
|
||||
)) as QueueItem[];
|
||||
|
||||
setItems(data ?? []);
|
||||
|
||||
// Keep selection only for items still visible
|
||||
setSelected((prev) => {
|
||||
const keep = new Set((data ?? []).map((x) => x.id));
|
||||
const next: Record<number, boolean> = {};
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
const id = Number(k);
|
||||
if (keep.has(id)) next[id] = v;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
|
||||
// Reset local status for items not in view
|
||||
setLocalStatus((prev) => {
|
||||
const keep = new Set((data ?? []).map((x) => x.id));
|
||||
const next: Record<number, EnrichmentStatus> = {};
|
||||
for (const [k, v] of Object.entries(prev)) {
|
||||
const id = Number(k);
|
||||
if (keep.has(id)) next[id] = v;
|
||||
}
|
||||
return next;
|
||||
});
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to load queue");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runRules() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(
|
||||
`/api/admin/enrichment/run?type=${encodeURIComponent(type)}&limit=200`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
setStatus("PENDING_REVIEW");
|
||||
setTimeout(load, 50);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to run rules");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function runAi() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
await apiFetch(
|
||||
`/api/admin/enrichment/ai/run?type=${encodeURIComponent(type)}&limit=200`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
setStatus("PENDING_REVIEW");
|
||||
setTimeout(load, 50);
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed to run AI");
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function act(id: number, action: "approve" | "reject" | "apply") {
|
||||
setBusyId(id);
|
||||
setError(null);
|
||||
|
||||
// Optimistic UI for approve/reject so the row instantly changes
|
||||
if (action === "approve")
|
||||
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
|
||||
if (action === "reject")
|
||||
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
|
||||
|
||||
try {
|
||||
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
// rollback optimistic local status on error
|
||||
setLocalStatus((p) => {
|
||||
const next = { ...p };
|
||||
delete next[id];
|
||||
return next;
|
||||
});
|
||||
setError(e?.message ?? `Failed to ${action}`);
|
||||
} finally {
|
||||
setBusyId(null);
|
||||
}
|
||||
}
|
||||
|
||||
async function bulk(action: "approve" | "reject" | "apply") {
|
||||
const ids = selectedIds;
|
||||
if (ids.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
// Apply should only run for items that are APPROVED (effective status) AND product caliber is blank
|
||||
const byId = new Map(items.map((x) => [x.id, x]));
|
||||
const filtered =
|
||||
action === "apply"
|
||||
? ids.filter((id) => {
|
||||
const it = byId.get(id);
|
||||
if (!it) return false;
|
||||
return (
|
||||
effectiveStatus(it) === "APPROVED" && !hasAlreadySet(it)
|
||||
);
|
||||
})
|
||||
: ids;
|
||||
|
||||
for (const id of filtered) {
|
||||
// optimistic statuses for approve/reject in bulk
|
||||
if (action === "approve")
|
||||
setLocalStatus((p) => ({ ...p, [id]: "APPROVED" }));
|
||||
if (action === "reject")
|
||||
setLocalStatus((p) => ({ ...p, [id]: "REJECTED" }));
|
||||
|
||||
await apiFetch(`/api/admin/enrichment/${id}/${action}`, {
|
||||
method: "POST",
|
||||
});
|
||||
}
|
||||
|
||||
setSelected({});
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? `Failed to bulk ${action}`);
|
||||
setLoading(false);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [type, status, limit]);
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
{/* Controls */}
|
||||
<div className="flex flex-col gap-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<div className="text-sm text-zinc-400">
|
||||
Showing <span className="text-zinc-200 font-medium">{count}</span>
|
||||
</div>
|
||||
|
||||
<select
|
||||
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
||||
value={type}
|
||||
onChange={(e) => setType(e.target.value as EnrichmentType)}
|
||||
>
|
||||
<option value="CALIBER">CALIBER</option>
|
||||
<option value="CALIBER_GROUP">CALIBER_GROUP</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
||||
value={status}
|
||||
onChange={(e) => {
|
||||
setSelected({});
|
||||
setLocalStatus({});
|
||||
setStatus(e.target.value as EnrichmentStatus);
|
||||
}}
|
||||
>
|
||||
<option value="PENDING_REVIEW">PENDING_REVIEW</option>
|
||||
<option value="APPROVED">APPROVED</option>
|
||||
<option value="REJECTED">REJECTED</option>
|
||||
<option value="APPLIED">APPLIED</option>
|
||||
</select>
|
||||
|
||||
<select
|
||||
className="h-9 rounded-md border border-zinc-800 bg-zinc-900 px-3 text-sm text-zinc-100"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(parseInt(e.target.value, 10))}
|
||||
>
|
||||
<option value={25}>25</option>
|
||||
<option value={50}>50</option>
|
||||
<option value={100}>100</option>
|
||||
<option value={200}>200</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={load}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
||||
disabled={loading}
|
||||
>
|
||||
<RefreshCw
|
||||
className={`h-4 w-4 ${loading ? "animate-spin" : ""}`}
|
||||
/>
|
||||
Refresh
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={runRules}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
||||
disabled={loading}
|
||||
title="Run enrichment rules to create new suggestions"
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
Run Rules
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={runAi}
|
||||
className="inline-flex items-center gap-2 rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm font-semibold text-zinc-100 hover:bg-zinc-800"
|
||||
disabled={loading}
|
||||
title="Run AI enrichment to create new suggestions"
|
||||
>
|
||||
<Wand2 className="h-4 w-4" />
|
||||
Run AI
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk action bar */}
|
||||
{selectedIds.length > 0 ? (
|
||||
<div className="flex flex-wrap items-center justify-between gap-2 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
|
||||
<div className="text-sm text-zinc-300">
|
||||
Selected{" "}
|
||||
<span className="font-semibold">{selectedIds.length}</span>
|
||||
<span className="ml-2 text-xs text-zinc-500">
|
||||
(Apply only works on APPROVED + product caliber blank)
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bulk("approve")}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
||||
disabled={loading}
|
||||
>
|
||||
Approve Selected
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bulk("reject")}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
||||
disabled={loading}
|
||||
>
|
||||
Reject Selected
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => bulk("apply")}
|
||||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300"
|
||||
disabled={loading}
|
||||
title="Applies only APPROVED rows where product caliber is blank"
|
||||
>
|
||||
Apply Selected
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSelected({})}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 hover:bg-zinc-800"
|
||||
disabled={loading}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{error ? (
|
||||
<div className="rounded-md border border-red-900/50 bg-red-950/30 p-3 text-sm text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* Table */}
|
||||
<div className="overflow-hidden rounded-md border border-zinc-800">
|
||||
<div className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 border-b border-zinc-800 bg-zinc-950 px-4 py-3 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500">
|
||||
<div className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allOnPageSelected}
|
||||
onChange={toggleAllOnPage}
|
||||
className="h-4 w-4 accent-amber-400"
|
||||
aria-label="Select all"
|
||||
/>
|
||||
</div>
|
||||
<div>Product</div>
|
||||
<div className="text-right">Confidence</div>
|
||||
<div className="text-right">Suggested</div>
|
||||
<div className="text-right">Actions</div>
|
||||
</div>
|
||||
|
||||
<div className="divide-y divide-zinc-800 bg-zinc-950/40">
|
||||
{items.length === 0 ? (
|
||||
<div className="p-6 text-sm text-zinc-400">No items found.</div>
|
||||
) : (
|
||||
items.map((it) => {
|
||||
const suggested =
|
||||
it.enrichmentType === "CALIBER"
|
||||
? it.attributes?.caliber
|
||||
: it.attributes?.caliberGroup; const isBusy = busyId === it.id;
|
||||
const st = effectiveStatus(it);
|
||||
const alreadySet = hasAlreadySet(it);
|
||||
const alreadySetValue = alreadySetLabel(it);
|
||||
|
||||
return (
|
||||
<div
|
||||
key={it.id}
|
||||
className="grid grid-cols-[40px_minmax(0,1fr)_120px_140px_160px] gap-3 px-4 py-3"
|
||||
>
|
||||
<div className="flex items-center justify-center">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!selected[it.id]}
|
||||
onChange={() => toggleOne(it.id)}
|
||||
className="h-4 w-4 accent-amber-400"
|
||||
aria-label={`Select enrichment ${it.id}`}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="flex items-start gap-3">
|
||||
{it.mainImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={it.mainImageUrl}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded border border-zinc-800 object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-900" />
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<a
|
||||
href={`/admin/products/${it.productId}`}
|
||||
className="text-sm font-semibold text-zinc-100 hover:underline line-clamp-2"
|
||||
>
|
||||
{it.productName ?? `Product #${it.productId}`}
|
||||
</a>
|
||||
|
||||
<div className="mt-0.5 text-xs text-zinc-500">
|
||||
{it.brandName ? `${it.brandName} • ` : ""}
|
||||
{it.source} • {st}
|
||||
{alreadySet ? (
|
||||
<span className="ml-2 inline-flex items-center rounded-md border border-zinc-700 bg-zinc-900 px-2 py-0.5 text-[10px] font-semibold text-zinc-300">
|
||||
Already set: {alreadySetValue}
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{it.rationale ? (
|
||||
<div className="mt-1 text-xs text-zinc-400 line-clamp-2">
|
||||
{it.rationale}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-right text-sm font-semibold text-zinc-200">
|
||||
{(it.confidence ?? 0).toFixed(2)}
|
||||
</div>
|
||||
|
||||
<div className="text-right text-sm text-amber-300">
|
||||
{suggested ?? "—"}
|
||||
</div>
|
||||
|
||||
<div className="flex justify-end gap-2">
|
||||
{st === "PENDING_REVIEW" && (
|
||||
<>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act(it.id, "approve")}
|
||||
disabled={isBusy || alreadySet}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
||||
title={
|
||||
alreadySet
|
||||
? "Product caliber already set"
|
||||
: "Approve"
|
||||
}
|
||||
aria-label="Approve"
|
||||
>
|
||||
<Check className="h-4 w-4" />
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act(it.id, "reject")}
|
||||
disabled={isBusy}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-800 bg-zinc-900 text-zinc-100 hover:bg-zinc-800 disabled:opacity-50"
|
||||
title="Reject"
|
||||
aria-label="Reject"
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</button>
|
||||
</>
|
||||
)}
|
||||
|
||||
{st === "APPROVED" && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => act(it.id, "apply")}
|
||||
disabled={isBusy || alreadySet}
|
||||
className="inline-flex h-8 items-center justify-center rounded-md bg-amber-400 px-3 text-xs font-semibold text-black hover:bg-amber-300 disabled:opacity-50"
|
||||
title={
|
||||
alreadySet
|
||||
? `Already set on product: ${alreadySetValue}`
|
||||
: "Apply (writes to product if blank)"
|
||||
}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
)}
|
||||
|
||||
{st === "APPLIED" && (
|
||||
<span className="inline-flex h-8 items-center rounded-md border border-emerald-500/30 bg-emerald-500/10 px-3 text-xs font-semibold text-emerald-200">
|
||||
Applied
|
||||
</span>
|
||||
)}
|
||||
|
||||
{st === "REJECTED" && (
|
||||
<span className="inline-flex h-8 items-center rounded-md border border-red-500/30 bg-red-500/10 px-3 text-xs font-semibold text-red-200">
|
||||
Rejected
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import EnrichmentQueueClient from "./EnrichmentQueueClient";
|
||||
|
||||
export const dynamic = "force-dynamic";
|
||||
|
||||
export default function AdminEnrichmentPage() {
|
||||
return (
|
||||
<div className="mx-auto w-full max-w-6xl px-6 py-8">
|
||||
<div className="mb-6">
|
||||
<h1 className="text-xl font-semibold text-zinc-50">Enrichment Queue</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Review AI/rules suggestions, approve/reject, then apply to products.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EnrichmentQueueClient />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
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) =>
|
||||
(typeof value === "number" ? value : 0).toLocaleString();
|
||||
@@ -46,8 +46,8 @@ export default function ImportStatusPage() {
|
||||
setError(null);
|
||||
|
||||
const [summaryRes, byMerchantRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/admin/import-status/summary`),
|
||||
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`),
|
||||
fetch('/api/admin/import-status/summary', { credentials: 'include' }),
|
||||
fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
|
||||
]);
|
||||
|
||||
if (!summaryRes.ok || !byMerchantRes.ok) {
|
||||
@@ -108,9 +108,10 @@ export default function ImportStatusPage() {
|
||||
try {
|
||||
setImportingId(merchantId);
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`,
|
||||
`/api/admin/merchants/${merchantId}/import`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
"use client";
|
||||
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import AdminLeftNavigation, {
|
||||
type AdminNavGroup,
|
||||
} from "@/components/AdminLeftNavigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
Download,
|
||||
Layers,
|
||||
Boxes,
|
||||
Store,
|
||||
Users,
|
||||
Settings,
|
||||
Mail,
|
||||
Wand2,
|
||||
FolderKanban,
|
||||
Shield,
|
||||
} from "lucide-react";
|
||||
|
||||
const navGroups: AdminNavGroup[] = [
|
||||
{
|
||||
label: "Overview",
|
||||
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||
items: [
|
||||
{
|
||||
label: "Dashboard",
|
||||
href: "/admin",
|
||||
icon: <LayoutDashboard className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Catalog",
|
||||
icon: <FolderKanban className="h-4 w-4" />,
|
||||
items: [
|
||||
{
|
||||
label: "Products",
|
||||
href: "/admin/products",
|
||||
icon: <Boxes className="h-4 w-4" />,
|
||||
},
|
||||
{
|
||||
label: "Platforms",
|
||||
href: "/admin/platforms",
|
||||
icon: <Layers className="h-4 w-4" />,
|
||||
},
|
||||
],
|
||||
},
|
||||
{
|
||||
label: "Data Ops",
|
||||
icon: <Download className="h-4 w-4" />,
|
||||
items: [
|
||||
{
|
||||
label: "Imports",
|
||||
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({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
const [collapsed, setCollapsed] = useState(false);
|
||||
const { user, loading } = useAuth();
|
||||
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
const next = useMemo(
|
||||
() => encodeURIComponent(pathname || "/admin"),
|
||||
[pathname]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
if (!user) {
|
||||
router.replace(`/login?next=${next}`);
|
||||
return;
|
||||
}
|
||||
|
||||
if (user.role !== "ADMIN") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [loading, user, router, next]);
|
||||
|
||||
if (loading) return null;
|
||||
if (!user) return null;
|
||||
if (user.role !== "ADMIN") return null;
|
||||
|
||||
return (
|
||||
// ✅ prevent browser-level horizontal scroll from any wide children
|
||||
<div className="h-screen bg-black text-zinc-50 overflow-hidden">
|
||||
<AdminLeftNavigation
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||
groups={navGroups}
|
||||
/>
|
||||
|
||||
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
|
||||
<div
|
||||
className="flex h-full flex-1 min-w-0 flex-col transition-all duration-300"
|
||||
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>
|
||||
<p className="text-xs uppercase tracking-[0.18em] text-zinc-500">
|
||||
Admin
|
||||
</p>
|
||||
<p className="text-sm text-zinc-200">Battl Control Panel</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ✅ min-w-0 here prevents table min-width from widening the page */}
|
||||
<main className="flex w-full flex-1 min-w-0 flex-col overflow-y-auto px-4 py-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
+622
-187
@@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
|
||||
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 = {
|
||||
merchantId: number;
|
||||
merchantName: string;
|
||||
@@ -13,186 +24,339 @@ type PendingBucket = {
|
||||
productCount: number;
|
||||
};
|
||||
|
||||
const PART_ROLE_OPTIONS: string[] = [
|
||||
// LOWER – receivers
|
||||
"LOWER_RECEIVER_STRIPPED",
|
||||
"LOWER_RECEIVER_COMPLETE",
|
||||
"LOWER_RECEIVER_COMPLETE_PISTOL",
|
||||
"LOWER_RECEIVER_COMPLETE_RIFLE",
|
||||
"LOWER_RECEIVER_80",
|
||||
"LOWER_RECEIVER_BILLET",
|
||||
"LOWER_RECEIVER_FORGED",
|
||||
"LOWER_RECEIVER_POLYMER",
|
||||
|
||||
// LOWER – internals & controls
|
||||
"LOWER_PARTS_KIT",
|
||||
"LOWER_PARTS_KIT_ENHANCED",
|
||||
/**
|
||||
* New “raw categories” row (for the combined UI).
|
||||
* 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;
|
||||
|
||||
"TRIGGER",
|
||||
"TRIGGER_OTHER",
|
||||
"TRIGGER_COMBAT",
|
||||
"TRIGGER_MATCH",
|
||||
"TRIGGER_DROP_IN",
|
||||
"TRIGGER_SINGLE_STAGE",
|
||||
"TRIGGER_TWO_STAGE",
|
||||
"TRIGGER_GUARD",
|
||||
"PISTOL_GRIP",
|
||||
"PISTOL_GRIP_ERGO",
|
||||
"PISTOL_GRIP_VERTICAL",
|
||||
"SAFETY_SELECTOR",
|
||||
"SAFETY_SELECTOR_AMBI",
|
||||
"SAFETY_SELECTOR_45_DEG",
|
||||
"MAG_RELEASE",
|
||||
"TAKEDOWN_PINS",
|
||||
"SPRINGS_PINS_MISC",
|
||||
|
||||
// LOWER – buffer & stock
|
||||
"BUFFER_KIT",
|
||||
"BUFFER_TUBE",
|
||||
"BUFFER_TUBE_MILSPEC",
|
||||
"BUFFER_TUBE_COMMERCIAL",
|
||||
"BUFFER_SPRING",
|
||||
"BUFFER_WEIGHT",
|
||||
"STOCK_ADJUSTABLE",
|
||||
"STOCK_FIXED",
|
||||
"STOCK_FOLDER",
|
||||
"STOCK_PRECISION",
|
||||
"BRACE_PISTOL",
|
||||
|
||||
// UPPER – receivers & BCG
|
||||
"UPPER_RECEIVER_STRIPPED",
|
||||
"UPPER_RECEIVER_BILLET",
|
||||
"UPPER_RECEIVER_MONOLITHIC",
|
||||
"UPPER_RECEIVER_COMPLETE",
|
||||
"UPPER_RECEIVER_COMPLETE_PISTOL",
|
||||
"UPPER_RECEIVER_COMPLETE_RIFLE",
|
||||
"BOLT_CARRIER_GROUP",
|
||||
"BCG_COMPLETE",
|
||||
"BCG_LIGHTWEIGHT",
|
||||
"BCG_NICKEL_BORON",
|
||||
|
||||
// UPPER – barrel & gas
|
||||
"BARREL",
|
||||
"BARREL_THREADED",
|
||||
"BARREL_PENCIL",
|
||||
"BARREL_MATCH",
|
||||
"BARREL_HEAVY",
|
||||
"BARREL_14_5_PINNED",
|
||||
"GAS_BLOCK",
|
||||
"GAS_BLOCK_ADJUSTABLE",
|
||||
"GAS_BLOCK_LOW_PROFILE",
|
||||
"GAS_TUBE_PISTOL",
|
||||
"GAS_TUBE_CAR",
|
||||
"GAS_TUBE_MID",
|
||||
"GAS_TUBE_RIFLE",
|
||||
|
||||
// UPPER – front end / muzzle
|
||||
"HANDGUARD_MLOK",
|
||||
"HANDGUARD_KEYMOD",
|
||||
"HANDGUARD_QUAD",
|
||||
"HANDGUARD_SLICK",
|
||||
"MUZZLE_BRAKE",
|
||||
"MUZZLE_COMPENSATOR",
|
||||
rawCategoryKey: string;
|
||||
productCount: number;
|
||||
|
||||
// 👇 generic / backup muzzle roles
|
||||
"MUZZLE_DEVICE",
|
||||
"MUZZLE_DEVICE_OTHER",
|
||||
|
||||
"FLASH_HIDER",
|
||||
"MUZZLE_DEVICE_QD_MOUNT",
|
||||
"SUPPRESSOR_DIRECT_THREAD",
|
||||
"SUPPRESSOR_QD",
|
||||
"SUPPRESSOR_RIFLE",
|
||||
"SUPPRESSOR_PISTOL",
|
||||
"CHARGING_HANDLE",
|
||||
"CHARGING_HANDLE_AMBI",
|
||||
"CHARGING_HANDLE_GAS_BUSTER",
|
||||
|
||||
// Sights / optics
|
||||
"SIGHTS_BACKUP",
|
||||
"SIGHTS_IRON_FIXED",
|
||||
"SIGHTS_IRON_FLIP",
|
||||
"SIGHTS_OFFSET",
|
||||
"OPTIC_REDDOT",
|
||||
"OPTIC_LPVO",
|
||||
"OPTIC_HOLOGRAPHIC",
|
||||
"OPTIC_PRISM",
|
||||
"OPTIC_MAGNIFIER",
|
||||
|
||||
// Accessories
|
||||
"MAGAZINE",
|
||||
"SLING",
|
||||
"BIPOD",
|
||||
"MOUNT_SCOPE",
|
||||
"MOUNT_OFFSET",
|
||||
"LIGHT_WEAPON",
|
||||
"LASER_VISIBLE",
|
||||
"LASER_IR",
|
||||
"TOOL",
|
||||
];
|
||||
// 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 = [
|
||||
// Assemblies / receivers
|
||||
"upper-receiver",
|
||||
"complete-upper",
|
||||
"lower-receiver",
|
||||
"complete-lower",
|
||||
|
||||
// Upper sub-parts
|
||||
"bcg",
|
||||
"barrel",
|
||||
"gas-block",
|
||||
"gas-tube",
|
||||
"muzzle-device",
|
||||
"suppressor",
|
||||
"handguard",
|
||||
"charging-handle",
|
||||
|
||||
// Lower sub-parts
|
||||
"lower-parts",
|
||||
"trigger",
|
||||
"grip",
|
||||
"safety",
|
||||
"buffer",
|
||||
"stock",
|
||||
|
||||
// Sights / optics
|
||||
"sights",
|
||||
"optic",
|
||||
|
||||
// Accessories / gear
|
||||
"magazine",
|
||||
"weapon-light",
|
||||
"foregrip",
|
||||
"bipod",
|
||||
"sling",
|
||||
"rail-accessory",
|
||||
"tools",
|
||||
] as const;
|
||||
|
||||
function toLabel(role: string) {
|
||||
return role
|
||||
.split("-")
|
||||
.map((w) => w.charAt(0).toUpperCase() + w.slice(1))
|
||||
.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() {
|
||||
const [rows, setRows] = useState<PendingBucket[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
// 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);
|
||||
|
||||
// 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) => ({
|
||||
value,
|
||||
label: toLabel(value),
|
||||
})),
|
||||
[]
|
||||
);
|
||||
|
||||
// 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(() => {
|
||||
async function load() {
|
||||
async function loadOptions() {
|
||||
setOptionsLoading(true);
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/mapping/pending-buckets`
|
||||
);
|
||||
|
||||
// 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) {
|
||||
throw new Error(`Failed to load pending buckets (${res.status})`);
|
||||
// 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 data: PendingBucket[] = await res.json();
|
||||
setRows(data);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e.message ?? "Failed to load pending buckets");
|
||||
|
||||
const json = (await res.json()) as MappingOptionsResponse;
|
||||
setMerchants(json.merchants ?? []);
|
||||
setCanonicalCategories(json.canonicalCategories ?? []);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load mapping options:", e);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setOptionsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
load();
|
||||
loadOptions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Default merchant if not provided and merchants list exists
|
||||
useEffect(() => {
|
||||
if (!merchantId && merchants.length > 0) {
|
||||
setMerchantId(String(merchants[0].id));
|
||||
syncUrl({ merchantId: String(merchants[0].id) });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [merchants]);
|
||||
|
||||
// Core loader: depends on tab
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
try {
|
||||
if (tab === "roles") {
|
||||
// Existing endpoint you already have:
|
||||
// GET /api/admin/mapping/pending-buckets
|
||||
const res = await fetch('/api/admin/mapping/pending-buckets', {
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: 'include',
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to load pending buckets (${res.status})${text ? `: ${text}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
const data: PendingBucket[] = await res.json();
|
||||
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) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to load mapping data");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
// Load on mount + when tab changes
|
||||
useEffect(() => {
|
||||
load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [tab]);
|
||||
|
||||
// ===== Roles tab actions =====
|
||||
|
||||
const handleChangeRole = (idx: number, value: string) => {
|
||||
setRows((prev) => {
|
||||
const normalized = value ? normalizeRole(value) : "";
|
||||
setRoleBuckets((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], mappedPartRole: value || null };
|
||||
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async (row: PendingBucket) => {
|
||||
const handleSaveRole = async (row: PendingBucket) => {
|
||||
if (!row.mappedPartRole) return;
|
||||
|
||||
const key = `${row.merchantId}-${row.rawCategoryKey}`;
|
||||
const mapped = normalizeRole(row.mappedPartRole);
|
||||
|
||||
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
|
||||
if (!allowed.has(mapped)) {
|
||||
setError(
|
||||
`Refusing to save unknown part role: "${row.mappedPartRole}". Pick a role from the dropdown list.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `roles-${row.merchantId}-${row.rawCategoryKey}`;
|
||||
try {
|
||||
setSavingKey(key);
|
||||
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",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
merchantId: row.merchantId,
|
||||
rawCategoryKey: row.rawCategoryKey,
|
||||
mappedPartRole: row.mappedPartRole,
|
||||
mappedPartRole: mapped,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to save mapping (${res.status})`);
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(
|
||||
`Failed to save mapping (${res.status})${text ? `: ${text}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
// After save, remove this bucket from the list
|
||||
setRows((prev) =>
|
||||
setRoleBuckets((prev) =>
|
||||
prev.filter(
|
||||
(r) =>
|
||||
!(
|
||||
@@ -203,46 +367,229 @@ export default function MappingAdminPage() {
|
||||
);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e.message ?? "Failed to save mapping");
|
||||
setError(e?.message ?? "Failed to save mapping");
|
||||
} finally {
|
||||
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 (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6">
|
||||
<h1 className="text-xl font-semibold tracking-tight">
|
||||
Bulk Mapping – Pending Categories
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Bucketed by merchant + raw category. Map each bucket to a part role to
|
||||
clean up the builder catalog.
|
||||
</p>
|
||||
<div className="flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Admin Mapping</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Manage <span className="text-zinc-200">builder part roles</span> and{" "}
|
||||
<span className="text-zinc-200">catalog categories</span> without
|
||||
blending worlds.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => load()}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-950/60 px-3 py-1.5 text-xs font-semibold text-zinc-100 hover:bg-zinc-900/60"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="mt-4 flex items-center gap-2">
|
||||
<button
|
||||
onClick={() => {
|
||||
setTab("roles");
|
||||
syncUrl({ tab: "roles" });
|
||||
}}
|
||||
className={[
|
||||
"rounded-md px-3 py-1.5 text-xs font-semibold",
|
||||
tab === "roles"
|
||||
? "bg-amber-400 text-black"
|
||||
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
|
||||
].join(" ")}
|
||||
>
|
||||
Builder Part Roles
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => {
|
||||
setTab("catalog");
|
||||
syncUrl({ tab: "catalog" });
|
||||
}}
|
||||
className={[
|
||||
"rounded-md px-3 py-1.5 text-xs font-semibold",
|
||||
tab === "catalog"
|
||||
? "bg-amber-400 text-black"
|
||||
: "border border-zinc-700 bg-zinc-950/60 text-zinc-100 hover:bg-zinc-900/60",
|
||||
].join(" ")}
|
||||
>
|
||||
Catalog Categories
|
||||
</button>
|
||||
|
||||
<span className="ml-auto text-xs text-zinc-400">
|
||||
{totals.buckets} rows • {totals.products} products
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Filters (catalog tab only) */}
|
||||
{tab === "catalog" && (
|
||||
<section className="mt-4 rounded-lg border border-zinc-800 bg-zinc-950/80 p-3">
|
||||
<div className="grid grid-cols-1 gap-3 sm:grid-cols-3">
|
||||
<div>
|
||||
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
||||
Merchant
|
||||
</label>
|
||||
<select
|
||||
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||
value={merchantId}
|
||||
onChange={(e) => {
|
||||
setMerchantId(e.target.value);
|
||||
syncUrl({ merchantId: e.target.value });
|
||||
}}
|
||||
>
|
||||
<option value="">Select merchant…</option>
|
||||
{merchants.map((m) => (
|
||||
<option key={m.id} value={String(m.id)}>
|
||||
{m.name} (#{m.id})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
{optionsLoading && (
|
||||
<p className="mt-1 text-[0.7rem] text-zinc-500">Loading merchants…</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
||||
Platform
|
||||
</label>
|
||||
<input
|
||||
className="mt-1 w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||
value={platform}
|
||||
onChange={(e) => {
|
||||
setPlatform(e.target.value);
|
||||
syncUrl({ platform: e.target.value });
|
||||
}}
|
||||
placeholder="AR-15"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="block text-[0.7rem] font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
||||
Search raw categories
|
||||
</label>
|
||||
<div className="mt-1 flex gap-2">
|
||||
<input
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-950/80 px-2 py-2 text-xs text-zinc-100 outline-none focus:border-amber-400"
|
||||
value={q}
|
||||
onChange={(e) => {
|
||||
setQ(e.target.value);
|
||||
syncUrl({ q: e.target.value });
|
||||
}}
|
||||
placeholder="e.g. Charging Handles"
|
||||
/>
|
||||
<button
|
||||
onClick={() => load()}
|
||||
className="rounded-md bg-emerald-500 px-3 py-2 text-xs font-semibold text-black"
|
||||
>
|
||||
Search
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
)}
|
||||
|
||||
{loading && (
|
||||
<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 && (
|
||||
<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">
|
||||
No pending mapping buckets 🎉
|
||||
No pending role buckets 🎉
|
||||
</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">
|
||||
<div className="mb-2 flex items-center justify-between">
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.14em] text-zinc-500">
|
||||
Pending Buckets
|
||||
Pending Part Role Buckets
|
||||
</h2>
|
||||
<span className="text-xs text-zinc-400">
|
||||
{rows.length} buckets • {rows.reduce((s, r) => s + r.productCount, 0)}{" "}
|
||||
products
|
||||
{totals.buckets} buckets • {totals.products} products
|
||||
</span>
|
||||
</div>
|
||||
|
||||
@@ -250,26 +597,17 @@ export default function MappingAdminPage() {
|
||||
<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">
|
||||
Merchant
|
||||
</th>
|
||||
<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">
|
||||
Part Role
|
||||
</th>
|
||||
<th className="px-2 py-1 text-right text-zinc-400">
|
||||
Action
|
||||
</th>
|
||||
<th className="px-2 py-1 text-left text-zinc-400">Merchant</th>
|
||||
<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">Part Role</th>
|
||||
<th className="px-2 py-1 text-right text-zinc-400">Action</th>
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{rows.map((row, idx) => {
|
||||
const rowKey = `${row.merchantId}-${row.rawCategoryKey}`;
|
||||
{roleBuckets.map((row, idx) => {
|
||||
const rowKey = `roles-${row.merchantId}-${row.rawCategoryKey}`;
|
||||
const isSaving = savingKey === rowKey;
|
||||
|
||||
return (
|
||||
@@ -277,32 +615,26 @@ export default function MappingAdminPage() {
|
||||
key={rowKey}
|
||||
className="border-b border-zinc-900 hover:bg-zinc-900/40"
|
||||
>
|
||||
<td className="px-2 py-1 text-zinc-100">
|
||||
{row.merchantName}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-zinc-300">
|
||||
{row.rawCategoryKey}
|
||||
</td>
|
||||
<td className="px-2 py-1 text-zinc-100">{row.merchantName}</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">
|
||||
<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={row.mappedPartRole ?? ""}
|
||||
onChange={(e) =>
|
||||
handleChangeRole(idx, e.target.value)
|
||||
}
|
||||
onChange={(e) => handleChangeRole(idx, e.target.value)}
|
||||
>
|
||||
<option value="">Select part role…</option>
|
||||
{PART_ROLE_OPTIONS.map((pr) => (
|
||||
<option key={pr} value={pr}>
|
||||
{pr}
|
||||
{roleOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label} ({opt.value})
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</td>
|
||||
<td className="px-2 py-1 text-right">
|
||||
<button
|
||||
onClick={() => handleSave(row)}
|
||||
onClick={() => handleSaveRole(row)}
|
||||
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"
|
||||
>
|
||||
@@ -317,6 +649,109 @@ export default function MappingAdminPage() {
|
||||
</div>
|
||||
</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>
|
||||
</main>
|
||||
);
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useApi } from "@/lib/useApi";
|
||||
|
||||
type Merchant = {
|
||||
@@ -91,7 +91,7 @@ export default function MerchantCategoryMappingPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
// 2) Load mappings when merchant changes (THIS sends merchantId)
|
||||
useEffect(() => {
|
||||
const loadMappings = useCallback(() => {
|
||||
if (selectedMerchantId == null) return;
|
||||
|
||||
setLoadingMappings(true);
|
||||
@@ -112,7 +112,11 @@ export default function MerchantCategoryMappingPage() {
|
||||
);
|
||||
})
|
||||
.finally(() => setLoadingMappings(false));
|
||||
}, [selectedMerchantId]);
|
||||
}, [selectedMerchantId, get]);
|
||||
|
||||
useEffect(() => {
|
||||
loadMappings();
|
||||
}, [loadMappings]);
|
||||
|
||||
// 3) Save a single mapping row (adjust endpoint if yours differs)
|
||||
const handleSaveMapping = async (
|
||||
|
||||
+499
-150
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
// API routes now handled by Next.js /api routes (server-side)
|
||||
|
||||
type MerchantAdminDto = {
|
||||
id: number;
|
||||
@@ -16,13 +15,50 @@ type MerchantAdminDto = {
|
||||
lastOfferSyncAt: string | null;
|
||||
};
|
||||
|
||||
function formatDate(value: string | null) {
|
||||
if (!value) return "—";
|
||||
// Helper: relative time formatting
|
||||
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);
|
||||
if (Number.isNaN(d.getTime())) return value;
|
||||
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() {
|
||||
const [merchants, setMerchants] = useState<MerchantAdminDto[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -31,58 +67,78 @@ export default function MerchantsAdminPage() {
|
||||
const [importingId, setImportingId] = useState<number | null>(null);
|
||||
const [offerSyncId, setOfferSyncId] = useState<number | 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 ---
|
||||
useEffect(() => {
|
||||
let cancelled = false;
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadMerchants() {
|
||||
try {
|
||||
console.log("Loading merchants from:", `${API_BASE_URL}/admin/merchants`);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/admin/merchants`, {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `/api/admin/merchants`;
|
||||
console.log("Loading merchants from:", url);
|
||||
|
||||
const res = await fetch(url, {
|
||||
method: "GET",
|
||||
headers: {
|
||||
Accept: "application/json",
|
||||
},
|
||||
headers: { Accept: "application/json" },
|
||||
signal: controller.signal,
|
||||
});
|
||||
|
||||
console.log("Merchants response status:", res.status);
|
||||
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(`HTTP ${res.status} ${res.statusText} – ${text}`);
|
||||
}
|
||||
|
||||
|
||||
const data: MerchantAdminDto[] = await res.json();
|
||||
setMerchants(data);
|
||||
setError(null);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
console.error("Error loading merchants", err);
|
||||
setError(err.message ?? "Failed to load merchants");
|
||||
setError(err?.message ?? "Failed to load merchants");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadMerchants();
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
};
|
||||
loadMerchants();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// --- local field editing ---
|
||||
const updateMerchantField = <K extends keyof MerchantAdminDto>(
|
||||
id: number,
|
||||
field: K,
|
||||
value: MerchantAdminDto[K],
|
||||
value: MerchantAdminDto[K]
|
||||
) => {
|
||||
setMerchants((prev) =>
|
||||
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m)),
|
||||
prev.map((m) => (m.id === id ? { ...m, [field]: value } : m))
|
||||
);
|
||||
};
|
||||
|
||||
// --- 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) ---
|
||||
const handleSave = async (id: number) => {
|
||||
const merchant = merchants.find((m) => m.id === id);
|
||||
@@ -93,7 +149,7 @@ export default function MerchantsAdminPage() {
|
||||
setError(null);
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/admin/merchants/${id}`, {
|
||||
const res = await fetch(`/api/admin/merchants/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -108,20 +164,16 @@ export default function MerchantsAdminPage() {
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Merchant save failed", res.status, text);
|
||||
throw new Error(
|
||||
`Save failed (${res.status})${text ? `: ${text}` : ""}`,
|
||||
);
|
||||
throw new Error(`Save failed (${res.status})${text ? `: ${text}` : ""}`);
|
||||
}
|
||||
|
||||
const updated: MerchantAdminDto = await res.json();
|
||||
setMerchants((prev) =>
|
||||
prev.map((m) => (m.id === id ? updated : m)),
|
||||
);
|
||||
setMerchants((prev) => prev.map((m) => (m.id === id ? updated : m)));
|
||||
|
||||
setBanner("Merchant saved successfully.");
|
||||
} catch (e: any) {
|
||||
console.error("Error saving merchant", e);
|
||||
setError(e.message ?? "Failed to save merchant");
|
||||
setError(e?.message ?? "Failed to save merchant");
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
setTimeout(() => setBanner(null), 3000);
|
||||
@@ -130,68 +182,116 @@ export default function MerchantsAdminPage() {
|
||||
|
||||
// --- run full import ---
|
||||
const handleRunFullImport = async (id: number) => {
|
||||
setOpenDropdown(null);
|
||||
try {
|
||||
setImportingId(id);
|
||||
setError(null);
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/admin/imports/${id}`, {
|
||||
const res = await fetch(`/api/admin/merchants/${id}/import`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Full import failed", res.status, text);
|
||||
throw new Error(
|
||||
`Import failed (${res.status})${text ? `: ${text}` : ""}`,
|
||||
);
|
||||
throw new Error(`Import failed (${res.status})${text ? `: ${text}` : ""}`);
|
||||
}
|
||||
|
||||
setBanner("Full import started successfully.");
|
||||
} catch (e: any) {
|
||||
console.error("Error starting full import", e);
|
||||
setError(e.message ?? "Failed to start full import");
|
||||
setError(e?.message ?? "Failed to start full import");
|
||||
} finally {
|
||||
setImportingId(null);
|
||||
setTimeout(() => setBanner(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// --- run offer sync (matches /{merchantId}/offers-only) ---
|
||||
// --- run offer sync ---
|
||||
const handleRunOfferSync = async (id: number) => {
|
||||
setOpenDropdown(null);
|
||||
try {
|
||||
setOfferSyncId(id);
|
||||
setError(null);
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/admin/imports/${id}/offers-only`,
|
||||
{
|
||||
method: "POST",
|
||||
},
|
||||
`/api/admin/merchants/${id}/offer-sync`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Offer sync failed", res.status, text);
|
||||
throw new Error(
|
||||
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`,
|
||||
`Offer sync failed (${res.status})${text ? `: ${text}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
setBanner("Offer sync started successfully.");
|
||||
} catch (e: any) {
|
||||
console.error("Error starting offer sync", e);
|
||||
setError(e.message ?? "Failed to start offer sync");
|
||||
setError(e?.message ?? "Failed to start offer sync");
|
||||
} finally {
|
||||
setOfferSyncId(null);
|
||||
setTimeout(() => setBanner(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
// --- 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 (
|
||||
<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 className="mb-6 flex items-center justify-between gap-4">
|
||||
<div>
|
||||
@@ -202,9 +302,9 @@ export default function MerchantsAdminPage() {
|
||||
Merchant Feeds <span className="text-amber-300">Admin</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||
Manage your AvantLink merchants, product feed URLs, and offer
|
||||
sync settings. Use this to onboard new merchants and keep feeds
|
||||
fresh without touching SQL.
|
||||
Manage your AvantLink merchants, product feed URLs, and offer sync
|
||||
settings. Use this to onboard new merchants and keep feeds fresh
|
||||
without touching SQL.
|
||||
</p>
|
||||
</div>
|
||||
</header>
|
||||
@@ -221,149 +321,398 @@ export default function MerchantsAdminPage() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Table */}
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-3 md:p-4">
|
||||
{/* Add New Merchant Section */}
|
||||
<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 ? (
|
||||
<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 ? (
|
||||
<p className="text-sm text-zinc-500">
|
||||
No merchants found. Seed some merchants in the backend.
|
||||
<p className="text-sm text-zinc-500 p-4">
|
||||
No merchants found. Use the "Add New Merchant" button above.
|
||||
</p>
|
||||
) : (
|
||||
<div className="overflow-x-auto">
|
||||
<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">
|
||||
<th className="py-2 pr-3">Merchant</th>
|
||||
<th className="py-2 px-3">AvantLink MID</th>
|
||||
<th className="py-2 px-3">Feed URL</th>
|
||||
<th className="py-2 px-3">Offer Feed URL</th>
|
||||
<th className="py-2 px-3">Active</th>
|
||||
<th className="py-2 px-3">Last Full Import</th>
|
||||
<th className="py-2 px-3">Last Offer Sync</th>
|
||||
<th className="py-2 pl-3 text-right">Actions</th>
|
||||
<th className="py-3 px-4 font-medium">Merchant</th>
|
||||
<th className="py-3 px-4 font-medium">MID</th>
|
||||
<th className="py-3 px-4 font-medium">Feed URL</th>
|
||||
<th className="py-3 px-4 font-medium">Offer URL</th>
|
||||
<th className="py-3 px-4 font-medium">Status</th>
|
||||
<th className="py-3 px-4 font-medium">Last Import</th>
|
||||
<th className="py-3 px-4 font-medium text-right">Actions</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-800">
|
||||
|
||||
<tbody className="divide-y divide-zinc-800/50">
|
||||
{merchants.map((m) => (
|
||||
<tr key={m.id} className="align-top">
|
||||
<td className="py-2 pr-3">
|
||||
<tr key={m.id} className="hover:bg-zinc-900/30 transition-colors">
|
||||
{/* Merchant Name */}
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.name}
|
||||
onChange={(e) =>
|
||||
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 className="py-2 px-3">
|
||||
|
||||
{/* AvantLink MID */}
|
||||
<td className="py-3 px-4">
|
||||
<input
|
||||
type="text"
|
||||
value={m.avantlinkMid}
|
||||
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 className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
value={m.feedUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateMerchantField(
|
||||
m.id,
|
||||
"feedUrl",
|
||||
e.target.value,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<input
|
||||
type="text"
|
||||
value={m.offerFeedUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateMerchantField(
|
||||
m.id,
|
||||
"offerFeedUrl",
|
||||
e.target.value || null,
|
||||
)
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs md:text-sm text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
</td>
|
||||
<td className="py-2 px-3">
|
||||
<label className="inline-flex items-center gap-2 text-xs text-zinc-300">
|
||||
|
||||
{/* Feed URL with copy button */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={m.isActive}
|
||||
type="text"
|
||||
value={m.feedUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateMerchantField(
|
||||
m.id,
|
||||
"isActive",
|
||||
e.target.checked,
|
||||
)
|
||||
updateMerchantField(m.id, "feedUrl", e.target.value)
|
||||
}
|
||||
className="h-4 w-4 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||
title={m.feedUrl ?? ""}
|
||||
className="flex-1 min-w-[180px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
<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 className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
||||
{formatDate(m.lastFullImportAt)}
|
||||
|
||||
{/* Offer Feed URL with copy button */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={m.offerFeedUrl ?? ""}
|
||||
onChange={(e) =>
|
||||
updateMerchantField(m.id, "offerFeedUrl", e.target.value || null)
|
||||
}
|
||||
placeholder="—"
|
||||
title={m.offerFeedUrl ?? "None"}
|
||||
className="flex-1 min-w-[180px] rounded border border-zinc-700 bg-zinc-900 px-2 py-1.5 text-xs font-mono text-zinc-50 placeholder:text-zinc-600 focus:border-amber-400/70 focus:outline-none focus:ring-0"
|
||||
/>
|
||||
{m.offerFeedUrl && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleCopyUrl(m.offerFeedUrl!, m.id)}
|
||||
className="flex-shrink-0 p-1.5 rounded hover:bg-zinc-800 transition-colors text-zinc-400 hover:text-zinc-200"
|
||||
title="Copy URL"
|
||||
>
|
||||
{copiedUrl === `${m.id}-${m.offerFeedUrl}` ? (
|
||||
<svg className="w-4 h-4 text-green-400" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M5 13l4 4L19 7" />
|
||||
</svg>
|
||||
) : (
|
||||
<svg className="w-4 h-4" fill="none" viewBox="0 0 24 24" stroke="currentColor">
|
||||
<path strokeLinecap="round" strokeLinejoin="round" strokeWidth={2} d="M8 16H6a2 2 0 01-2-2V6a2 2 0 012-2h8a2 2 0 012 2v2m-6 12h8a2 2 0 002-2v-8a2 2 0 00-2-2h-8a2 2 0 00-2 2v8a2 2 0 002 2z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</td>
|
||||
<td className="py-2 px-3 text-xs text-zinc-400 whitespace-nowrap">
|
||||
{formatDate(m.lastOfferSyncAt)}
|
||||
|
||||
{/* Status Badge (replacing checkbox) */}
|
||||
<td className="py-3 px-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
updateMerchantField(m.id, "isActive", !m.isActive)
|
||||
}
|
||||
className={`inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium transition-colors ${
|
||||
m.isActive
|
||||
? "bg-emerald-500/10 text-emerald-400 hover:bg-emerald-500/20"
|
||||
: "bg-zinc-700/50 text-zinc-400 hover:bg-zinc-700"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`w-1.5 h-1.5 rounded-full ${
|
||||
m.isActive ? "bg-emerald-400" : "bg-zinc-500"
|
||||
}`}
|
||||
/>
|
||||
{m.isActive ? "Active" : "Inactive"}
|
||||
</button>
|
||||
</td>
|
||||
<td className="py-2 pl-3">
|
||||
<div className="flex flex-col items-end gap-1">
|
||||
|
||||
{/* Last Import with tooltip */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex flex-col gap-0.5">
|
||||
<span
|
||||
className="text-xs text-zinc-400"
|
||||
title={`Full: ${formatFullDate(m.lastFullImportAt)}`}
|
||||
>
|
||||
{formatRelativeTime(m.lastFullImportAt)}
|
||||
</span>
|
||||
<span
|
||||
className="text-[10px] text-zinc-500"
|
||||
title={`Offers: ${formatFullDate(m.lastOfferSyncAt)}`}
|
||||
>
|
||||
Offers: {formatRelativeTime(m.lastOfferSyncAt)}
|
||||
</span>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{/* Actions Dropdown */}
|
||||
<td className="py-3 px-4">
|
||||
<div className="flex items-center justify-end gap-2">
|
||||
{/* Primary Save Button */}
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleSave(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
|
||||
? "bg-amber-400/20 text-amber-200 cursor-wait"
|
||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
{savingId === m.id ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRunFullImport(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 ${
|
||||
importingId === m.id
|
||||
? "opacity-60 cursor-wait"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{importingId === m.id
|
||||
? "Importing…"
|
||||
: "Run Full Import"}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRunOfferSync(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 ${
|
||||
offerSyncId === m.id
|
||||
? "opacity-60 cursor-wait"
|
||||
: ""
|
||||
}`}
|
||||
>
|
||||
{offerSyncId === m.id
|
||||
? "Syncing Offers…"
|
||||
: "Run Offer Sync"}
|
||||
{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>
|
||||
|
||||
{/* 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
|
||||
type="button"
|
||||
onClick={() => handleRunFullImport(m.id)}
|
||||
disabled={importingId === m.id}
|
||||
className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{importingId === m.id ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Importing...
|
||||
</span>
|
||||
) : (
|
||||
"Run Full Import"
|
||||
)}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleRunOfferSync(m.id)}
|
||||
disabled={offerSyncId === m.id}
|
||||
className="w-full text-left px-3 py-2 text-xs text-zinc-300 hover:bg-zinc-800 hover:text-white transition-colors disabled:opacity-50 disabled:cursor-wait"
|
||||
>
|
||||
{offerSyncId === m.id ? (
|
||||
<span className="flex items-center gap-2">
|
||||
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Syncing...
|
||||
</span>
|
||||
) : (
|
||||
"Run Offer Sync"
|
||||
)}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
@@ -376,4 +725,4 @@ export default function MerchantsAdminPage() {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -4,7 +4,7 @@ import { useEffect, useState } from "react";
|
||||
import Link from "next/link";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
type AdminOverview = {
|
||||
totalProducts: number;
|
||||
@@ -24,7 +24,9 @@ export default function AdminLandingPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
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) {
|
||||
throw new Error(`Failed to load dashboard (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -0,0 +1,319 @@
|
||||
// app/admin/platforms/page.tsx
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Pencil, Trash2, X } from "lucide-react";
|
||||
import {
|
||||
createPlatform,
|
||||
deletePlatform,
|
||||
fetchPlatforms,
|
||||
updatePlatform,
|
||||
type Platform,
|
||||
type CreatePlatformDto,
|
||||
type UpdatePlatformDto,
|
||||
} from "@/lib/api/platforms";
|
||||
|
||||
export default function AdminPlatformsPage() {
|
||||
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [showModal, setShowModal] = useState(false);
|
||||
const [modalMode, setModalMode] = useState<"create" | "edit">("create");
|
||||
const [selected, setSelected] = useState<Platform | null>(null);
|
||||
|
||||
const [form, setForm] = useState<CreatePlatformDto>({
|
||||
key: "",
|
||||
label: "",
|
||||
isActive: true,
|
||||
});
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchPlatforms({});
|
||||
setPlatforms(data);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to load platforms");
|
||||
setPlatforms([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
void load();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
const sorted = useMemo(() => {
|
||||
return [...platforms].sort((a, b) => a.key.localeCompare(b.key));
|
||||
}, [platforms]);
|
||||
|
||||
function openCreate() {
|
||||
setModalMode("create");
|
||||
setSelected(null);
|
||||
setForm({ key: "", label: "", isActive: true });
|
||||
setShowModal(true);
|
||||
}
|
||||
|
||||
function openEdit(p: Platform) {
|
||||
setModalMode("edit");
|
||||
setSelected(p);
|
||||
setForm({ key: p.key, label: p.label, isActive: p.isActive });
|
||||
setShowModal(true);
|
||||
}
|
||||
|
||||
async function onDelete(p: Platform) {
|
||||
if (!window.confirm(`Delete platform "${p.key}"? This cannot be undone.`))
|
||||
return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
await deletePlatform({}, p.id);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to delete platform");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function onSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
|
||||
const key = (form.key ?? "").trim();
|
||||
const label = (form.label ?? "").trim();
|
||||
|
||||
if (!key || !label) {
|
||||
setError("Key and Label are required.");
|
||||
return;
|
||||
}
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
|
||||
if (modalMode === "create") {
|
||||
await createPlatform({}, { ...form, key, label });
|
||||
} else if (selected) {
|
||||
const update: UpdatePlatformDto = {};
|
||||
if (key !== selected.key) update.key = key;
|
||||
if (label !== selected.label) update.label = label;
|
||||
if ((form.isActive ?? true) !== selected.isActive)
|
||||
update.isActive = form.isActive;
|
||||
|
||||
await updatePlatform({}, selected.id, update);
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e?.message ?? "Failed to save platform");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-zinc-100">Platforms</h1>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Manage canonical platforms used across products and filtering.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={openCreate}
|
||||
className="inline-flex items-center gap-2 rounded-md bg-emerald-600 px-3 py-1.5 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
disabled={loading || saving}
|
||||
>
|
||||
<Plus size={14} />
|
||||
Add Platform
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="text-sm text-zinc-400">Loading…</div>
|
||||
) : sorted.length === 0 ? (
|
||||
<div className="rounded-md border border-zinc-900 bg-zinc-950 px-4 py-6 text-sm text-zinc-400">
|
||||
No platforms found.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-zinc-900 bg-zinc-950">
|
||||
<table className="min-w-full border-separate border-spacing-0 text-xs">
|
||||
<thead className="bg-zinc-950/80">
|
||||
<tr className="text-left text-[11px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
<th className="border-b border-zinc-900 px-3 py-2">ID</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">Active</th>
|
||||
<th className="border-b border-zinc-900 px-3 py-2 text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{sorted.map((p, idx) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className={idx % 2 === 0 ? "bg-zinc-950" : "bg-zinc-950/60"}
|
||||
>
|
||||
<td className="border-b border-zinc-900 px-3 py-2 text-zinc-400">
|
||||
{p.id}
|
||||
</td>
|
||||
<td className="border-b border-zinc-900 px-3 py-2 text-zinc-100">
|
||||
{p.key}
|
||||
</td>
|
||||
<td className="border-b border-zinc-900 px-3 py-2 text-zinc-300">
|
||||
{p.label}
|
||||
</td>
|
||||
<td className="border-b border-zinc-900 px-3 py-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
p.isActive
|
||||
? "border border-emerald-500/40 bg-emerald-500/10 text-emerald-200"
|
||||
: "border border-zinc-700 bg-zinc-900 text-zinc-400"
|
||||
}`}
|
||||
>
|
||||
{p.isActive ? "ACTIVE" : "INACTIVE"}
|
||||
</span>
|
||||
</td>
|
||||
<td className="border-b border-zinc-900 px-3 py-2 text-right">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => openEdit(p)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
title="Edit"
|
||||
disabled={saving}
|
||||
>
|
||||
<Pencil size={14} />
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => void onDelete(p)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-red-900/50 hover:text-red-400"
|
||||
title="Delete"
|
||||
disabled={saving}
|
||||
>
|
||||
<Trash2 size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showModal && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center bg-black/60 p-4">
|
||||
<div className="w-full max-w-md rounded-lg border border-zinc-800 bg-zinc-950 p-6 shadow-xl">
|
||||
<div className="mb-4 flex items-center justify-between">
|
||||
<h2 className="text-base font-semibold">
|
||||
{modalMode === "create" ? "Create Platform" : "Edit Platform"}
|
||||
</h2>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-zinc-800 hover:text-zinc-100"
|
||||
>
|
||||
<X size={16} />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<form onSubmit={onSubmit} className="space-y-4">
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||
Key
|
||||
</label>
|
||||
<input
|
||||
value={form.key}
|
||||
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"
|
||||
placeholder="AR15"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<label className="mb-1 block text-xs font-medium text-zinc-300">
|
||||
Label
|
||||
</label>
|
||||
<input
|
||||
value={form.label}
|
||||
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"
|
||||
placeholder="AR-15"
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-zinc-300">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.isActive}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, isActive: e.target.checked }))
|
||||
}
|
||||
className="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-amber-400"
|
||||
/>
|
||||
Active
|
||||
</label>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setShowModal(false)}
|
||||
className="flex-1 rounded-md border border-zinc-700 px-4 py-2 text-xs font-medium text-zinc-300 hover:bg-zinc-800"
|
||||
disabled={saving}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="submit"
|
||||
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving
|
||||
? "Saving…"
|
||||
: modalMode === "create"
|
||||
? "Create"
|
||||
: "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -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;
|
||||
};
|
||||
@@ -0,0 +1,5 @@
|
||||
import AdminProductsClient from "./_components/adminProductsClient";
|
||||
|
||||
export default function Page() {
|
||||
return <AdminProductsClient />;
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
// app/admin/settings/page.tsx
|
||||
"use client";
|
||||
|
||||
import { Settings } from "lucide-react";
|
||||
|
||||
export default function Page() {
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-zinc-100">Settings</h1>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Admin configuration and environment controls.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="rounded-md border border-zinc-900 bg-zinc-950 px-6 py-10">
|
||||
<div className="flex items-center gap-3">
|
||||
<div className="flex h-10 w-10 items-center justify-center rounded-md border border-zinc-800 bg-zinc-950 text-zinc-300">
|
||||
<Settings className="h-5 w-5" />
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<p className="text-sm font-semibold text-zinc-100">Coming soon</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
This section is under construction. Check back after the next deploy.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,228 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext"; // adjust path if needed
|
||||
import { Loader2 } from "lucide-react";
|
||||
|
||||
import type { AdminUser, UserRole } from "@/types/user";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { getAuthHeaders } = useAuth();
|
||||
const [users, setUsers] = useState<AdminUser[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [savingId, setSavingId] = useState<string | null>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
async function fetchUsers() {
|
||||
console.log("in the users fetch");
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const res = await fetch(`/api/admin/users`, {
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load users (${res.status})`);
|
||||
}
|
||||
|
||||
const data: AdminUser[] = await res.json();
|
||||
setUsers(data);
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message ?? "Failed to load users");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
fetchUsers();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
async function handleRoleChange(userId: string, newRole: UserRole) {
|
||||
setSavingId(userId);
|
||||
setError(null);
|
||||
|
||||
const prev = users;
|
||||
setUsers((cur) =>
|
||||
cur.map((u) => (u.id === userId ? { ...u, role: newRole } : u))
|
||||
);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/admin/users/${userId}/role`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
body: JSON.stringify({ role: newRole }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to update role (${res.status})`);
|
||||
}
|
||||
} catch (err: any) {
|
||||
console.error(err);
|
||||
setError(err.message ?? "Failed to update role");
|
||||
// rollback
|
||||
setUsers(prev);
|
||||
} finally {
|
||||
setSavingId(null);
|
||||
}
|
||||
}
|
||||
|
||||
function handleRoleSelectChange(user: AdminUser, newRole: UserRole) {
|
||||
if (newRole === user.role) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm promotions to ADMIN
|
||||
if (
|
||||
newRole === "ADMIN" &&
|
||||
!window.confirm(
|
||||
`Promote ${user.email} to ADMIN? This will give them access to the admin console.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
// Confirm demotions from ADMIN
|
||||
if (
|
||||
user.role === "ADMIN" &&
|
||||
newRole === "USER" &&
|
||||
!window.confirm(
|
||||
`Remove ADMIN access from ${user.email}? They will lose access to the admin console.`
|
||||
)
|
||||
) {
|
||||
return;
|
||||
}
|
||||
|
||||
void handleRoleChange(user.id, newRole);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-1 flex-col gap-4">
|
||||
<div className="flex items-center justify-between gap-3">
|
||||
<div>
|
||||
<h1 className="text-base font-semibold text-zinc-100">
|
||||
Users & Roles
|
||||
</h1>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Promote trusted accounts to admins. All new signups start as{" "}
|
||||
<span className="font-medium text-zinc-300">USER</span>.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
type="button"
|
||||
onClick={fetchUsers}
|
||||
className="inline-flex items-center gap-1 rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300 transition hover:border-amber-400/60 hover:text-amber-300"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading && (
|
||||
<Loader2 className="h-3 w-3 animate-spin text-zinc-500" />
|
||||
)}
|
||||
<span>Refresh</span>
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-900/60 bg-red-950/40 px-3 py-2 text-xs text-red-200">
|
||||
{error}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{loading ? (
|
||||
<div className="flex flex-1 items-center justify-center text-xs text-zinc-500">
|
||||
<Loader2 className="mr-2 h-4 w-4 animate-spin text-zinc-500" />
|
||||
Loading users…
|
||||
</div>
|
||||
) : users.length === 0 ? (
|
||||
<div className="rounded-md border border-zinc-900 bg-zinc-950 px-4 py-6 text-sm text-zinc-400">
|
||||
No users found yet. Once people start signing up, they'll show up
|
||||
here.
|
||||
</div>
|
||||
) : (
|
||||
<div className="overflow-hidden rounded-md border border-zinc-900 bg-zinc-950">
|
||||
<table className="min-w-full border-separate border-spacing-0 text-xs">
|
||||
<thead className="bg-zinc-950/80">
|
||||
<tr className="text-left text-[11px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
<th className="border-b border-zinc-900 px-3 py-2">Email</th>
|
||||
<th className="border-b border-zinc-900 px-3 py-2">
|
||||
Display Name
|
||||
</th>
|
||||
<th className="border-b border-zinc-900 px-3 py-2">Role</th>
|
||||
<th className="border-b border-zinc-900 px-3 py-2">Created</th>
|
||||
<th className="border-b border-zinc-900 px-3 py-2">
|
||||
Last Login
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{users.map((u, idx) => (
|
||||
<tr
|
||||
key={u.id}
|
||||
className={idx % 2 === 0 ? "bg-zinc-950" : "bg-zinc-950/60"}
|
||||
>
|
||||
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-100">
|
||||
{u.email}
|
||||
</td>
|
||||
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-300">
|
||||
{u.displayName || "—"}
|
||||
</td>
|
||||
<td className="border-b border-zinc-900 px-3 py-2 align-middle">
|
||||
<div className="inline-flex items-center gap-2">
|
||||
<span
|
||||
className={`inline-flex items-center rounded-full px-2 py-0.5 text-[10px] font-medium ${
|
||||
u.role === "ADMIN"
|
||||
? "border border-amber-500/60 bg-amber-500/10 text-amber-200"
|
||||
: "border border-zinc-700 bg-zinc-900 text-zinc-300"
|
||||
}`}
|
||||
>
|
||||
{u.role}
|
||||
</span>
|
||||
<select
|
||||
value={u.role}
|
||||
onChange={(e) =>
|
||||
handleRoleSelectChange(u, e.target.value as UserRole)
|
||||
}
|
||||
disabled={savingId === u.id}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 outline-none transition hover:border-amber-400/60 focus:border-amber-400/80"
|
||||
>
|
||||
<option value="USER">USER</option>
|
||||
<option value="ADMIN">ADMIN</option>
|
||||
</select>
|
||||
</div>
|
||||
</td>
|
||||
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-400">
|
||||
{u.createdAt
|
||||
? new Date(u.createdAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</td>
|
||||
<td className="border-b border-zinc-900 px-3 py-2 align-middle text-zinc-400">
|
||||
{u.lastLoginAt
|
||||
? new Date(u.lastLoginAt).toLocaleDateString()
|
||||
: "—"}
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<p className="mt-2 text-[10px] text-zinc-600">
|
||||
Note: Role changes take effect on the user's next request. All new
|
||||
accounts are created as <span className="text-zinc-300">USER</span> by
|
||||
default.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
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 cookieStore = await cookies();
|
||||
|
||||
const token = cookieStore.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,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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user