Compare commits
88 Commits
clean-data
...
main
| Author | SHA1 | Date | |
|---|---|---|---|
| 1aafd08af0 | |||
| cdb3a8dbb3 | |||
| 0e4c49928b | |||
| 82a38a7a15 | |||
| ca292c4782 | |||
| e77e6e4e6d | |||
| 98c2b12340 | |||
| 8f2a46d3bb | |||
| 0ca864ff20 | |||
| 93aa4469e8 | |||
| 89e39e7b18 | |||
| 8e33a5497a | |||
| 26bc1a8c21 | |||
| 2d55af4de2 | |||
| d387484872 | |||
| e2a2af8a6f | |||
| c977e69654 | |||
| 9d0eb40554 | |||
| 2a97ae4190 | |||
| a1bb9e97e1 | |||
| b26bc5263b | |||
| 895a2e7406 | |||
| 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 |
+2
-3
@@ -5,6 +5,5 @@ BACKEND_BASE_URL=http://localhost:8080
|
||||
# Middleware to limited site to only root page
|
||||
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
|
||||
|
||||
# Brevo API key for beta sign up collection
|
||||
BREVO_API_KEY=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
|
||||
BREVO_LIST_ID=9
|
||||
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
|
||||
|
||||
|
||||
+5
-5
@@ -1,9 +1,9 @@
|
||||
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
|
||||
|
||||
# Middleware to limited site to only root page
|
||||
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
|
||||
LAUNCH_ONLY_ROOT=false
|
||||
|
||||
NEXT_PUBLIC_SHORTLINK_BASE_URL=https://battl.build
|
||||
|
||||
|
||||
# Brevo API key for beta sign up collection
|
||||
BREVO_API_KEY=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
|
||||
BREVO_LIST_ID=9
|
||||
|
||||
@@ -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
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="db-tree-configuration">
|
||||
<option name="data" value="" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1774884294221}
|
||||
@@ -0,0 +1,10 @@
|
||||
{"type":"server-started","port":63304,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63304","screen_dir":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/waiting.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/waiting-2.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/changelog-layout.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/roadmap-tab.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/roadmap-votes.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/feedback-form.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/feedback-form-v2.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/waiting-3.html"}
|
||||
{"type":"server-stopped","reason":"idle timeout"}
|
||||
@@ -0,0 +1 @@
|
||||
20969
|
||||
@@ -0,0 +1,113 @@
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
|
||||
.wrap { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
h2 { font-size: 13px; font-weight: 600; color: #a1a1aa; margin-bottom: 20px; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
|
||||
/* ── Tabs ── */
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
|
||||
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
|
||||
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
|
||||
|
||||
/* ── Changelog entries ── */
|
||||
.entries { display: flex; flex-direction: column; gap: 0; }
|
||||
.entry { display: grid; grid-template-columns: 120px 1fr; gap: 0 32px; border-bottom: 1px solid #18181b; padding: 32px 0; }
|
||||
.entry:last-child { border-bottom: none; }
|
||||
.entry-meta { padding-top: 4px; }
|
||||
.entry-version { font-size: 12px; font-weight: 700; color: #fbbf24; font-family: monospace; margin-bottom: 4px; }
|
||||
.entry-date { font-size: 11px; color: #3f3f46; }
|
||||
.entry-title { font-size: 17px; font-weight: 600; color: #fff; margin-bottom: 8px; }
|
||||
.entry-body { font-size: 13px; color: #71717a; line-height: 1.7; }
|
||||
.entry-tags { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
|
||||
.tag { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 99px; letter-spacing: 0.05em; text-transform: uppercase; }
|
||||
.tag-feature { background: rgba(251,191,36,0.1); color: #fbbf24; border: 1px solid rgba(251,191,36,0.25); }
|
||||
.tag-fix { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.2); }
|
||||
.tag-improvement { background: rgba(147,197,253,0.1); color: #93c5fd; border: 1px solid rgba(147,197,253,0.2); }
|
||||
|
||||
/* ── Roadmap items ── */
|
||||
.roadmap { display: flex; flex-direction: column; gap: 12px; }
|
||||
.roadmap-item { display: flex; align-items: flex-start; gap: 16px; padding: 20px; border: 1px solid #1c1c1f; border-radius: 10px; background: #0d0d0f; }
|
||||
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; }
|
||||
.status-planned { background: #3f3f46; }
|
||||
.status-inprogress { background: #fbbf24; box-shadow: 0 0 8px rgba(251,191,36,0.4); }
|
||||
.status-shipped { background: #4ade80; }
|
||||
.roadmap-item-title { font-size: 14px; font-weight: 600; color: #e4e4e7; margin-bottom: 4px; }
|
||||
.roadmap-item-desc { font-size: 12px; color: #52525b; line-height: 1.6; }
|
||||
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 99px; letter-spacing: 0.06em; text-transform: uppercase; margin-left: auto; flex-shrink: 0; }
|
||||
.badge-planned { background: rgba(63,63,70,0.4); color: #71717a; border: 1px solid #27272a; }
|
||||
.badge-inprogress { background: rgba(251,191,36,0.1); color: #fbbf24; border: 1px solid rgba(251,191,36,0.3); }
|
||||
.badge-shipped { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.2); }
|
||||
|
||||
.page-header { margin-bottom: 40px; }
|
||||
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
|
||||
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
|
||||
.page-sub { font-size: 14px; color: #52525b; }
|
||||
|
||||
/* ── Section separators for roadmap ── */
|
||||
.roadmap-group-label { font-size: 10px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: #3f3f46; margin: 24px 0 10px; }
|
||||
.roadmap-group-label:first-child { margin-top: 0; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="page-header">
|
||||
<p class="page-label">Battl Builders</p>
|
||||
<h1 class="page-title">Updates</h1>
|
||||
<p class="page-sub">What we've shipped and what's coming next.</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab active">What's New</div>
|
||||
<div class="tab">What's Next</div>
|
||||
<div class="tab">Give Feedback</div>
|
||||
</div>
|
||||
|
||||
<!-- WHAT'S NEW -->
|
||||
<div id="whats-new">
|
||||
<div class="entries">
|
||||
<div class="entry">
|
||||
<div class="entry-meta">
|
||||
<div class="entry-version">v0.4</div>
|
||||
<div class="entry-date">Mar 28, 2026</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="entry-tags">
|
||||
<span class="tag tag-feature">Feature</span>
|
||||
<span class="tag tag-improvement">Improvement</span>
|
||||
</div>
|
||||
<div class="entry-title">Homepage redesign + open access</div>
|
||||
<div class="entry-body">Removed the beta gate. New landing page explains what Battl does without the jargon. Compatibility conflict warnings now surface inline while you build.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry">
|
||||
<div class="entry-meta">
|
||||
<div class="entry-version">v0.3</div>
|
||||
<div class="entry-date">Mar 14, 2026</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="entry-tags">
|
||||
<span class="tag tag-feature">Feature</span>
|
||||
<span class="tag tag-fix">Fix</span>
|
||||
</div>
|
||||
<div class="entry-title">Shareable build links + vault</div>
|
||||
<div class="entry-body">Save and share builds with a single link. Anyone can view the parts list without an account. Fixed gas system validation that was incorrectly flagging mid-length configs.</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="entry">
|
||||
<div class="entry-meta">
|
||||
<div class="entry-version">v0.2</div>
|
||||
<div class="entry-date">Feb 28, 2026</div>
|
||||
</div>
|
||||
<div>
|
||||
<div class="entry-tags">
|
||||
<span class="tag tag-feature">Feature</span>
|
||||
</div>
|
||||
<div class="entry-title">Live pricing from real retailers</div>
|
||||
<div class="entry-body">AvantLink feed integration. Running total updates as you add parts. Prices are pulled daily — no estimated MSRP, no sponsored rankings.</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,88 @@
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
|
||||
.wrap { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
|
||||
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
|
||||
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
|
||||
|
||||
.page-header { margin-bottom: 40px; }
|
||||
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
|
||||
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
|
||||
.page-sub { font-size: 14px; color: #52525b; }
|
||||
|
||||
.feedback-header { margin-bottom: 28px; }
|
||||
.feedback-header h2 { font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -0.015em; margin-bottom: 6px; }
|
||||
.feedback-header p { font-size: 13px; color: #52525b; line-height: 1.6; }
|
||||
|
||||
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
|
||||
.form-field { margin-bottom: 20px; }
|
||||
.form-label { font-size: 11px; font-weight: 600; color: #71717a; letter-spacing: 0.05em; text-transform: uppercase; margin-bottom: 8px; display: block; }
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%; padding: 10px 14px; font-size: 13px; font-family: inherit;
|
||||
background: #0d0d0f; border: 1px solid #27272a; border-radius: 8px;
|
||||
color: #e4e4e7; outline: none;
|
||||
}
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus { border-color: rgba(251,191,36,0.4); }
|
||||
.form-textarea { resize: vertical; min-height: 160px; line-height: 1.6; }
|
||||
.form-select { appearance: none; cursor: pointer; }
|
||||
.form-hint { font-size: 11px; color: #3f3f46; margin-top: 6px; }
|
||||
.char-count { font-size: 11px; color: #3f3f46; text-align: right; margin-top: 6px; }
|
||||
|
||||
.form-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
|
||||
.form-footer-note { font-size: 11px; color: #3f3f46; }
|
||||
|
||||
.btn-submit {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: rgba(251,191,36,0.9); color: #000; font-size: 13px; font-weight: 700;
|
||||
padding: 10px 28px; border-radius: 8px; border: none; cursor: pointer;
|
||||
letter-spacing: 0.02em; font-family: inherit;
|
||||
}
|
||||
.btn-submit:hover { background: #fbbf24; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="page-header">
|
||||
<p class="page-label">Battl Builders</p>
|
||||
<h1 class="page-title">Updates</h1>
|
||||
<p class="page-sub">What we've shipped and what's coming next.</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab">What's New</div>
|
||||
<div class="tab">What's Next</div>
|
||||
<div class="tab active">Give Feedback</div>
|
||||
</div>
|
||||
|
||||
<div class="feedback-header">
|
||||
<h2>Tell us what you think.</h2>
|
||||
<p>Bug, feature idea, or general thought — we read everything. Rough language welcome.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
<div class="form-field">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select">
|
||||
<option>Bug report</option>
|
||||
<option>Feature request</option>
|
||||
<option>General feedback</option>
|
||||
</select>
|
||||
</div>
|
||||
<div class="form-field">
|
||||
<label class="form-label">Email <span style="color:#3f3f46; font-weight:400; text-transform:none; letter-spacing:0;">(optional — if you want a reply)</span></label>
|
||||
<input class="form-input" type="email" placeholder="you@email.com" />
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">Message</label>
|
||||
<textarea class="form-textarea" placeholder="What's on your mind?"></textarea>
|
||||
<div class="char-count">0 / 1000</div>
|
||||
</div>
|
||||
|
||||
<div class="form-footer">
|
||||
<span class="form-footer-note">We'll only use your email to follow up on your feedback.</span>
|
||||
<button class="btn-submit">Send Feedback →</button>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,160 @@
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
|
||||
.wrap { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
|
||||
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
|
||||
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
|
||||
|
||||
.page-header { margin-bottom: 40px; }
|
||||
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
|
||||
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
|
||||
.page-sub { font-size: 14px; color: #52525b; }
|
||||
|
||||
/* ── Shared form styles ── */
|
||||
.form-label { font-size: 11px; font-weight: 600; color: #71717a; letter-spacing: 0.05em; text-transform: uppercase; margin-bottom: 8px; display: block; }
|
||||
.form-field { margin-bottom: 20px; }
|
||||
.form-input, .form-select, .form-textarea {
|
||||
width: 100%; padding: 10px 14px; font-size: 13px; font-family: inherit;
|
||||
background: #0d0d0f; border: 1px solid #27272a; border-radius: 8px;
|
||||
color: #e4e4e7; outline: none;
|
||||
}
|
||||
.form-input:focus, .form-select:focus, .form-textarea:focus { border-color: rgba(251,191,36,0.4); }
|
||||
.form-textarea { resize: vertical; min-height: 120px; line-height: 1.6; }
|
||||
.form-select { appearance: none; cursor: pointer; }
|
||||
.form-hint { font-size: 11px; color: #3f3f46; margin-top: 6px; }
|
||||
.btn-submit {
|
||||
display: inline-flex; align-items: center; gap-8px; gap: 8px;
|
||||
background: rgba(251,191,36,0.9); color: #000; font-size: 13px; font-weight: 700;
|
||||
padding: 10px 24px; border-radius: 8px; border: none; cursor: pointer;
|
||||
letter-spacing: 0.02em; font-family: inherit;
|
||||
}
|
||||
.btn-submit:hover { background: #fbbf24; }
|
||||
|
||||
/* ── Split view ── */
|
||||
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; align-items: start; }
|
||||
|
||||
/* ── Page form (tab) ── */
|
||||
.feedback-page { max-width: 560px; }
|
||||
.feedback-page-header { margin-bottom: 28px; }
|
||||
.feedback-page-header h2 { font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -0.015em; margin-bottom: 6px; }
|
||||
.feedback-page-header p { font-size: 13px; color: #52525b; line-height: 1.6; }
|
||||
|
||||
/* ── Modal ── */
|
||||
.modal-wrap { position: relative; }
|
||||
.modal-label { font-size: 10px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: #52525b; margin-bottom: 12px; text-align: center; }
|
||||
.modal {
|
||||
background: #111113; border: 1px solid #27272a; border-radius: 14px;
|
||||
padding: 28px; width: 100%;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
|
||||
}
|
||||
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
|
||||
.modal-title { font-size: 16px; font-weight: 700; color: #fff; letter-spacing: -0.01em; }
|
||||
.modal-close { font-size: 18px; color: #3f3f46; cursor: pointer; background: none; border: none; font-family: inherit; padding: 0; line-height: 1; }
|
||||
.modal-close:hover { color: #71717a; }
|
||||
|
||||
/* Nav trigger button (shown for context) */
|
||||
.nav-context { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #18181b; }
|
||||
.nav-context-label { font-size: 10px; color: #3f3f46; letter-spacing: 0.05em; }
|
||||
.btn-feedback-nav {
|
||||
padding: 6px 12px; border-radius: 6px; font-size: 11px; font-weight: 600;
|
||||
border: 1px solid #27272a; color: #71717a; background: transparent;
|
||||
cursor: pointer; font-family: inherit; letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
.divider { width: 1px; background: #18181b; align-self: stretch; }
|
||||
|
||||
.char-count { font-size: 11px; color: #3f3f46; text-align: right; margin-top: 6px; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="page-header">
|
||||
<p class="page-label">Battl Builders</p>
|
||||
<h1 class="page-title">Updates</h1>
|
||||
<p class="page-sub">What we've shipped and what's coming next.</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab">What's New</div>
|
||||
<div class="tab">What's Next</div>
|
||||
<div class="tab active">Give Feedback</div>
|
||||
</div>
|
||||
|
||||
<div class="split">
|
||||
|
||||
<!-- LEFT: Tab / page form -->
|
||||
<div>
|
||||
<p class="modal-label" style="text-align:left; margin-bottom: 20px;">Give Feedback tab (full page)</p>
|
||||
<div class="feedback-page">
|
||||
<div class="feedback-page-header">
|
||||
<h2>Tell us what you think.</h2>
|
||||
<p>Bug, feature idea, or general thought — we read everything. Rough language welcome.</p>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select">
|
||||
<option>Bug report</option>
|
||||
<option>Feature request</option>
|
||||
<option>General feedback</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">Message</label>
|
||||
<textarea class="form-textarea" placeholder="What's on your mind?"></textarea>
|
||||
<div class="char-count">0 / 1000</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">Email <span style="color:#3f3f46; font-weight:400; text-transform:none; letter-spacing:0;">(optional — if you want a reply)</span></label>
|
||||
<input class="form-input" type="email" placeholder="you@email.com" />
|
||||
<p class="form-hint">We'll only use this to follow up on your feedback.</p>
|
||||
</div>
|
||||
|
||||
<button class="btn-submit">Send Feedback →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="divider"></div>
|
||||
|
||||
<!-- RIGHT: Modal (triggered from nav/footer) -->
|
||||
<div>
|
||||
<div class="nav-context">
|
||||
<span class="nav-context-label">Triggered from nav/footer →</span>
|
||||
<button class="btn-feedback-nav">Send Feedback</button>
|
||||
</div>
|
||||
<p class="modal-label">Modal</p>
|
||||
<div class="modal">
|
||||
<div class="modal-header">
|
||||
<span class="modal-title">Send Feedback</span>
|
||||
<button class="modal-close">✕</button>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">Category</label>
|
||||
<select class="form-select">
|
||||
<option>Bug report</option>
|
||||
<option>Feature request</option>
|
||||
<option>General feedback</option>
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">Message</label>
|
||||
<textarea class="form-textarea" style="min-height:100px;" placeholder="What's on your mind?"></textarea>
|
||||
<div class="char-count">0 / 1000</div>
|
||||
</div>
|
||||
|
||||
<div class="form-field">
|
||||
<label class="form-label">Email <span style="color:#3f3f46; font-weight:400; text-transform:none; letter-spacing:0;">(optional)</span></label>
|
||||
<input class="form-input" type="email" placeholder="you@email.com" />
|
||||
</div>
|
||||
|
||||
<button class="btn-submit" style="width:100%; justify-content:center;">Send Feedback →</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,104 @@
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
|
||||
.wrap { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
|
||||
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
|
||||
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
|
||||
|
||||
.page-header { margin-bottom: 40px; }
|
||||
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
|
||||
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
|
||||
.page-sub { font-size: 14px; color: #52525b; }
|
||||
|
||||
.roadmap { display: flex; flex-direction: column; gap: 10px; }
|
||||
.roadmap-item { display: flex; align-items: flex-start; gap: 16px; padding: 20px; border: 1px solid #1c1c1f; border-radius: 10px; background: #0d0d0f; }
|
||||
.dot-col { display: flex; flex-direction: column; align-items: center; gap: 0; padding-top: 5px; flex-shrink: 0; }
|
||||
.status-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
|
||||
.status-planned .status-dot { background: #3f3f46; }
|
||||
.status-inprogress .status-dot { background: #fbbf24; box-shadow: 0 0 10px rgba(251,191,36,0.5); }
|
||||
.status-shipped .status-dot { background: #4ade80; }
|
||||
.roadmap-body { flex: 1; }
|
||||
.roadmap-item-title { font-size: 14px; font-weight: 600; color: #e4e4e7; margin-bottom: 4px; }
|
||||
.roadmap-item-desc { font-size: 12px; color: #52525b; line-height: 1.65; }
|
||||
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 9px; border-radius: 99px; letter-spacing: 0.06em; text-transform: uppercase; flex-shrink: 0; margin-top: 2px; }
|
||||
.status-planned .status-badge { background: rgba(63,63,70,0.4); color: #52525b; border: 1px solid #27272a; }
|
||||
.status-inprogress .status-badge { background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.25); }
|
||||
.status-shipped .status-badge { background: rgba(74,222,128,0.08); color: #4ade80; border: 1px solid rgba(74,222,128,0.2); }
|
||||
|
||||
.group-label { font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: #3f3f46; margin: 28px 0 10px; }
|
||||
.group-label:first-child { margin-top: 0; }
|
||||
|
||||
.roadmap-note { margin-top: 32px; padding: 16px 20px; border: 1px solid #18181b; border-radius: 8px; font-size: 12px; color: #3f3f46; line-height: 1.6; }
|
||||
.roadmap-note a { color: #52525b; text-decoration: underline; text-underline-offset: 3px; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="page-header">
|
||||
<p class="page-label">Battl Builders</p>
|
||||
<h1 class="page-title">Updates</h1>
|
||||
<p class="page-sub">What we've shipped and what's coming next.</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab">What's New</div>
|
||||
<div class="tab active">What's Next</div>
|
||||
<div class="tab">Give Feedback</div>
|
||||
</div>
|
||||
|
||||
<div class="roadmap">
|
||||
<div class="group-label">In Progress</div>
|
||||
|
||||
<div class="roadmap-item status-inprogress">
|
||||
<div class="dot-col"><span class="status-dot"></span></div>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Compatibility engine — gas system + buffer</div>
|
||||
<div class="roadmap-item-desc">Automatic warnings when barrel length and gas system length don't match, and when buffer weight conflicts with your stock/brace selection.</div>
|
||||
</div>
|
||||
<span class="status-badge">In Progress</span>
|
||||
</div>
|
||||
|
||||
<div class="roadmap-item status-inprogress">
|
||||
<div class="dot-col"><span class="status-dot"></span></div>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Mobile builder experience</div>
|
||||
<div class="roadmap-item-desc">Full 375px breakpoint pass on the builder, parts browser, and build summary. Lighthouse score target ≥70.</div>
|
||||
</div>
|
||||
<span class="status-badge">In Progress</span>
|
||||
</div>
|
||||
|
||||
<div class="group-label">Planned</div>
|
||||
|
||||
<div class="roadmap-item status-planned">
|
||||
<div class="dot-col"><span class="status-dot"></span></div>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Expanded parts catalog</div>
|
||||
<div class="roadmap-item-desc">50–75 SKUs across all 10 slots, 3–5 options per category, with live AvantLink pricing.</div>
|
||||
</div>
|
||||
<span class="status-badge">Planned</span>
|
||||
</div>
|
||||
|
||||
<div class="roadmap-item status-planned">
|
||||
<div class="dot-col"><span class="status-dot"></span></div>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Build guides — first wave</div>
|
||||
<div class="roadmap-item-desc">Three long-form guides targeting "AR-15 build planner" and related keywords. Written for the ICP: experienced shooter, not an expert builder.</div>
|
||||
</div>
|
||||
<span class="status-badge">Planned</span>
|
||||
</div>
|
||||
|
||||
<div class="roadmap-item status-planned">
|
||||
<div class="dot-col"><span class="status-dot"></span></div>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Caliber cross-compatibility checks</div>
|
||||
<div class="roadmap-item-desc">Flag mismatches between upper and lower when mixing calibers (e.g., .300 Blackout upper on a 5.56 lower).</div>
|
||||
</div>
|
||||
<span class="status-badge">Planned</span>
|
||||
</div>
|
||||
|
||||
<div class="roadmap-note">
|
||||
Have something you want to see? <a href="#">Give us feedback →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,150 @@
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
|
||||
.wrap { max-width: 900px; margin: 0 auto; }
|
||||
|
||||
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
|
||||
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
|
||||
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
|
||||
|
||||
.page-header { margin-bottom: 40px; }
|
||||
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
|
||||
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
|
||||
.page-sub { font-size: 14px; color: #52525b; }
|
||||
|
||||
.roadmap { display: flex; flex-direction: column; gap: 10px; }
|
||||
|
||||
.roadmap-item {
|
||||
display: grid;
|
||||
grid-template-columns: 52px 1fr auto;
|
||||
gap: 0 16px;
|
||||
align-items: center;
|
||||
padding: 20px;
|
||||
border: 1px solid #1c1c1f;
|
||||
border-radius: 10px;
|
||||
background: #0d0d0f;
|
||||
}
|
||||
|
||||
/* Upvote button */
|
||||
.upvote {
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center;
|
||||
gap: 3px; width: 44px; height: 52px;
|
||||
border: 1px solid #27272a; border-radius: 8px; background: transparent;
|
||||
cursor: pointer; color: #52525b; transition: all 0.15s;
|
||||
font-size: 11px; font-weight: 700; font-family: inherit;
|
||||
}
|
||||
.upvote:hover { border-color: #fbbf24; color: #fbbf24; background: rgba(251,191,36,0.06); }
|
||||
.upvote.voted { border-color: rgba(251,191,36,0.4); color: #fbbf24; background: rgba(251,191,36,0.08); }
|
||||
.upvote svg { width: 14px; height: 14px; }
|
||||
|
||||
.roadmap-body { }
|
||||
.roadmap-item-title { font-size: 14px; font-weight: 600; color: #e4e4e7; margin-bottom: 4px; }
|
||||
.roadmap-item-desc { font-size: 12px; color: #52525b; line-height: 1.65; }
|
||||
|
||||
.right-col { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
|
||||
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 9px; border-radius: 99px; letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; }
|
||||
.badge-planned { background: rgba(63,63,70,0.4); color: #52525b; border: 1px solid #27272a; }
|
||||
.badge-inprogress { background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.25); }
|
||||
|
||||
.group-label { font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: #3f3f46; margin: 28px 0 10px; }
|
||||
.group-label:first-child { margin-top: 0; }
|
||||
|
||||
.roadmap-note { margin-top: 32px; padding: 16px 20px; border: 1px solid #18181b; border-radius: 8px; font-size: 12px; color: #3f3f46; line-height: 1.6; }
|
||||
.roadmap-note a { color: #52525b; text-decoration: underline; text-underline-offset: 3px; }
|
||||
|
||||
.auth-hint { font-size: 10px; color: #3f3f46; margin-top: 4px; }
|
||||
</style>
|
||||
|
||||
<div class="wrap">
|
||||
<div class="page-header">
|
||||
<p class="page-label">Battl Builders</p>
|
||||
<h1 class="page-title">Updates</h1>
|
||||
<p class="page-sub">What we've shipped and what's coming next.</p>
|
||||
</div>
|
||||
|
||||
<div class="tabs">
|
||||
<div class="tab">What's New</div>
|
||||
<div class="tab active">What's Next</div>
|
||||
<div class="tab">Give Feedback</div>
|
||||
</div>
|
||||
|
||||
<div class="roadmap">
|
||||
<div class="group-label">In Progress</div>
|
||||
|
||||
<div class="roadmap-item">
|
||||
<button class="upvote voted" title="Remove vote">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
|
||||
24
|
||||
</button>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Compatibility engine — gas system + buffer</div>
|
||||
<div class="roadmap-item-desc">Automatic warnings when barrel length and gas system length don't match, and when buffer weight conflicts with your stock or brace selection.</div>
|
||||
</div>
|
||||
<div class="right-col">
|
||||
<span class="status-badge badge-inprogress">In Progress</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roadmap-item">
|
||||
<button class="upvote" title="Upvote">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
|
||||
11
|
||||
</button>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Mobile builder experience</div>
|
||||
<div class="roadmap-item-desc">Full 375px breakpoint pass on the builder, parts browser, and build summary. Lighthouse score target ≥70.</div>
|
||||
</div>
|
||||
<div class="right-col">
|
||||
<span class="status-badge badge-inprogress">In Progress</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="group-label">Planned</div>
|
||||
|
||||
<div class="roadmap-item">
|
||||
<button class="upvote" title="Upvote">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
|
||||
38
|
||||
</button>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Expanded parts catalog</div>
|
||||
<div class="roadmap-item-desc">50–75 SKUs across all 10 slots, 3–5 options per category, with live AvantLink pricing.</div>
|
||||
</div>
|
||||
<div class="right-col">
|
||||
<span class="status-badge badge-planned">Planned</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roadmap-item">
|
||||
<button class="upvote" title="Upvote">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
|
||||
17
|
||||
</button>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Caliber cross-compatibility checks</div>
|
||||
<div class="roadmap-item-desc">Flag mismatches between upper and lower when mixing calibers (e.g., .300 Blackout upper on a 5.56 lower).</div>
|
||||
</div>
|
||||
<div class="right-col">
|
||||
<span class="status-badge badge-planned">Planned</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roadmap-item">
|
||||
<button class="upvote" title="Upvote">
|
||||
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
|
||||
9
|
||||
</button>
|
||||
<div class="roadmap-body">
|
||||
<div class="roadmap-item-title">Build guides — first wave</div>
|
||||
<div class="roadmap-item-desc">Three long-form guides targeting "AR-15 build planner" and related keywords. Written for the ICP: experienced shooter, not an expert builder.</div>
|
||||
</div>
|
||||
<div class="right-col">
|
||||
<span class="status-badge badge-planned">Planned</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="roadmap-note">
|
||||
Don't see what you need? <a href="#">Give us feedback →</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1774866020266}
|
||||
@@ -0,0 +1,10 @@
|
||||
{"type":"server-started","port":60692,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:60692","screen_dir":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/hero-layout.html"}
|
||||
{"source":"user-event","type":"click","text":"AR-15 Build Planner\n Build smarter rifles.No spreadsheets.\n Pick parts by role, catch compatibility issues, compare live prices — all in one place.\n \n START A BUILD →\n \n \n ▶ Builder preview video\n \n \n \n \n A — Centered / Full-width\n Headline + tagline centered, single dominant CTA button, product video below. Clean, magazine-style. Works great on mobile.","choice":"centered","id":null,"timestamp":1774863085356}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/page-sections.html"}
|
||||
{"source":"user-event","type":"click","text":"HERO\n \"Stop Guessing. Start Building.\" + subhead + CTA\n \n ↓\n \n HOW IT WORKS\n 3 steps with icons\n \n ↓\n \n BUILDER PREVIEW VIDEO\n Tool in action\n \n ↓\n \n FEATURE CARDS\n 6 capabilities\n \n ↓\n \n SEO COPY BLOCK\n Short paragraph: \"What is an AR-15 builder tool?\" — targets search\n \n ↓\n \n FINAL CTA\n Second \"Start a Build\" button\n \n \n \n \n C — SEO-Optimized (6 sections)\n Same as B but adds a short prose block at the bottom targeting search keywords (\"AR-15 parts compatibility tool\", \"AR-15 build planner\"). Best for organic discovery over time.","choice":"seo","id":null,"timestamp":1774863262165}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/waiting-1.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/full-design-v1.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/full-design-v2.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/full-design-v3.html"}
|
||||
{"type":"server-stopped","reason":"idle timeout"}
|
||||
@@ -0,0 +1 @@
|
||||
3712
|
||||
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Battl Builders — Homepage Redesign Mockup</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
|
||||
/* ── GRID BACKGROUND ── */
|
||||
.bg-grid {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(251,191,36,0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(251,191,36,0.07) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
.wrap { position: relative; z-index: 1; max-width: 960px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
/* ── NAV ── */
|
||||
nav {
|
||||
position: relative; z-index: 10;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px; max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
.logo { height: 32px; display: flex; align-items: center; gap: 8px; }
|
||||
.logo-text { font-size: 18px; font-weight: 800; letter-spacing: -0.02em; color: #fff; }
|
||||
.logo-text span { color: #fbbf24; }
|
||||
.nav-links { display: flex; gap: 8px; }
|
||||
.btn-ghost {
|
||||
padding: 7px 14px; border-radius: 6px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid #27272a; color: #d4d4d8; background: transparent;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.btn-ghost:hover { background: #18181b; color: #fff; }
|
||||
.btn-amber {
|
||||
padding: 7px 16px; border-radius: 6px; font-size: 12px; font-weight: 700;
|
||||
background: #fbbf24; color: #000; border: none; cursor: pointer;
|
||||
text-decoration: none; letter-spacing: 0.02em;
|
||||
}
|
||||
.btn-amber:hover { background: #f59e0b; }
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero {
|
||||
text-align: center; padding: 80px 24px 72px;
|
||||
max-width: 760px; margin: 0 auto;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.4);
|
||||
border-radius: 99px; padding: 4px 12px;
|
||||
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: rgba(255,255,255,0.6);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.hero-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #fbbf24; }
|
||||
.hero h1 {
|
||||
font-size: clamp(36px, 6vw, 60px); font-weight: 700; line-height: 1.1;
|
||||
letter-spacing: -0.03em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.hero h1 .amber { color: #fbbf24; display: block; }
|
||||
.hero-sub {
|
||||
font-size: 16px; color: #a1a1aa; line-height: 1.6;
|
||||
max-width: 540px; margin: 20px auto 0;
|
||||
}
|
||||
.hero-cta-wrap { margin-top: 36px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||
.btn-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: #fbbf24; color: #000; font-size: 15px; font-weight: 800;
|
||||
padding: 14px 36px; border-radius: 8px; border: none; cursor: pointer;
|
||||
letter-spacing: 0.02em; text-decoration: none;
|
||||
box-shadow: 0 0 32px rgba(251,191,36,0.25);
|
||||
}
|
||||
.btn-cta:hover { background: #f59e0b; }
|
||||
.hero-micro { font-size: 11px; color: #52525b; }
|
||||
|
||||
/* ── HOW IT WORKS ── */
|
||||
.how { padding: 64px 24px; }
|
||||
.section-label {
|
||||
text-align: center; font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.25em; text-transform: uppercase; color: #fbbf24; margin-bottom: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.how h2 {
|
||||
text-align: center; font-size: 28px; font-weight: 700;
|
||||
letter-spacing: -0.02em; color: #fff; margin-bottom: 48px;
|
||||
}
|
||||
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; max-width: 800px; margin: 0 auto; }
|
||||
.step {
|
||||
text-align: center; padding: 32px 20px;
|
||||
background: rgba(255,255,255,0.03); border: 1px solid #1c1c1f; border-radius: 12px;
|
||||
}
|
||||
.step-num {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3);
|
||||
color: #fbbf24; font-size: 14px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
.step h3 { font-size: 14px; font-weight: 600; color: #fff; margin-bottom: 8px; }
|
||||
.step p { font-size: 12px; color: #71717a; line-height: 1.5; }
|
||||
.step-connector { display: none; }
|
||||
|
||||
/* ── VIDEO ── */
|
||||
.video-section { padding: 0 24px 64px; }
|
||||
.video-wrap {
|
||||
max-width: 900px; margin: 0 auto;
|
||||
border-radius: 16px; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: #000;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
|
||||
}
|
||||
.video-placeholder {
|
||||
aspect-ratio: 16/9; background: #0f0f0f;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
|
||||
}
|
||||
.play-btn {
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
background: rgba(251,191,36,0.15); border: 1px solid rgba(251,191,36,0.35);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
.video-placeholder p { font-size: 12px; color: #52525b; }
|
||||
|
||||
/* ── FEATURES ── */
|
||||
.features { padding: 64px 24px; }
|
||||
.features h2 {
|
||||
text-align: center; font-size: 28px; font-weight: 700;
|
||||
letter-spacing: -0.02em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.features-sub { text-align: center; font-size: 14px; color: #71717a; margin-bottom: 44px; }
|
||||
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; max-width: 900px; margin: 0 auto; }
|
||||
.feature-card {
|
||||
padding: 22px; background: rgba(255,255,255,0.025);
|
||||
border: 1px solid #1c1c1f; border-radius: 12px;
|
||||
}
|
||||
.feature-icon { font-size: 20px; margin-bottom: 12px; }
|
||||
.feature-card h3 { font-size: 13px; font-weight: 600; color: #e4e4e7; margin-bottom: 6px; }
|
||||
.feature-card p { font-size: 11px; color: #71717a; line-height: 1.5; }
|
||||
|
||||
/* ── SEO BLOCK ── */
|
||||
.seo-block {
|
||||
padding: 64px 24px;
|
||||
border-top: 1px solid #1c1c1f;
|
||||
}
|
||||
.seo-inner {
|
||||
max-width: 680px; margin: 0 auto;
|
||||
}
|
||||
.seo-inner h2 { font-size: 20px; font-weight: 700; color: #e4e4e7; margin-bottom: 14px; }
|
||||
.seo-inner p { font-size: 13px; color: #71717a; line-height: 1.7; margin-bottom: 12px; }
|
||||
.seo-inner strong { color: #a1a1aa; }
|
||||
|
||||
/* ── FINAL CTA ── */
|
||||
.final-cta {
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #1c1c1f;
|
||||
}
|
||||
.final-cta h2 { font-size: 32px; font-weight: 700; color: #fff; margin-bottom: 12px; letter-spacing: -0.02em; }
|
||||
.final-cta p { font-size: 14px; color: #71717a; margin-bottom: 32px; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
footer {
|
||||
border-top: 1px solid #18181b; padding: 20px 24px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 11px; color: #3f3f46; max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── ANNOTATION ── */
|
||||
.annotation {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
background: #18181b; border: 1px solid #27272a; border-radius: 8px;
|
||||
padding: 12px 16px; font-size: 11px; color: #a1a1aa; max-width: 260px;
|
||||
z-index: 100;
|
||||
}
|
||||
.annotation strong { color: #fbbf24; display: block; margin-bottom: 4px; }
|
||||
|
||||
/* Selection overlay */
|
||||
#selection-indicator {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav>
|
||||
<div class="logo">
|
||||
<div class="logo-text">BATTL<span>.</span>BUILDERS</div>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="btn-ghost">Sign In</a>
|
||||
<a href="#" class="btn-amber">Start a Build</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="hero-badge">
|
||||
<span class="hero-badge-dot"></span>
|
||||
AR-15 Build Planner
|
||||
<span style="color:rgba(255,255,255,0.3)">•</span>
|
||||
<span>Free to use</span>
|
||||
</div>
|
||||
<h1>
|
||||
Stop Guessing.
|
||||
<span class="amber">Start Building.</span>
|
||||
</h1>
|
||||
<p class="hero-sub">
|
||||
Pick parts by role, catch compatibility issues before you order, and compare live retailer prices — all without a spreadsheet.
|
||||
</p>
|
||||
<div class="hero-cta-wrap">
|
||||
<a href="#" class="btn-cta">
|
||||
Start a Build →
|
||||
</a>
|
||||
<span class="hero-micro">No account required · Free to use</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT WORKS -->
|
||||
<section class="how wrap">
|
||||
<div class="section-label">How it works</div>
|
||||
<h2>Three steps from idea to order</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<h3>Pick your parts</h3>
|
||||
<p>Choose from real parts organized by role — barrel, lower, upper, and more. No part numbers required.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<h3>Check compatibility</h3>
|
||||
<p>Catch mismatches before you order. Gas system, buffer weight, caliber — flagged automatically.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<h3>Compare prices & buy</h3>
|
||||
<p>See live prices from real retailers. Click through to buy — we never touch your order.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- VIDEO -->
|
||||
<section class="video-section">
|
||||
<div class="video-wrap">
|
||||
<div class="video-placeholder">
|
||||
<div class="play-btn">▶</div>
|
||||
<p>Builder preview — looping screen recording</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="features wrap">
|
||||
<div class="section-label">What's included</div>
|
||||
<h2>Everything you need to plan a build</h2>
|
||||
<p class="features-sub">No fluff. No upsells. Just the tools to plan your AR-15 right.</p>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔧</div>
|
||||
<h3>Guided part roles</h3>
|
||||
<p>Pick parts by role so you're never guessing what fits where.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>Compatibility warnings</h3>
|
||||
<p>Catch obvious mismatches — gas system, buffer, caliber — before you buy.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💰</div>
|
||||
<h3>Live pricing totals</h3>
|
||||
<p>Running total as you build, with real retailer listings updated regularly.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔖</div>
|
||||
<h3>Save your builds</h3>
|
||||
<p>Create a free account to save builds and pick up where you left off.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔗</div>
|
||||
<h3>Shareable build links</h3>
|
||||
<p>Share a build URL with your buddy or your gunsmith. No login needed to view.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🚫</div>
|
||||
<h3>No account to browse</h3>
|
||||
<p>Explore parts and build freely. Create an account only when you're ready to save.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SEO COPY BLOCK -->
|
||||
<section class="seo-block">
|
||||
<div class="seo-inner">
|
||||
<h2>What is Battl Builders?</h2>
|
||||
<p>
|
||||
Battl Builders is a free <strong>AR-15 build planner</strong> that helps you select compatible parts, compare prices across retailers, and plan your build from start to finish — without juggling spreadsheets or browser tabs.
|
||||
</p>
|
||||
<p>
|
||||
Unlike a generic parts list, Battl Builders understands <strong>AR-15 compatibility rules</strong>: gas system length vs. barrel length, buffer weight vs. stock type, caliber consistency across upper and lower. You get warnings when something doesn't match before you spend money on the wrong part.
|
||||
</p>
|
||||
<p>
|
||||
Whether you're planning your first build or your tenth, Battl Builders gives you a structured, guided way to research parts and <strong>buy with confidence</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FINAL CTA -->
|
||||
<section class="final-cta">
|
||||
<h2>Ready to build?</h2>
|
||||
<p>It's free, no account required, and takes about 5 minutes to start.</p>
|
||||
<a href="#" class="btn-cta">
|
||||
Start a Build →
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer>
|
||||
<span>© 2026 BATTL BUILDERS™</span>
|
||||
<span>Free AR-15 build planner</span>
|
||||
</footer>
|
||||
|
||||
<div class="annotation">
|
||||
<strong>📐 Design Mockup</strong>
|
||||
Full homepage redesign preview. Scroll to see all 6 sections. Reply in terminal with feedback.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// No selection needed for full-page mockup — just viewing
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,312 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Battl Builders — Homepage Redesign Mockup v2</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
|
||||
.bg-grid {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(251,191,36,0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(251,191,36,0.07) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
.wrap { position: relative; z-index: 1; max-width: 960px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
/* ── NAV ── */
|
||||
nav {
|
||||
position: relative; z-index: 10;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px; max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
.logo-text { font-size: 18px; font-weight: 800; letter-spacing: -0.02em; color: #fff; }
|
||||
.logo-text span { color: #fbbf24; }
|
||||
.nav-links { display: flex; gap: 8px; }
|
||||
.btn-ghost {
|
||||
padding: 7px 14px; border-radius: 6px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid #27272a; color: #d4d4d8; background: transparent;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.btn-ghost:hover { background: #18181b; color: #fff; }
|
||||
.btn-amber {
|
||||
padding: 7px 16px; border-radius: 6px; font-size: 12px; font-weight: 700;
|
||||
background: #fbbf24; color: #000; border: none; cursor: pointer;
|
||||
text-decoration: none; letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero {
|
||||
text-align: center; padding: 80px 24px 72px;
|
||||
max-width: 760px; margin: 0 auto; position: relative; z-index: 1;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.4);
|
||||
border-radius: 99px; padding: 5px 14px;
|
||||
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: rgba(255,255,255,0.55);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.hero-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #fbbf24; flex-shrink: 0; }
|
||||
.hero h1 {
|
||||
font-size: clamp(38px, 6.5vw, 64px); font-weight: 700; line-height: 1.05;
|
||||
letter-spacing: -0.035em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.hero h1 .amber { color: #fbbf24; display: block; }
|
||||
.hero-sub {
|
||||
font-size: 17px; color: #a1a1aa; line-height: 1.65;
|
||||
max-width: 520px; margin: 22px auto 0;
|
||||
}
|
||||
.hero-cta-wrap { margin-top: 38px; display: flex; flex-direction: column; align-items: center; gap: 11px; }
|
||||
.btn-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: #fbbf24; color: #000; font-size: 15px; font-weight: 800;
|
||||
padding: 15px 38px; border-radius: 8px; border: none; cursor: pointer;
|
||||
letter-spacing: 0.03em; text-decoration: none;
|
||||
box-shadow: 0 0 40px rgba(251,191,36,0.2);
|
||||
}
|
||||
.btn-cta:hover { background: #f59e0b; }
|
||||
.hero-micro { font-size: 11px; color: #52525b; letter-spacing: 0.05em; }
|
||||
|
||||
/* ── HOW IT WORKS ── */
|
||||
.how { padding: 72px 24px; position: relative; z-index: 1; }
|
||||
.section-label {
|
||||
text-align: center; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: 0.25em; text-transform: uppercase; color: #fbbf24;
|
||||
margin-bottom: 12px; opacity: 0.85;
|
||||
}
|
||||
.how h2 {
|
||||
text-align: center; font-size: 30px; font-weight: 700;
|
||||
letter-spacing: -0.025em; color: #fff; margin-bottom: 6px;
|
||||
}
|
||||
.how-sub { text-align: center; font-size: 14px; color: #71717a; margin-bottom: 48px; }
|
||||
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; max-width: 840px; margin: 0 auto; background: #18181b; border-radius: 12px; overflow: hidden; border: 1px solid #1c1c1f; }
|
||||
.step { padding: 32px 24px; background: #0d0d0f; }
|
||||
.step-num {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.15em;
|
||||
color: #fbbf24; text-transform: uppercase; margin-bottom: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.step h3 { font-size: 16px; font-weight: 700; color: #fff; margin-bottom: 10px; letter-spacing: -0.01em; }
|
||||
.step p { font-size: 13px; color: #71717a; line-height: 1.6; }
|
||||
|
||||
/* ── VIDEO ── */
|
||||
.video-section { padding: 0 24px 72px; position: relative; z-index: 1; }
|
||||
.video-wrap {
|
||||
max-width: 900px; margin: 0 auto;
|
||||
border-radius: 14px; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
background: #000;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(251,191,36,0.05);
|
||||
}
|
||||
.video-placeholder {
|
||||
aspect-ratio: 16/9; background: #0a0a0a;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.play-btn {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 20px;
|
||||
}
|
||||
.video-placeholder p { font-size: 12px; color: #3f3f46; }
|
||||
|
||||
/* ── FEATURES ── */
|
||||
.features { padding: 72px 24px; position: relative; z-index: 1; }
|
||||
.features h2 {
|
||||
text-align: center; font-size: 30px; font-weight: 700;
|
||||
letter-spacing: -0.025em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.features-sub { text-align: center; font-size: 13px; color: #52525b; margin-bottom: 44px; letter-spacing: 0.02em; }
|
||||
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; max-width: 900px; margin: 0 auto; background: #18181b; border-radius: 12px; overflow: hidden; border: 1px solid #1c1c1f; }
|
||||
.feature-card { padding: 24px; background: #0d0d0f; }
|
||||
.feature-icon { font-size: 18px; margin-bottom: 14px; display: block; }
|
||||
.feature-card h3 { font-size: 13px; font-weight: 700; color: #e4e4e7; margin-bottom: 7px; letter-spacing: -0.01em; }
|
||||
.feature-card p { font-size: 12px; color: #52525b; line-height: 1.6; }
|
||||
|
||||
/* ── SEO BLOCK ── */
|
||||
.seo-block {
|
||||
padding: 72px 24px;
|
||||
border-top: 1px solid #18181b;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.seo-inner { max-width: 680px; margin: 0 auto; }
|
||||
.seo-inner h2 { font-size: 22px; font-weight: 700; color: #d4d4d8; margin-bottom: 18px; letter-spacing: -0.02em; }
|
||||
.seo-inner p { font-size: 14px; color: #71717a; line-height: 1.75; margin-bottom: 14px; }
|
||||
.seo-inner strong { color: #a1a1aa; font-weight: 600; }
|
||||
|
||||
/* ── FINAL CTA ── */
|
||||
.final-cta {
|
||||
padding: 96px 24px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #18181b;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.final-cta h2 { font-size: 36px; font-weight: 700; color: #fff; margin-bottom: 12px; letter-spacing: -0.03em; }
|
||||
.final-cta p { font-size: 14px; color: #52525b; margin-bottom: 32px; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
footer {
|
||||
border-top: 1px solid #18181b; padding: 20px 24px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 11px; color: #3f3f46; max-width: 960px; margin: 0 auto;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
/* annotation */
|
||||
.annotation {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
background: #18181b; border: 1px solid #27272a; border-radius: 8px;
|
||||
padding: 12px 16px; font-size: 11px; color: #a1a1aa; max-width: 240px;
|
||||
z-index: 200; line-height: 1.5;
|
||||
}
|
||||
.annotation strong { color: #fbbf24; display: block; margin-bottom: 4px; }
|
||||
|
||||
#selection-indicator { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav>
|
||||
<div class="logo-text">BATTL<span>.</span>BUILDERS</div>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="btn-ghost">Sign In</a>
|
||||
<a href="#" class="btn-amber">Start a Build</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="hero-badge">
|
||||
<span class="hero-badge-dot"></span>
|
||||
AR-15 Builds · 100% Free
|
||||
</div>
|
||||
<h1>
|
||||
Stop Guessing.
|
||||
<span class="amber">Start Building.</span>
|
||||
</h1>
|
||||
<p class="hero-sub">
|
||||
Choose components by role. Catch incompatibilities before they cost you.
|
||||
Compare prices across real retailers — no spreadsheets, no guesswork.
|
||||
</p>
|
||||
<div class="hero-cta-wrap">
|
||||
<a href="#" class="btn-cta">Start a Build →</a>
|
||||
<span class="hero-micro">100% Free · No account required</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT WORKS -->
|
||||
<section class="how wrap">
|
||||
<div class="section-label">The process</div>
|
||||
<h2>Three moves. One solid build.</h2>
|
||||
<p class="how-sub">No prior experience required. No part numbers to memorize.</p>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">Step 01</div>
|
||||
<h3>Select your components</h3>
|
||||
<p>Real parts, organized by role — barrel, gas system, upper, lower, and more. Pick what fits your build without needing to know SKUs.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">Step 02</div>
|
||||
<h3>Flag the conflicts</h3>
|
||||
<p>Gas length. Buffer weight. Caliber stack. Battl surfaces the clashes before you spend money on the wrong part.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">Step 03</div>
|
||||
<h3>Price it. Pull the trigger.</h3>
|
||||
<p>Live prices from real retailers, side by side. Click through to buy. We never touch your order.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- VIDEO -->
|
||||
<section class="video-section">
|
||||
<div class="video-wrap">
|
||||
<div class="video-placeholder">
|
||||
<div class="play-btn">▶</div>
|
||||
<p>Builder preview — looping screen recording</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="features wrap">
|
||||
<div class="section-label">What you get</div>
|
||||
<h2>Built for builders who do their homework.</h2>
|
||||
<p class="features-sub">NO SUBSCRIPTIONS · NO SPONSORED RANKINGS · NO BULLSHIT</p>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔧</span>
|
||||
<h3>Build by role, not part number</h3>
|
||||
<p>Parts organized by function. Know what you need, not what SKU to search for.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">⚠️</span>
|
||||
<h3>Conflicts caught early</h3>
|
||||
<p>Gas system, buffer weight, caliber — flagged before checkout, not after the box arrives.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">💰</span>
|
||||
<h3>Real prices. Real retailers.</h3>
|
||||
<p>Running total with live prices. No estimated MSRP. What it actually costs, today.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔖</span>
|
||||
<h3>Save your work</h3>
|
||||
<p>Free account. Save builds, pick up where you left off, run multiple configs in parallel.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔗</span>
|
||||
<h3>Share the build</h3>
|
||||
<p>One link. Your buddy sees the full parts list — no login, no friction, no excuses.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🚫</span>
|
||||
<h3>No gates. No friction.</h3>
|
||||
<p>Browse and build freely. Create an account only when you're ready to save.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SEO COPY BLOCK -->
|
||||
<section class="seo-block">
|
||||
<div class="seo-inner">
|
||||
<h2>What is Battl Builders?</h2>
|
||||
<p>
|
||||
Battl Builders is a free <strong>AR-15 build planner</strong> — the tool you've been doing in spreadsheets, rebuilt for how builders actually work. Select parts by role, validate compatibility as you go, and price your build across multiple retailers without opening a new tab.
|
||||
</p>
|
||||
<p>
|
||||
Most <strong>AR-15 compatibility mistakes</strong> happen at the parts list stage: wrong gas system length for the barrel, buffer weight that fights your stock, caliber mismatches between upper and lower. Battl flags those conflicts automatically, so you catch them before they cost you a return label.
|
||||
</p>
|
||||
<p>
|
||||
Whether you're planning your first rifle or spec'ing a dedicated precision upper, Battl Builders gives you a structured, guided way to research parts and <strong>buy with confidence</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FINAL CTA -->
|
||||
<section class="final-cta">
|
||||
<h2>Your build starts here.</h2>
|
||||
<p>Free. No account required. Takes five minutes to get rolling.</p>
|
||||
<a href="#" class="btn-cta">Start a Build →</a>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer>
|
||||
<span>© 2026 BATTL BUILDERS™</span>
|
||||
<span>Free AR-15 build planner</span>
|
||||
</footer>
|
||||
|
||||
<div class="annotation">
|
||||
<strong>v2 — Copy Rewrite</strong>
|
||||
Field ethos tone throughout. Scroll all sections and share feedback in terminal.
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,321 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Battl Builders — Homepage Redesign Mockup v3</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
|
||||
.bg-grid {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(251,191,36,0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(251,191,36,0.07) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
.wrap { position: relative; z-index: 1; max-width: 960px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
nav {
|
||||
position: relative; z-index: 10;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px; max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
.logo-text { font-size: 18px; font-weight: 800; letter-spacing: -0.02em; color: #fff; }
|
||||
.logo-text span { color: #fbbf24; }
|
||||
.nav-links { display: flex; gap: 8px; }
|
||||
.btn-ghost {
|
||||
padding: 7px 14px; border-radius: 6px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid #27272a; color: #d4d4d8; background: transparent;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.btn-amber {
|
||||
padding: 7px 16px; border-radius: 6px; font-size: 12px; font-weight: 700;
|
||||
background: #fbbf24; color: #000; border: none; cursor: pointer;
|
||||
text-decoration: none; letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero {
|
||||
text-align: center; padding: 80px 24px 72px;
|
||||
max-width: 760px; margin: 0 auto; position: relative; z-index: 1;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.4);
|
||||
border-radius: 99px; padding: 5px 14px;
|
||||
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: rgba(255,255,255,0.55);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.hero-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #fbbf24; flex-shrink: 0; }
|
||||
.hero h1 {
|
||||
font-size: clamp(38px, 6.5vw, 64px); font-weight: 700; line-height: 1.05;
|
||||
letter-spacing: -0.035em; color: #fff;
|
||||
}
|
||||
.hero h1 .amber { color: #fbbf24; display: block; }
|
||||
.hero-sub {
|
||||
font-size: 17px; color: #a1a1aa; line-height: 1.65;
|
||||
max-width: 540px; margin: 24px auto 0;
|
||||
}
|
||||
.hero-cta-wrap { margin-top: 38px; display: flex; flex-direction: column; align-items: center; gap: 11px; }
|
||||
.btn-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: #fbbf24; color: #000; font-size: 15px; font-weight: 800;
|
||||
padding: 15px 38px; border-radius: 8px; border: none; cursor: pointer;
|
||||
letter-spacing: 0.03em; text-decoration: none;
|
||||
box-shadow: 0 0 40px rgba(251,191,36,0.2);
|
||||
}
|
||||
.hero-micro { font-size: 11px; color: #52525b; letter-spacing: 0.05em; }
|
||||
|
||||
/* ── HOW IT WORKS ── */
|
||||
.how { padding: 72px 24px; position: relative; z-index: 1; }
|
||||
.section-label {
|
||||
text-align: center; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: 0.25em; text-transform: uppercase; color: #fbbf24;
|
||||
margin-bottom: 12px; opacity: 0.85;
|
||||
}
|
||||
.how h2 {
|
||||
text-align: center; font-size: 30px; font-weight: 700;
|
||||
letter-spacing: -0.025em; color: #fff; margin-bottom: 6px;
|
||||
}
|
||||
.how-sub { text-align: center; font-size: 14px; color: #71717a; margin-bottom: 48px; }
|
||||
.steps {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px;
|
||||
max-width: 840px; margin: 0 auto; background: #18181b;
|
||||
border-radius: 12px; overflow: hidden; border: 1px solid #1c1c1f;
|
||||
}
|
||||
.step { padding: 32px 24px; background: #0d0d0f; }
|
||||
.step-num {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.15em;
|
||||
color: #fbbf24; text-transform: uppercase; margin-bottom: 14px; opacity: 0.8;
|
||||
}
|
||||
.step h3 { font-size: 16px; font-weight: 700; color: #fff; margin-bottom: 10px; letter-spacing: -0.01em; }
|
||||
.step p { font-size: 13px; color: #71717a; line-height: 1.6; }
|
||||
|
||||
/* ── VIDEO ── */
|
||||
.video-section { padding: 0 24px 72px; position: relative; z-index: 1; }
|
||||
.video-wrap {
|
||||
max-width: 900px; margin: 0 auto; border-radius: 14px; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.07); background: #000;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(251,191,36,0.05);
|
||||
}
|
||||
.video-placeholder {
|
||||
aspect-ratio: 16/9; background: #0a0a0a;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.play-btn {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 20px;
|
||||
}
|
||||
.video-placeholder p { font-size: 12px; color: #3f3f46; }
|
||||
|
||||
/* ── FEATURES ── */
|
||||
.features { padding: 72px 24px; position: relative; z-index: 1; }
|
||||
.features h2 {
|
||||
text-align: center; font-size: 30px; font-weight: 700;
|
||||
letter-spacing: -0.025em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.features-sub { text-align: center; font-size: 13px; color: #52525b; margin-bottom: 44px; letter-spacing: 0.02em; }
|
||||
.feature-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
|
||||
max-width: 900px; margin: 0 auto; background: #18181b;
|
||||
border-radius: 12px; overflow: hidden; border: 1px solid #1c1c1f;
|
||||
}
|
||||
.feature-card { padding: 24px; background: #0d0d0f; }
|
||||
.feature-icon { font-size: 18px; margin-bottom: 14px; display: block; }
|
||||
.feature-card h3 { font-size: 13px; font-weight: 700; color: #e4e4e7; margin-bottom: 7px; letter-spacing: -0.01em; }
|
||||
.feature-card p { font-size: 12px; color: #52525b; line-height: 1.6; }
|
||||
|
||||
/* ── SEO BLOCK ── */
|
||||
.seo-block { padding: 72px 24px; border-top: 1px solid #18181b; position: relative; z-index: 1; }
|
||||
.seo-inner { max-width: 680px; margin: 0 auto; }
|
||||
.seo-inner h2 { font-size: 22px; font-weight: 700; color: #d4d4d8; margin-bottom: 18px; letter-spacing: -0.02em; }
|
||||
.seo-inner p { font-size: 14px; color: #71717a; line-height: 1.75; margin-bottom: 14px; }
|
||||
.seo-inner strong { color: #a1a1aa; font-weight: 600; }
|
||||
|
||||
/* ── FINAL CTA ── */
|
||||
.final-cta {
|
||||
padding: 96px 24px; text-align: center;
|
||||
border-top: 1px solid #18181b; position: relative; z-index: 1;
|
||||
}
|
||||
.final-cta h2 { font-size: 36px; font-weight: 700; color: #fff; margin-bottom: 12px; letter-spacing: -0.03em; }
|
||||
.final-cta p { font-size: 14px; color: #52525b; margin-bottom: 32px; }
|
||||
|
||||
footer {
|
||||
border-top: 1px solid #18181b; padding: 20px 24px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 11px; color: #3f3f46; max-width: 960px; margin: 0 auto;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
/* changed copy callouts */
|
||||
.changed {
|
||||
outline: 1px dashed rgba(251,191,36,0.4);
|
||||
outline-offset: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.annotation {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
background: #18181b; border: 1px solid #27272a; border-radius: 8px;
|
||||
padding: 12px 16px; font-size: 11px; color: #a1a1aa; max-width: 240px;
|
||||
z-index: 200; line-height: 1.6;
|
||||
}
|
||||
.annotation strong { color: #fbbf24; display: block; margin-bottom: 4px; }
|
||||
.annotation .legend { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 10px; color: #52525b; }
|
||||
.legend-box { width: 12px; height: 12px; border: 1px dashed rgba(251,191,36,0.5); border-radius: 2px; flex-shrink: 0; }
|
||||
|
||||
#selection-indicator { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav>
|
||||
<div class="logo-text">BATTL<span>.</span>BUILDERS</div>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="btn-ghost">Sign In</a>
|
||||
<a href="#" class="btn-amber">Start a Build</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<!-- CHANGED: AR-15 Builds → AR-15 Builder -->
|
||||
<div class="hero-badge changed">
|
||||
<span class="hero-badge-dot"></span>
|
||||
AR-15 Builder · 100% Free
|
||||
</div>
|
||||
<h1>
|
||||
Stop Guessing.
|
||||
<span class="amber">Start Building.</span>
|
||||
</h1>
|
||||
<!-- CHANGED: hero subhead — jargon removed, ICP-first language -->
|
||||
<p class="hero-sub changed">
|
||||
Not sure which parts work together? We'll walk you through it. Pick your components, get warned about anything that won't fit, and see real prices from real retailers — all in one place.
|
||||
</p>
|
||||
<div class="hero-cta-wrap">
|
||||
<a href="#" class="btn-cta">Start a Build →</a>
|
||||
<span class="hero-micro">100% Free · No account required</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT WORKS -->
|
||||
<section class="how wrap">
|
||||
<div class="section-label">The process</div>
|
||||
<!-- CHANGED: "Three moves. One solid build." → "3 Steps. One Battl-Ready Build." -->
|
||||
<h2 class="changed">3 Steps. One Battl-Ready Build.</h2>
|
||||
<p class="how-sub">No experience required. No part numbers to memorize.</p>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">Step 01</div>
|
||||
<h3>Pick your parts</h3>
|
||||
<p>Browse real components organized by what they do — barrel, trigger, lower, upper, and more. No prior knowledge required.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">Step 02</div>
|
||||
<h3>Flag the conflicts</h3>
|
||||
<p>Gas length, buffer weight, caliber — Battl catches the mismatches before you spend money on the wrong part.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">Step 03</div>
|
||||
<h3>Price it. Pull the trigger.</h3>
|
||||
<p>Live prices from real retailers, side by side. Click through to buy. We never touch your order.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- VIDEO -->
|
||||
<section class="video-section">
|
||||
<div class="video-wrap">
|
||||
<div class="video-placeholder">
|
||||
<div class="play-btn">▶</div>
|
||||
<p>Builder preview — looping screen recording</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="features wrap">
|
||||
<div class="section-label">What you get</div>
|
||||
<!-- CHANGED: "Built for builders who do their homework." → gun nerd / everyday builder positioning -->
|
||||
<h2 class="changed">Built by gun nerds, for the everyday AR builder.</h2>
|
||||
<p class="features-sub changed">NO SUBSCRIPTIONS · NO SPONSORED RANKINGS · NO BULLSHIT</p>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔧</span>
|
||||
<h3>Build by role, not part number</h3>
|
||||
<p>Parts organized by function. Know what you need, not what SKU to search for.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">⚠️</span>
|
||||
<h3>Conflicts caught early</h3>
|
||||
<p>Gas system, buffer weight, caliber — flagged before checkout, not after the box arrives.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">💰</span>
|
||||
<h3>Real prices. Real retailers.</h3>
|
||||
<p>Running total with live prices. No estimated MSRP. What it actually costs, today.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔖</span>
|
||||
<h3>Save your work</h3>
|
||||
<p>Free account. Save builds, pick up where you left off, run multiple configs in parallel.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔗</span>
|
||||
<h3>Share the build</h3>
|
||||
<p>One link. Your buddy sees the full parts list — no login, no friction, no excuses.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🚫</span>
|
||||
<h3>No gates. No friction.</h3>
|
||||
<p>Browse and build freely. Create an account only when you're ready to save.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SEO COPY BLOCK -->
|
||||
<section class="seo-block">
|
||||
<div class="seo-inner">
|
||||
<h2>What is Battl Builders?</h2>
|
||||
<p>
|
||||
Battl Builders is a free <strong>AR-15 build planner</strong> — the tool you've been doing in spreadsheets, rebuilt for how builders actually work. Select parts by what they do, validate compatibility as you go, and price your build across multiple retailers without opening a new tab.
|
||||
</p>
|
||||
<p>
|
||||
Most <strong>AR-15 compatibility mistakes</strong> happen at the parts list stage: wrong gas system length for the barrel, buffer weight that fights your stock, caliber mismatches between upper and lower. Battl flags those conflicts automatically, so you catch them before they cost you a return label.
|
||||
</p>
|
||||
<p>
|
||||
Whether you're planning your first rifle or spec'ing a dedicated precision upper, Battl Builders gives you a structured, guided way to research parts and <strong>buy with confidence</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FINAL CTA -->
|
||||
<section class="final-cta">
|
||||
<h2>Your build starts here.</h2>
|
||||
<p>Free. No account required. Takes five minutes to get rolling.</p>
|
||||
<a href="#" class="btn-cta">Start a Build →</a>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>© 2026 BATTL BUILDERS™</span>
|
||||
<span>Free AR-15 build planner</span>
|
||||
</footer>
|
||||
|
||||
<div class="annotation">
|
||||
<strong>v3 — Copy Revision</strong>
|
||||
Updated copy is outlined in amber dashes.
|
||||
<div class="legend">
|
||||
<div class="legend-box"></div>
|
||||
Changed from v2
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<h2>Hero Layout: What's the main structure?</h2>
|
||||
<p class="subtitle">The hero is the first thing visitors see. Which layout best suits a "start your build now" homepage?</p>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<div class="card" data-choice="centered" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:28px 20px;font-family:sans-serif;min-height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;border:1px solid #27272a;">
|
||||
<div style="font-size:10px;letter-spacing:0.2em;color:#fbbf24;text-transform:uppercase;opacity:0.8;">AR-15 Build Planner</div>
|
||||
<div style="font-size:22px;font-weight:700;color:#fff;text-align:center;line-height:1.2;">Build smarter rifles.<br><span style="color:#fbbf24;">No spreadsheets.</span></div>
|
||||
<div style="font-size:11px;color:#a1a1aa;text-align:center;max-width:280px;">Pick parts by role, catch compatibility issues, compare live prices — all in one place.</div>
|
||||
<div style="display:flex;gap:10px;margin-top:8px;">
|
||||
<div style="background:#fbbf24;color:#000;font-size:11px;font-weight:700;padding:8px 20px;border-radius:6px;letter-spacing:0.05em;">START A BUILD →</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;background:#1a1a1a;border-radius:6px;border:1px solid #27272a;width:100%;height:60px;display:flex;align-items:center;justify-content:center;">
|
||||
<div style="font-size:10px;color:#52525b;">▶ Builder preview video</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>A — Centered / Full-width</h3>
|
||||
<p>Headline + tagline centered, single dominant CTA button, product video below. Clean, magazine-style. Works great on mobile.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="split" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;min-height:220px;display:flex;align-items:stretch;gap:16px;border:1px solid #27272a;">
|
||||
<div style="flex:1.1;display:flex;flex-direction:column;justify-content:center;gap:10px;">
|
||||
<div style="font-size:10px;letter-spacing:0.2em;color:#fbbf24;text-transform:uppercase;opacity:0.8;">AR-15 Build Planner</div>
|
||||
<div style="font-size:18px;font-weight:700;color:#fff;line-height:1.25;">Build smarter rifles.<br><span style="color:#fbbf24;">No spreadsheets.</span></div>
|
||||
<div style="font-size:10px;color:#a1a1aa;line-height:1.5;">Pick parts, catch clashes, compare prices.</div>
|
||||
<div style="background:#fbbf24;color:#000;font-size:11px;font-weight:700;padding:8px 16px;border-radius:6px;letter-spacing:0.05em;align-self:flex-start;margin-top:4px;">START A BUILD →</div>
|
||||
</div>
|
||||
<div style="flex:1;background:#111;border-radius:8px;border:1px solid #27272a;display:flex;align-items:center;justify-content:center;">
|
||||
<div style="font-size:10px;color:#52525b;text-align:center;">App screenshot<br>or animation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>B — Split (text left, visual right)</h3>
|
||||
<p>Copy on the left, app screenshot or short animation on the right. Shows the product immediately. Strong for conversion, more complex layout.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="immersive" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;min-height:220px;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:10px;border:1px solid #27272a;position:relative;overflow:hidden;">
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(135deg,#111 0%,#0a0a0a 100%);opacity:0.9;"></div>
|
||||
<div style="position:absolute;top:16px;left:0;right:0;text-align:center;">
|
||||
<div style="font-size:10px;color:#52525b;letter-spacing:0.1em;">Full-bleed product image or video background</div>
|
||||
</div>
|
||||
<div style="position:relative;z-index:1;text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px;padding-bottom:10px;">
|
||||
<div style="font-size:20px;font-weight:700;color:#fff;text-align:center;line-height:1.2;">Build smarter rifles.</div>
|
||||
<div style="font-size:18px;font-weight:700;color:#fbbf24;">No spreadsheets.</div>
|
||||
<div style="background:#fbbf24;color:#000;font-size:11px;font-weight:700;padding:8px 22px;border-radius:6px;letter-spacing:0.05em;margin-top:6px;">START A BUILD →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>C — Immersive / Full-bleed</h3>
|
||||
<p>Hero image or dark atmospheric background fills the viewport. Copy overlaid with high contrast. Dramatic, brand-forward. Requires great imagery.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
<h2>Below the Hero: Page Section Flow</h2>
|
||||
<p class="subtitle">What content comes after the hero + CTA? Pick the flow that best supports "I just landed here, convince me to start a build."</p>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<div class="card" data-choice="lean" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;border:1px solid #27272a;display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HERO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">"Stop Guessing. Start Building." + subhead + CTA</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">BUILDER PREVIEW</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Looping video / animated screenshot of the tool</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">3 FEATURE PILLARS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Guided roles · Compat warnings · Live prices</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:10px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fbbf24;">FINAL CTA</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Second "Start a Build" button</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>A — Lean (4 sections)</h3>
|
||||
<p>Hero → video preview → 3 feature pillars → closing CTA. Fast to read, zero fluff. Gets users to the builder quickly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="howitworks" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;border:1px solid #27272a;display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HERO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">"Stop Guessing. Start Building." + subhead + CTA</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HOW IT WORKS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">3 steps: Pick parts → See warnings → Buy with confidence</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">BUILDER PREVIEW VIDEO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Show the tool in action</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">FEATURE CARDS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">6 capabilities at a glance</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:10px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fbbf24;">FINAL CTA</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Second "Start a Build" button</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>B — Explain + Show (5 sections)</h3>
|
||||
<p>Adds a "How it Works" 3-step strip between hero and video. Answers "but what does it actually do?" before showing it. Better for first-time visitors unfamiliar with PCPartPicker-style tools.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="seo" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;border:1px solid #27272a;display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HERO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">"Stop Guessing. Start Building." + subhead + CTA</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HOW IT WORKS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">3 steps with icons</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">BUILDER PREVIEW VIDEO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Tool in action</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">FEATURE CARDS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">6 capabilities</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">SEO COPY BLOCK</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Short paragraph: "What is an AR-15 builder tool?" — targets search</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:10px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fbbf24;">FINAL CTA</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Second "Start a Build" button</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>C — SEO-Optimized (6 sections)</h3>
|
||||
<p>Same as B but adds a short prose block at the bottom targeting search keywords ("AR-15 parts compatibility tool", "AR-15 build planner"). Best for organic discovery over time.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
@@ -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.
|
||||
@@ -0,0 +1,8 @@
|
||||
export default function FavoritesPage() {
|
||||
return (
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-6">
|
||||
<h2 className="text-lg font-semibold">Favorite Parts</h2>
|
||||
<p className="mt-2 text-sm text-zinc-400">Coming soon.</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,350 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
export default function OnboardingPage() {
|
||||
const router = useRouter();
|
||||
const { user, loading, token, logout, refreshMeAndSession } = useAuth();
|
||||
|
||||
// Step: 1=password, 2=username, 3=optional display name
|
||||
const [step, setStep] = useState<1 | 2 | 3>(1);
|
||||
|
||||
// Password
|
||||
const [pw1, setPw1] = useState("");
|
||||
const [pw2, setPw2] = useState("");
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||||
|
||||
// Username
|
||||
const [username, setUsername] = useState("");
|
||||
const [checking, setChecking] = useState(false);
|
||||
const [available, setAvailable] = useState<boolean | null>(null);
|
||||
const [userMsg, setUserMsg] = useState<string | null>(null);
|
||||
const [savingUsername, setSavingUsername] = useState(false);
|
||||
|
||||
// Display name (optional)
|
||||
const [displayName, setDisplayName] = useState("");
|
||||
const [savingName, setSavingName] = useState(false);
|
||||
const [nameMsg, setNameMsg] = useState<string | null>(null);
|
||||
|
||||
const hasUsername = useMemo(() => {
|
||||
const current = String((user as any)?.username ?? "").trim();
|
||||
return current.length > 0;
|
||||
}, [user]);
|
||||
|
||||
const hasPassword = useMemo(() => {
|
||||
return !!(user as any)?.passwordSetAt;
|
||||
}, [user]);
|
||||
|
||||
const pwMismatch = pw1.length > 0 && pw2.length > 0 && pw1 !== pw2;
|
||||
const pwTooShort = pw1.length > 0 && pw1.length < 8;
|
||||
const canSavePassword = !pwSaving && pw1.length >= 8 && pw1 === pw2;
|
||||
|
||||
// Hydrate local inputs when user becomes available
|
||||
useEffect(() => {
|
||||
if (!user) return;
|
||||
setUsername(String((user as any)?.username ?? ""));
|
||||
setDisplayName(String(user.displayName ?? ""));
|
||||
}, [user]);
|
||||
|
||||
// Route guards + step sync (NO redirects during render)
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
if (!user) return;
|
||||
|
||||
// If complete, bounce them out
|
||||
if (hasUsername && hasPassword) {
|
||||
router.replace("/builder");
|
||||
return;
|
||||
}
|
||||
|
||||
// Choose the right step based on what's missing
|
||||
if (!hasPassword) setStep(1);
|
||||
else if (!hasUsername) setStep(2);
|
||||
else setStep(3);
|
||||
}, [loading, user, hasUsername, hasPassword, router]);
|
||||
|
||||
if (loading) return <div className="text-sm opacity-70">Loading…</div>;
|
||||
if (!user) return <div className="text-sm opacity-70">Not logged in.</div>;
|
||||
|
||||
async function savePassword() {
|
||||
setPwMsg(null);
|
||||
|
||||
if (pw1.length < 8) return setPwMsg("Password must be at least 8 characters.");
|
||||
if (pw1 !== pw2) return setPwMsg("Passwords don’t match.");
|
||||
|
||||
setPwSaving(true);
|
||||
try {
|
||||
const res = await fetch(`/api/users/me/password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ password: pw1 }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error((await res.text().catch(() => "")) || "Failed to set password");
|
||||
}
|
||||
|
||||
await refreshMeAndSession();
|
||||
|
||||
setPwMsg("Password saved ✅");
|
||||
setPw1("");
|
||||
setPw2("");
|
||||
// step will auto-sync via useEffect once user updates, but this makes UI feel instant:
|
||||
setStep(2);
|
||||
} catch (e: any) {
|
||||
setPwMsg(e?.message || "Couldn’t set password.");
|
||||
} finally {
|
||||
setPwSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function checkUsername() {
|
||||
setChecking(true);
|
||||
setUserMsg(null);
|
||||
setAvailable(null);
|
||||
|
||||
try {
|
||||
const u = username.trim().toLowerCase();
|
||||
const res = await fetch(
|
||||
`/api/users/me/username-available?username=${encodeURIComponent(u)}`,
|
||||
{
|
||||
headers: { ...(token ? { Authorization: `Bearer ${token}` } : {}) },
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error((await res.text().catch(() => "")) || "Failed to check username");
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
setAvailable(!!data?.available);
|
||||
} catch (e: any) {
|
||||
setUserMsg(e?.message || "Couldn’t check username.");
|
||||
} finally {
|
||||
setChecking(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveUsername() {
|
||||
setSavingUsername(true);
|
||||
setUserMsg(null);
|
||||
|
||||
try {
|
||||
const u = username.trim().toLowerCase();
|
||||
const res = await fetch(`/api/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ username: u }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error((await res.text().catch(() => "")) || "Failed to save username");
|
||||
}
|
||||
|
||||
await refreshMeAndSession();
|
||||
|
||||
setAvailable(true);
|
||||
setUserMsg("Username saved ✅");
|
||||
setStep(3);
|
||||
} catch (e: any) {
|
||||
setUserMsg(e?.message || "Couldn’t save username.");
|
||||
} finally {
|
||||
setSavingUsername(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function saveDisplayName() {
|
||||
setSavingName(true);
|
||||
setNameMsg(null);
|
||||
|
||||
try {
|
||||
const next = displayName.trim();
|
||||
const res = await fetch(`/api/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ displayName: next }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error((await res.text().catch(() => "")) || "Failed to save display name");
|
||||
}
|
||||
|
||||
await refreshMeAndSession();
|
||||
setNameMsg("Saved ✅");
|
||||
} catch (e: any) {
|
||||
setNameMsg(e?.message || "Couldn’t save display name.");
|
||||
} finally {
|
||||
setSavingName(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="rounded-2xl border border-amber-500/20 bg-amber-500/5 p-6">
|
||||
<h2 className="text-lg font-semibold">Welcome to the beta 👋</h2>
|
||||
<p className="mt-1 text-sm text-zinc-300/80">
|
||||
Step {step} of 3. Password + username required.
|
||||
</p>
|
||||
|
||||
{step === 1 && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="text-sm font-medium">Set password *</div>
|
||||
|
||||
<input
|
||||
type="password"
|
||||
value={pw1}
|
||||
onChange={(e) => setPw1(e.target.value)}
|
||||
placeholder="Password (min 8 chars)"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={pw2}
|
||||
onChange={(e) => setPw2(e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
|
||||
/>
|
||||
|
||||
{(pwTooShort || pwMismatch) && (
|
||||
<div className="text-xs text-red-300">
|
||||
{pwTooShort ? "Password must be at least 8 characters." : "Passwords don’t match."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={savePassword}
|
||||
disabled={!canSavePassword}
|
||||
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||||
>
|
||||
{pwSaving ? "Saving…" : "Save password"}
|
||||
</button>
|
||||
|
||||
{pwMsg && <div className="text-xs text-zinc-200/80">{pwMsg}</div>}
|
||||
|
||||
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="text-xs underline opacity-70 hover:opacity-100"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 2 && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="text-sm font-medium">Choose username *</div>
|
||||
|
||||
<input
|
||||
value={username}
|
||||
onChange={(e) => {
|
||||
setUsername(e.target.value);
|
||||
setAvailable(null);
|
||||
setUserMsg(null);
|
||||
}}
|
||||
placeholder="e.g. s2tactical"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={checkUsername}
|
||||
disabled={checking || savingUsername || username.trim().length < 3}
|
||||
className="rounded-md border border-white/10 px-3 py-2 text-xs hover:bg-white/5 disabled:opacity-50"
|
||||
>
|
||||
{checking ? "Checking…" : "Check availability"}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveUsername}
|
||||
disabled={savingUsername || available !== true}
|
||||
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
|
||||
>
|
||||
{savingUsername ? "Saving…" : "Save username"}
|
||||
</button>
|
||||
|
||||
{available !== null && (
|
||||
<span className={`text-xs ${available ? "text-emerald-300" : "text-red-300"}`}>
|
||||
{available ? "Available" : "Taken / invalid"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{userMsg && <div className="text-xs text-zinc-200/80">{userMsg}</div>}
|
||||
|
||||
<div className="pt-4 border-t border-white/10 flex items-center justify-between">
|
||||
<button
|
||||
type="button"
|
||||
onClick={logout}
|
||||
className="text-xs underline opacity-70 hover:opacity-100"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{step === 3 && (
|
||||
<div className="mt-6 space-y-3">
|
||||
<div className="text-sm font-medium">
|
||||
Display name <span className="text-zinc-400">(optional)</span>
|
||||
</div>
|
||||
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="e.g. Sean / S2Tactical / DirtNinja69"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2"
|
||||
/>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={saveDisplayName}
|
||||
disabled={savingName || displayName.trim().length === 0}
|
||||
className="rounded-md border border-white/10 px-3 py-2 text-xs hover:bg-white/5 disabled:opacity-50"
|
||||
>
|
||||
{savingName ? "Saving…" : "Save display name"}
|
||||
</button>
|
||||
|
||||
{nameMsg && <div className="text-xs text-zinc-200/80">{nameMsg}</div>}
|
||||
|
||||
<div className="mt-6 flex items-center justify-between border-t border-white/10 pt-4">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace("/builder")}
|
||||
disabled={!(hasUsername && hasPassword)}
|
||||
className="rounded-md bg-amber-400 px-4 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||||
>
|
||||
Continue to Builder
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => router.replace("/account/profile")}
|
||||
className="text-xs underline opacity-70 hover:opacity-100"
|
||||
>
|
||||
I’ll finish later
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -1,439 +1,5 @@
|
||||
// app/(account)/account/page.tsx
|
||||
"use client";
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
import { useMemo, useState } from "react";
|
||||
import { useSearchParams, useRouter } from "next/navigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
export default function AccountPage() {
|
||||
const { user, loading, token, setSession, logout } = useAuth();
|
||||
const searchParams = useSearchParams();
|
||||
const router = useRouter();
|
||||
|
||||
const welcome = searchParams.get("welcome") === "1";
|
||||
|
||||
const needsDisplayName = useMemo(() => {
|
||||
return !user?.displayName || user.displayName.trim().length === 0;
|
||||
}, [user?.displayName]);
|
||||
|
||||
// Only show welcome panel if they actually need setup
|
||||
const showWelcomeCard = welcome && needsDisplayName;
|
||||
|
||||
// ----------------------------
|
||||
// Display Name state (shared)
|
||||
// ----------------------------
|
||||
const [displayName, setDisplayName] = useState(user?.displayName ?? "");
|
||||
const [saving, setSaving] = useState(false);
|
||||
const [msg, setMsg] = useState<string | null>(null);
|
||||
|
||||
// Inline profile editor
|
||||
const [editingName, setEditingName] = useState(false);
|
||||
|
||||
// ----------------------------
|
||||
// Password state
|
||||
// ----------------------------
|
||||
const [showPassword, setShowPassword] = useState(false);
|
||||
const [pw1, setPw1] = useState("");
|
||||
const [pw2, setPw2] = useState("");
|
||||
const [pwSaving, setPwSaving] = useState(false);
|
||||
const [pwMsg, setPwMsg] = useState<string | null>(null);
|
||||
|
||||
const canSaveName = useMemo(() => {
|
||||
return displayName.trim().length > 0 && !saving;
|
||||
}, [displayName, saving]);
|
||||
|
||||
const pwMismatch = useMemo(
|
||||
() => pw1.length > 0 && pw2.length > 0 && pw1 !== pw2,
|
||||
[pw1, pw2]
|
||||
);
|
||||
const pwTooShort = useMemo(() => pw1.length > 0 && pw1.length < 8, [pw1]);
|
||||
const canSavePassword = useMemo(() => {
|
||||
return !pwSaving && pw1.length >= 8 && pw1 === pw2;
|
||||
}, [pwSaving, pw1, pw2]);
|
||||
|
||||
if (loading) return <div className="text-sm opacity-70">Loading…</div>;
|
||||
|
||||
if (!user) {
|
||||
return (
|
||||
<div className="text-sm opacity-70">
|
||||
You’re not logged in. Please log in to view your account.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
const authedUser = user; // user is non-null from here down
|
||||
|
||||
async function saveDisplayName() {
|
||||
setSaving(true);
|
||||
setMsg(null);
|
||||
|
||||
try {
|
||||
const nextName = displayName.trim();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||
method: "PATCH",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ displayName: nextName }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || "Failed to update profile");
|
||||
}
|
||||
|
||||
const updated = await res.json().catch(() => null);
|
||||
|
||||
const nextUser = {
|
||||
email: authedUser.email,
|
||||
displayName: updated?.displayName ?? nextName,
|
||||
role: authedUser.role,
|
||||
};
|
||||
|
||||
if (token) setSession(token, nextUser);
|
||||
|
||||
setMsg("Saved ✅");
|
||||
setEditingName(false);
|
||||
|
||||
// If we came from the welcome flow, clear the param
|
||||
if (welcome) router.replace("/account");
|
||||
} catch (e: any) {
|
||||
setMsg(e?.message || "Couldn’t save. Try again.");
|
||||
} finally {
|
||||
setSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function savePassword() {
|
||||
setPwMsg(null);
|
||||
|
||||
if (pw1.length < 8) {
|
||||
setPwMsg("Password must be at least 8 characters.");
|
||||
return;
|
||||
}
|
||||
if (pw1 !== pw2) {
|
||||
setPwMsg("Passwords don’t match.");
|
||||
return;
|
||||
}
|
||||
|
||||
setPwSaving(true);
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me/password`, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
},
|
||||
body: JSON.stringify({ password: pw1 }),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
throw new Error(text || "Failed to set password");
|
||||
}
|
||||
|
||||
setPwMsg("Password set ✅ You can now use email + password login.");
|
||||
setPw1("");
|
||||
setPw2("");
|
||||
setShowPassword(false);
|
||||
|
||||
if (welcome) router.replace("/account");
|
||||
} catch (e: any) {
|
||||
setPwMsg(e?.message || "Couldn’t set password. Try again.");
|
||||
} finally {
|
||||
setPwSaving(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
{/* Welcome / Setup card (only if needed) */}
|
||||
{showWelcomeCard && (
|
||||
<div className="rounded-xl border border-amber-500/30 bg-amber-500/10 p-4 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-base font-semibold">Welcome to the beta 👋</h2>
|
||||
<p className="text-sm text-zinc-300/80">
|
||||
Quick setup and you’re ready to cook.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Display name */}
|
||||
<div className="space-y-2">
|
||||
<label className="text-xs font-medium text-zinc-200">
|
||||
Display name
|
||||
</label>
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="e.g. Sean / S2Tactical / DirtNinja69"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||
/>
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={saveDisplayName}
|
||||
disabled={!canSaveName}
|
||||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
{msg && (
|
||||
<span
|
||||
className={`text-xs ${
|
||||
msg.toLowerCase().includes("couldn")
|
||||
? "text-red-300"
|
||||
: "text-zinc-200/80"
|
||||
}`}
|
||||
>
|
||||
{msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Password */}
|
||||
<div className="pt-2 border-t border-white/10">
|
||||
<p className="text-xs text-zinc-300/80">
|
||||
Want password login later? Optional (but recommended).
|
||||
</p>
|
||||
|
||||
{!showPassword ? (
|
||||
<button
|
||||
onClick={() => setShowPassword(true)}
|
||||
className="mt-2 text-xs underline opacity-80 hover:opacity-100"
|
||||
>
|
||||
Set a password
|
||||
</button>
|
||||
) : (
|
||||
<div className="mt-3 space-y-2">
|
||||
<input
|
||||
type="password"
|
||||
value={pw1}
|
||||
onChange={(e) => setPw1(e.target.value)}
|
||||
placeholder="New password (min 8 chars)"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={pw2}
|
||||
onChange={(e) => setPw2(e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||
/>
|
||||
|
||||
{(pwTooShort || pwMismatch) && (
|
||||
<div className="text-xs text-red-300">
|
||||
{pwTooShort
|
||||
? "Password must be at least 8 characters."
|
||||
: "Passwords don’t match."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={savePassword}
|
||||
disabled={!canSavePassword}
|
||||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||||
>
|
||||
{pwSaving ? "Saving…" : "Save password"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPassword(false);
|
||||
setPw1("");
|
||||
setPw2("");
|
||||
setPwMsg(null);
|
||||
}}
|
||||
className="text-xs opacity-80 hover:opacity-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pwMsg && (
|
||||
<div
|
||||
className={`text-xs ${
|
||||
pwMsg.toLowerCase().includes("couldn")
|
||||
? "text-red-300"
|
||||
: "text-zinc-200/80"
|
||||
}`}
|
||||
>
|
||||
{pwMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Profile header */}
|
||||
<div>
|
||||
<h2 className="text-lg font-semibold">Profile</h2>
|
||||
<p className="text-sm opacity-70">Basic account info.</p>
|
||||
</div>
|
||||
|
||||
{/* Profile card */}
|
||||
<div className="rounded-xl border border-white/10 bg-white/5 p-4 space-y-3">
|
||||
<div className="text-sm">
|
||||
<span className="opacity-70">Email:</span>{" "}
|
||||
<span className="font-medium">{user.email}</span>
|
||||
</div>
|
||||
|
||||
{/* Display name (inline editable) */}
|
||||
<div className="text-sm">
|
||||
<span className="opacity-70">Display name:</span>{" "}
|
||||
{!editingName ? (
|
||||
<>
|
||||
<span className="font-medium">{user.displayName || "—"}</span>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingName(true);
|
||||
setMsg(null);
|
||||
setDisplayName(user.displayName ?? "");
|
||||
}}
|
||||
className="ml-3 text-xs underline opacity-70 hover:opacity-100"
|
||||
>
|
||||
Edit
|
||||
</button>
|
||||
</>
|
||||
) : (
|
||||
<div className="mt-2 space-y-2">
|
||||
<input
|
||||
value={displayName}
|
||||
onChange={(e) => setDisplayName(e.target.value)}
|
||||
placeholder="Your display name"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||
/>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={saveDisplayName}
|
||||
disabled={!canSaveName}
|
||||
className="rounded-md bg-amber-400 px-3 py-2 text-xs font-medium text-black disabled:opacity-50"
|
||||
>
|
||||
{saving ? "Saving…" : "Save"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setEditingName(false);
|
||||
setDisplayName(user.displayName ?? "");
|
||||
setMsg(null);
|
||||
}}
|
||||
className="text-xs opacity-80 hover:opacity-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
{msg && (
|
||||
<span
|
||||
className={`text-xs ${
|
||||
msg.toLowerCase().includes("couldn")
|
||||
? "text-red-300"
|
||||
: "text-zinc-200/80"
|
||||
}`}
|
||||
>
|
||||
{msg}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="text-sm">
|
||||
<span className="opacity-70">Role:</span>{" "}
|
||||
<span className="font-medium">{user.role}</span>
|
||||
</div>
|
||||
|
||||
<div className="pt-3 border-t border-white/10 flex items-center justify-between">
|
||||
<button
|
||||
onClick={logout}
|
||||
className="rounded-md border border-white/10 px-3 py-2 text-xs opacity-80 hover:opacity-100"
|
||||
>
|
||||
Log out
|
||||
</button>
|
||||
|
||||
{/* Optional: quick link to set password later */}
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPassword(true);
|
||||
// Scroll user’s attention to welcome panel area if it exists,
|
||||
// otherwise just open the password UI in their mind.
|
||||
// (Kept simple: you can add a dedicated card later if you want)
|
||||
setPwMsg(null);
|
||||
}}
|
||||
className="text-xs underline opacity-70 hover:opacity-100"
|
||||
>
|
||||
Set / change password
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* If user clicks "Set/change password" outside welcome flow, show it here */}
|
||||
{!showWelcomeCard && showPassword && (
|
||||
<div className="pt-4 border-t border-white/10 space-y-2">
|
||||
<div className="text-sm font-medium">Set password</div>
|
||||
<input
|
||||
type="password"
|
||||
value={pw1}
|
||||
onChange={(e) => setPw1(e.target.value)}
|
||||
placeholder="New password (min 8 chars)"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||
/>
|
||||
<input
|
||||
type="password"
|
||||
value={pw2}
|
||||
onChange={(e) => setPw2(e.target.value)}
|
||||
placeholder="Confirm password"
|
||||
className="w-full rounded-md border border-white/10 bg-black/40 px-3 py-2 text-sm outline-none focus:ring-2 focus:ring-amber-500/40"
|
||||
/>
|
||||
|
||||
{(pwTooShort || pwMismatch) && (
|
||||
<div className="text-xs text-red-300">
|
||||
{pwTooShort
|
||||
? "Password must be at least 8 characters."
|
||||
: "Passwords don’t match."}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<button
|
||||
onClick={savePassword}
|
||||
disabled={!canSavePassword}
|
||||
className="rounded-md bg-amber-400 px-3 py-2 text-sm font-medium text-black disabled:opacity-50"
|
||||
>
|
||||
{pwSaving ? "Saving…" : "Save password"}
|
||||
</button>
|
||||
<button
|
||||
onClick={() => {
|
||||
setShowPassword(false);
|
||||
setPw1("");
|
||||
setPw2("");
|
||||
setPwMsg(null);
|
||||
}}
|
||||
className="text-xs opacity-80 hover:opacity-100"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{pwMsg && (
|
||||
<div
|
||||
className={`text-xs ${
|
||||
pwMsg.toLowerCase().includes("couldn")
|
||||
? "text-red-300"
|
||||
: "text-zinc-200/80"
|
||||
}`}
|
||||
>
|
||||
{pwMsg}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
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>
|
||||
);
|
||||
}
|
||||
+24
-26
@@ -1,59 +1,57 @@
|
||||
// app/(account)/layout.tsx
|
||||
import Link from "next/link";
|
||||
import AccountChrome from "./AccountChrome";
|
||||
|
||||
const nav = [
|
||||
{ href: "/account", label: "My Account" },
|
||||
{ href: "/account/settings", label: "Settings" },
|
||||
{ href: "/vault", label: "My Vault" },
|
||||
{ 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 }) {
|
||||
export default function AccountLayout({
|
||||
children,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<div className="min-h-screen">
|
||||
<AccountChrome />
|
||||
|
||||
<div className="mx-auto w-full max-w-6xl px-4 py-6">
|
||||
{/* Header */}
|
||||
<div className="mb-6 flex flex-col gap-3 sm:flex-row sm:items-end sm:justify-between">
|
||||
<div 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-[11px] uppercase tracking-[0.22em] text-white/50">
|
||||
<div className="text-xs tracking-[0.25em] uppercase text-zinc-400">
|
||||
Account
|
||||
</div>
|
||||
<h1 className="mt-1 text-2xl font-semibold text-white/90">
|
||||
Settings & Profile
|
||||
</h1>
|
||||
<p className="mt-1 text-sm text-white/60">
|
||||
<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="inline-flex items-center justify-center rounded-lg border border-white/10 bg-white/5 px-3 py-2 text-sm font-semibold text-white/80 hover:bg-white/10"
|
||||
className="rounded-lg border border-white/10 bg-white/5 px-4 py-2 text-sm hover:bg-white/10"
|
||||
>
|
||||
← Back to Builder
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
<div className="grid gap-6 lg:grid-cols-12">
|
||||
<aside className="lg:col-span-3 rounded-2xl border border-white/10 bg-white/5 p-4">
|
||||
<nav className="flex flex-col gap-1">
|
||||
<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="rounded-lg px-3 py-2 text-sm text-white/70 hover:bg-white/10 hover:text-white"
|
||||
className="block rounded-lg px-3 py-2 text-sm text-zinc-200 hover:bg-white/5"
|
||||
>
|
||||
{item.label}
|
||||
{item.name}
|
||||
</Link>
|
||||
))}
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<main className="lg:col-span-9 rounded-2xl border border-white/10 bg-white/5 p-5">
|
||||
{children}
|
||||
</main>
|
||||
{/* 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>
|
||||
);
|
||||
}
|
||||
File diff suppressed because it is too large
Load Diff
@@ -1,431 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
import {
|
||||
PART_ROLE_TO_CATEGORY,
|
||||
normalizePartRole,
|
||||
} from "@/lib/catalogMappings";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
imageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
inStock?: boolean | null;
|
||||
};
|
||||
|
||||
type BuildState = Partial<Record<CategoryId, string>>;
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
const PLATFORMS = ["AR-15", "AR-10", "AR-9"] as const;
|
||||
|
||||
function extractNumericId(productSlug: string): number | null {
|
||||
if (!productSlug) return null;
|
||||
const first = productSlug.split("-")[0];
|
||||
const n = Number(first);
|
||||
return Number.isFinite(n) && n > 0 ? n : null;
|
||||
}
|
||||
|
||||
function formatPrice(price: number | null | undefined): string {
|
||||
if (price == null) return "—";
|
||||
return `$${price.toFixed(2)}`;
|
||||
}
|
||||
|
||||
export default function ProductDetailsPage() {
|
||||
const params = useParams();
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
// NEW ROUTE PARAMS:
|
||||
const platformParam = String(params.platform ?? "");
|
||||
const partRoleParam = String(params.partRole ?? "");
|
||||
const productSlug = String(params.productSlug ?? "");
|
||||
|
||||
// Platform comes from path segment (fallback to ?platform just in case)
|
||||
const platform =
|
||||
platformParam ||
|
||||
(searchParams.get("platform") as string) ||
|
||||
"AR-15";
|
||||
|
||||
const normalizedRole = useMemo(
|
||||
() => normalizePartRole(partRoleParam),
|
||||
[partRoleParam]
|
||||
);
|
||||
|
||||
const categoryId = useMemo(() => {
|
||||
return PART_ROLE_TO_CATEGORY[partRoleParam] ?? PART_ROLE_TO_CATEGORY[normalizedRole] ?? null;
|
||||
}, [partRoleParam, normalizedRole]);
|
||||
|
||||
const numericId = useMemo(
|
||||
() => extractNumericId(productSlug),
|
||||
[productSlug]
|
||||
);
|
||||
|
||||
// Read-only build state (builder page owns writes)
|
||||
const [build] = useState<BuildState>(() => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as BuildState) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const selectedPartIdForCategory = categoryId ? build?.[categoryId] : undefined;
|
||||
const isSelected =
|
||||
!!categoryId &&
|
||||
selectedPartIdForCategory === (numericId ? String(numericId) : "");
|
||||
|
||||
const [product, setProduct] = useState<GunbuilderProductFromApi | null>(null);
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// If platform segment is missing/empty, normalize into the new route (not /builder)
|
||||
useEffect(() => {
|
||||
if (platformParam) return;
|
||||
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", platform || "AR-15");
|
||||
|
||||
router.replace(
|
||||
`/parts/p/${encodeURIComponent(platform || "AR-15")}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}/${encodeURIComponent(productSlug)}?${qp.toString()}`
|
||||
);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [platformParam, platform, partRoleParam, productSlug, router, searchParams]);
|
||||
|
||||
// Fetch product
|
||||
useEffect(() => {
|
||||
if (!numericId) {
|
||||
setError("Invalid product id.");
|
||||
setLoading(false);
|
||||
return;
|
||||
}
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchProduct() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load product (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi = await res.json();
|
||||
setProduct(data);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
setError(err?.message ?? "Failed to load product");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchProduct();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [numericId]);
|
||||
|
||||
const handleTogglePart = () => {
|
||||
if (!numericId) return;
|
||||
|
||||
// If mapping is missing, don’t send a bogus builder action
|
||||
if (!categoryId) {
|
||||
alert(
|
||||
`No CategoryId mapping found for partRole "${partRoleParam}". Add it in catalogMappings.`
|
||||
);
|
||||
return;
|
||||
}
|
||||
|
||||
const qp = new URLSearchParams();
|
||||
qp.set("platform", platform || "AR-15");
|
||||
|
||||
if (isSelected) {
|
||||
qp.set("remove", String(categoryId));
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
qp.set("select", `${categoryId}:${numericId}`);
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
};
|
||||
|
||||
if (!categoryId) {
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
|
||||
Unknown Part Role
|
||||
</h1>
|
||||
<p className="text-sm text-zinc-400 mb-6">
|
||||
No mapping found for <span className="text-zinc-200">{partRoleParam}</span>.
|
||||
Add it to <span className="text-zinc-200">catalogMappings</span>.
|
||||
</p>
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}`}
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Back to Parts List
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
{/* Breadcrumbs */}
|
||||
<header className="mb-6">
|
||||
<div className="flex flex-wrap items-center gap-2 text-xs text-zinc-500">
|
||||
<Link
|
||||
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||
className="hover:text-zinc-300"
|
||||
>
|
||||
The Builder
|
||||
</Link>
|
||||
<span className="text-zinc-700">/</span>
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}`}
|
||||
className="hover:text-zinc-300"
|
||||
>
|
||||
Parts
|
||||
</Link>
|
||||
<span className="text-zinc-700">/</span>
|
||||
<span className="text-zinc-300">Details</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
BATTL BUILDERS
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Product <span className="text-amber-300">Details</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||
Live product data pulled from your Ballistic backend. Add it to your build
|
||||
or jump back to compare options.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Platform switch (updates NEW route) */}
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="platform-select" className="text-xs text-zinc-500">
|
||||
Platform
|
||||
</label>
|
||||
<select
|
||||
id="platform-select"
|
||||
value={platform}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
router.replace(
|
||||
`/parts/p/${encodeURIComponent(next)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}/${encodeURIComponent(productSlug)}`
|
||||
);
|
||||
}}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{PLATFORMS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Body */}
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-4 md:p-6">
|
||||
{loading ? (
|
||||
<p className="py-10 text-center text-sm text-zinc-500">Loading product…</p>
|
||||
) : error ? (
|
||||
<p className="py-10 text-center text-sm text-red-400">
|
||||
{error} — check that the Ballistic API is running.
|
||||
</p>
|
||||
) : !product ? (
|
||||
<p className="py-10 text-center text-sm text-zinc-500">Product not found.</p>
|
||||
) : (
|
||||
<div className="grid gap-6 md:grid-cols-[280px_1fr]">
|
||||
{/* Left: image */}
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-3">
|
||||
<div className="aspect-square w-full overflow-hidden rounded-md border border-zinc-800 bg-black">
|
||||
{product.imageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={product.imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-contain p-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
) : (
|
||||
<div className="flex h-full w-full items-center justify-center text-xs text-zinc-600">
|
||||
No image
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex items-center justify-between">
|
||||
<span className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
Stock
|
||||
</span>
|
||||
<span
|
||||
className={`text-xs font-semibold ${
|
||||
product.inStock === false ? "text-red-300" : "text-emerald-300"
|
||||
}`}
|
||||
>
|
||||
{product.inStock === false ? "Out of stock" : "In stock"}
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="mt-3 flex gap-2">
|
||||
<Link
|
||||
href={`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(
|
||||
partRoleParam
|
||||
)}`}
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-800"
|
||||
>
|
||||
Back to List
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePart}
|
||||
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
|
||||
isSelected
|
||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Right: details */}
|
||||
<div className="min-w-0">
|
||||
<div className="rounded-md border border-zinc-800 bg-zinc-950/80 p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-start md:justify-between">
|
||||
<div className="min-w-0">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
{product.brand}
|
||||
</p>
|
||||
<h2 className="mt-1 text-xl md:text-2xl font-semibold text-zinc-50">
|
||||
{product.name}
|
||||
</h2>
|
||||
|
||||
<div className="mt-2 flex flex-wrap gap-2 text-xs">
|
||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||
Platform:{" "}
|
||||
<span className="font-semibold text-zinc-100">
|
||||
{product.platform || platform}
|
||||
</span>
|
||||
</span>
|
||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||
Role:{" "}
|
||||
<span className="font-semibold text-zinc-100">
|
||||
{product.partRole || partRoleParam}
|
||||
</span>
|
||||
</span>
|
||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||
ID:{" "}
|
||||
<span className="font-semibold text-zinc-100">
|
||||
{product.id}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="shrink-0 text-right">
|
||||
<div className="text-[10px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
Current price
|
||||
</div>
|
||||
<div className="mt-1 text-2xl font-semibold text-amber-300">
|
||||
{formatPrice(product.price)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-col gap-2 sm:flex-row">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleTogglePart}
|
||||
className={`rounded-md px-4 py-2 text-sm font-semibold transition-colors ${
|
||||
isSelected
|
||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||
</button>
|
||||
|
||||
{product.buyUrl ? (
|
||||
<a
|
||||
href={product.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-center text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||
>
|
||||
Buy from Merchant →
|
||||
</a>
|
||||
) : (
|
||||
<span className="rounded-md border border-zinc-800 bg-zinc-950 px-4 py-2 text-center text-sm text-zinc-500">
|
||||
No buy link available
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="mt-4 border-t border-zinc-800 pt-4">
|
||||
<h3 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
Notes
|
||||
</h3>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
This details page is intentionally “thin” for now — once we wire in
|
||||
offers, price history, and richer product fields, this section becomes
|
||||
the spec + comparison hub.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap gap-2 text-xs text-zinc-500">
|
||||
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||
Tip: use “Back to List” to compare multiple parts quickly.
|
||||
</span>
|
||||
<span className="rounded border border-zinc-800 bg-zinc-950/60 px-2 py-1">
|
||||
Platform comes from the URL segment:{" "}
|
||||
<span className="text-zinc-300">/parts/p/{platform}/...</span>
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,116 +0,0 @@
|
||||
// app/builder/[categoryId]/[partId]/data.ts
|
||||
|
||||
// Simulated data functions - Replace these with API calls when ready
|
||||
// Example: export async function getPricingHistory(partId: string) { ... }
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
export interface PriceHistoryPoint {
|
||||
date: string; // ISO date string
|
||||
price: number;
|
||||
}
|
||||
|
||||
export interface RetailerOffer {
|
||||
id: string;
|
||||
retailerName: string;
|
||||
price: number;
|
||||
originalPrice?: number;
|
||||
inStock: boolean;
|
||||
affiliateUrl: string;
|
||||
lastUpdated: string; // ISO date string
|
||||
}
|
||||
|
||||
/**
|
||||
* Get pricing history for a part
|
||||
* TODO: Replace with API call: GET /api/parts/{partId}/pricing-history
|
||||
*
|
||||
* For now, we keep this simulated.
|
||||
*/
|
||||
export function getPricingHistory(
|
||||
partId: string,
|
||||
basePrice: number,
|
||||
): PriceHistoryPoint[] {
|
||||
// Generate 30 days of price history with some variation
|
||||
const days = 30;
|
||||
const history: PriceHistoryPoint[] = [];
|
||||
const today = new Date();
|
||||
|
||||
for (let i = days - 1; i >= 0; i--) {
|
||||
const date = new Date(today);
|
||||
date.setDate(date.getDate() - i);
|
||||
|
||||
// Simulate price fluctuations (±15% variation)
|
||||
const variation = (Math.random() - 0.5) * 0.3; // -15% to +15%
|
||||
const price = basePrice * (1 + variation);
|
||||
|
||||
history.push({
|
||||
date: date.toISOString().split("T")[0],
|
||||
price: Math.round(price * 100) / 100,
|
||||
});
|
||||
}
|
||||
|
||||
return history;
|
||||
}
|
||||
|
||||
/**
|
||||
* Get retailer offers for a part
|
||||
* Real implementation using Ballistic backend:
|
||||
* GET /api/v1/products/{productId}/offers
|
||||
*/
|
||||
export async function getRetailerOffers(
|
||||
productId: string,
|
||||
): Promise<RetailerOffer[]> {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/products/${productId}/offers`,
|
||||
{
|
||||
// This will be called from the client detail page,
|
||||
// so we *don't* use cache here.
|
||||
cache: "no-store",
|
||||
},
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(
|
||||
`Failed to load retailer offers (${res.status}) for product ${productId}`,
|
||||
);
|
||||
}
|
||||
|
||||
// Expected backend payload (example):
|
||||
// [
|
||||
// {
|
||||
// "id": "uuid-or-int",
|
||||
// "merchantName": "Aero Precision",
|
||||
// "price": 189.99,
|
||||
// "originalPrice": 210.0,
|
||||
// "inStock": true,
|
||||
// "buyUrl": "https://classic.avantlink.com/click.php?...",
|
||||
// "lastUpdated": "2025-11-30T15:22:00Z"
|
||||
// },
|
||||
// ...
|
||||
// ]
|
||||
const data: Array<{
|
||||
id: string | number;
|
||||
merchantName: string;
|
||||
price: number;
|
||||
originalPrice?: number | null;
|
||||
inStock: boolean;
|
||||
buyUrl: string;
|
||||
lastUpdated: string;
|
||||
}> = await res.json();
|
||||
|
||||
const offers: RetailerOffer[] = data
|
||||
.map((o) => ({
|
||||
id: String(o.id),
|
||||
retailerName: o.merchantName,
|
||||
price: o.price,
|
||||
originalPrice: o.originalPrice ?? undefined,
|
||||
inStock: o.inStock,
|
||||
affiliateUrl: o.buyUrl,
|
||||
lastUpdated: o.lastUpdated,
|
||||
}))
|
||||
// Sort by lowest price first
|
||||
.sort((a, b) => a.price - b.price);
|
||||
|
||||
return offers;
|
||||
}
|
||||
@@ -1,846 +0,0 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
import { CATEGORIES } from "@/data/gunbuilderParts";
|
||||
import type { CategoryId, Part } from "@/types/gunbuilder";
|
||||
import { CATEGORY_TO_PART_ROLES } from "@/data/partRoleMappings";
|
||||
|
||||
type ViewMode = "card" | "list";
|
||||
|
||||
type GunbuilderProductFromApi = {
|
||||
id: number;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
imageUrl: string | null;
|
||||
buyUrl: string | null;
|
||||
inStock?: boolean | null;
|
||||
};
|
||||
|
||||
type UiPart = Part & {
|
||||
// local-only stock flag for filtering
|
||||
inStock?: boolean;
|
||||
};
|
||||
|
||||
const PLATFORMS = ["AR-15", "AR-10", "AR-9"];
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
type BuildState = Partial<Record<CategoryId, string>>; // categoryId -> partId
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
// sort options
|
||||
type SortOption = "relevance" | "price-asc" | "price-desc" | "brand-asc";
|
||||
|
||||
// support for url id-slug
|
||||
const slugify = (str: string) =>
|
||||
str
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "");
|
||||
|
||||
const normalizeRole = (role: string) =>
|
||||
role.trim().toLowerCase().replace(/_/g, "-");
|
||||
|
||||
function getNormalizedPartRolesForCategory(categoryId: string): string[] {
|
||||
const roles = (CATEGORY_TO_PART_ROLES as any)[categoryId] as
|
||||
| string[]
|
||||
| undefined;
|
||||
if (!roles || roles.length === 0) return [];
|
||||
return Array.from(new Set(roles.map(normalizeRole)));
|
||||
}
|
||||
|
||||
export default function CategoryPage() {
|
||||
const params = useParams();
|
||||
const categoryId = params.categoryId as CategoryId;
|
||||
const partRoles = useMemo(
|
||||
() => getNormalizedPartRolesForCategory(String(categoryId)),
|
||||
[categoryId]
|
||||
);
|
||||
const router = useRouter();
|
||||
const searchParams = useSearchParams();
|
||||
|
||||
const [viewMode, setViewMode] = useState<ViewMode>("list");
|
||||
const [parts, setParts] = useState<UiPart[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
// filter + sort state
|
||||
const [brandFilter, setBrandFilter] = useState<string[]>([]);
|
||||
const [sortBy, setSortBy] = useState<SortOption>("relevance");
|
||||
const [searchQuery, setSearchQuery] = useState<string>("");
|
||||
const [priceRange, setPriceRange] = useState<{
|
||||
min: number | null;
|
||||
max: number | null;
|
||||
}>({
|
||||
min: null,
|
||||
max: null,
|
||||
});
|
||||
const [inStockOnly, setInStockOnly] = useState<boolean>(false);
|
||||
const [currentPage, setCurrentPage] = useState<number>(1);
|
||||
const PAGE_SIZE = 24;
|
||||
|
||||
// build state for this page (read-only), hydrated from localStorage
|
||||
// NOTE: The main /builder page is the single source of truth for writing to STORAGE_KEY.
|
||||
const [build] = useState<BuildState>(() => {
|
||||
if (typeof window === "undefined") return {};
|
||||
try {
|
||||
const stored = window.localStorage.getItem(STORAGE_KEY);
|
||||
return stored ? (JSON.parse(stored) as BuildState) : {};
|
||||
} catch {
|
||||
return {};
|
||||
}
|
||||
});
|
||||
|
||||
const initialPlatform = (searchParams.get("platform") as string) || "AR-15";
|
||||
const [platform, setPlatform] = useState<string>(initialPlatform);
|
||||
|
||||
// If a user lands here without ?platform=..., normalize the URL so downstream navigation keeps platform.
|
||||
useEffect(() => {
|
||||
const qpPlatform = searchParams.get("platform");
|
||||
if (qpPlatform) return;
|
||||
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", platform || "AR-15");
|
||||
router.replace(`/builder/${categoryId}?${qp.toString()}`);
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [categoryId, router, searchParams]);
|
||||
|
||||
const category = useMemo(
|
||||
() => CATEGORIES.find((c) => c.id === categoryId),
|
||||
[categoryId]
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!categoryId) return;
|
||||
|
||||
const controller = new AbortController();
|
||||
|
||||
async function fetchCategoryParts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const search = new URLSearchParams();
|
||||
search.set("platform", platform);
|
||||
|
||||
// ✅ Scope results to this category's backend roles
|
||||
for (const r of partRoles) {
|
||||
search.append("partRoles", r);
|
||||
}
|
||||
|
||||
const url = `${API_BASE_URL}/api/v1/products?${search.toString()}`;
|
||||
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load products (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi[] = await res.json();
|
||||
|
||||
const normalized: UiPart[] = data.map((p) => ({
|
||||
id: String(p.id), // normalize to string for BuildState
|
||||
categoryId,
|
||||
name: p.name,
|
||||
brand: p.brand,
|
||||
price: p.price ?? 0,
|
||||
imageUrl: p.imageUrl ?? undefined, // match backend field name
|
||||
url: p.buyUrl ?? undefined,
|
||||
notes: undefined,
|
||||
inStock: p.inStock ?? true,
|
||||
}));
|
||||
|
||||
setParts(normalized);
|
||||
} catch (err: any) {
|
||||
if (err.name === "AbortError") return;
|
||||
setError(err.message ?? "Failed to load products");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
fetchCategoryParts();
|
||||
|
||||
return () => controller.abort();
|
||||
}, [categoryId, platform, partRoles]);
|
||||
|
||||
// handler to toggle Add / Remove for this category
|
||||
const handleTogglePart = (categoryId: CategoryId, partId: string) => {
|
||||
const isSelected = build[categoryId] === partId;
|
||||
|
||||
const qp = new URLSearchParams();
|
||||
qp.set("platform", platform || "AR-15");
|
||||
|
||||
if (isSelected) {
|
||||
// Tell the main builder to remove this category selection
|
||||
qp.set("remove", String(categoryId));
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Tell the main builder to add/replace this category selection
|
||||
qp.set("select", `${categoryId}:${partId}`);
|
||||
router.push(`/builder?${qp.toString()}`);
|
||||
};
|
||||
|
||||
// available brands for filter
|
||||
const availableBrands = useMemo(
|
||||
() =>
|
||||
Array.from(new Set(parts.map((p) => p.brand)))
|
||||
.filter(Boolean)
|
||||
.sort((a, b) => a.localeCompare(b)),
|
||||
[parts]
|
||||
);
|
||||
|
||||
const priceBounds = useMemo(() => {
|
||||
if (parts.length === 0) {
|
||||
return { min: null as number | null, max: null as number | null };
|
||||
}
|
||||
|
||||
let min = Number.POSITIVE_INFINITY;
|
||||
let max = Number.NEGATIVE_INFINITY;
|
||||
|
||||
for (const p of parts) {
|
||||
const price = p.price ?? 0;
|
||||
if (price < min) min = price;
|
||||
if (price > max) max = price;
|
||||
}
|
||||
|
||||
if (!Number.isFinite(min) || !Number.isFinite(max)) {
|
||||
return { min: null as number | null, max: null as number | null };
|
||||
}
|
||||
|
||||
return { min, max };
|
||||
}, [parts]);
|
||||
|
||||
// initialize / clamp priceRange whenever bounds change
|
||||
useEffect(() => {
|
||||
if (priceBounds.min == null || priceBounds.max == null) return;
|
||||
|
||||
setPriceRange((prev) => {
|
||||
const min = prev.min ?? priceBounds.min!;
|
||||
const max = prev.max ?? priceBounds.max!;
|
||||
return {
|
||||
min: Math.max(priceBounds.min!, Math.min(min, priceBounds.max!)),
|
||||
max: Math.min(priceBounds.max!, Math.max(max, priceBounds.min!)),
|
||||
};
|
||||
});
|
||||
}, [priceBounds.min, priceBounds.max]);
|
||||
|
||||
const filteredParts = useMemo(() => {
|
||||
let result = [...parts];
|
||||
|
||||
// Price range filter
|
||||
const effectiveMin =
|
||||
priceRange.min ?? (priceBounds.min != null ? priceBounds.min : null);
|
||||
const effectiveMax =
|
||||
priceRange.max ?? (priceBounds.max != null ? priceBounds.max : null);
|
||||
|
||||
if (effectiveMin != null && effectiveMax != null) {
|
||||
result = result.filter((p) => {
|
||||
const price = p.price ?? 0;
|
||||
return price >= effectiveMin && price <= effectiveMax;
|
||||
});
|
||||
}
|
||||
|
||||
// Brand filter
|
||||
if (brandFilter.length > 0) {
|
||||
result = result.filter((p) => brandFilter.includes(p.brand));
|
||||
}
|
||||
|
||||
// In-stock filter
|
||||
if (inStockOnly) {
|
||||
result = result.filter((p) => p.inStock ?? true);
|
||||
}
|
||||
|
||||
const normalizedQuery = searchQuery.trim().toLowerCase();
|
||||
if (normalizedQuery) {
|
||||
result = result.filter((p) => {
|
||||
const name = p.name.toLowerCase();
|
||||
const brand = p.brand.toLowerCase();
|
||||
return name.includes(normalizedQuery) || brand.includes(normalizedQuery);
|
||||
});
|
||||
}
|
||||
|
||||
switch (sortBy) {
|
||||
case "price-asc":
|
||||
result.sort((a, b) => a.price - b.price);
|
||||
break;
|
||||
case "price-desc":
|
||||
result.sort((a, b) => b.price - a.price);
|
||||
break;
|
||||
case "brand-asc":
|
||||
result.sort((a, b) => a.brand.localeCompare(b.brand));
|
||||
break;
|
||||
case "relevance":
|
||||
default:
|
||||
// leave in backend order
|
||||
break;
|
||||
}
|
||||
|
||||
// Pin the currently selected part (for this category) to the top, if any
|
||||
const selectedId = build[categoryId];
|
||||
if (selectedId) {
|
||||
const selected = result.filter((p) => p.id === selectedId);
|
||||
const others = result.filter((p) => p.id !== selectedId);
|
||||
result = [...selected, ...others];
|
||||
}
|
||||
|
||||
return result;
|
||||
}, [parts, brandFilter, sortBy, build, categoryId, searchQuery, priceBounds, priceRange, inStockOnly]);
|
||||
|
||||
const totalPages = useMemo(
|
||||
() =>
|
||||
filteredParts.length === 0 ? 1 : Math.ceil(filteredParts.length / PAGE_SIZE),
|
||||
[filteredParts, PAGE_SIZE]
|
||||
);
|
||||
|
||||
const paginatedParts = useMemo(() => {
|
||||
if (filteredParts.length === 0) return [];
|
||||
const startIndex = (currentPage - 1) * PAGE_SIZE;
|
||||
return filteredParts.slice(startIndex, startIndex + PAGE_SIZE);
|
||||
}, [filteredParts, currentPage, PAGE_SIZE]);
|
||||
|
||||
useEffect(() => {
|
||||
// whenever the category or filters change, jump back to page 1
|
||||
setCurrentPage(1);
|
||||
}, [categoryId, brandFilter, sortBy, searchQuery, priceRange, inStockOnly, platform]);
|
||||
|
||||
useEffect(() => {
|
||||
const nextPlatform = (searchParams.get("platform") as string) || "AR-15";
|
||||
setPlatform(nextPlatform);
|
||||
}, [searchParams]);
|
||||
|
||||
// Keep selection highlight in sync if the build is changed elsewhere and user comes back.
|
||||
useEffect(() => {
|
||||
if (typeof window === "undefined") return;
|
||||
const onStorage = (e: StorageEvent) => {
|
||||
if (e.key !== STORAGE_KEY) return;
|
||||
// Force a re-render by reading current storage (since build state is read-only)
|
||||
// This keeps the selected row pinned/highlighted when navigating back.
|
||||
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
|
||||
window.localStorage.getItem(STORAGE_KEY);
|
||||
};
|
||||
window.addEventListener("storage", onStorage);
|
||||
return () => window.removeEventListener("storage", onStorage);
|
||||
}, []);
|
||||
|
||||
const visibleRange = useMemo(() => {
|
||||
if (filteredParts.length === 0) {
|
||||
return { start: 0, end: 0 };
|
||||
}
|
||||
const start = (currentPage - 1) * PAGE_SIZE + 1;
|
||||
const end = Math.min(start + paginatedParts.length - 1, filteredParts.length);
|
||||
return { start, end };
|
||||
}, [filteredParts.length, currentPage, paginatedParts.length, PAGE_SIZE]);
|
||||
|
||||
if (!category) {
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
<div className="text-center">
|
||||
<h1 className="text-2xl font-semibold text-zinc-50 mb-4">
|
||||
Category Not Found
|
||||
</h1>
|
||||
<Link
|
||||
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||
className="text-amber-300 hover:text-amber-200 underline"
|
||||
>
|
||||
Return to Builder
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
{/* Header */}
|
||||
<header className="mb-6">
|
||||
<Link
|
||||
href={{ pathname: "/builder", query: platform ? { platform } : {} }}
|
||||
className="text-xs text-zinc-400 hover:text-zinc-300 mb-4 inline-block"
|
||||
>
|
||||
← Back to the Builder
|
||||
</Link>
|
||||
<div className="flex items-start justify-between gap-4">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Shadow Standard
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
{category.name} <span className="text-amber-300">Parts</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||
Browse all available {category.name.toLowerCase()} options for your
|
||||
build, pulled live from your Ballistic backend.
|
||||
</p>
|
||||
</div>
|
||||
{!loading && !error && parts.length > 0 && (
|
||||
<div className="flex gap-2 border border-zinc-800 rounded-md p-1 bg-zinc-950/60">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("card")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
viewMode === "card"
|
||||
? "bg-zinc-800 text-zinc-50"
|
||||
: "text-zinc-400 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-label="Card view"
|
||||
>
|
||||
Card
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setViewMode("list")}
|
||||
className={`px-3 py-1.5 text-xs font-medium rounded transition-colors ${
|
||||
viewMode === "list"
|
||||
? "bg-zinc-800 text-zinc-50"
|
||||
: "text-zinc-400 hover:text-zinc-300"
|
||||
}`}
|
||||
aria-label="List view"
|
||||
>
|
||||
List
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Parts Grid/List */}
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||
<div className="flex flex-col gap-4 md:flex-row">
|
||||
{/* Left sidebar: filters */}
|
||||
{!loading && !error && parts.length > 0 && (
|
||||
<aside className="w-full md:w-64 shrink-0 rounded-md border border-zinc-800 bg-zinc-950/80 p-3 space-y-4">
|
||||
<div>
|
||||
<h2 className="text-xs font-semibold uppercase tracking-[0.16em] text-zinc-500">
|
||||
Filters
|
||||
</h2>
|
||||
<p className="mt-1 text-[11px] text-zinc-500">
|
||||
Narrow down the {category.name.toLowerCase()} list.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Price range */}
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||
Price
|
||||
</span>
|
||||
{priceRange.min !== null || priceRange.max !== null ? (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() =>
|
||||
setPriceRange({ min: priceBounds.min, max: priceBounds.max })
|
||||
}
|
||||
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
Reset
|
||||
</button>
|
||||
) : null}
|
||||
</div>
|
||||
{priceBounds.min == null || priceBounds.max == null ? (
|
||||
<p className="text-[11px] text-zinc-500">
|
||||
No price data available.
|
||||
</p>
|
||||
) : (
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between text-[10px] text-zinc-400">
|
||||
<span>
|
||||
Min:{" "}
|
||||
<span className="font-semibold text-zinc-200">
|
||||
${(priceRange.min ?? priceBounds.min).toFixed(0)}
|
||||
</span>
|
||||
</span>
|
||||
<span>
|
||||
Max:{" "}
|
||||
<span className="font-semibold text-zinc-200">
|
||||
${(priceRange.max ?? priceBounds.max).toFixed(0)}
|
||||
</span>
|
||||
</span>
|
||||
</div>
|
||||
<div className="space-y-2">
|
||||
<input
|
||||
type="range"
|
||||
min={priceBounds.min ?? 0}
|
||||
max={priceBounds.max ?? 0}
|
||||
value={priceRange.min ?? priceBounds.min ?? 0}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setPriceRange((prev) => ({
|
||||
min: Math.min(value, prev.max ?? priceBounds.max ?? value),
|
||||
max: prev.max ?? priceBounds.max ?? value,
|
||||
}));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
<input
|
||||
type="range"
|
||||
min={priceBounds.min ?? 0}
|
||||
max={priceBounds.max ?? 0}
|
||||
value={priceRange.max ?? priceBounds.max ?? 0}
|
||||
onChange={(e) => {
|
||||
const value = Number(e.target.value);
|
||||
setPriceRange((prev) => ({
|
||||
min: prev.min ?? priceBounds.min ?? value,
|
||||
max: Math.max(value, prev.min ?? priceBounds.min ?? value),
|
||||
}));
|
||||
}}
|
||||
className="w-full"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Brand multi-select */}
|
||||
<div>
|
||||
<div className="mb-1.5 flex items-center justify-between">
|
||||
<span className="text-[11px] font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||
Brand
|
||||
</span>
|
||||
{brandFilter.length > 0 && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setBrandFilter([])}
|
||||
className="text-[10px] text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{availableBrands.length === 0 ? (
|
||||
<p className="text-[11px] text-zinc-500">
|
||||
No brands available yet.
|
||||
</p>
|
||||
) : (
|
||||
<div className="max-h-56 space-y-1 overflow-y-auto pr-1">
|
||||
{availableBrands.map((b) => {
|
||||
const checked = brandFilter.includes(b);
|
||||
return (
|
||||
<label
|
||||
key={b}
|
||||
className="flex cursor-pointer items-center gap-2 rounded px-1 py-0.5 text-[11px] text-zinc-200 hover:bg-zinc-900/80"
|
||||
>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||
checked={checked}
|
||||
onChange={(e) => {
|
||||
setBrandFilter((prev) =>
|
||||
e.target.checked
|
||||
? [...prev, b]
|
||||
: prev.filter((brand) => brand !== b)
|
||||
);
|
||||
}}
|
||||
/>
|
||||
<span className="truncate">{b}</span>
|
||||
</label>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
{brandFilter.length === 0 && availableBrands.length > 0 && (
|
||||
<p className="mt-1 text-[10px] text-zinc-500">
|
||||
No brands selected — showing all.
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* In-stock toggle */}
|
||||
<div className="border-t border-zinc-800 pt-3">
|
||||
<label className="flex cursor-pointer items-center justify-between text-[11px] text-zinc-200">
|
||||
<span className="font-medium uppercase tracking-[0.12em] text-zinc-500">
|
||||
In stock only
|
||||
</span>
|
||||
<input
|
||||
type="checkbox"
|
||||
className="h-3 w-3 rounded border-zinc-600 bg-zinc-900 text-amber-400 focus:ring-amber-400"
|
||||
checked={inStockOnly}
|
||||
onChange={(e) => setInStockOnly(e.target.checked)}
|
||||
/>
|
||||
</label>
|
||||
<p className="mt-1 text-[10px] text-zinc-500">
|
||||
Hides items marked out of stock by the backend.
|
||||
</p>
|
||||
</div>
|
||||
</aside>
|
||||
)}
|
||||
|
||||
{/* Right side: toolbar + results */}
|
||||
<div className="flex-1">
|
||||
{/* Top toolbar: count + search + sort */}
|
||||
{!loading && !error && parts.length > 0 && (
|
||||
<div className="mb-4 flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="text-xs text-zinc-500">
|
||||
Showing{" "}
|
||||
<span className="font-medium text-zinc-200">
|
||||
{visibleRange.start}-{visibleRange.end}
|
||||
</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-medium text-zinc-200">
|
||||
{filteredParts.length}
|
||||
</span>{" "}
|
||||
matching parts
|
||||
</div>
|
||||
<div className="flex flex-wrap gap-3">
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="part-search" className="text-xs text-zinc-500">
|
||||
Search
|
||||
</label>
|
||||
<div className="relative">
|
||||
<input
|
||||
id="part-search"
|
||||
type="text"
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
placeholder={`Search ${category.name.toLowerCase()}...`}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 pr-6 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
{searchQuery && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setSearchQuery("")}
|
||||
className="absolute right-1 top-1/2 -translate-y-1/2 text-xs leading-none text-zinc-500 hover:text-zinc-300"
|
||||
aria-label="Clear search"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="platform-select" className="text-xs text-zinc-500">
|
||||
Platform
|
||||
</label>
|
||||
<select
|
||||
id="platform-select"
|
||||
value={platform}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value;
|
||||
setPlatform(next);
|
||||
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", next);
|
||||
router.replace(`/builder/${categoryId}?${qp.toString()}`);
|
||||
}}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{PLATFORMS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{p}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="sort-by" className="text-xs text-zinc-500">
|
||||
Sort
|
||||
</label>
|
||||
<select
|
||||
id="sort-by"
|
||||
value={sortBy}
|
||||
onChange={(e) => setSortBy(e.target.value as SortOption)}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
<option value="relevance">Relevance</option>
|
||||
<option value="price-asc">Price: Low → High</option>
|
||||
<option value="price-desc">Price: High → Low</option>
|
||||
<option value="brand-asc">Brand: A → Z</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Results / states */}
|
||||
{loading ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-500">
|
||||
Loading {category.name.toLowerCase()}…
|
||||
</p>
|
||||
) : error ? (
|
||||
<p className="py-8 text-center text-sm text-red-400">
|
||||
{error} — check that the Ballistic API is running.
|
||||
</p>
|
||||
) : filteredParts.length === 0 ? (
|
||||
<p className="py-8 text-center text-sm text-zinc-500">
|
||||
No parts available for this category yet.
|
||||
</p>
|
||||
) : viewMode === "card" ? (
|
||||
<div className="grid gap-3 md:grid-cols-2 lg:grid-cols-3">
|
||||
{paginatedParts.map((part) => {
|
||||
const isSelected = build[categoryId] === part.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={part.id}
|
||||
className={`group relative flex flex-col rounded-md border p-3 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
|
||||
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||
}`}
|
||||
>
|
||||
<div className="mb-3 flex items-center gap-2 justify-between">
|
||||
<div className="flex-1">
|
||||
<div className="text-sm font-semibold text-zinc-50">
|
||||
{part.brand}{" "}
|
||||
<span className="font-normal">— {part.name}</span>
|
||||
</div>
|
||||
{part.notes && (
|
||||
<p className="mt-1 line-clamp-2 text-xs text-zinc-400">
|
||||
{part.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="whitespace-nowrap text-sm font-semibold text-amber-300">
|
||||
${part.price.toFixed(2)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-2 flex gap-2">
|
||||
<Link
|
||||
href={{
|
||||
pathname: `/builder/${categoryId}/${part.id}-${slugify(
|
||||
part.name
|
||||
)}`,
|
||||
query: { platform },
|
||||
}}
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-2 text-center text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePart(categoryId, part.id)}
|
||||
className={`flex-1 rounded-md px-3 py-2 text-center text-xs font-semibold transition-colors ${
|
||||
isSelected
|
||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
{/* Header row for list view */}
|
||||
<div className="hidden grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] px-3 pb-2 text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 md:grid">
|
||||
<span>Part</span>
|
||||
<span>Brand</span>
|
||||
<span className="text-right">Price</span>
|
||||
<span className="pr-2 text-right">Actions</span>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
{paginatedParts.map((part) => {
|
||||
const isSelected = build[categoryId] === part.id;
|
||||
|
||||
return (
|
||||
<div
|
||||
key={part.id}
|
||||
className={`group relative rounded-md border p-3 transition-all duration-200 ${
|
||||
isSelected
|
||||
? "border-amber-400/70 bg-amber-400/10 shadow-[0_0_0_1px_rgba(251,191,36,0.25)]"
|
||||
: "border-zinc-700 bg-zinc-900/50 hover:border-amber-400/60 hover:bg-amber-400/10"
|
||||
}`}
|
||||
>
|
||||
<div className="flex flex-col gap-2 md:grid md:grid-cols-[minmax(0,3fr)_minmax(0,1fr)_minmax(0,1fr)_auto] md:items-center md:gap-4">
|
||||
<div className="min-w-0 flex-1">
|
||||
<div className="text-sm font-semibold text-zinc-50">
|
||||
{part.name}
|
||||
</div>
|
||||
{part.notes && (
|
||||
<p className="mt-1 line-clamp-1 text-xs text-zinc-400">
|
||||
{part.notes}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
<div className="text-xs text-zinc-400 md:text-left md:text-sm">
|
||||
{part.brand}
|
||||
</div>
|
||||
<div className="text-sm font-semibold text-amber-300 md:text-right">
|
||||
${part.price.toFixed(2)}
|
||||
</div>
|
||||
<div className="flex justify-end gap-2">
|
||||
<Link
|
||||
href={{
|
||||
pathname: `/builder/${categoryId}/${part.id}-${slugify(
|
||||
part.name
|
||||
)}`,
|
||||
query: { platform },
|
||||
}}
|
||||
className="whitespace-nowrap rounded-md border border-zinc-700 bg-zinc-800/50 px-3 py-1.5 text-xs font-medium text-zinc-300 transition-colors hover:border-zinc-600 hover:bg-zinc-700"
|
||||
>
|
||||
View Details
|
||||
</Link>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => handleTogglePart(categoryId, part.id)}
|
||||
className={`whitespace-nowrap rounded-md px-3 py-1.5 text-xs font-semibold transition-colors ${
|
||||
isSelected
|
||||
? "border border-zinc-600 bg-zinc-800 text-zinc-100 hover:bg-zinc-700"
|
||||
: "bg-amber-400 text-black hover:bg-amber-300"
|
||||
}`}
|
||||
>
|
||||
{isSelected ? "Remove from Build" : "Add to Build"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Pagination controls */}
|
||||
{filteredParts.length > 0 && totalPages > 1 && (
|
||||
<div className="mt-4 flex items-center justify-between text-xs text-zinc-400">
|
||||
<div>
|
||||
Page{" "}
|
||||
<span className="font-semibold text-zinc-200">{currentPage}</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-semibold text-zinc-200">{totalPages}</span>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.max(1, p - 1))}
|
||||
disabled={currentPage === 1}
|
||||
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setCurrentPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={currentPage === totalPages}
|
||||
className="rounded border border-zinc-700 bg-zinc-900/70 px-3 py-1 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
@@ -1,14 +0,0 @@
|
||||
import { redirect } from "next/navigation";
|
||||
|
||||
export default async function PartsRoleRedirectPage({
|
||||
params,
|
||||
searchParams,
|
||||
}: {
|
||||
params: { partRole: string };
|
||||
searchParams?: { platform?: string };
|
||||
}) {
|
||||
const partRole = params.partRole;
|
||||
const platform = searchParams?.platform ?? "AR-15";
|
||||
|
||||
redirect(`/parts/p/${encodeURIComponent(platform)}/${encodeURIComponent(partRole)}`);
|
||||
}
|
||||
@@ -4,8 +4,10 @@ import { useEffect, useMemo, useState } from "react";
|
||||
import Link from "next/link";
|
||||
import { useParams, useRouter, useSearchParams } from "next/navigation";
|
||||
|
||||
import type { CategoryId } from "@/types/gunbuilder";
|
||||
import 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
|
||||
@@ -18,6 +20,7 @@ type OfferFromApi = {
|
||||
price?: number | null;
|
||||
originalPrice?: number | null;
|
||||
buyUrl?: string | null;
|
||||
buyShortUrl?: string | null;
|
||||
inStock?: boolean | null;
|
||||
lastUpdated?: string | null;
|
||||
};
|
||||
@@ -29,6 +32,7 @@ type GunbuilderProductFromApi = {
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
caliber?: string | null;
|
||||
|
||||
// Optional (legacy fallback label if product.offers is missing)
|
||||
merchantName?: string | null;
|
||||
@@ -37,6 +41,7 @@ type GunbuilderProductFromApi = {
|
||||
mainImageUrl?: string | null;
|
||||
|
||||
buyUrl?: string | null;
|
||||
buyShortUrl?: string | null;
|
||||
inStock?: boolean | null;
|
||||
|
||||
shortDescription?: string | null;
|
||||
@@ -46,10 +51,10 @@ type GunbuilderProductFromApi = {
|
||||
offers?: OfferFromApi[] | null;
|
||||
};
|
||||
|
||||
type BuildState = Partial<Record<CategoryId, string>>;
|
||||
type BuildState = Partial<Record<BuilderSlotKey, string>>;
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
@@ -238,14 +243,23 @@ export default function ProductDetailsPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
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;
|
||||
@@ -272,14 +286,18 @@ export default function ProductDetailsPage() {
|
||||
try {
|
||||
setOffersLoading(true);
|
||||
|
||||
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
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;
|
||||
@@ -330,14 +348,23 @@ export default function ProductDetailsPage() {
|
||||
* - Fallback to legacy single-offer derived from product if needed
|
||||
*/
|
||||
const offers = useMemo(() => {
|
||||
if (offersFromApi.length > 0) return sortOffers(offersFromApi);
|
||||
if (offersFromApi.length > 0) {
|
||||
console.log('[DEBUG] Using offers from API, first offer:', offersFromApi[0]);
|
||||
const fallbackShortUrl = product?.buyShortUrl ?? null;
|
||||
const normalized = offersFromApi.map((offer) => ({
|
||||
...offer,
|
||||
buyShortUrl: offer.buyShortUrl || fallbackShortUrl,
|
||||
}));
|
||||
return sortOffers(normalized);
|
||||
}
|
||||
|
||||
if (product?.buyUrl) {
|
||||
if (product?.buyUrl || product?.buyShortUrl) {
|
||||
console.log('[DEBUG] Using fallback from product, buyShortUrl:', product.buyShortUrl, 'buyUrl:', product.buyUrl);
|
||||
return sortOffers([
|
||||
{
|
||||
merchantName: product.merchantName ?? "Retailer",
|
||||
price: product.price,
|
||||
buyUrl: product.buyUrl,
|
||||
buyUrl: product.buyShortUrl || product.buyUrl,
|
||||
inStock: product.inStock ?? true,
|
||||
},
|
||||
]);
|
||||
@@ -467,12 +494,10 @@ export default function ProductDetailsPage() {
|
||||
className="h-full w-full"
|
||||
title="Click to enlarge"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ProductImage
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-contain p-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
@@ -559,6 +584,14 @@ export default function ProductDetailsPage() {
|
||||
{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">
|
||||
@@ -660,12 +693,13 @@ export default function ProductDetailsPage() {
|
||||
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{o.buyUrl ? (
|
||||
{(o.buyShortUrl || o.buyUrl) ? (
|
||||
<a
|
||||
href={o.buyUrl}
|
||||
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>
|
||||
@@ -686,13 +720,14 @@ export default function ProductDetailsPage() {
|
||||
)}
|
||||
|
||||
{/* Best Offer CTA */}
|
||||
{bestOffer?.buyUrl ? (
|
||||
{(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<a
|
||||
href={bestOffer.buyUrl}
|
||||
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>
|
||||
@@ -735,8 +770,7 @@ export default function ProductDetailsPage() {
|
||||
Close
|
||||
</button>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<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"
|
||||
@@ -747,4 +781,4 @@ export default function ProductDetailsPage() {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,45 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { changelogEntriesQuery, roadmapItemsQuery } from '@/lib/sanity'
|
||||
import { sanityFetch } from '@/lib/sanityFetch'
|
||||
import { ChangelogTabs } from '@/components/ChangelogTabs'
|
||||
import type { ChangelogEntry, RoadmapItemData } from '@/types/changelog'
|
||||
|
||||
export const revalidate = 60
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: "What's New — Battl Builders",
|
||||
description:
|
||||
'Release notes, upcoming features, and product updates from Battl Builders.',
|
||||
}
|
||||
|
||||
export default async function ChangelogPage() {
|
||||
let entries: ChangelogEntry[] = []
|
||||
let roadmapItems: RoadmapItemData[] = []
|
||||
|
||||
try {
|
||||
entries = await sanityFetch<ChangelogEntry[]>(changelogEntriesQuery)
|
||||
} catch (err) {
|
||||
console.error('[Changelog] entries fetch failed:', err)
|
||||
}
|
||||
|
||||
try {
|
||||
roadmapItems = await sanityFetch<RoadmapItemData[]>(roadmapItemsQuery)
|
||||
} catch (err) {
|
||||
console.error('[Changelog] roadmap 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">Updates</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
What we've shipped and what's coming next.
|
||||
</p>
|
||||
</header>
|
||||
<ChangelogTabs entries={entries} roadmapItems={roadmapItems} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import type { Metadata } from 'next'
|
||||
import { FeedbackForm } from '@/components/FeedbackForm'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Send Feedback — Battl Builders',
|
||||
description:
|
||||
'Report a bug, suggest a feature, or share a thought with the Battl Builders team.',
|
||||
}
|
||||
|
||||
export default function FeedbackPage() {
|
||||
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">
|
||||
Send Feedback
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400">
|
||||
Bug, feature idea, or general thought — we read everything.
|
||||
</p>
|
||||
</header>
|
||||
<FeedbackForm />
|
||||
</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>
|
||||
)
|
||||
}
|
||||
@@ -1,15 +1,20 @@
|
||||
// app/(builder)/layout.tsx
|
||||
import type { ReactNode } from "react";
|
||||
import { Suspense } from "react";
|
||||
|
||||
import { BuilderNav } from "@/components/BuilderNav";
|
||||
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 />
|
||||
<BuilderNav />
|
||||
<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>
|
||||
);
|
||||
}
|
||||
@@ -5,6 +5,7 @@ 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;
|
||||
@@ -556,7 +557,7 @@ export default function VaultBuildEditPage() {
|
||||
✕
|
||||
</button>
|
||||
|
||||
<img
|
||||
<Image
|
||||
src={p.url}
|
||||
alt="Pending upload"
|
||||
className="h-32 w-full object-cover"
|
||||
@@ -573,7 +574,7 @@ export default function VaultBuildEditPage() {
|
||||
key={p.uuid}
|
||||
className="overflow-hidden rounded-lg border border-white/10 bg-black/20"
|
||||
>
|
||||
<img
|
||||
<Image
|
||||
src={p.url}
|
||||
alt={p.caption ?? "Build photo"}
|
||||
className="h-32 w-full object-cover"
|
||||
|
||||
@@ -18,8 +18,7 @@ type BuildDto = {
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
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)
|
||||
|
||||
// UUID validator (defensive)
|
||||
const isUuid = (v: string) =>
|
||||
@@ -40,14 +39,14 @@ export default function VaultPage() {
|
||||
|
||||
// NOTE:
|
||||
// - `loading` here is AuthContext hydration, not network.
|
||||
// - `token` is the real gate for /me endpoints.
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
// - 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 && !!token;
|
||||
const authed = !!user;
|
||||
|
||||
// Redirect if not logged in (after auth hydrates)
|
||||
useEffect(() => {
|
||||
@@ -56,12 +55,12 @@ export default function VaultPage() {
|
||||
}
|
||||
}, [authLoading, authed, router]);
|
||||
|
||||
// When auth identity changes, clear stale list to avoid “ghost builds”
|
||||
// When auth identity changes, clear stale list to avoid "ghost builds"
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
setBuilds([]);
|
||||
setError(null);
|
||||
}, [authLoading, user?.uuid, token]);
|
||||
}, [authLoading, user?.uuid]);
|
||||
|
||||
// Fetch user builds
|
||||
useEffect(() => {
|
||||
@@ -75,8 +74,8 @@ export default function VaultPage() {
|
||||
|
||||
try {
|
||||
// IMPORTANT:
|
||||
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
|
||||
const res = await api.get("/api/v1/builds/me");
|
||||
// Use api wrapper which includes credentials (cookies) automatically.
|
||||
const res = await api.get("/api/builds");
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
@@ -241,7 +240,7 @@ export default function VaultPage() {
|
||||
}`}
|
||||
title={valid ? "Edit details / publish later" : "Invalid build id"}
|
||||
>
|
||||
Edit Title
|
||||
View / Edit
|
||||
</Link>
|
||||
</div>
|
||||
</li>
|
||||
|
||||
+397
-49
@@ -1,20 +1,111 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
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("1");
|
||||
const [limit, setLimit] = useState("25");
|
||||
const [tokenMinutes, setTokenMinutes] = useState("30");
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [result, setResult] = useState<any>(null);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [loadingBatch, setLoadingBatch] = useState(false);
|
||||
const [batchResult, setBatchResult] = useState<any>(null);
|
||||
const [batchError, setBatchError] = useState<string | null>(null);
|
||||
|
||||
async function runInvites() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
setResult(null);
|
||||
setLoadingBatch(true);
|
||||
setBatchError(null);
|
||||
setBatchResult(null);
|
||||
|
||||
try {
|
||||
const qs = new URLSearchParams({
|
||||
@@ -23,70 +114,327 @@ export default function AdminBetaInvitesPage() {
|
||||
tokenMinutes: String(tokenMinutes || "30"),
|
||||
});
|
||||
|
||||
const res = await fetch(`/api/admin/beta-invites?${qs.toString()}`, {
|
||||
const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
const data = await res.json().catch(() => ({}));
|
||||
if (!res.ok) throw new Error(data?.message || data?.error || "Request failed");
|
||||
|
||||
setResult(data);
|
||||
setBatchResult(data);
|
||||
await loadRequests();
|
||||
} catch (e: any) {
|
||||
setError(e?.message ?? "Failed");
|
||||
setBatchError(e?.message ?? "Failed");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
setLoadingBatch(false);
|
||||
}
|
||||
}
|
||||
|
||||
// ------------------------------
|
||||
// Requests table
|
||||
// ------------------------------
|
||||
const [page, setPage] = useState(0);
|
||||
const size = 25;
|
||||
|
||||
const [loadingList, setLoadingList] = useState(false);
|
||||
const [listError, setListError] = useState<string | null>(null);
|
||||
const [requests, setRequests] =
|
||||
useState<PageResponse<AdminBetaRequestDto> | null>(null);
|
||||
|
||||
// per-row loading state
|
||||
const [rowBusy, setRowBusy] = useState<Record<number, boolean>>({});
|
||||
|
||||
// last invite response (for visibility / debug)
|
||||
const [lastInvite, setLastInvite] = useState<AdminInviteResponse | null>(
|
||||
null
|
||||
);
|
||||
|
||||
async function loadRequests() {
|
||||
setLoadingList(true);
|
||||
setListError(null);
|
||||
|
||||
try {
|
||||
const data = (await fetchJson(
|
||||
`/api/admin/beta/requests?page=${page}&size=${size}`,
|
||||
{ method: "GET" }
|
||||
)) as PageResponse<AdminBetaRequestDto>;
|
||||
|
||||
setRequests(data);
|
||||
} catch (e: any) {
|
||||
setListError(e?.message ?? "Failed to load beta requests");
|
||||
} finally {
|
||||
setLoadingList(false);
|
||||
}
|
||||
}
|
||||
|
||||
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
|
||||
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
|
||||
return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
|
||||
method: "POST",
|
||||
})) as AdminInviteResponse;
|
||||
}
|
||||
|
||||
async function onSendInviteEmail(userId: number) {
|
||||
setListError(null);
|
||||
setLastInvite(null);
|
||||
setRowBusy((m) => ({ ...m, [userId]: true }));
|
||||
|
||||
try {
|
||||
const resp = await inviteSingle(userId);
|
||||
setLastInvite(resp);
|
||||
await loadRequests();
|
||||
} catch (e: any) {
|
||||
setListError(e?.message ?? "Failed to send invite");
|
||||
} finally {
|
||||
setRowBusy((m) => ({ ...m, [userId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
async function onCopyInviteLink(userId: number) {
|
||||
setListError(null);
|
||||
setLastInvite(null);
|
||||
setRowBusy((m) => ({ ...m, [userId]: true }));
|
||||
|
||||
try {
|
||||
const resp = await inviteSingle(userId);
|
||||
setLastInvite(resp);
|
||||
await loadRequests();
|
||||
|
||||
// build URL
|
||||
const url =
|
||||
resp.inviteUrl || // what your backend returns
|
||||
resp.magicUrl ||
|
||||
(resp.token
|
||||
? `${window.location.origin}/beta/magic?token=${resp.token}`
|
||||
: null);
|
||||
|
||||
if (!url) {
|
||||
throw new Error(
|
||||
`Invite response missing inviteUrl. Backend should return {"inviteUrl": "..."}`
|
||||
);
|
||||
}
|
||||
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch (e: any) {
|
||||
setListError(e?.message ?? "Failed to copy invite link");
|
||||
} finally {
|
||||
setRowBusy((m) => ({ ...m, [userId]: false }));
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
loadRequests();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold">Send Beta Invites</h1>
|
||||
<h1 className="text-xl font-semibold">Beta Access</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
Runs the backend batch invite sender (uses your email templates + tracking).
|
||||
Review pending beta requests and send invite links (batch or
|
||||
per-user).
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="rounded-lg border border-zinc-900 bg-zinc-950 p-4 space-y-4">
|
||||
<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-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>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-3">
|
||||
<Field label="Limit" htmlFor="limit">
|
||||
<Input id="limit" value={limit} onChange={(e) => setLimit(e.target.value)} />
|
||||
</Field>
|
||||
<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>
|
||||
|
||||
<Field label="Token minutes" htmlFor="tokenMinutes">
|
||||
<Input
|
||||
id="tokenMinutes"
|
||||
value={tokenMinutes}
|
||||
onChange={(e) => setTokenMinutes(e.target.value)}
|
||||
<label className="flex items-center gap-2 text-sm text-zinc-200">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={dryRun}
|
||||
onChange={(e) => setDryRun(e.target.checked)}
|
||||
/>
|
||||
</Field>
|
||||
Dry run (do everything except actually send)
|
||||
</label>
|
||||
|
||||
<div className="grid gap-4 md:grid-cols-2">
|
||||
<Field label="Limit" htmlFor="limit">
|
||||
<Input
|
||||
id="limit"
|
||||
value={limit}
|
||||
onChange={(e) => setLimit(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Token minutes" htmlFor="tokenMinutes">
|
||||
<Input
|
||||
id="tokenMinutes"
|
||||
value={tokenMinutes}
|
||||
onChange={(e) => setTokenMinutes(e.target.value)}
|
||||
/>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
<Button onClick={runInvites} disabled={loadingBatch}>
|
||||
{loadingBatch ? "Running…" : "Send Beta Invites"}
|
||||
</Button>
|
||||
|
||||
{batchError && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{batchError}
|
||||
</div>
|
||||
)}
|
||||
|
||||
{batchResult && (
|
||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||
{JSON.stringify(batchResult, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<Button onClick={runInvites} disabled={loading}>
|
||||
{loading ? "Running…" : "Send Beta Invites"}
|
||||
</Button>
|
||||
{/* 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>
|
||||
|
||||
{error && (
|
||||
<div className="rounded-md border border-red-500/40 bg-red-500/10 px-3 py-2 text-sm text-red-200">
|
||||
{error}
|
||||
<Button
|
||||
onClick={loadRequests}
|
||||
disabled={loadingList}
|
||||
className="w-auto px-6"
|
||||
>
|
||||
{loadingList ? "Refreshing…" : "Refresh"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{result && (
|
||||
<pre className="overflow-auto rounded-md border border-zinc-800 bg-black/40 p-3 text-xs text-zinc-200">
|
||||
{JSON.stringify(result, null, 2)}
|
||||
</pre>
|
||||
)}
|
||||
{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>
|
||||
);
|
||||
}
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import {
|
||||
fetchEmailRequests,
|
||||
createEmailRequest,
|
||||
@@ -17,7 +16,6 @@ import { formatDate } from "@/lib/dateFormattingUtility";
|
||||
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
|
||||
|
||||
export default function AdminEmailPage() {
|
||||
const { token } = useAuth();
|
||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -32,13 +30,11 @@ export default function AdminEmailPage() {
|
||||
|
||||
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
|
||||
|
||||
const loadEmails = async () => {
|
||||
if (!token) return;
|
||||
|
||||
const loadEmails = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchEmailRequests(token);
|
||||
setEmails(data.data);
|
||||
const data = await fetchEmailRequests();
|
||||
setEmails(data);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
@@ -46,11 +42,11 @@ export default function AdminEmailPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
}, [token]);
|
||||
}, [loadEmails]);
|
||||
|
||||
const normalizeStatus = (s: unknown) => String(s ?? "").trim().toUpperCase();
|
||||
|
||||
@@ -108,11 +104,10 @@ export default function AdminEmailPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!token) return;
|
||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||
|
||||
try {
|
||||
await deleteEmailRequest(token, id);
|
||||
await deleteEmailRequest(id);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to delete email request");
|
||||
@@ -121,17 +116,15 @@ export default function AdminEmailPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (modalMode === "create") {
|
||||
await createEmailRequest(token, formData);
|
||||
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(token, selectedEmail.id, updateData);
|
||||
await updateEmailRequest(selectedEmail.id, updateData);
|
||||
}
|
||||
setShowModal(false);
|
||||
await loadEmails();
|
||||
@@ -142,14 +135,6 @@ export default function AdminEmailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-500">
|
||||
You must be logged in to view this page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import {
|
||||
fetchEmailRequests,
|
||||
createEmailRequest,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
||||
import { formatDate } from "@/lib/dateFormattingUtility";
|
||||
export default function AdminEmailPage() {
|
||||
const { token } = useAuth();
|
||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -27,13 +25,11 @@ export default function AdminEmailPage() {
|
||||
});
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadEmails = async () => {
|
||||
if (!token) return;
|
||||
|
||||
const loadEmails = useCallback(async () => {
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchEmailRequests(token);
|
||||
setEmails(data.data);
|
||||
const data = await fetchEmailRequests();
|
||||
setEmails(data);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
@@ -41,11 +37,11 @@ export default function AdminEmailPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
}, [token]);
|
||||
}, [loadEmails]);
|
||||
|
||||
const handleCreate = () => {
|
||||
setModalMode("create");
|
||||
@@ -62,11 +58,10 @@ export default function AdminEmailPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!token) return;
|
||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||
|
||||
try {
|
||||
await deleteEmailRequest(token, id);
|
||||
await deleteEmailRequest(id);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to delete email request");
|
||||
@@ -75,17 +70,15 @@ export default function AdminEmailPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (modalMode === "create") {
|
||||
await createEmailRequest(token, formData);
|
||||
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(token, selectedEmail.id, updateData);
|
||||
await updateEmailRequest(selectedEmail.id, updateData);
|
||||
}
|
||||
setShowModal(false);
|
||||
await loadEmails();
|
||||
@@ -96,14 +89,6 @@ export default function AdminEmailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-500">
|
||||
You must be logged in to view this page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,7 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "/app/actions/sendEmail";
|
||||
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
|
||||
import { Button, Field, Input, Textarea } from "@/components/ui/form";
|
||||
|
||||
export default function SendEmailForm(): JSX.Element {
|
||||
|
||||
@@ -29,7 +29,7 @@ type QueueItem = {
|
||||
};
|
||||
|
||||
const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
function getAuthHeaders(): HeadersInit {
|
||||
if (typeof window === "undefined") return {};
|
||||
|
||||
@@ -0,0 +1,194 @@
|
||||
'use client'
|
||||
|
||||
import { useEffect, useState } from 'react'
|
||||
import { useAuth } from '@/context/AuthContext'
|
||||
|
||||
type FeedbackSubmission = {
|
||||
id: string
|
||||
category: 'BUG' | 'FEATURE_REQUEST' | 'GENERAL'
|
||||
message: string
|
||||
email: string | null
|
||||
// ipAddress is returned by the API but not displayed in the table (privacy/noise)
|
||||
reviewed: boolean
|
||||
createdAt: string
|
||||
}
|
||||
|
||||
const CATEGORY_LABELS: Record<string, string> = {
|
||||
BUG: 'Bug',
|
||||
FEATURE_REQUEST: 'Feature Request',
|
||||
GENERAL: 'General',
|
||||
}
|
||||
|
||||
const CATEGORY_STYLES: Record<string, string> = {
|
||||
BUG: 'bg-red-500/10 text-red-400 border border-red-500/20',
|
||||
FEATURE_REQUEST: 'bg-amber-500/10 text-amber-400 border border-amber-500/20',
|
||||
GENERAL: 'bg-zinc-800 text-zinc-400 border border-zinc-700',
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'short',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export default function AdminFeedbackPage() {
|
||||
const { getAuthHeaders } = useAuth()
|
||||
const [items, setItems] = useState<FeedbackSubmission[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [error, setError] = useState<string | null>(null)
|
||||
const [categoryFilter, setCategoryFilter] = useState('ALL')
|
||||
const [reviewedFilter, setReviewedFilter] = useState('ALL')
|
||||
|
||||
async function load() {
|
||||
try {
|
||||
setLoading(true)
|
||||
setError(null)
|
||||
const res = await fetch('/api/admin/feedback', {
|
||||
headers: getAuthHeaders(),
|
||||
credentials: 'include',
|
||||
})
|
||||
if (!res.ok) throw new Error(`Failed to load feedback (${res.status})`)
|
||||
setItems(await res.json())
|
||||
} catch (e: unknown) {
|
||||
setError(e instanceof Error ? e.message : 'Failed to load')
|
||||
} finally {
|
||||
setLoading(false)
|
||||
}
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
load()
|
||||
}, []) // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
async function toggleReviewed(item: FeedbackSubmission) {
|
||||
const prev = items
|
||||
setItems((cur) =>
|
||||
cur.map((i) => (i.id === item.id ? { ...i, reviewed: !i.reviewed } : i))
|
||||
)
|
||||
try {
|
||||
const res = await fetch(`/api/admin/feedback/${item.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ reviewed: !item.reviewed }),
|
||||
})
|
||||
if (!res.ok) setItems(prev)
|
||||
} catch {
|
||||
setItems(prev)
|
||||
}
|
||||
}
|
||||
|
||||
const filtered = items.filter((i) => {
|
||||
if (categoryFilter !== 'ALL' && i.category !== categoryFilter) return false
|
||||
if (reviewedFilter === 'REVIEWED' && !i.reviewed) return false
|
||||
if (reviewedFilter === 'UNREVIEWED' && i.reviewed) return false
|
||||
return true
|
||||
})
|
||||
|
||||
return (
|
||||
<div>
|
||||
<div className="mb-6 flex items-center justify-between">
|
||||
<div>
|
||||
<h1 className="text-xl font-semibold tracking-tight">Feedback</h1>
|
||||
<p className="mt-1 text-sm text-zinc-400">
|
||||
User-submitted feedback. Mark items reviewed to track triage.
|
||||
</p>
|
||||
</div>
|
||||
<button
|
||||
onClick={load}
|
||||
className="rounded-md border border-zinc-800 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="mb-6 flex gap-3">
|
||||
<select
|
||||
value={categoryFilter}
|
||||
onChange={(e) => setCategoryFilter(e.target.value)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300"
|
||||
>
|
||||
<option value="ALL">All categories</option>
|
||||
<option value="BUG">Bug</option>
|
||||
<option value="FEATURE_REQUEST">Feature Request</option>
|
||||
<option value="GENERAL">General</option>
|
||||
</select>
|
||||
<select
|
||||
value={reviewedFilter}
|
||||
onChange={(e) => setReviewedFilter(e.target.value)}
|
||||
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300"
|
||||
>
|
||||
<option value="ALL">All statuses</option>
|
||||
<option value="UNREVIEWED">Unreviewed</option>
|
||||
<option value="REVIEWED">Reviewed</option>
|
||||
</select>
|
||||
<span className="ml-auto self-center text-xs text-zinc-600">
|
||||
{filtered.length} of {items.length} submissions
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{loading && <p className="text-sm text-zinc-500">Loading…</p>}
|
||||
{error && <p className="text-sm text-red-400">{error}</p>}
|
||||
|
||||
{!loading && !error && filtered.length === 0 && (
|
||||
<p className="text-sm text-zinc-500">No submissions match your filters.</p>
|
||||
)}
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div className="overflow-x-auto rounded-xl border border-zinc-800">
|
||||
<table className="w-full text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 text-left text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
|
||||
<th className="px-4 py-3">Date</th>
|
||||
<th className="px-4 py-3">Category</th>
|
||||
<th className="px-4 py-3">Message</th>
|
||||
<th className="px-4 py-3">Email</th>
|
||||
<th className="px-4 py-3">Reviewed</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody className="divide-y divide-zinc-900">
|
||||
{filtered.map((item) => (
|
||||
<tr key={item.id} className="hover:bg-zinc-950/60">
|
||||
<td className="whitespace-nowrap px-4 py-3 text-xs text-zinc-500">
|
||||
{formatDate(item.createdAt)}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<span
|
||||
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
|
||||
CATEGORY_STYLES[item.category]
|
||||
}`}
|
||||
>
|
||||
{CATEGORY_LABELS[item.category]}
|
||||
</span>
|
||||
</td>
|
||||
<td className="max-w-sm px-4 py-3 text-xs text-zinc-300">
|
||||
{item.message.length > 120
|
||||
? `${item.message.slice(0, 120)}…`
|
||||
: item.message}
|
||||
</td>
|
||||
<td className="px-4 py-3 text-xs text-zinc-500">
|
||||
{item.email ?? '—'}
|
||||
</td>
|
||||
<td className="px-4 py-3">
|
||||
<button
|
||||
onClick={() => toggleReviewed(item)}
|
||||
className={`rounded px-2 py-0.5 text-[10px] font-semibold transition-colors ${
|
||||
item.reviewed
|
||||
? 'bg-green-500/10 text-green-400 hover:bg-red-500/10 hover:text-red-400'
|
||||
: 'bg-zinc-800 text-zinc-500 hover:bg-green-500/10 hover:text-green-400'
|
||||
}`}
|
||||
>
|
||||
{item.reviewed ? 'Reviewed' : 'Mark reviewed'}
|
||||
</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)}
|
||||
</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',
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+116
-45
@@ -3,7 +3,9 @@
|
||||
import type React from "react";
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { usePathname, useRouter } from "next/navigation";
|
||||
import AdminLeftNavigation from "@/components/AdminLeftNavigation";
|
||||
import AdminLeftNavigation, {
|
||||
type AdminNavGroup,
|
||||
} from "@/components/AdminLeftNavigation";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
LayoutDashboard,
|
||||
@@ -13,24 +15,109 @@ import {
|
||||
Store,
|
||||
Users,
|
||||
Settings,
|
||||
LucideMail,
|
||||
Mail,
|
||||
Wand2,
|
||||
FolderKanban,
|
||||
Shield,
|
||||
MessageSquare,
|
||||
} from "lucide-react";
|
||||
|
||||
const navItems = [
|
||||
{ label: "Dashboard", href: "/admin", icon: <LayoutDashboard className="h-4 w-4" /> },
|
||||
{ label: "Imports", href: "/admin/import-status", icon: <Download className="h-4 w-4" /> },
|
||||
{ label: "Mappings", href: "/admin/mapping", icon: <Layers className="h-4 w-4" /> },
|
||||
{ label: "Products", href: "/admin/products", icon: <Boxes className="h-4 w-4" /> },
|
||||
{ label: "Merchants", href: "/admin/merchants", icon: <Store className="h-4 w-4" /> },
|
||||
{ label: "Platforms", href: "/admin/platforms", icon: <Layers className="h-4 w-4" /> },
|
||||
{ label: "Enrichment", href: "/admin/enrichment", icon: <Wand2 className="h-4 w-4" /> },
|
||||
{ label: "Users", href: "/admin/users", icon: <Users className="h-4 w-4" /> },
|
||||
{ label: "Settings", href: "/admin/settings", icon: <Settings className="h-4 w-4" /> },
|
||||
{ label: "List Emails", href: "/admin/email", icon: <LucideMail className="h-4 w-4" /> },
|
||||
{ label: "Send an Email", href: "/admin/email/send", icon: <LucideMail className="h-4 w-4" /> },
|
||||
{ label: "Beta Invites", href: "/admin/beta-invites", icon: <LucideMail className="h-4 w-4" /> },
|
||||
|
||||
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: "Feedback",
|
||||
href: "/admin/feedback",
|
||||
icon: <MessageSquare 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({
|
||||
@@ -44,72 +131,56 @@ export default function AdminLayout({
|
||||
const router = useRouter();
|
||||
const pathname = usePathname();
|
||||
|
||||
// Where to send people back after login
|
||||
const next = useMemo(
|
||||
() => encodeURIComponent(pathname || "/admin"),
|
||||
[pathname]
|
||||
);
|
||||
|
||||
/**
|
||||
* ✅ AUTH GUARD
|
||||
* Redirects happen in useEffect (NOT during render)
|
||||
*/
|
||||
useEffect(() => {
|
||||
if (loading) return;
|
||||
|
||||
// Not logged in
|
||||
if (!user) {
|
||||
router.replace(`/login?next=${next}`);
|
||||
return;
|
||||
}
|
||||
|
||||
// Logged in but not admin
|
||||
if (user.role !== "ADMIN") {
|
||||
router.replace("/");
|
||||
}
|
||||
}, [loading, user, router, next]);
|
||||
|
||||
// While loading OR redirecting, render nothing
|
||||
if (loading) return null;
|
||||
if (!user) return null;
|
||||
if (user.role !== "ADMIN") return null;
|
||||
|
||||
return (
|
||||
<div className="flex min-h-screen bg-black text-zinc-50">
|
||||
// ✅ prevent browser-level horizontal scroll from any wide children
|
||||
<div className="h-screen bg-black text-zinc-50 overflow-hidden">
|
||||
<AdminLeftNavigation
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||
items={navItems}
|
||||
groups={navGroups}
|
||||
/>
|
||||
|
||||
{/* Main column */}
|
||||
<div className="flex min-h-screen flex-1 flex-col">
|
||||
{/* Top bar */}
|
||||
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3">
|
||||
{/* ✅ 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 Builders Control Panel
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-3">
|
||||
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
|
||||
Internal • v0.1
|
||||
</span>
|
||||
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
|
||||
ADMIN
|
||||
</div>
|
||||
<p className="text-sm text-zinc-200">Battl Control Panel</p>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Content */}
|
||||
<main className="mx-auto flex w-full max-w-6xl flex-1 flex-col px-4 py-6">
|
||||
{/* ✅ 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>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
+546
-86
@@ -1,10 +1,21 @@
|
||||
"use client";
|
||||
|
||||
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;
|
||||
@@ -14,8 +25,47 @@ type PendingBucket = {
|
||||
};
|
||||
|
||||
/**
|
||||
* Canonical part roles (kebab-case) — should match what the importer normalizes to.
|
||||
* Keep this list tight + intentional so mappings don't create junk roles in the DB.
|
||||
* 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;
|
||||
|
||||
rawCategoryKey: string;
|
||||
productCount: number;
|
||||
|
||||
// current mapping state (merchant_category_map row)
|
||||
mcmId?: number | null;
|
||||
enabled?: boolean | null;
|
||||
|
||||
canonicalPartRole?: string | null;
|
||||
|
||||
canonicalCategoryId?: number | null;
|
||||
canonicalCategoryName?: string | null;
|
||||
};
|
||||
|
||||
type CanonicalCategoryOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
slug?: string | null;
|
||||
};
|
||||
|
||||
type MerchantOption = {
|
||||
id: number;
|
||||
name: string;
|
||||
};
|
||||
|
||||
type MappingOptionsResponse = {
|
||||
merchants: MerchantOption[];
|
||||
canonicalCategories: CanonicalCategoryOption[];
|
||||
};
|
||||
|
||||
const DEFAULT_PLATFORM = "AR-15";
|
||||
|
||||
/**
|
||||
* Canonical part roles (kebab-case)
|
||||
*/
|
||||
const PART_ROLE_OPTIONS = [
|
||||
// Assemblies / receivers
|
||||
@@ -56,23 +106,62 @@ const PART_ROLE_OPTIONS = [
|
||||
"tools",
|
||||
] as const;
|
||||
|
||||
type PartRole = (typeof PART_ROLE_OPTIONS)[number];
|
||||
|
||||
function toLabel(role: string) {
|
||||
// "complete-upper" -> "Complete Upper"
|
||||
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);
|
||||
|
||||
const options = useMemo(
|
||||
// Options (for catalog tab + merchant dropdown)
|
||||
const [optionsLoading, setOptionsLoading] = useState<boolean>(false);
|
||||
const [merchants, setMerchants] = useState<MerchantOption[]>([]);
|
||||
const [canonicalCategories, setCanonicalCategories] = useState<
|
||||
CanonicalCategoryOption[]
|
||||
>([]);
|
||||
|
||||
// Rows
|
||||
const [roleBuckets, setRoleBuckets] = useState<PendingBucket[]>([]);
|
||||
const [rawRows, setRawRows] = useState<RawCategoryRow[]>([]);
|
||||
|
||||
// Saving state
|
||||
const [savingKey, setSavingKey] = useState<string | null>(null);
|
||||
|
||||
// Part role dropdown options
|
||||
const roleOptions = useMemo(
|
||||
() =>
|
||||
PART_ROLE_OPTIONS.map((value) => ({
|
||||
value,
|
||||
@@ -81,16 +170,85 @@ export default function MappingAdminPage() {
|
||||
[]
|
||||
);
|
||||
|
||||
// Keep URL in sync (so reconcile can deep-link)
|
||||
function syncUrl(next: Partial<{ tab: TabKey; merchantId: string; platform: string; q: string }>) {
|
||||
const params = new URLSearchParams(searchParams.toString());
|
||||
if (next.tab) params.set("tab", next.tab);
|
||||
if (next.merchantId !== undefined) {
|
||||
if (next.merchantId) params.set("merchantId", next.merchantId);
|
||||
else params.delete("merchantId");
|
||||
}
|
||||
if (next.platform !== undefined) {
|
||||
if (next.platform) params.set("platform", next.platform);
|
||||
else params.delete("platform");
|
||||
}
|
||||
if (next.q !== undefined) {
|
||||
if (next.q) params.set("q", next.q);
|
||||
else params.delete("q");
|
||||
}
|
||||
router.replace(`/admin/mapping?${params.toString()}`);
|
||||
}
|
||||
|
||||
// Load merchants + canonical categories (needed for catalog tab, but also nice for filtering everywhere)
|
||||
useEffect(() => {
|
||||
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`,
|
||||
{ headers: { Accept: "application/json" } }
|
||||
);
|
||||
// EXPECTED backend endpoint (new):
|
||||
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
|
||||
const res = await fetch('/api/admin/mapping/options', {
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: 'include',
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
// Don’t hard-fail the whole page if options aren’t wired yet.
|
||||
// Roles tab can still function using pending-buckets.
|
||||
const txt = await res.text().catch(() => "");
|
||||
console.warn("options endpoint not ready:", res.status, txt);
|
||||
return;
|
||||
}
|
||||
|
||||
const json = (await res.json()) as MappingOptionsResponse;
|
||||
setMerchants(json.merchants ?? []);
|
||||
setCanonicalCategories(json.canonicalCategories ?? []);
|
||||
} catch (e) {
|
||||
console.warn("Failed to load mapping options:", e);
|
||||
} finally {
|
||||
setOptionsLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadOptions();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, []);
|
||||
|
||||
// Default merchant if not provided and merchants list exists
|
||||
useEffect(() => {
|
||||
if (!merchantId && merchants.length > 0) {
|
||||
setMerchantId(String(merchants[0].id));
|
||||
syncUrl({ merchantId: String(merchants[0].id) });
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [merchants]);
|
||||
|
||||
// Core loader: depends on tab
|
||||
async function load() {
|
||||
setLoading(true);
|
||||
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(() => "");
|
||||
@@ -100,40 +258,71 @@ export default function MappingAdminPage() {
|
||||
}
|
||||
|
||||
const data: PendingBucket[] = await res.json();
|
||||
setRows(data);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
setError(e.message ?? "Failed to load pending buckets");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
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) => {
|
||||
// Always store normalized kebab-case (defensive)
|
||||
const normalized = value
|
||||
? value.trim().toLowerCase().replace(/_/g, "-")
|
||||
: "";
|
||||
|
||||
setRows((prev) => {
|
||||
const normalized = value ? normalizeRole(value) : "";
|
||||
setRoleBuckets((prev) => {
|
||||
const next = [...prev];
|
||||
next[idx] = { ...next[idx], mappedPartRole: normalized || null };
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const handleSave = async (row: PendingBucket) => {
|
||||
const handleSaveRole = async (row: PendingBucket) => {
|
||||
if (!row.mappedPartRole) return;
|
||||
|
||||
const mapped = row.mappedPartRole
|
||||
.trim()
|
||||
.toLowerCase()
|
||||
.replace(/_/g, "-");
|
||||
const mapped = normalizeRole(row.mappedPartRole);
|
||||
|
||||
// Basic guard: prevent saving random roles from stale data
|
||||
const allowed = new Set<string>(PART_ROLE_OPTIONS as readonly string[]);
|
||||
if (!allowed.has(mapped)) {
|
||||
setError(
|
||||
@@ -142,14 +331,17 @@ export default function MappingAdminPage() {
|
||||
return;
|
||||
}
|
||||
|
||||
const key = `${row.merchantId}-${row.rawCategoryKey}`;
|
||||
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,
|
||||
@@ -164,8 +356,7 @@ export default function MappingAdminPage() {
|
||||
);
|
||||
}
|
||||
|
||||
// After save, remove this bucket from the list
|
||||
setRows((prev) =>
|
||||
setRoleBuckets((prev) =>
|
||||
prev.filter(
|
||||
(r) =>
|
||||
!(
|
||||
@@ -176,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>
|
||||
|
||||
@@ -223,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 (
|
||||
@@ -250,25 +615,17 @@ 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-200">
|
||||
{row.productCount}
|
||||
</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>
|
||||
{options.map((opt) => (
|
||||
{roleOptions.map((opt) => (
|
||||
<option key={opt.value} value={opt.value}>
|
||||
{opt.label} ({opt.value})
|
||||
</option>
|
||||
@@ -277,7 +634,7 @@ export default function MappingAdminPage() {
|
||||
</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"
|
||||
>
|
||||
@@ -292,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 (
|
||||
|
||||
+461
-88
@@ -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,6 +67,19 @@ 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(() => {
|
||||
@@ -41,7 +90,7 @@ export default function MerchantsAdminPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `${API_BASE_URL}/api/admin/merchants`;
|
||||
const url = `/api/admin/merchants`;
|
||||
console.log("Loading merchants from:", url);
|
||||
|
||||
const res = await fetch(url, {
|
||||
@@ -81,6 +130,15 @@ export default function MerchantsAdminPage() {
|
||||
);
|
||||
};
|
||||
|
||||
// --- copy URL handler ---
|
||||
const handleCopyUrl = async (url: string, id: number) => {
|
||||
const success = await copyToClipboard(url);
|
||||
if (success) {
|
||||
setCopiedUrl(`${id}-${url}`);
|
||||
setTimeout(() => setCopiedUrl(null), 2000);
|
||||
}
|
||||
};
|
||||
|
||||
// --- save merchant (name/feed URLs/isActive) ---
|
||||
const handleSave = async (id: number) => {
|
||||
const merchant = merchants.find((m) => m.id === id);
|
||||
@@ -91,7 +149,7 @@ export default function MerchantsAdminPage() {
|
||||
setError(null);
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, {
|
||||
const res = await fetch(`/api/admin/merchants/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -124,12 +182,13 @@ 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}/api/admin/imports/${id}`, {
|
||||
const res = await fetch(`/api/admin/merchants/${id}/import`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -151,13 +210,14 @@ export default function MerchantsAdminPage() {
|
||||
|
||||
// --- run offer sync ---
|
||||
const handleRunOfferSync = async (id: number) => {
|
||||
setOpenDropdown(null);
|
||||
try {
|
||||
setOfferSyncId(id);
|
||||
setError(null);
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`,
|
||||
`/api/admin/merchants/${id}/offer-sync`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
|
||||
@@ -179,9 +239,59 @@ export default function MerchantsAdminPage() {
|
||||
}
|
||||
};
|
||||
|
||||
// --- create new merchant ---
|
||||
const handleCreateMerchant = async () => {
|
||||
try {
|
||||
setCreating(true);
|
||||
setError(null);
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(`/api/admin/merchants`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
name: newMerchant.name,
|
||||
avantlinkMid: newMerchant.avantlinkMid,
|
||||
feedUrl: newMerchant.feedUrl,
|
||||
offerFeedUrl: newMerchant.offerFeedUrl || null,
|
||||
isActive: newMerchant.isActive,
|
||||
}),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
console.error("Merchant creation failed", res.status, text);
|
||||
throw new Error(
|
||||
`Creation failed (${res.status})${text ? `: ${text}` : ""}`
|
||||
);
|
||||
}
|
||||
|
||||
const created: MerchantAdminDto = await res.json();
|
||||
setMerchants((prev) => [...prev, created]);
|
||||
|
||||
// Reset form
|
||||
setNewMerchant({
|
||||
name: "",
|
||||
avantlinkMid: "",
|
||||
feedUrl: "",
|
||||
offerFeedUrl: "",
|
||||
isActive: true,
|
||||
});
|
||||
setShowNewMerchantForm(false);
|
||||
|
||||
setBanner("Merchant created successfully.");
|
||||
} catch (e: any) {
|
||||
console.error("Error creating merchant", e);
|
||||
setError(e?.message ?? "Failed to create merchant");
|
||||
} finally {
|
||||
setCreating(false);
|
||||
setTimeout(() => setBanner(null), 3000);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<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>
|
||||
@@ -211,135 +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)
|
||||
}
|
||||
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"}
|
||||
{savingId === m.id ? (
|
||||
<span className="flex items-center gap-1">
|
||||
<svg className="animate-spin h-3 w-3" fill="none" viewBox="0 0 24 24">
|
||||
<circle className="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" strokeWidth="4" />
|
||||
<path className="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4zm2 5.291A7.962 7.962 0 014 12H0c0 3.042 1.135 5.824 3 7.938l3-2.647z" />
|
||||
</svg>
|
||||
Saving
|
||||
</span>
|
||||
) : (
|
||||
"Save"
|
||||
)}
|
||||
</button>
|
||||
|
||||
<button
|
||||
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>
|
||||
{/* 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>
|
||||
|
||||
<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"}
|
||||
</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>
|
||||
@@ -352,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})`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Pencil, Trash2, X } from "lucide-react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
createPlatform,
|
||||
deletePlatform,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
} from "@/lib/api/platforms";
|
||||
|
||||
export default function AdminPlatformsPage() {
|
||||
const { getAuthHeaders } = useAuth();
|
||||
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -36,7 +34,7 @@ export default function AdminPlatformsPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchPlatforms(getAuthHeaders());
|
||||
const data = await fetchPlatforms({});
|
||||
setPlatforms(data);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
@@ -71,12 +69,13 @@ export default function AdminPlatformsPage() {
|
||||
}
|
||||
|
||||
async function onDelete(p: Platform) {
|
||||
if (!window.confirm(`Delete platform "${p.key}"? This cannot be undone.`)) return;
|
||||
if (!window.confirm(`Delete platform "${p.key}"? This cannot be undone.`))
|
||||
return;
|
||||
|
||||
try {
|
||||
setSaving(true);
|
||||
setError(null);
|
||||
await deletePlatform(getAuthHeaders(), p.id);
|
||||
await deletePlatform({}, p.id);
|
||||
await load();
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
@@ -102,14 +101,15 @@ export default function AdminPlatformsPage() {
|
||||
setError(null);
|
||||
|
||||
if (modalMode === "create") {
|
||||
await createPlatform(getAuthHeaders(), { ...form, key, label });
|
||||
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;
|
||||
if ((form.isActive ?? true) !== selected.isActive)
|
||||
update.isActive = form.isActive;
|
||||
|
||||
await updatePlatform(getAuthHeaders(), selected.id, update);
|
||||
await updatePlatform({}, selected.id, update);
|
||||
}
|
||||
|
||||
setShowModal(false);
|
||||
@@ -164,7 +164,9 @@ export default function AdminPlatformsPage() {
|
||||
<th className="border-b border-zinc-900 px-3 py-2">Key</th>
|
||||
<th className="border-b border-zinc-900 px-3 py-2">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>
|
||||
<th className="border-b border-zinc-900 px-3 py-2 text-right">
|
||||
Actions
|
||||
</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
@@ -245,7 +247,15 @@ export default function AdminPlatformsPage() {
|
||||
</label>
|
||||
<input
|
||||
value={form.key}
|
||||
onChange={(e) => setForm((s) => ({ ...s, key: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({
|
||||
...s,
|
||||
key: e.target.value
|
||||
.toUpperCase()
|
||||
.replace(/\s+/g, "") // remove spaces
|
||||
.replace(/-/g, ""),
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="AR15"
|
||||
required
|
||||
@@ -258,7 +268,9 @@ export default function AdminPlatformsPage() {
|
||||
</label>
|
||||
<input
|
||||
value={form.label}
|
||||
onChange={(e) => setForm((s) => ({ ...s, label: e.target.value }))}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, label: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-800 bg-zinc-900 px-3 py-2 text-sm text-zinc-100 placeholder-zinc-500 focus:border-amber-400/80 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
placeholder="AR-15"
|
||||
required
|
||||
@@ -269,7 +281,9 @@ export default function AdminPlatformsPage() {
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={!!form.isActive}
|
||||
onChange={(e) => setForm((s) => ({ ...s, isActive: e.target.checked }))}
|
||||
onChange={(e) =>
|
||||
setForm((s) => ({ ...s, isActive: e.target.checked }))
|
||||
}
|
||||
className="h-4 w-4 rounded border-zinc-700 bg-zinc-900 text-amber-400"
|
||||
/>
|
||||
Active
|
||||
@@ -289,7 +303,11 @@ export default function AdminPlatformsPage() {
|
||||
className="flex-1 rounded-md bg-emerald-600 px-4 py-2 text-xs font-medium text-white hover:bg-emerald-500 disabled:opacity-50"
|
||||
disabled={saving}
|
||||
>
|
||||
{saving ? "Saving…" : modalMode === "create" ? "Create" : "Update"}
|
||||
{saving
|
||||
? "Saving…"
|
||||
: modalMode === "create"
|
||||
? "Create"
|
||||
: "Update"}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
|
||||
@@ -0,0 +1,366 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useMemo, useState } from "react";
|
||||
import type { CaliberDto, PlatformDto } from "../_lib/types";
|
||||
import { RightDrawer } from "./RightDrawer";
|
||||
|
||||
type ProductVisibility = "PUBLIC" | "HIDDEN";
|
||||
type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
|
||||
|
||||
type BulkUpdateSet = Partial<{
|
||||
visibility: ProductVisibility;
|
||||
status: ProductStatus;
|
||||
builderEligible: boolean;
|
||||
adminLocked: boolean;
|
||||
adminNote: string;
|
||||
|
||||
platform: string;
|
||||
platformLocked: boolean;
|
||||
|
||||
caliber: string;
|
||||
caliberGroup: string;
|
||||
caliberLocked: boolean;
|
||||
forceCaliberUpdate: boolean;
|
||||
|
||||
partRole: string;
|
||||
partRoleLocked: boolean;
|
||||
|
||||
classificationReason: "Admin bulk override";
|
||||
}>;
|
||||
|
||||
export function BulkBar(props: {
|
||||
selectedCount: number;
|
||||
loading: boolean;
|
||||
|
||||
// options
|
||||
platformOptions: PlatformDto[];
|
||||
roleOptions: string[];
|
||||
caliberOptions: CaliberDto[];
|
||||
|
||||
// state
|
||||
bulkPlatformKey: string;
|
||||
setBulkPlatformKey: (v: string) => void;
|
||||
bulkPlatformLock: boolean;
|
||||
setBulkPlatformLock: (v: boolean) => void;
|
||||
|
||||
bulkRole: string;
|
||||
setBulkRole: (v: string) => void;
|
||||
bulkRoleLock: boolean;
|
||||
setBulkRoleLock: (v: boolean) => void;
|
||||
|
||||
bulkCaliber: string;
|
||||
setBulkCaliber: (v: string) => void;
|
||||
bulkCaliberGroup: string;
|
||||
setBulkCaliberGroup: (v: string) => void;
|
||||
bulkCaliberLock: boolean;
|
||||
setBulkCaliberLock: (v: boolean) => void;
|
||||
bulkForceCaliber: boolean;
|
||||
setBulkForceCaliber: (v: boolean) => void;
|
||||
|
||||
// action
|
||||
runBulk: (set: BulkUpdateSet) => void;
|
||||
}) {
|
||||
const {
|
||||
selectedCount,
|
||||
loading,
|
||||
platformOptions,
|
||||
roleOptions,
|
||||
caliberOptions,
|
||||
|
||||
bulkPlatformKey,
|
||||
setBulkPlatformKey,
|
||||
bulkPlatformLock,
|
||||
setBulkPlatformLock,
|
||||
|
||||
bulkRole,
|
||||
setBulkRole,
|
||||
bulkRoleLock,
|
||||
setBulkRoleLock,
|
||||
|
||||
bulkCaliber,
|
||||
setBulkCaliber,
|
||||
bulkCaliberGroup,
|
||||
setBulkCaliberGroup,
|
||||
bulkCaliberLock,
|
||||
setBulkCaliberLock,
|
||||
bulkForceCaliber,
|
||||
setBulkForceCaliber,
|
||||
|
||||
runBulk,
|
||||
} = props;
|
||||
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const disabled = loading || selectedCount <= 0;
|
||||
|
||||
// one button to apply staged field updates
|
||||
const applyFieldChanges = useCallback(() => {
|
||||
const patch: BulkUpdateSet = { classificationReason: "Admin bulk override" };
|
||||
|
||||
if (bulkPlatformKey) {
|
||||
patch.platform = bulkPlatformKey;
|
||||
if (bulkPlatformLock) patch.platformLocked = true;
|
||||
}
|
||||
|
||||
if (bulkRole) {
|
||||
patch.partRole = bulkRole;
|
||||
patch.partRoleLocked = bulkRoleLock;
|
||||
}
|
||||
|
||||
if (bulkCaliber) patch.caliber = bulkCaliber;
|
||||
if (bulkCaliberGroup.trim()) patch.caliberGroup = bulkCaliberGroup.trim();
|
||||
if (bulkCaliber || bulkCaliberGroup.trim()) {
|
||||
patch.caliberLocked = bulkCaliberLock;
|
||||
patch.forceCaliberUpdate = bulkForceCaliber;
|
||||
}
|
||||
|
||||
// If user didn't stage anything, do nothing
|
||||
const keys = Object.keys(patch).filter((k) => k !== "classificationReason");
|
||||
if (keys.length === 0) return;
|
||||
|
||||
runBulk(patch);
|
||||
}, [bulkPlatformKey, bulkPlatformLock, bulkRole, bulkRoleLock, bulkCaliber, bulkCaliberGroup, bulkCaliberLock, bulkForceCaliber, runBulk]);
|
||||
|
||||
if (selectedCount <= 0) return null;
|
||||
|
||||
let footer: JSX.Element;
|
||||
// eslint-disable-next-line react-hooks/rules-of-hooks
|
||||
footer = useMemo(() => {
|
||||
return (
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<div className="text-sm text-white/60">
|
||||
Selected: <span className="font-semibold text-white">{selectedCount}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="rounded-md bg-emerald-600 px-3 py-2 text-sm font-semibold hover:bg-emerald-500 disabled:opacity-50"
|
||||
disabled={disabled}
|
||||
onClick={() => {
|
||||
applyFieldChanges();
|
||||
setOpen(false);
|
||||
}}
|
||||
>
|
||||
Apply changes
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}, [applyFieldChanges, disabled, selectedCount]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Tiny inline bar */}
|
||||
<div className="mb-3 flex items-center justify-between rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
|
||||
<div className="text-sm opacity-80">
|
||||
Selected: <span className="font-semibold">{selectedCount}</span>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Bulk actions
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Drawer: all bulk controls */}
|
||||
<RightDrawer
|
||||
open={open}
|
||||
onClose={setOpen}
|
||||
title="Bulk actions"
|
||||
widthClassName="max-w-lg"
|
||||
footer={footer}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Set fields */}
|
||||
<section className="rounded-lg border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-3 text-xs font-semibold uppercase tracking-wide text-white/60">
|
||||
Set fields
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
{/* Platform */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs text-white/60">Platform</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={bulkPlatformKey}
|
||||
onChange={(e) => setBulkPlatformKey(e.target.value)}
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— No change —</option>
|
||||
{platformOptions.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkPlatformLock}
|
||||
onChange={(e) => setBulkPlatformLock(e.target.checked)}
|
||||
/>
|
||||
Lock
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Role */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs text-white/60">Role</label>
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={bulkRole}
|
||||
onChange={(e) => setBulkRole(e.target.value)}
|
||||
className="flex-1 rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— No change —</option>
|
||||
{roleOptions.filter(Boolean).map((r) => (
|
||||
<option key={r} value={r}>
|
||||
{r}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkRoleLock}
|
||||
onChange={(e) => setBulkRoleLock(e.target.checked)}
|
||||
/>
|
||||
Lock
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Caliber */}
|
||||
<div className="grid gap-2">
|
||||
<label className="text-xs text-white/60">Caliber</label>
|
||||
|
||||
<select
|
||||
value={bulkCaliber}
|
||||
onChange={(e) => setBulkCaliber(e.target.value)}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">— No change —</option>
|
||||
{caliberOptions
|
||||
.filter((c) => Boolean(c && c.isActive && c.key))
|
||||
.map((c) => (
|
||||
<option key={c.id ?? c.key} value={c.key}>
|
||||
{c.label || c.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<input
|
||||
value={bulkCaliberGroup}
|
||||
onChange={(e) => setBulkCaliberGroup(e.target.value)}
|
||||
placeholder="Caliber group (optional)"
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
/>
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<label className="flex items-center gap-2 text-xs text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkCaliberLock}
|
||||
onChange={(e) => setBulkCaliberLock(e.target.checked)}
|
||||
/>
|
||||
Lock caliber
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2 text-xs text-white/70">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={bulkForceCaliber}
|
||||
onChange={(e) => setBulkForceCaliber(e.target.checked)}
|
||||
/>
|
||||
Force update locked
|
||||
</label>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 text-xs text-white/40">
|
||||
Tip: stage changes above, then use <span className="text-white/70">Apply changes</span>.
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Quick actions */}
|
||||
<section className="rounded-lg border border-white/10 bg-white/5 p-4">
|
||||
<div className="mb-3 text-xs font-semibold uppercase tracking-wide text-white/60">
|
||||
Quick actions
|
||||
</div>
|
||||
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => runBulk({ builderEligible: false })}
|
||||
>
|
||||
Remove from Builder
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => runBulk({ visibility: "HIDDEN" })}
|
||||
>
|
||||
Hide
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => runBulk({ status: "DISABLED" })}
|
||||
>
|
||||
Disable
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => runBulk({ adminLocked: true })}
|
||||
>
|
||||
Lock
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() => runBulk({ adminLocked: false })}
|
||||
>
|
||||
Unlock
|
||||
</button>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
{/* Danger zone */}
|
||||
<section className="rounded-lg border border-red-900/40 bg-red-950/20 p-4">
|
||||
<div className="mb-2 text-xs font-semibold uppercase tracking-wide text-red-200">
|
||||
Danger zone
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="rounded-md bg-red-600/80 px-3 py-2 text-sm font-semibold hover:bg-red-600 disabled:opacity-50"
|
||||
disabled={loading}
|
||||
onClick={() =>
|
||||
runBulk({
|
||||
visibility: "HIDDEN",
|
||||
builderEligible: false,
|
||||
adminLocked: true,
|
||||
adminNote: "Hidden + removed from builder (bulk)",
|
||||
})
|
||||
}
|
||||
title="Hides products, removes from builder, and locks editing"
|
||||
>
|
||||
Hide + Remove + Lock
|
||||
</button>
|
||||
</section>
|
||||
</div>
|
||||
</RightDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,440 @@
|
||||
/* eslint-disable */
|
||||
"use client";
|
||||
|
||||
import type { AdminFilters, PlatformDto, CaliberDto } from "../_lib/types";
|
||||
import { useMemo, useState } from "react";
|
||||
import { RightDrawer } from "./RightDrawer";
|
||||
import { Field } from "./ui";
|
||||
|
||||
type VisibleCols = {
|
||||
brand: boolean;
|
||||
platform: boolean;
|
||||
role: boolean;
|
||||
caliber: boolean;
|
||||
import: boolean;
|
||||
flags: boolean;
|
||||
updated: boolean;
|
||||
};
|
||||
|
||||
function countActiveFilters(draft: AdminFilters) {
|
||||
const entries: [string, any][] = [
|
||||
["platform", draft.platform],
|
||||
["partRole", draft.partRole],
|
||||
["caliber", draft.caliber],
|
||||
["visibility", draft.visibility],
|
||||
["status", draft.status],
|
||||
["builderEligible", draft.builderEligible],
|
||||
["adminLocked", draft.adminLocked],
|
||||
];
|
||||
return entries.filter(([, v]) => v !== "" && v != null).length;
|
||||
}
|
||||
|
||||
export default function ProductsToolbar({
|
||||
draft,
|
||||
setDraft,
|
||||
platformOptions,
|
||||
roleOptions,
|
||||
caliberOptions,
|
||||
loading,
|
||||
onApply,
|
||||
onClear,
|
||||
selectedCount,
|
||||
visibleCols,
|
||||
setVisibleCols,
|
||||
}: {
|
||||
draft: AdminFilters;
|
||||
setDraft: React.Dispatch<React.SetStateAction<AdminFilters>>;
|
||||
platformOptions: PlatformDto[];
|
||||
roleOptions: string[];
|
||||
caliberOptions: CaliberDto[];
|
||||
loading: boolean;
|
||||
onApply: () => void;
|
||||
onClear: () => void;
|
||||
selectedCount: number;
|
||||
|
||||
// column toggles
|
||||
visibleCols: VisibleCols;
|
||||
setVisibleCols: React.Dispatch<React.SetStateAction<VisibleCols>>;
|
||||
}) {
|
||||
const [open, setOpen] = useState(false);
|
||||
|
||||
const activeCount = useMemo(() => countActiveFilters(draft), [draft]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* Top toolbar: stays small even on 14" */}
|
||||
<div className="mb-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field label="Search" widthClass="min-w-[280px] flex-1">
|
||||
<input
|
||||
value={draft.q}
|
||||
onChange={(e) => setDraft((s) => ({ ...s, q: e.target.value }))}
|
||||
placeholder="name, slug, mpn, upc…"
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900"
|
||||
onClick={() => setOpen(true)}
|
||||
disabled={loading}
|
||||
>
|
||||
Filters{activeCount ? ` (${activeCount})` : ""}
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={selectedCount === 0 || loading}
|
||||
title={
|
||||
selectedCount === 0
|
||||
? "Select rows to enable bulk actions"
|
||||
: undefined
|
||||
}
|
||||
onClick={() => setOpen(true)}
|
||||
>
|
||||
Bulk ({selectedCount})
|
||||
</button>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md bg-emerald-600 px-3 py-2 text-sm hover:bg-emerald-500 disabled:opacity-50"
|
||||
onClick={onApply}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Drawer: all the heavy controls */}
|
||||
<RightDrawer
|
||||
open={open}
|
||||
onClose={setOpen}
|
||||
title="Filters & Actions"
|
||||
widthClassName="max-w-lg"
|
||||
footer={
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
onClick={onClear}
|
||||
disabled={loading}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md bg-emerald-600 px-3 py-2 text-sm hover:bg-emerald-500 disabled:opacity-50"
|
||||
onClick={() => {
|
||||
onApply();
|
||||
setOpen(false);
|
||||
}}
|
||||
disabled={loading}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
>
|
||||
<div className="space-y-6">
|
||||
{/* Filters */}
|
||||
<div className="text-xs uppercase tracking-wide text-white/50">
|
||||
Filters
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-1 gap-3">
|
||||
<Field label="Platform">
|
||||
<select
|
||||
value={draft.platform}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, platform: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{platformOptions.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Role">
|
||||
<select
|
||||
value={draft.partRole}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, partRole: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r || "any"} value={r}>
|
||||
{r || "Any"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Caliber">
|
||||
<select
|
||||
value={draft.caliber}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, caliber: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="__UNSET__">— Not set —</option>
|
||||
{caliberOptions
|
||||
.filter((c) => !!c && !!c.key)
|
||||
.map((c) => (
|
||||
<option key={c.id ?? c.key} value={c.key}>
|
||||
{c.label || c.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Visibility">
|
||||
<select
|
||||
value={draft.visibility}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({
|
||||
...s,
|
||||
visibility: e.target.value as any,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="PUBLIC">PUBLIC</option>
|
||||
<option value="HIDDEN">HIDDEN</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Status">
|
||||
<select
|
||||
value={draft.status}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, status: e.target.value as any }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="DISABLED">DISABLED</option>
|
||||
<option value="ARCHIVED">ARCHIVED</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Builder">
|
||||
<select
|
||||
value={draft.builderEligible}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({
|
||||
...s,
|
||||
builderEligible: e.target.value as any,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="true">Eligible</option>
|
||||
<option value="false">Not eligible</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Locked">
|
||||
<select
|
||||
value={draft.adminLocked}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({
|
||||
...s,
|
||||
adminLocked: e.target.value as any,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="true">Locked</option>
|
||||
<option value="false">Unlocked</option>
|
||||
</select>
|
||||
</Field>
|
||||
</div>
|
||||
|
||||
{/* Columns */}
|
||||
<div className="pt-2">
|
||||
<div className="text-xs uppercase tracking-wide text-white/50">
|
||||
Columns
|
||||
</div>
|
||||
|
||||
<div className="mt-2 rounded-lg border border-white/10 bg-white/5 p-4">
|
||||
{/* Presets */}
|
||||
<div className="mb-3 flex flex-wrap gap-2">
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs hover:bg-zinc-900"
|
||||
onClick={() =>
|
||||
setVisibleCols({
|
||||
brand: true,
|
||||
platform: true,
|
||||
role: true,
|
||||
caliber: true,
|
||||
import: true,
|
||||
flags: true,
|
||||
updated: true,
|
||||
})
|
||||
}
|
||||
>
|
||||
Full
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs hover:bg-zinc-900"
|
||||
onClick={() =>
|
||||
setVisibleCols({
|
||||
brand: true,
|
||||
platform: true,
|
||||
role: true,
|
||||
caliber: true,
|
||||
import: true,
|
||||
flags: false,
|
||||
updated: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
Compact
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
className="rounded-md border border-zinc-700 px-2.5 py-1.5 text-xs hover:bg-zinc-900"
|
||||
onClick={() =>
|
||||
setVisibleCols({
|
||||
brand: false,
|
||||
platform: true,
|
||||
role: true,
|
||||
caliber: true,
|
||||
import: true,
|
||||
flags: true,
|
||||
updated: false,
|
||||
})
|
||||
}
|
||||
>
|
||||
Review
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Toggles */}
|
||||
<div className="grid grid-cols-2 gap-2 text-sm">
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleCols.brand}
|
||||
onChange={() =>
|
||||
setVisibleCols((c) => ({ ...c, brand: !c.brand }))
|
||||
}
|
||||
/>
|
||||
Brand
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleCols.platform}
|
||||
onChange={() =>
|
||||
setVisibleCols((c) => ({
|
||||
...c,
|
||||
platform: !c.platform,
|
||||
}))
|
||||
}
|
||||
/>
|
||||
Platform
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleCols.role}
|
||||
onChange={() =>
|
||||
setVisibleCols((c) => ({ ...c, role: !c.role }))
|
||||
}
|
||||
/>
|
||||
Role
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleCols.caliber}
|
||||
onChange={() =>
|
||||
setVisibleCols((c) => ({ ...c, caliber: !c.caliber }))
|
||||
}
|
||||
/>
|
||||
Caliber
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleCols.import}
|
||||
onChange={() =>
|
||||
setVisibleCols((c) => ({ ...c, import: !c.import }))
|
||||
}
|
||||
/>
|
||||
Import
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleCols.flags}
|
||||
onChange={() =>
|
||||
setVisibleCols((c) => ({ ...c, flags: !c.flags }))
|
||||
}
|
||||
/>
|
||||
Flags
|
||||
</label>
|
||||
|
||||
<label className="flex items-center gap-2">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={visibleCols.updated}
|
||||
onChange={() =>
|
||||
setVisibleCols((c) => ({ ...c, updated: !c.updated }))
|
||||
}
|
||||
/>
|
||||
Updated
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<p className="mt-3 text-xs text-white/40">
|
||||
Product + checkbox are always shown.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Bulk actions placeholder */}
|
||||
<div className="pt-2">
|
||||
<div className="text-xs uppercase tracking-wide text-white/50">
|
||||
Bulk actions
|
||||
</div>
|
||||
<div className="mt-2 rounded-md border border-zinc-800 bg-black/30 p-3 text-sm text-white/70">
|
||||
{selectedCount === 0
|
||||
? "Select rows to enable bulk actions."
|
||||
: `Ready for bulk actions on ${selectedCount} selected item(s).`}
|
||||
<div className="mt-2 text-xs text-white/40">
|
||||
(Hook your actual bulk controls here next.)
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</RightDrawer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,86 @@
|
||||
"use client";
|
||||
|
||||
import { Fragment } from "react";
|
||||
import { Dialog, DialogPanel, DialogTitle, Transition, TransitionChild } from "@headlessui/react";
|
||||
import { XMarkIcon } from "@heroicons/react/24/outline";
|
||||
|
||||
export function RightDrawer({
|
||||
open,
|
||||
onClose,
|
||||
title,
|
||||
children,
|
||||
footer,
|
||||
widthClassName = "max-w-lg",
|
||||
}: {
|
||||
open: boolean;
|
||||
onClose: (open: boolean) => void;
|
||||
title: string;
|
||||
children: React.ReactNode;
|
||||
footer?: React.ReactNode;
|
||||
widthClassName?: string;
|
||||
}) {
|
||||
return (
|
||||
<Transition show={open} as={Fragment}>
|
||||
<Dialog onClose={onClose} className="relative z-50">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="ease-out duration-200"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-150"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black/60" />
|
||||
</TransitionChild>
|
||||
|
||||
<div className="fixed inset-0 overflow-hidden">
|
||||
<div className="absolute inset-0 overflow-hidden">
|
||||
<div className="pointer-events-none fixed inset-y-0 right-0 flex max-w-full pl-10">
|
||||
<TransitionChild
|
||||
as={Fragment}
|
||||
enter="transform transition ease-in-out duration-300"
|
||||
enterFrom="translate-x-full"
|
||||
enterTo="translate-x-0"
|
||||
leave="transform transition ease-in-out duration-200"
|
||||
leaveFrom="translate-x-0"
|
||||
leaveTo="translate-x-full"
|
||||
>
|
||||
<DialogPanel
|
||||
className={`pointer-events-auto w-screen ${widthClassName} bg-zinc-950 shadow-xl ring-1 ring-white/10`}
|
||||
>
|
||||
<div className="flex h-full flex-col">
|
||||
<div className="flex items-start justify-between border-b border-white/10 px-4 py-4">
|
||||
<DialogTitle className="text-base font-semibold text-white">
|
||||
{title}
|
||||
</DialogTitle>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => onClose(false)}
|
||||
className="rounded-md p-2 text-white/60 hover:text-white hover:bg-white/5 focus:outline-none focus:ring-2 focus:ring-emerald-500"
|
||||
>
|
||||
<span className="sr-only">Close panel</span>
|
||||
<XMarkIcon className="h-5 w-5" aria-hidden="true" />
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div className="flex-1 overflow-y-auto px-4 py-4">
|
||||
{children}
|
||||
</div>
|
||||
|
||||
{footer ? (
|
||||
<div className="border-t border-white/10 px-4 py-3">
|
||||
{footer}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</DialogPanel>
|
||||
</TransitionChild>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,330 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import type {
|
||||
AdminFilters,
|
||||
AdminProductRow,
|
||||
PageResponse,
|
||||
PlatformDto,
|
||||
CaliberDto,
|
||||
} from "../_lib/types";
|
||||
|
||||
|
||||
import { initialFilters } from "../_lib/types";
|
||||
import {
|
||||
bulkUpdate,
|
||||
fetchAdminCalibers,
|
||||
fetchAdminProducts,
|
||||
fetchAdminRoles,
|
||||
fetchPlatforms,
|
||||
} from "../_lib/api";
|
||||
|
||||
import FilterChips from "./filterChips";
|
||||
import ProductsTable from "./productsTable";
|
||||
import PaginationBar from "./paginationBar";
|
||||
import { BulkBar } from "./BulkBar";
|
||||
import ProductsToolbar from "./ProductsToolbar";
|
||||
|
||||
export default function AdminProductsClient() {
|
||||
const [page, setPage] = useState(0);
|
||||
const [size, setSize] = useState(50);
|
||||
|
||||
const [platformOptions, setPlatformOptions] = useState<PlatformDto[]>([]);
|
||||
const [data, setData] = useState<PageResponse<AdminProductRow> | null>(null);
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [err, setErr] = useState<string | null>(null);
|
||||
|
||||
const [selected, setSelected] = useState<Set<number>>(new Set());
|
||||
|
||||
const [draft, setDraft] = useState<AdminFilters>(initialFilters);
|
||||
const [applied, setApplied] = useState<AdminFilters>(initialFilters);
|
||||
|
||||
const [roleOptions, setRoleOptions] = useState<string[]>([""]);
|
||||
const [caliberOptions, setCaliberOptions] = useState<CaliberDto[]>([]);
|
||||
// ------------------------------
|
||||
// Column Collasping
|
||||
// ------------------------------
|
||||
const [visibleCols, setVisibleCols] = useState({
|
||||
brand: true,
|
||||
platform: true,
|
||||
role: true,
|
||||
caliber: true,
|
||||
import: true,
|
||||
flags: true,
|
||||
updated: true,
|
||||
});
|
||||
// ------------------------------
|
||||
// Bulk action state
|
||||
// ------------------------------
|
||||
const [bulkPlatformKey, setBulkPlatformKey] = useState("");
|
||||
const [bulkPlatformLock, setBulkPlatformLock] = useState(false);
|
||||
|
||||
const [bulkRole, setBulkRole] = useState("");
|
||||
const [bulkRoleLock, setBulkRoleLock] = useState(false);
|
||||
|
||||
const [bulkCaliber, setBulkCaliber] = useState("");
|
||||
const [bulkCaliberGroup, setBulkCaliberGroup] = useState("");
|
||||
const [bulkCaliberLock, setBulkCaliberLock] = useState(false);
|
||||
const [bulkForceCaliber, setBulkForceCaliber] = useState(false);
|
||||
|
||||
const queryParams = useMemo(() => {
|
||||
return {
|
||||
...applied,
|
||||
builderEligible:
|
||||
applied.builderEligible === "" ? null : applied.builderEligible,
|
||||
adminLocked: applied.adminLocked === "" ? null : applied.adminLocked,
|
||||
page,
|
||||
size,
|
||||
sort: "updatedAt,desc",
|
||||
};
|
||||
}, [applied, page, size]);
|
||||
|
||||
const refresh = async () => {
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
const res: any = await fetchAdminProducts(queryParams);
|
||||
|
||||
// API returns `{ content, page: { size, number, totalElements, totalPages } }`
|
||||
// but the UI expects `totalPages/totalElements/number/size` at the top-level.
|
||||
const normalized: any = res?.page
|
||||
? {
|
||||
content: res.content ?? [],
|
||||
...res.page,
|
||||
}
|
||||
: res;
|
||||
|
||||
setData(normalized);
|
||||
setSelected(new Set());
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? "Failed to load.");
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
useEffect(() => {
|
||||
refresh();
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [queryParams]);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const roles = await fetchAdminRoles({});
|
||||
setRoleOptions(["", ...roles]);
|
||||
} catch {
|
||||
setRoleOptions([""]);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const cals = await fetchAdminCalibers({ activeOnly: true });
|
||||
setCaliberOptions(Array.isArray(cals) ? cals : []);
|
||||
} catch {
|
||||
setCaliberOptions([]);
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
(async () => {
|
||||
try {
|
||||
const plats = await fetchPlatforms();
|
||||
plats.sort((a, b) => a.label.localeCompare(b.label));
|
||||
setPlatformOptions(plats);
|
||||
} catch (e: any) {
|
||||
console.warn(e?.message ?? "Failed to load platforms");
|
||||
}
|
||||
})();
|
||||
}, []);
|
||||
|
||||
const content = data?.content ?? [];
|
||||
const selectedIds = Array.from(selected);
|
||||
|
||||
const allOnPageSelected =
|
||||
content.length > 0 && content.every((r) => selected.has(r.id));
|
||||
|
||||
const toggleAllOnPage = () => {
|
||||
const next = new Set(selected);
|
||||
if (allOnPageSelected) content.forEach((r) => next.delete(r.id));
|
||||
else content.forEach((r) => next.add(r.id));
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const toggleOne = (id: number) => {
|
||||
const next = new Set(selected);
|
||||
if (next.has(id)) next.delete(id);
|
||||
else next.add(id);
|
||||
setSelected(next);
|
||||
};
|
||||
|
||||
const applyFilters = () => {
|
||||
setPage(0);
|
||||
setApplied({ ...draft });
|
||||
};
|
||||
|
||||
const clearFilters = () => {
|
||||
setDraft(initialFilters);
|
||||
setApplied(initialFilters);
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const chips = useMemo(() => {
|
||||
const out: { key: keyof AdminFilters; label: string }[] = [];
|
||||
if (applied.q) out.push({ key: "q", label: `Search: ${applied.q}` });
|
||||
if (applied.platform)
|
||||
out.push({ key: "platform", label: `Platform: ${applied.platform}` });
|
||||
if (applied.partRole)
|
||||
out.push({ key: "partRole", label: `Role: ${applied.partRole}` });
|
||||
if (applied.caliber)
|
||||
out.push({ key: "caliber", label: `Caliber: ${applied.caliber}` });
|
||||
if (applied.visibility)
|
||||
out.push({
|
||||
key: "visibility",
|
||||
label: `Visibility: ${applied.visibility}`,
|
||||
});
|
||||
if (applied.status)
|
||||
out.push({ key: "status", label: `Status: ${applied.status}` });
|
||||
if (applied.builderEligible)
|
||||
out.push({
|
||||
key: "builderEligible",
|
||||
label: `Builder: ${applied.builderEligible}`,
|
||||
});
|
||||
if (applied.adminLocked)
|
||||
out.push({ key: "adminLocked", label: `Locked: ${applied.adminLocked}` });
|
||||
return out;
|
||||
}, [applied]);
|
||||
|
||||
const clearOne = async (key: keyof AdminFilters) => {
|
||||
setDraft((d) => ({ ...d, [key]: "" } as AdminFilters));
|
||||
setApplied((a) => ({ ...a, [key]: "" } as AdminFilters));
|
||||
setPage(0);
|
||||
};
|
||||
|
||||
const runBulk = async (set: Parameters<typeof bulkUpdate>[1]) => {
|
||||
if (selectedIds.length === 0) return;
|
||||
|
||||
setLoading(true);
|
||||
setErr(null);
|
||||
try {
|
||||
await bulkUpdate(selectedIds, set);
|
||||
await refresh();
|
||||
} catch (e: any) {
|
||||
setErr(e?.message ?? "Bulk update failed.");
|
||||
setLoading(false);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
// Prevent page-level horizontal scroll; allow table-only horizontal scroll.
|
||||
<div className="w-full max-w-full overflow-x-hidden">
|
||||
{/* Header */}
|
||||
<div className="mb-4 flex items-start justify-between gap-4">
|
||||
<div className="min-w-0">
|
||||
<h1 className="text-2xl font-semibold">Admin · Products</h1>
|
||||
<p className="text-sm opacity-70">
|
||||
Filter, select, and apply bulk actions. This is where bad imports
|
||||
come to die.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<button
|
||||
className="shrink-0 rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
onClick={() => refresh()}
|
||||
disabled={loading}
|
||||
>
|
||||
Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* OPTIONAL IMPROVEMENT:
|
||||
Sticky controls area so table scroll doesn't push search/actions away.
|
||||
If you hate sticky, remove the wrapper div and keep the inner content. */}
|
||||
<div className="sticky top-0 z-20 -mx-2 px-2 pb-3 pt-2 bg-black/70 backdrop-blur border-b border-zinc-900">
|
||||
<ProductsToolbar
|
||||
draft={draft}
|
||||
setDraft={setDraft}
|
||||
platformOptions={platformOptions}
|
||||
roleOptions={roleOptions}
|
||||
caliberOptions={caliberOptions}
|
||||
loading={loading}
|
||||
onApply={applyFilters}
|
||||
onClear={clearFilters}
|
||||
selectedCount={selected.size}
|
||||
visibleCols={visibleCols}
|
||||
setVisibleCols={setVisibleCols}
|
||||
/>
|
||||
|
||||
{/* Chips stay visible too */}
|
||||
<FilterChips chips={chips} onClearOne={(k) => void clearOne(k)} />
|
||||
|
||||
{/* Bulk actions */}
|
||||
{selectedIds.length > 0 && (
|
||||
<BulkBar
|
||||
selectedCount={selectedIds.length}
|
||||
loading={loading}
|
||||
platformOptions={platformOptions}
|
||||
roleOptions={roleOptions}
|
||||
caliberOptions={caliberOptions}
|
||||
bulkPlatformKey={bulkPlatformKey}
|
||||
setBulkPlatformKey={setBulkPlatformKey}
|
||||
bulkPlatformLock={bulkPlatformLock}
|
||||
setBulkPlatformLock={setBulkPlatformLock}
|
||||
bulkRole={bulkRole}
|
||||
setBulkRole={setBulkRole}
|
||||
bulkRoleLock={bulkRoleLock}
|
||||
setBulkRoleLock={setBulkRoleLock}
|
||||
bulkCaliber={bulkCaliber}
|
||||
setBulkCaliber={setBulkCaliber}
|
||||
bulkCaliberGroup={bulkCaliberGroup}
|
||||
setBulkCaliberGroup={setBulkCaliberGroup}
|
||||
bulkCaliberLock={bulkCaliberLock}
|
||||
setBulkCaliberLock={setBulkCaliberLock}
|
||||
bulkForceCaliber={bulkForceCaliber}
|
||||
setBulkForceCaliber={setBulkForceCaliber}
|
||||
runBulk={runBulk}
|
||||
/>
|
||||
)}
|
||||
|
||||
{err && (
|
||||
<div className="mt-3 rounded-md border border-red-800 bg-red-950/40 px-3 py-2 text-sm">
|
||||
{err}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Table + pagination area
|
||||
min-w-0 is crucial to stop flex children from forcing horizontal overflow */}
|
||||
<div className="flex min-h-0 min-w-0 flex-1 flex-col gap-3 pt-3">
|
||||
{/* This min-w-0 wrapper ensures the table container can shrink to viewport width.
|
||||
The ProductsTable itself should have overflow-x-auto on its wrapper and a min-w on the table. */}
|
||||
<div className="min-w-0">
|
||||
<ProductsTable
|
||||
rows={content}
|
||||
loading={loading}
|
||||
selected={selected}
|
||||
onToggleAll={toggleAllOnPage}
|
||||
onToggleOne={toggleOne}
|
||||
allOnPageSelected={allOnPageSelected}
|
||||
visibleCols={visibleCols}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<PaginationBar
|
||||
page={page}
|
||||
setPage={setPage}
|
||||
size={size}
|
||||
setSize={setSize}
|
||||
loading={loading}
|
||||
totalPages={(data as any)?.totalPages ?? (data as any)?.page?.totalPages ?? 1}
|
||||
totalElements={(data as any)?.totalElements ?? (data as any)?.page?.totalElements ?? 0}
|
||||
number={(data as any)?.number ?? (data as any)?.page?.number ?? page}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,170 @@
|
||||
import type { AdminFilters, PlatformDto } from "../_lib/types";
|
||||
import { Field } from "./ui";
|
||||
|
||||
export default function FilterBar({
|
||||
draft,
|
||||
setDraft,
|
||||
platformOptions,
|
||||
roleOptions,
|
||||
caliberOptions,
|
||||
loading,
|
||||
onApply,
|
||||
onClear,
|
||||
}: {
|
||||
draft: AdminFilters;
|
||||
setDraft: React.Dispatch<React.SetStateAction<AdminFilters>>;
|
||||
platformOptions: PlatformDto[];
|
||||
roleOptions: string[];
|
||||
caliberOptions: string[];
|
||||
loading: boolean;
|
||||
onApply: () => void;
|
||||
onClear: () => void;
|
||||
}) {
|
||||
return (
|
||||
<div className="mb-3 rounded-md border border-zinc-800 bg-zinc-950/40 p-3">
|
||||
<div className="flex flex-wrap items-end gap-2">
|
||||
<Field label="Search" widthClass="min-w-[260px] flex-1">
|
||||
<input
|
||||
value={draft.q}
|
||||
onChange={(e) => setDraft((s) => ({ ...s, q: e.target.value }))}
|
||||
placeholder="name, slug, mpn, upc…"
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
/>
|
||||
</Field>
|
||||
|
||||
<Field label="Platform">
|
||||
<select
|
||||
value={draft.platform}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, platform: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{platformOptions.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.key}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Role">
|
||||
<select
|
||||
value={draft.partRole}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, partRole: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
{roleOptions.map((r) => (
|
||||
<option key={r || "any"} value={r}>
|
||||
{r || "Any"}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Caliber">
|
||||
<select
|
||||
value={draft.caliber}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, caliber: e.target.value }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="__UNSET__">— Not set —</option>
|
||||
|
||||
{caliberOptions
|
||||
.filter((c) => c)
|
||||
.map((c) => (
|
||||
<option key={c} value={c}>
|
||||
{c}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Visibility">
|
||||
<select
|
||||
value={draft.visibility}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, visibility: e.target.value as any }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="PUBLIC">PUBLIC</option>
|
||||
<option value="HIDDEN">HIDDEN</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Status">
|
||||
<select
|
||||
value={draft.status}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, status: e.target.value as any }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="ACTIVE">ACTIVE</option>
|
||||
<option value="DISABLED">DISABLED</option>
|
||||
<option value="ARCHIVED">ARCHIVED</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Builder">
|
||||
<select
|
||||
value={draft.builderEligible}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({
|
||||
...s,
|
||||
builderEligible: e.target.value as any,
|
||||
}))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="true">Eligible</option>
|
||||
<option value="false">Not eligible</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<Field label="Locked">
|
||||
<select
|
||||
value={draft.adminLocked}
|
||||
onChange={(e) =>
|
||||
setDraft((s) => ({ ...s, adminLocked: e.target.value as any }))
|
||||
}
|
||||
className="w-full rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
<option value="">Any</option>
|
||||
<option value="true">Locked</option>
|
||||
<option value="false">Unlocked</option>
|
||||
</select>
|
||||
</Field>
|
||||
|
||||
<div className="ml-auto flex items-center gap-2">
|
||||
<button
|
||||
className="rounded-md bg-emerald-600 px-3 py-2 text-sm hover:bg-emerald-500 disabled:opacity-50"
|
||||
onClick={onApply}
|
||||
disabled={loading}
|
||||
>
|
||||
Apply
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900"
|
||||
onClick={onClear}
|
||||
disabled={loading}
|
||||
>
|
||||
Clear
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,20 @@
|
||||
import type { AdminFilters } from "../_lib/types";
|
||||
import { Chip } from "./ui";
|
||||
|
||||
export default function FilterChips({
|
||||
chips,
|
||||
onClearOne,
|
||||
}: {
|
||||
chips: { key: keyof AdminFilters; label: string }[];
|
||||
onClearOne: (key: keyof AdminFilters) => void;
|
||||
}) {
|
||||
if (chips.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="mt-3 flex flex-wrap gap-2">
|
||||
{chips.map((c) => (
|
||||
<Chip key={c.key} text={c.label} onClear={() => onClearOne(c.key)} />
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
export default function PaginationBar({
|
||||
page,
|
||||
setPage,
|
||||
size,
|
||||
setSize,
|
||||
loading,
|
||||
totalPages,
|
||||
totalElements,
|
||||
number,
|
||||
}: {
|
||||
page: number;
|
||||
setPage: React.Dispatch<React.SetStateAction<number>>;
|
||||
size: number;
|
||||
setSize: React.Dispatch<React.SetStateAction<number>>;
|
||||
loading: boolean;
|
||||
totalPages: number;
|
||||
totalElements: number;
|
||||
number: number;
|
||||
}) {
|
||||
return (
|
||||
<div className="mt-4 flex items-center justify-between gap-3">
|
||||
<div className="text-sm opacity-70">
|
||||
Page {number + 1} of {totalPages} • {totalElements} total
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<select
|
||||
value={size}
|
||||
onChange={(e) => {
|
||||
setSize(Number(e.target.value));
|
||||
setPage(0);
|
||||
}}
|
||||
className="rounded-md border border-zinc-700 bg-black px-3 py-2 text-sm"
|
||||
>
|
||||
{[25, 50, 100, 200].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}/page
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || page <= 0}
|
||||
onClick={() => setPage((p) => Math.max(0, p - 1))}
|
||||
>
|
||||
Prev
|
||||
</button>
|
||||
|
||||
<button
|
||||
className="rounded-md border border-zinc-700 px-3 py-2 text-sm hover:bg-zinc-900 disabled:opacity-50"
|
||||
disabled={loading || page >= totalPages - 1}
|
||||
onClick={() => setPage((p) => p + 1)}
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,204 @@
|
||||
import type { AdminProductRow } from "../_lib/types";
|
||||
|
||||
export type ProductsTableVisibleCols = {
|
||||
brand: boolean;
|
||||
platform: boolean;
|
||||
role: boolean;
|
||||
caliber: boolean;
|
||||
import: boolean;
|
||||
flags: boolean;
|
||||
updated: boolean;
|
||||
};
|
||||
|
||||
export default function ProductsTable({
|
||||
rows,
|
||||
loading,
|
||||
selected,
|
||||
onToggleAll,
|
||||
onToggleOne,
|
||||
allOnPageSelected,
|
||||
visibleCols,
|
||||
}: {
|
||||
rows: AdminProductRow[];
|
||||
loading: boolean;
|
||||
selected: Set<number>;
|
||||
onToggleAll: () => void;
|
||||
onToggleOne: (id: number) => void;
|
||||
allOnPageSelected: boolean;
|
||||
visibleCols: ProductsTableVisibleCols;
|
||||
}) {
|
||||
// Compute colSpan based on visible columns + 2 sticky columns (checkbox + product)
|
||||
const colSpan =
|
||||
2 +
|
||||
(visibleCols.brand ? 1 : 0) +
|
||||
(visibleCols.platform ? 1 : 0) +
|
||||
(visibleCols.role ? 1 : 0) +
|
||||
(visibleCols.caliber ? 1 : 0) +
|
||||
(visibleCols.import ? 1 : 0) +
|
||||
(visibleCols.flags ? 1 : 0) +
|
||||
(visibleCols.updated ? 1 : 0);
|
||||
|
||||
return (
|
||||
<div className="overflow-x-auto max-w-full min-w-0 rounded-md border border-zinc-800">
|
||||
<table className="w-full min-w-[1100px] text-sm">
|
||||
<thead className="bg-zinc-950">
|
||||
<tr className="text-left">
|
||||
{/* Sticky checkbox col */}
|
||||
<th className="w-10 px-3 py-2 sticky left-0 z-30 bg-zinc-950">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={allOnPageSelected}
|
||||
onChange={onToggleAll}
|
||||
/>
|
||||
</th>
|
||||
|
||||
{/* Sticky product col */}
|
||||
<th className="px-3 py-2 sticky left-10 z-20 bg-zinc-950 shadow-[1px_0_0_0_rgba(255,255,255,0.08)] w-[480px] max-w-[480px]">
|
||||
Product
|
||||
</th>
|
||||
|
||||
{visibleCols.brand && <th className="px-3 py-2">Brand</th>}
|
||||
{visibleCols.platform && <th className="px-3 py-2">Platform</th>}
|
||||
{visibleCols.role && <th className="px-3 py-2">Role</th>}
|
||||
{visibleCols.caliber && <th className="px-3 py-2">Caliber</th>}
|
||||
{visibleCols.import && <th className="px-3 py-2">Import</th>}
|
||||
{visibleCols.flags && <th className="px-3 py-2">Flags</th>}
|
||||
{visibleCols.updated && <th className="px-3 py-2">Updated</th>}
|
||||
</tr>
|
||||
</thead>
|
||||
|
||||
<tbody>
|
||||
{loading && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-3 py-6 text-center opacity-70">
|
||||
Loading…
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading && rows.length === 0 && (
|
||||
<tr>
|
||||
<td colSpan={colSpan} className="px-3 py-6 text-center opacity-70">
|
||||
No results.
|
||||
</td>
|
||||
</tr>
|
||||
)}
|
||||
|
||||
{!loading &&
|
||||
rows.map((row) => (
|
||||
<tr
|
||||
key={row.id}
|
||||
className="border-t border-zinc-900 hover:bg-zinc-950/40"
|
||||
>
|
||||
{/* Sticky checkbox */}
|
||||
<td className="px-3 py-2 sticky left-0 z-30 bg-black">
|
||||
<input
|
||||
type="checkbox"
|
||||
checked={selected.has(row.id)}
|
||||
onChange={() => onToggleOne(row.id)}
|
||||
/>
|
||||
</td>
|
||||
|
||||
{/* Sticky product */}
|
||||
<td className="px-3 py-2 sticky left-10 z-20 bg-black shadow-[1px_0_0_0_rgba(255,255,255,0.08)] w-[480px] max-w-[480px]">
|
||||
<div className="flex items-center gap-3">
|
||||
{row.mainImageUrl ? (
|
||||
// eslint-disable-next-line @next/next/no-img-element
|
||||
<img
|
||||
src={row.mainImageUrl}
|
||||
alt=""
|
||||
className="h-10 w-10 rounded object-cover border border-zinc-800"
|
||||
/>
|
||||
) : (
|
||||
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-950" />
|
||||
)}
|
||||
|
||||
<div className="min-w-0">
|
||||
<div className="font-medium whitespace-normal break-words leading-snug">
|
||||
{row.name}
|
||||
</div>
|
||||
<div className="truncate text-xs opacity-60">#{row.id}</div>
|
||||
{row.adminNote ? (
|
||||
<div className="truncate text-xs opacity-60">
|
||||
📝 {row.adminNote}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
</td>
|
||||
|
||||
{visibleCols.brand && (
|
||||
<td className="px-3 py-2">{row.brandName ?? "—"}</td>
|
||||
)}
|
||||
|
||||
{visibleCols.platform && (
|
||||
<td className="px-3 py-2">{row.platform ?? "—"}</td>
|
||||
)}
|
||||
|
||||
{visibleCols.role && (
|
||||
<td className="px-3 py-2">{row.partRole ?? "—"}</td>
|
||||
)}
|
||||
|
||||
{visibleCols.caliber && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="text-sm">
|
||||
{row.caliber ?? "—"}
|
||||
{row.caliberLocked ? (
|
||||
<span className="ml-2 rounded border border-zinc-700 px-1.5 py-0.5 text-[10px] opacity-80">
|
||||
LOCK
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
{row.caliberGroup ? (
|
||||
<div className="text-xs opacity-60">
|
||||
{row.caliberGroup}
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{visibleCols.import && (
|
||||
<td className="px-3 py-2">{row.importStatus ?? "—"}</td>
|
||||
)}
|
||||
|
||||
{visibleCols.flags && (
|
||||
<td className="px-3 py-2">
|
||||
<div className="flex flex-wrap gap-1 text-xs">
|
||||
{row.visibility === "HIDDEN" ? (
|
||||
<span className="rounded border border-zinc-700 px-2 py-0.5">
|
||||
HIDDEN
|
||||
</span>
|
||||
) : null}
|
||||
{row.status !== "ACTIVE" ? (
|
||||
<span className="rounded border border-zinc-700 px-2 py-0.5">
|
||||
{row.status}
|
||||
</span>
|
||||
) : null}
|
||||
{row.builderEligible === false ? (
|
||||
<span className="rounded border border-zinc-700 px-2 py-0.5">
|
||||
NO_BUILDER
|
||||
</span>
|
||||
) : null}
|
||||
{row.adminLocked ? (
|
||||
<span className="rounded border border-zinc-700 px-2 py-0.5">
|
||||
LOCKED
|
||||
</span>
|
||||
) : null}
|
||||
</div>
|
||||
</td>
|
||||
)}
|
||||
|
||||
{visibleCols.updated && (
|
||||
<td className="px-3 py-2 text-xs opacity-70">
|
||||
{row.updatedAt ?? "—"}
|
||||
</td>
|
||||
)}
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,6 @@
|
||||
export type PlatformDto = {
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
@@ -0,0 +1,31 @@
|
||||
export function Field({
|
||||
label,
|
||||
children,
|
||||
widthClass,
|
||||
}: {
|
||||
label: string;
|
||||
children: React.ReactNode;
|
||||
widthClass?: string;
|
||||
}) {
|
||||
return (
|
||||
<div className={widthClass ?? "w-[180px]"}>
|
||||
<div className="mb-1 text-[11px] uppercase tracking-[0.16em] text-zinc-500">
|
||||
{label}
|
||||
</div>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function Chip({ text, onClear }: { text: string; onClear: () => void }) {
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={onClear}
|
||||
className="rounded-full border border-zinc-800 bg-zinc-950 px-2 py-1 text-[11px] text-zinc-200 hover:bg-zinc-900"
|
||||
title="Clear filter"
|
||||
>
|
||||
{text} <span className="ml-1 text-zinc-500">×</span>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
import { API_BASE, qs } from "./auth";
|
||||
import type {
|
||||
AdminProductRow,
|
||||
PageResponse,
|
||||
PlatformDto,
|
||||
ProductStatus,
|
||||
ProductVisibility,
|
||||
} from "./types";
|
||||
|
||||
import type { CaliberDto } from "./types";
|
||||
|
||||
export async function fetchAdminRoles(params: Record<string, any>) {
|
||||
const queryString = qs(params);
|
||||
const res = await fetch(
|
||||
`/api/admin/products/roles${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to load roles (${res.status}): ${await res.text()}`);
|
||||
return (await res.json()) as string[];
|
||||
}
|
||||
|
||||
// Facet/filtering can still use distinct values seen on products (optional)
|
||||
export async function fetchProductCaliberFacet(params: Record<string, any>) {
|
||||
const queryString = qs(params);
|
||||
const res = await fetch(
|
||||
`/api/admin/products/calibers${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
if (!res.ok)
|
||||
throw new Error(
|
||||
`Failed to load caliber facet (${res.status}): ${await res.text()}`
|
||||
);
|
||||
return (await res.json()) as string[];
|
||||
}
|
||||
|
||||
export async function fetchAdminProducts(params: Record<string, any>) {
|
||||
const queryString = qs(params);
|
||||
const res = await fetch(
|
||||
`/api/admin/products${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
if (!res.ok) throw new Error(`Failed to load admin products (${res.status}): ${await res.text()}`);
|
||||
return (await res.json()) as PageResponse<AdminProductRow>;
|
||||
}
|
||||
|
||||
export async function fetchPlatforms() {
|
||||
const res = await fetch(`${API_BASE}/api/platforms`, {
|
||||
method: "GET",
|
||||
headers: { Accept: "application/json" },
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Failed to load platforms (${res.status}): ${await res.text()}`);
|
||||
const all = (await res.json()) as PlatformDto[];
|
||||
return all.filter((p) => (p as any).is_active ?? (p as any).isActive);
|
||||
}
|
||||
|
||||
export async function bulkUpdate(
|
||||
productIds: number[],
|
||||
set: Partial<{
|
||||
visibility: ProductVisibility;
|
||||
status: ProductStatus;
|
||||
builderEligible: boolean;
|
||||
adminLocked: boolean;
|
||||
adminNote: string;
|
||||
platform: string;
|
||||
platformLocked: boolean;
|
||||
caliber: string;
|
||||
caliberGroup: string;
|
||||
caliberLocked: boolean;
|
||||
forceCaliberUpdate: boolean;
|
||||
partRole: string;
|
||||
partRoleLocked: boolean;
|
||||
classificationReason: "Admin bulk override";
|
||||
}>
|
||||
) {
|
||||
const res = await fetch(`/api/admin/products/bulk`, {
|
||||
method: "PATCH",
|
||||
credentials: "include",
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
},
|
||||
body: JSON.stringify({ productIds, ...set }),
|
||||
});
|
||||
|
||||
if (!res.ok) throw new Error(`Bulk update failed (${res.status}): ${await res.text()}`);
|
||||
|
||||
return (await res.json()) as { updatedCount: number; skippedLockedCount: number };
|
||||
}
|
||||
|
||||
|
||||
export async function fetchAdminCalibers(params: Record<string, any>) {
|
||||
const queryString = qs(params);
|
||||
const res = await fetch(
|
||||
`/api/admin/calibers${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
credentials: "include",
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load calibers (${res.status}): ${await res.text()}`);
|
||||
}
|
||||
return (await res.json()) as CaliberDto[];
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
export const API_BASE =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
export function getCookie(name: string): string | null {
|
||||
if (typeof document === "undefined") return null;
|
||||
const match = document.cookie.match(
|
||||
new RegExp(
|
||||
"(^| )" + name.replace(/[-[\]{}()*+?.,\\^$|#\s]/g, "\\$&") + "=([^;]+)"
|
||||
)
|
||||
);
|
||||
return match ? decodeURIComponent(match[2]) : null;
|
||||
}
|
||||
|
||||
export function getToken(): string | null {
|
||||
if (typeof window === "undefined") return null;
|
||||
|
||||
const ls = localStorage.getItem("ballistic_auth_token");
|
||||
if (ls && ls.trim().length > 0) return ls;
|
||||
|
||||
const ck = getCookie("bb_access_token");
|
||||
if (ck && ck.trim().length > 0) return ck;
|
||||
|
||||
return null;
|
||||
}
|
||||
|
||||
export function qs(params: Record<string, any>) {
|
||||
const sp = new URLSearchParams();
|
||||
Object.entries(params).forEach(([k, v]) => {
|
||||
if (v === undefined || v === null || v === "") return;
|
||||
sp.set(k, String(v));
|
||||
});
|
||||
return sp.toString();
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
export type ProductVisibility = "PUBLIC" | "HIDDEN";
|
||||
export type ProductStatus = "ACTIVE" | "DISABLED" | "ARCHIVED";
|
||||
|
||||
export type AdminProductRow = {
|
||||
id: number;
|
||||
uuid: string;
|
||||
name: string;
|
||||
slug: string;
|
||||
platform: string | null;
|
||||
partRole: string | null;
|
||||
brandName: string | null;
|
||||
importStatus: string | null;
|
||||
caliber: string | null;
|
||||
caliberGroup: string | null;
|
||||
caliberLocked: boolean;
|
||||
visibility: ProductVisibility;
|
||||
status: ProductStatus;
|
||||
builderEligible: boolean;
|
||||
adminLocked: boolean;
|
||||
adminNote: string | null;
|
||||
mainImageUrl: string | null;
|
||||
createdAt: string | null;
|
||||
updatedAt: string | null;
|
||||
};
|
||||
|
||||
export type PageResponse<T> = {
|
||||
content: T[];
|
||||
totalElements: number;
|
||||
totalPages: number;
|
||||
number: number;
|
||||
size: number;
|
||||
};
|
||||
|
||||
export type AdminFilters = {
|
||||
q: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
caliber: string;
|
||||
visibility: ProductVisibility | "";
|
||||
status: ProductStatus | "";
|
||||
builderEligible: "" | "true" | "false";
|
||||
adminLocked: "" | "true" | "false";
|
||||
};
|
||||
|
||||
export type PlatformDto = {
|
||||
id: number;
|
||||
key: string;
|
||||
label: string;
|
||||
is_active: boolean;
|
||||
};
|
||||
|
||||
export const initialFilters: AdminFilters = {
|
||||
q: "",
|
||||
platform: "",
|
||||
partRole: "",
|
||||
caliber: "",
|
||||
visibility: "",
|
||||
status: "",
|
||||
builderEligible: "",
|
||||
adminLocked: "",
|
||||
};
|
||||
|
||||
export type CaliberDto = {
|
||||
id: number;
|
||||
key: string; // canonical key (ex: "5.56 NATO")
|
||||
label: string; // human-readable label
|
||||
isActive: boolean;
|
||||
createdAt?: string;
|
||||
updatedAt?: string;
|
||||
};
|
||||
+3
-409
@@ -1,411 +1,5 @@
|
||||
"use client";
|
||||
import AdminProductsClient from "./_components/adminProductsClient";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
|
||||
type ProductSummary = {
|
||||
id: number;
|
||||
name: string;
|
||||
brand: string;
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
buyUrl: string | null;
|
||||
imageUrl: string | null;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
|
||||
type PlatformOption = {
|
||||
key: string; // e.g. AR15, AR9
|
||||
label: string; // e.g. "Armalite Rifle model 15"
|
||||
};
|
||||
|
||||
// Backend currently filters products by canonical platform strings like "AR-15".
|
||||
// DB platform keys are like "AR15" / "AR9". Translate when calling /api/v1/products.
|
||||
const platformKeyToApiPlatform = (key: string) => {
|
||||
const k = (key ?? "").toUpperCase().trim();
|
||||
if (!k) return "AR-15";
|
||||
if (k === "AR15" || k === "AR-15") return "AR-15";
|
||||
if (k === "AR10" || k === "AR-10") return "AR-10";
|
||||
if (k === "AR9" || k === "AR-9") return "AR-9";
|
||||
// Fallback: if it already contains a dash, assume it's canonical.
|
||||
if (k.includes("-")) return k;
|
||||
// Otherwise, best-effort: insert a dash after AR.
|
||||
if (k.startsWith("AR") && k.length > 2) return `AR-${k.slice(2)}`;
|
||||
return k;
|
||||
};
|
||||
|
||||
const FALLBACK_PLATFORMS: PlatformOption[] = [
|
||||
{ key: "AR15", label: "AR-15" },
|
||||
{ key: "AR10", label: "AR-10" },
|
||||
{ key: "AR9", label: "AR-9" },
|
||||
];
|
||||
|
||||
export default function AdminProductsPage() {
|
||||
const [platforms, setPlatforms] = useState<PlatformOption[]>(FALLBACK_PLATFORMS);
|
||||
const [platform, setPlatform] = useState<string>("AR15");
|
||||
const [loading, setLoading] = useState<boolean>(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [products, setProducts] = useState<ProductSummary[]>([]);
|
||||
const [query, setQuery] = useState<string>("");
|
||||
|
||||
const [page, setPage] = useState<number>(1);
|
||||
const [pageSize, setPageSize] = useState<number>(50);
|
||||
|
||||
// Load available platforms (dynamic if backend supports it; otherwise fallback)
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadPlatforms() {
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/platforms`, {
|
||||
signal: controller.signal,
|
||||
});
|
||||
if (!res.ok) return;
|
||||
|
||||
const data = (await res.json()) as unknown;
|
||||
|
||||
// Expected shape:
|
||||
// [{ id, key, label, is_active, ... }]
|
||||
const items = Array.isArray(data) ? data : [];
|
||||
|
||||
const cleaned: PlatformOption[] = items
|
||||
.map((item) => {
|
||||
if (!item || typeof item !== "object") return null;
|
||||
const obj = item as Record<string, unknown>;
|
||||
|
||||
const key = typeof obj.key === "string" ? obj.key.trim() : "";
|
||||
const label = typeof obj.label === "string" ? obj.label.trim() : "";
|
||||
|
||||
// Some payloads might use isActive; yours currently uses is_active
|
||||
const activeRaw = obj.is_active ?? obj.isActive;
|
||||
const isActive =
|
||||
typeof activeRaw === "boolean"
|
||||
? activeRaw
|
||||
: typeof activeRaw === "number"
|
||||
? activeRaw === 1
|
||||
: true;
|
||||
|
||||
if (!key || !label || !isActive) return null;
|
||||
return { key, label } as PlatformOption;
|
||||
})
|
||||
.filter((v): v is PlatformOption => v !== null);
|
||||
|
||||
if (cleaned.length === 0) return;
|
||||
|
||||
// Keep them stable and unique by key
|
||||
const byKey = new Map<string, PlatformOption>();
|
||||
for (const p of cleaned) byKey.set(p.key, p);
|
||||
const unique = Array.from(byKey.values());
|
||||
|
||||
setPlatforms(unique);
|
||||
setPlatform((prev) => (unique.some((p) => p.key === prev) ? prev : unique[0].key));
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
// ignore (fallback list stays)
|
||||
}
|
||||
}
|
||||
|
||||
loadPlatforms();
|
||||
return () => controller.abort();
|
||||
}, []);
|
||||
|
||||
// Load products for selected platform
|
||||
useEffect(() => {
|
||||
const controller = new AbortController();
|
||||
|
||||
async function loadProducts() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const apiPlatform = platformKeyToApiPlatform(platform);
|
||||
|
||||
// NOTE: backend endpoint expects `platform`, not `platformKey`.
|
||||
const url = `${API_BASE_URL}/api/v1/products?platform=${encodeURIComponent(
|
||||
apiPlatform
|
||||
)}`;
|
||||
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load products (${res.status})`);
|
||||
}
|
||||
|
||||
const data = (await res.json()) as ProductSummary[];
|
||||
setProducts(Array.isArray(data) ? data : []);
|
||||
setPage(1);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
setError(err?.message ?? "Failed to load products");
|
||||
setProducts([]);
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
loadProducts();
|
||||
return () => controller.abort();
|
||||
}, [platform]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
const q = query.trim().toLowerCase();
|
||||
if (!q) return products;
|
||||
|
||||
return products.filter((p) => {
|
||||
const name = (p.name ?? "").toLowerCase();
|
||||
const brand = (p.brand ?? "").toLowerCase();
|
||||
const role = (p.partRole ?? "").toLowerCase();
|
||||
return name.includes(q) || brand.includes(q) || role.includes(q);
|
||||
});
|
||||
}, [products, query]);
|
||||
|
||||
// Reset to page 1 when the search query changes
|
||||
useEffect(() => {
|
||||
setPage(1);
|
||||
}, [query, platform, pageSize]);
|
||||
|
||||
const totalPages = useMemo(() => {
|
||||
const total = filtered.length;
|
||||
return total === 0 ? 1 : Math.ceil(total / pageSize);
|
||||
}, [filtered.length, pageSize]);
|
||||
|
||||
const clampedPage = useMemo(() => {
|
||||
if (page < 1) return 1;
|
||||
if (page > totalPages) return totalPages;
|
||||
return page;
|
||||
}, [page, totalPages]);
|
||||
|
||||
const pageStart = useMemo(() => (clampedPage - 1) * pageSize, [clampedPage, pageSize]);
|
||||
const pageEndExclusive = useMemo(
|
||||
() => Math.min(pageStart + pageSize, filtered.length),
|
||||
[pageStart, pageSize, filtered.length]
|
||||
);
|
||||
|
||||
const paginated = useMemo(() => {
|
||||
return filtered.slice(pageStart, pageEndExclusive);
|
||||
}, [filtered, pageStart, pageEndExclusive]);
|
||||
|
||||
return (
|
||||
<main className="min-h-screen bg-black text-zinc-50">
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
<header className="mb-6">
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
Admin
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Products <span className="text-amber-300">Catalog</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-2xl">
|
||||
Read-only view of products coming from the Ballistic backend. Filter by
|
||||
platform, search, and spot-check pricing + buy links.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
<section className="rounded-lg border border-zinc-800 bg-zinc-950/60 p-3 md:p-4">
|
||||
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
|
||||
<div className="flex flex-wrap items-center gap-3">
|
||||
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
||||
Platform
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(e) => setPlatform(e.target.value)}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{platforms.map((p) => (
|
||||
<option key={p.key} value={p.key}>
|
||||
{p.label}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<label htmlFor="q" className="text-xs text-zinc-500">
|
||||
Search
|
||||
</label>
|
||||
<input
|
||||
id="q"
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
placeholder="brand, name, part role…"
|
||||
className="w-64 max-w-full rounded-md border border-zinc-700 bg-zinc-900/70 px-2 py-1 text-xs text-zinc-200 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
||||
Page size
|
||||
<select
|
||||
value={pageSize}
|
||||
onChange={(e) => setPageSize(Number(e.target.value))}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{[25, 50, 100, 200].map((n) => (
|
||||
<option key={n} value={n}>
|
||||
{n}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div className="text-xs text-zinc-500">
|
||||
{loading ? (
|
||||
<span>Loading…</span>
|
||||
) : error ? (
|
||||
<span className="text-red-400">{error}</span>
|
||||
) : (
|
||||
<span>
|
||||
Showing{" "}
|
||||
<span className="font-medium text-zinc-200">
|
||||
{filtered.length === 0 ? 0 : pageStart + 1}-{pageEndExclusive}
|
||||
</span>
|
||||
{" "}of{" "}
|
||||
<span className="font-medium text-zinc-200">{filtered.length}</span>
|
||||
{" "}{filtered.length === 1 ? "product" : "products"}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div className="mt-3 flex flex-col gap-2 sm:flex-row sm:items-center sm:justify-between">
|
||||
<div className="text-[0.7rem] text-zinc-500">
|
||||
Page <span className="font-semibold text-zinc-200">{clampedPage}</span> of{" "}
|
||||
<span className="font-semibold text-zinc-200">{totalPages}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center gap-2">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={clampedPage === 1}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Previous
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={clampedPage === totalPages}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-3 py-1.5 text-xs font-semibold text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||
>
|
||||
Next
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-4 overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
|
||||
<table className="min-w-full text-xs md:text-sm">
|
||||
<thead>
|
||||
<tr className="border-b border-zinc-800 bg-zinc-900/60">
|
||||
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Product</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Brand</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Platform</th>
|
||||
<th className="px-3 py-2 text-left font-semibold text-zinc-400">Part Role</th>
|
||||
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Price</th>
|
||||
<th className="px-3 py-2 text-right font-semibold text-zinc-400">Link</th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{loading ? (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
Loading products…
|
||||
</td>
|
||||
</tr>
|
||||
) : error ? (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-center text-sm text-red-400" colSpan={6}>
|
||||
{error}
|
||||
</td>
|
||||
</tr>
|
||||
) : paginated.length === 0 ? (
|
||||
<tr>
|
||||
<td className="px-3 py-6 text-center text-sm text-zinc-500" colSpan={6}>
|
||||
No products found.
|
||||
</td>
|
||||
</tr>
|
||||
) : (
|
||||
paginated.map((p) => (
|
||||
<tr
|
||||
key={p.id}
|
||||
className="border-t border-zinc-900 hover:bg-zinc-900/60 transition-colors"
|
||||
>
|
||||
<td className="px-3 py-2 align-top">
|
||||
<div className="font-medium text-zinc-100 line-clamp-2">{p.name}</div>
|
||||
<div className="mt-0.5 text-[0.7rem] text-zinc-600">ID: {p.id}</div>
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-zinc-300">{p.brand}</td>
|
||||
<td className="px-3 py-2 align-top text-zinc-300">
|
||||
{p.platform ?? platformKeyToApiPlatform(platform)}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-zinc-300">{p.partRole}</td>
|
||||
<td className="px-3 py-2 align-top text-right text-amber-300">
|
||||
{p.price == null ? "—" : `$${Number(p.price).toFixed(2)}`}
|
||||
</td>
|
||||
<td className="px-3 py-2 align-top text-right">
|
||||
{p.buyUrl ? (
|
||||
<a
|
||||
href={p.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center whitespace-nowrap rounded-md bg-amber-400 px-2.5 py-1.5 text-xs font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Open
|
||||
</a>
|
||||
) : (
|
||||
<span className="text-zinc-600">—</span>
|
||||
)}
|
||||
</td>
|
||||
</tr>
|
||||
))
|
||||
)}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
|
||||
{!loading && !error && filtered.length > 0 && (
|
||||
<div className="mt-3 flex items-center justify-center gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.max(1, p - 1))}
|
||||
disabled={clampedPage === 1}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||
aria-label="Previous page"
|
||||
>
|
||||
<
|
||||
</button>
|
||||
|
||||
<span className="text-xs text-zinc-500">
|
||||
Page{" "}
|
||||
<span className="font-semibold text-zinc-200">
|
||||
{clampedPage}
|
||||
</span>{" "}
|
||||
of{" "}
|
||||
<span className="font-semibold text-zinc-200">
|
||||
{totalPages}
|
||||
</span>
|
||||
</span>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setPage((p) => Math.min(totalPages, p + 1))}
|
||||
disabled={clampedPage === totalPages}
|
||||
className="inline-flex h-8 w-8 items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/70 text-zinc-200 hover:bg-zinc-800 disabled:opacity-40"
|
||||
aria-label="Next page"
|
||||
>
|
||||
>
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-3 text-[0.7rem] text-zinc-500">
|
||||
Backend: <span className="font-mono text-zinc-400">{API_BASE_URL}</span> · Endpoint: <span className="font-mono text-zinc-400">/api/v1/products?platform=...</span> · Page size: <span className="font-mono text-zinc-400">{pageSize}</span>
|
||||
</div>
|
||||
</section>
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
export default function Page() {
|
||||
return <AdminProductsClient />;
|
||||
}
|
||||
@@ -7,7 +7,7 @@ import { Loader2 } from "lucide-react";
|
||||
import type { AdminUser, UserRole } from "@/types/user";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
export default function AdminUsersPage() {
|
||||
const { getAuthHeaders } = useAuth();
|
||||
@@ -17,11 +17,12 @@ export default function AdminUsersPage() {
|
||||
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_BASE_URL}/admin/users`, {
|
||||
const res = await fetch(`/api/admin/users`, {
|
||||
headers: {
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
|
||||
@@ -2,11 +2,13 @@ import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "http://localhost:8080";
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
||||
export async function POST(req: Request) {
|
||||
const token = cookies().get("bb_access_token")?.value;
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const token = cookieStore.get("session_token")?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
||||
@@ -32,4 +34,4 @@ export async function POST(req: Request) {
|
||||
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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user