34 Commits

Author SHA1 Message Date
sean 47d5d17d29 placeholder images
CI / deploy (push) Failing after 35s
2026-03-29 07:54:52 -04:00
sean c686da0c03 feat: use ProductImage for part thumbnails on build detail page 2026-03-29 07:43:55 -04:00
sean 680d2b21a1 feat: use ProductImage on product detail page and modal 2026-03-29 07:42:15 -04:00
sean c86d49f317 feat: use ProductImage in ProductDetailPageClient 2026-03-29 07:37:46 -04:00
sean b8a0fd1963 feat: use ProductImage in PartsGrid list view 2026-03-29 07:37:44 -04:00
sean 00613ebc9f feat: add ProductImage component with onError fallback 2026-03-29 07:34:13 -04:00
sean 05a4ad2720 feat: add product image placeholder SVG 2026-03-29 07:28:40 -04:00
sean eb1dfef7b7 analytics
CI / deploy (push) Failing after 5s
2026-03-25 07:16:14 -04:00
sean 10148acaa9 fix: use merchantLabel(o) for retailer in offer row affiliate tracking 2026-03-25 07:10:52 -04:00
sean 019e1892de feat: wire affiliate_link_clicked event in product detail page 2026-03-25 07:09:01 -04:00
sean 668398e66a fix: reset previousPartIdsRef on build clear; clarify first-hydration skip comment 2026-03-25 07:05:57 -04:00
sean f12e2ffee7 fix: fire part_selected after hydration to get real part name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 06:57:13 -04:00
sean 3a345b6c26 feat: wire Umami events in builder page (build_started, part_selected, affiliate_link_clicked, build_shared, build_saved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 06:50:44 -04:00
sean 754a378782 feat: add Umami analytics helper and window type declaration 2026-03-25 06:41:59 -04:00
sean 37921469d5 builder link
CI / deploy (push) Failing after 6s
2026-03-24 06:10:51 -04:00
dstrawsb 5526920ff8 modded ci.yaml file to deploy both images
CI / deploy (push) Failing after 7s
2026-03-20 22:58:11 -04:00
sean fb52521495 fixed nav, added sanity and guides pages
CI / test (push) Successful in 5s
2026-03-12 07:10:37 -04:00
sean f8265063d5 clean up and disabled beta lock
CI / test (push) Successful in 27s
2026-03-11 07:32:58 -04:00
sean 140b83bd65 small stuff
CI / test (push) Successful in 5s
2026-02-01 08:43:31 -05:00
sean 775ee0f691 small stuff 2026-02-01 07:51:42 -05:00
dstrawsb 4989bf6de4 upgraded to next 15 and react 19.
CI / test (push) Successful in 5s
2026-01-30 22:22:41 -05:00
dstrawsb 4267c34399 package.json changes
CI / test (push) Successful in 5s
2026-01-29 22:45:10 -05:00
dstrawsb 6d1697e672 small name change
CI / test (push) Successful in 6s
2026-01-29 22:40:30 -05:00
dstrawsb 45270f9b18 fixes to admin using client fetch
CI / test (push) Successful in 7s
2026-01-29 21:52:12 -05:00
sean a13687635b middleware fixes
CI / test (push) Successful in 5s
2026-01-27 18:57:08 -05:00
sean c2600b6a68 fix env and middleware
CI / test (push) Successful in 5s
2026-01-27 13:40:49 -05:00
sean b45493f5bd fixed umami
CI / test (push) Successful in 5s
2026-01-27 12:54:16 -05:00
sean cb46430ce4 fixed login auth issue
CI / test (push) Successful in 5s
2026-01-27 09:23:38 -05:00
sean 77c31ae4f9 fixed all admin email pages. added admin user info and button on builder page to route to admin page.
CI / test (push) Successful in 5s
2026-01-26 14:58:56 -05:00
sean fb2effca47 productSlug fixes
CI / test (push) Successful in 7s
2026-01-26 07:10:37 -05:00
sean b09ccc542e fixed login session for user name, and updated the api calls on the product detials pages.
CI / test (push) Successful in 6s
2026-01-25 20:20:48 -05:00
sean 3aee0e6755 fixing admin pages
CI / test (push) Successful in 6s
2026-01-25 14:22:58 -05:00
sean e49dc96522 clean up
CI / test (push) Successful in 5s
2026-01-25 14:05:36 -05:00
sean 50ef395a38 woof. killed useClient and use nextjs api
CI / test (push) Successful in 5s
2026-01-25 13:40:10 -05:00
151 changed files with 18835 additions and 2568 deletions
-4
View File
@@ -7,7 +7,3 @@ NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080 NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
# Brevo API key for beta sign up collection
BREVO_API_KEY=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
BREVO_LIST_ID=9
+2 -4
View File
@@ -2,10 +2,8 @@
NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080 NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
# Middleware to limited site to only root page # Middleware to limited site to only root page
NEXT_PUBLIC_LAUNCH_ONLY_ROOT=true LAUNCH_ONLY_ROOT=false
NEXT_PUBLIC_SHORTLINK_BASE_URL=https://battl.build 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
+12 -3
View File
@@ -1,11 +1,20 @@
name: CI name: CI
on: on:
push: push:
pull_request: branches:
- develop
jobs: jobs:
test: deploy:
runs-on: ubuntu runs-on: ubuntu
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- run: echo "Hello from Gitea Actions"
- 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
+6
View File
@@ -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>
+68
View File
@@ -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,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>
);
}
+132 -36
View File
@@ -28,6 +28,7 @@ import { X, Link2, Square, CheckSquare } from "lucide-react";
import { useApi } from "@/lib/api"; import { useApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext"; import { useAuth } from "@/context/AuthContext";
import { trackBuildStarted, trackPartSelected, trackAffiliateLinkClicked, trackBuildShared, trackBuildSaved } from "@/lib/analytics";
import { CATEGORIES } from "@/data/gunbuilderParts"; import { CATEGORIES } from "@/data/gunbuilderParts";
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots"; import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
@@ -74,8 +75,7 @@ const isUuid = (v: string) =>
v v
); );
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
const STORAGE_KEY = "gunbuilder-build-state"; const STORAGE_KEY = "gunbuilder-build-state";
@@ -296,7 +296,7 @@ function buildAssemblyRows(params: {
rows.push({ rows.push({
key: `${groupLabel}-${paths.complete}`, key: `${groupLabel}-${paths.complete}`,
kind: "category", kind: "category",
label: "Complete Assembly (Fastest)", label: `Completed ${groupLabel.split(" ")[0]} (Fastest)`,
categoryId: paths.complete, categoryId: paths.complete,
indent: 0, indent: 0,
pill: mode === "complete" ? "Selected" : undefined, pill: mode === "complete" ? "Selected" : undefined,
@@ -337,8 +337,8 @@ function GunbuilderPageContent() {
const api = useApi(); const api = useApi();
// ✅ Auth: we need the token ready before calling /builds/me/* // ✅ Auth: check if user is logged in
const { token, loading: authLoading, getAuthHeaders } = useAuth(); const { user, loading: authLoading } = useAuth();
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15"); const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15");
@@ -356,6 +356,8 @@ function GunbuilderPageContent() {
// Parts data state // Parts data state
// ----------------------------- // -----------------------------
const [parts, setParts] = useState<Part[]>([]); const [parts, setParts] = useState<Part[]>([]);
const previousPartIdsRef = useRef<Set<string>>(new Set());
const isFirstPartsHydrationRef = useRef(true);
const [loading, setLoading] = useState(false); const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -370,20 +372,33 @@ function GunbuilderPageContent() {
useEffect(() => { useEffect(() => {
if (typeof window === "undefined") return; if (typeof window === "undefined") return;
// Hydrate persisted build state only. URL actions (platform/select/remove) // Intentional: Object.keys(...).length > 0 tightens the original condition.
// are handled by the dedicated useSearchParams effect later in this file. // Previously, an empty {} from localStorage would call setBuild(normalizeBuildState({})),
// which is a no-op. The new check skips that call, which is safe and correct.
let hasBuild = false;
const stored = localStorage.getItem(STORAGE_KEY); const stored = localStorage.getItem(STORAGE_KEY);
if (stored) { if (stored) {
try { try {
const parsed = JSON.parse(stored); const parsed = JSON.parse(stored);
if (parsed && typeof parsed === "object") { if (parsed && typeof parsed === "object" && Object.keys(parsed).length > 0) {
setBuild(normalizeBuildState(parsed)); setBuild(normalizeBuildState(parsed));
hasBuild = true;
} }
} catch { } catch {
// ignore // ignore
} }
} }
// Fire build_started only for genuinely new builds:
// - No existing parts in localStorage
// - No ?select= param (share-link arrivals have empty localStorage but are not starting fresh)
// Use window.location.search directly: this effect runs at mount before any router
// processing, making window.location.search the authoritative source of the landing URL.
const hasSelectParam = new URLSearchParams(window.location.search).has("select");
if (!hasBuild && !hasSelectParam) {
trackBuildStarted();
}
// Signal that hydration is complete - this allows URL param processing to run // Signal that hydration is complete - this allows URL param processing to run
setIsHydrated(true); setIsHydrated(true);
}, []); }, []);
@@ -603,6 +618,7 @@ function GunbuilderPageContent() {
if (ids.length === 0) { if (ids.length === 0) {
lastHydrateKeyRef.current = ""; lastHydrateKeyRef.current = "";
setParts([]); setParts([]);
previousPartIdsRef.current = new Set(); // keep in sync so re-selecting fires the event
setError(null); setError(null);
setLoading(false); setLoading(false);
return; return;
@@ -616,7 +632,7 @@ function GunbuilderPageContent() {
try { try {
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/products/by-ids`, `/api/catalog/products/by-ids`,
{ {
method: "POST", method: "POST",
signal: controller.signal, signal: controller.signal,
@@ -655,6 +671,21 @@ function GunbuilderPageContent() {
setParts(hydrated); setParts(hydrated);
// Track newly selected parts after API fetch — fires with real name, not a numeric ID.
// Skip the first hydration: this covers both localStorage build restoration and ?select=
// share-link arrivals. Neither represents a live user interaction in the current session.
if (isFirstPartsHydrationRef.current) {
isFirstPartsHydrationRef.current = false;
} else {
const prevIds = previousPartIdsRef.current;
for (const p of hydrated) {
if (!prevIds.has(String(p.id))) {
trackPartSelected(p.categoryId as BuilderSlotKey, p.name, String(p.id));
}
}
}
previousPartIdsRef.current = new Set(hydrated.map((p) => String(p.id)));
// ✅ Mark key as hydrated ONLY after success // ✅ Mark key as hydrated ONLY after success
lastHydrateKeyRef.current = key; lastHydrateKeyRef.current = key;
} catch (err: any) { } catch (err: any) {
@@ -728,7 +759,7 @@ function GunbuilderPageContent() {
if (authLoading) return; if (authLoading) return;
// If not logged in, don't spam the backend; show a helpful message instead. // If not logged in, don't spam the backend; show a helpful message instead.
if (!token) { if (!user) {
setShareStatus("Please log in to load builds from your Vault."); setShareStatus("Please log in to load builds from your Vault.");
window.setTimeout(() => setShareStatus(null), 4500); window.setTimeout(() => setShareStatus(null), 4500);
return; return;
@@ -738,14 +769,12 @@ function GunbuilderPageContent() {
try { try {
setShareStatus("Loading build…"); setShareStatus("Loading build…");
// NOTE: using fetch here avoids any “stale api instance” issues and lets us // NOTE: using fetch here goes through our Next.js API routes with HTTP-only cookies
// explicitly attach JWT headers.
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`, `/api/builds/${encodeURIComponent(uuidToLoad)}`,
{ {
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...getAuthHeaders(),
}, },
} }
); );
@@ -787,9 +816,7 @@ function GunbuilderPageContent() {
searchParams, searchParams,
router, router,
authLoading, authLoading,
token, user,
getAuthHeaders,
// API_BASE_URL is a module constant; no need to include
]); ]);
// ----------------------------- // -----------------------------
@@ -847,6 +874,7 @@ function GunbuilderPageContent() {
type: "success", type: "success",
message: "Saved to your Vault ✅ ", message: "Saved to your Vault ✅ ",
}); });
trackBuildSaved();
// Close modal + reset inputs for next variant // Close modal + reset inputs for next variant
setSaveAsOpen(false); setSaveAsOpen(false);
@@ -1060,6 +1088,7 @@ function GunbuilderPageContent() {
try { try {
await navigator.clipboard.writeText(shareUrl); await navigator.clipboard.writeText(shareUrl);
trackBuildShared();
setShareStatus("Shareable link copied to clipboard."); setShareStatus("Shareable link copied to clipboard.");
} catch { } catch {
setShareStatus("Could not copy link to clipboard."); setShareStatus("Could not copy link to clipboard.");
@@ -1079,6 +1108,7 @@ function GunbuilderPageContent() {
text: "Check out my Battl Build.", text: "Check out my Battl Build.",
url: shareUrl, url: shareUrl,
}); });
trackBuildShared(); // fires only on successful share, not on cancel
setShareStatus("Share dialog opened."); setShareStatus("Share dialog opened.");
} catch { } catch {
setShareStatus( setShareStatus(
@@ -1200,6 +1230,7 @@ function GunbuilderPageContent() {
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10"> <div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */} {/* Header */}
<header className="mb-6"> <header className="mb-6">
<div className="flex flex-wrap items-start justify-between gap-3">
<div> <div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500"> <p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
BATTL BUILDERS BATTL BUILDERS
@@ -1213,6 +1244,17 @@ function GunbuilderPageContent() {
update as you go. This early-access builder keeps your setup saved update as you go. This early-access builder keeps your setup saved
locally so you can come back and refine it anytime. locally so you can come back and refine it anytime.
</p> </p>
</div>
{user?.role === "ADMIN" ? (
<Link
href="/admin"
className="inline-flex items-center gap-2 rounded-md border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs font-semibold text-amber-200 hover:border-amber-300 hover:text-amber-100"
>
Admin Toolbar
</Link>
) : null}
</div>
<div className="mt-4 flex flex-wrap items-center gap-3"> <div className="mt-4 flex flex-wrap items-center gap-3">
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2"> <label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
@@ -1246,7 +1288,6 @@ function GunbuilderPageContent() {
Parts list updates automatically when you change platforms. Parts list updates automatically when you change platforms.
</p> </p>
</div> </div>
</div>
</header> </header>
{/* Build summary panel */} {/* Build summary panel */}
@@ -1475,6 +1516,11 @@ function GunbuilderPageContent() {
if (groupCategories.length === 0) return null; if (groupCategories.length === 0) return null;
// Check if there are locked/conflicting parts in this group
const hasConflicts = tableRows.some(
(row) => row.kind === "category" && row.locked
);
return ( return (
<div <div
id={`group-${group.id}`} id={`group-${group.id}`}
@@ -1494,26 +1540,43 @@ function GunbuilderPageContent() {
)} )}
</div> </div>
</div> </div>
{hasConflicts && (
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
<div className="flex items-start gap-2">
<span className="text-amber-400 text-sm"></span>
<div className="flex-1">
<p className="text-sm font-medium text-amber-300">
Part Conflict Detected
</p>
<p className="text-xs text-zinc-300 mt-1">
Some parts below are disabled because you've selected a complete assembly that already includes them. To customize individual parts, remove the complete assembly first.
</p>
</div>
</div>
</div>
)}
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80"> <div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
<table className="min-w-full text-xs md:text-sm"> <table className="min-w-full text-[11px] sm:text-xs md:text-sm">
<thead> <thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60"> <tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-3 py-2 text-left font-semibold text-zinc-400"> <th className="px-2 sm:px-3 py-2 text-left font-semibold text-zinc-400">
Component / Part Type Component / Part Type
</th> </th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400"> <th className="hidden sm:table-cell px-2 sm:px-3 py-2 text-left font-semibold text-zinc-400">
Brand Brand
</th> </th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400"> <th className="px-2 sm:px-3 py-2 text-right font-semibold text-zinc-400">
Price Price
</th> </th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400"> <th className="hidden md:table-cell px-2 sm:px-3 py-2 text-right font-semibold text-zinc-400">
Sale Price Sale Price
</th> </th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400"> <th className="hidden md:table-cell px-2 sm:px-3 py-2 text-left font-semibold text-zinc-400">
Caliber Caliber
</th> </th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400"> <th className="px-2 sm:px-3 py-2 text-right font-semibold text-zinc-400">
Buy / Choose Buy / Choose
</th> </th>
</tr> </tr>
@@ -1529,7 +1592,7 @@ function GunbuilderPageContent() {
> >
<td <td
colSpan={6} colSpan={6}
className="px-3 py-2 text-[0.7rem] text-zinc-500 bg-zinc-950/70" className="px-2 sm:px-3 py-2 text-[0.7rem] text-zinc-500 bg-zinc-950/70"
> >
<div className="flex items-center justify-between gap-2"> <div className="flex items-center justify-between gap-2">
<div className="font-semibold text-zinc-400"> <div className="font-semibold text-zinc-400">
@@ -1578,7 +1641,7 @@ function GunbuilderPageContent() {
row.tone === "muted" ? "opacity-70" : "" row.tone === "muted" ? "opacity-70" : ""
}`} }`}
> >
<td className="px-3 py-2 align-top"> <td className="px-2 sm:px-3 py-2 align-top">
<div className="flex items-start gap-2"> <div className="flex items-start gap-2">
{indent > 0 && ( {indent > 0 && (
<div <div
@@ -1608,9 +1671,15 @@ function GunbuilderPageContent() {
</div> </div>
{selectedPart ? ( {selectedPart ? (
<div className="text-[0.7rem] text-zinc-500 line-clamp-1"> <Link
href={`/parts/p/${canonicalPlatform}/${category.id}/${selectedPart.id}-${selectedPart.name
.toLowerCase()
.replace(/[^a-z0-9]+/g, "-")
.replace(/(^-|-$)/g, "")}`}
className="text-[0.7rem] text-zinc-500 hover:text-amber-300 hover:underline line-clamp-1 transition-colors"
>
{selectedPart.name} {selectedPart.name}
</div> </Link>
) : ( ) : (
<div className="text-[0.7rem] text-zinc-600"> <div className="text-[0.7rem] text-zinc-600">
No part selected No part selected
@@ -1620,28 +1689,47 @@ function GunbuilderPageContent() {
</div> </div>
</td> </td>
<td className="px-3 py-2 align-top text-zinc-300"> <td className="hidden sm:table-cell px-2 sm:px-3 py-2 align-top text-zinc-300">
{selectedPart ? selectedPart.brand : "—"} {selectedPart ? selectedPart.brand : "—"}
</td> </td>
<td className="px-3 py-2 align-top text-right text-amber-300"> <td className="px-2 sm:px-3 py-2 align-top text-right text-amber-300">
{selectedPart {selectedPart
? `$${selectedPart.price.toFixed(2)}` ? `$${selectedPart.price.toFixed(2)}`
: "—"} : "—"}
</td> </td>
<td className="px-3 py-2 align-top text-right text-zinc-400"> <td className="hidden md:table-cell px-2 sm:px-3 py-2 align-top text-right text-zinc-400">
</td> </td>
<td className="px-3 py-2 align-top text-zinc-400"> <td className="hidden md:table-cell px-2 sm:px-3 py-2 align-top text-zinc-400">
</td> </td>
<td className="px-3 py-2 align-top"> <td className="px-2 sm:px-3 py-2 align-top">
<div className="flex justify-end gap-2"> <div className="flex justify-end gap-2">
{isLocked ? ( {isLocked ? (
<div className="text-[0.7rem] text-zinc-500"> <div className="flex items-center gap-2">
<span className="text-[0.7rem] text-amber-400/70">
⚠ Disabled
</span>
<div className="group relative">
<button
type="button"
className="text-zinc-500 hover:text-zinc-300 text-xs"
title="Why is this disabled?"
>
</button>
<div className="invisible group-hover:visible absolute right-0 top-6 z-10 w-64 rounded-md border border-zinc-800 bg-zinc-950 p-3 text-xs text-zinc-300 shadow-xl">
<p className="font-semibold text-amber-300 mb-1">
Part Conflict
</p>
<p>
You've selected a complete assembly which already includes this part. Remove the complete assembly to select individual parts.
</p>
</div>
</div>
</div> </div>
) : selectedPart ? ( ) : selectedPart ? (
<> <>
@@ -1651,6 +1739,14 @@ function GunbuilderPageContent() {
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-amber-600 bg-amber-500 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-400 transition-colors" className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-amber-600 bg-amber-500 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-400 transition-colors"
onClick={() => {
try {
const retailer = new URL(selectedPart.affiliateUrl).hostname.replace(/^www\./, "");
trackAffiliateLinkClicked(selectedPart.name, retailer);
} catch {
// ignore malformed URLs — if affiliateUrl is malformed, the link href is also broken
}
}}
> >
Buy Buy
</a> </a>
@@ -6,6 +6,8 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { BuilderSlotKey } from "@/types/builderSlots"; import type { BuilderSlotKey } from "@/types/builderSlots";
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings"; import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
import { trackAffiliateLinkClicked } from "@/lib/analytics";
import { ProductImage } from "@/components/parts/ProductImage";
/** /**
* API Shapes * API Shapes
@@ -18,6 +20,7 @@ type OfferFromApi = {
price?: number | null; price?: number | null;
originalPrice?: number | null; originalPrice?: number | null;
buyUrl?: string | null; buyUrl?: string | null;
buyShortUrl?: string | null;
inStock?: boolean | null; inStock?: boolean | null;
lastUpdated?: string | null; lastUpdated?: string | null;
}; };
@@ -29,6 +32,7 @@ type GunbuilderProductFromApi = {
platform: string; platform: string;
partRole: string; partRole: string;
price: number | null; price: number | null;
caliber?: string | null;
// Optional (legacy fallback label if product.offers is missing) // Optional (legacy fallback label if product.offers is missing)
merchantName?: string | null; merchantName?: string | null;
@@ -37,6 +41,7 @@ type GunbuilderProductFromApi = {
mainImageUrl?: string | null; mainImageUrl?: string | null;
buyUrl?: string | null; buyUrl?: string | null;
buyShortUrl?: string | null;
inStock?: boolean | null; inStock?: boolean | null;
shortDescription?: string | null; shortDescription?: string | null;
@@ -238,14 +243,23 @@ export default function ProductDetailsPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/v1/products/${numericId}`; const url = `/api/catalog/products/${numericId}`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, {
signal: controller.signal,
credentials: 'include',
});
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load product (${res.status})`); throw new Error(`Failed to load product (${res.status})`);
} }
const data: GunbuilderProductFromApi = await res.json(); const data: GunbuilderProductFromApi = await res.json();
console.log('[DEBUG] Product from API:', {
id: data.id,
name: data.name,
buyUrl: data.buyUrl,
buyShortUrl: data.buyShortUrl
});
setProduct(data); setProduct(data);
} catch (err: any) { } catch (err: any) {
if (err?.name === "AbortError") return; if (err?.name === "AbortError") return;
@@ -272,14 +286,18 @@ export default function ProductDetailsPage() {
try { try {
setOffersLoading(true); setOffersLoading(true);
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`; const url = `/api/catalog/products/${numericId}/offers`;
const res = await fetch(url, { signal: controller.signal }); const res = await fetch(url, {
signal: controller.signal,
credentials: 'include',
});
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load offers (${res.status})`); throw new Error(`Failed to load offers (${res.status})`);
} }
const data: OfferFromApi[] = await res.json(); const data: OfferFromApi[] = await res.json();
console.log('[DEBUG] Offers from API:', data);
setOffersFromApi(Array.isArray(data) ? data : []); setOffersFromApi(Array.isArray(data) ? data : []);
} catch (err: any) { } catch (err: any) {
if (err?.name === "AbortError") return; if (err?.name === "AbortError") return;
@@ -330,14 +348,23 @@ export default function ProductDetailsPage() {
* - Fallback to legacy single-offer derived from product if needed * - Fallback to legacy single-offer derived from product if needed
*/ */
const offers = useMemo(() => { const offers = useMemo(() => {
if (offersFromApi.length > 0) return sortOffers(offersFromApi); if (offersFromApi.length > 0) {
console.log('[DEBUG] Using offers from API, first offer:', offersFromApi[0]);
const fallbackShortUrl = product?.buyShortUrl ?? null;
const normalized = offersFromApi.map((offer) => ({
...offer,
buyShortUrl: offer.buyShortUrl || fallbackShortUrl,
}));
return sortOffers(normalized);
}
if (product?.buyUrl) { if (product?.buyUrl || product?.buyShortUrl) {
console.log('[DEBUG] Using fallback from product, buyShortUrl:', product.buyShortUrl, 'buyUrl:', product.buyUrl);
return sortOffers([ return sortOffers([
{ {
merchantName: product.merchantName ?? "Retailer", merchantName: product.merchantName ?? "Retailer",
price: product.price, price: product.price,
buyUrl: product.buyUrl, buyUrl: product.buyShortUrl || product.buyUrl,
inStock: product.inStock ?? true, inStock: product.inStock ?? true,
}, },
]); ]);
@@ -467,12 +494,10 @@ export default function ProductDetailsPage() {
className="h-full w-full" className="h-full w-full"
title="Click to enlarge" title="Click to enlarge"
> >
{/* eslint-disable-next-line @next/next/no-img-element */} <ProductImage
<img
src={imageUrl} src={imageUrl}
alt={product.name} alt={product.name}
className="h-full w-full object-contain p-2" className="h-full w-full object-contain p-2"
loading="lazy"
/> />
</button> </button>
) : ( ) : (
@@ -559,6 +584,14 @@ export default function ProductDetailsPage() {
{product.platform || platform} {product.platform || platform}
</span> </span>
</span> </span>
{product.caliber && (
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Caliber:{" "}
<span className="font-semibold text-zinc-100">
{product.caliber}
</span>
</span>
)}
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300"> <span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
Role:{" "} Role:{" "}
<span className="font-semibold text-zinc-100"> <span className="font-semibold text-zinc-100">
@@ -660,12 +693,13 @@ export default function ProductDetailsPage() {
{o.price != null ? `$${o.price.toFixed(2)}` : "—"} {o.price != null ? `$${o.price.toFixed(2)}` : "—"}
</td> </td>
<td className="px-3 py-2 text-right"> <td className="px-3 py-2 text-right">
{o.buyUrl ? ( {(o.buyShortUrl || o.buyUrl) ? (
<a <a
href={o.buyUrl} href={o.buyShortUrl || o.buyUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300" className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(o))}
> >
Buy Buy
</a> </a>
@@ -686,13 +720,14 @@ export default function ProductDetailsPage() {
)} )}
{/* Best Offer CTA */} {/* Best Offer CTA */}
{bestOffer?.buyUrl ? ( {(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
<div className="mt-3 flex justify-end"> <div className="mt-3 flex justify-end">
<a <a
href={bestOffer.buyUrl} href={bestOffer.buyShortUrl || bestOffer.buyUrl}
target="_blank" target="_blank"
rel="noreferrer" rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800" className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(bestOffer))}
> >
Buy from {merchantLabel(bestOffer)} Buy from {merchantLabel(bestOffer)}
</a> </a>
@@ -735,8 +770,7 @@ export default function ProductDetailsPage() {
Close Close
</button> </button>
{/* eslint-disable-next-line @next/next/no-img-element */} <ProductImage
<img
src={imageUrl} src={imageUrl}
alt={product?.name ?? "Product image"} alt={product?.name ?? "Product image"}
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain" className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
@@ -1,6 +1,5 @@
import PartsBrowseClient from "@/components/parts/PartsBrowseClient"; import PartsBrowseClient from "@/components/parts/PartsBrowseClient";
export default async function Page(props: { params: Promise<{ platform: string; partRole: string }> }) { export default function Page({ params }: { params: { platform: string; partRole: string } }) {
const params = await props.params;
return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />; return <PartsBrowseClient partRole={params.partRole} platform={params.platform} />;
} }
+151
View File
@@ -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>
)
}
+119
View File
@@ -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>
)
}
+2 -2
View File
@@ -2,7 +2,7 @@
import type { ReactNode } from "react"; import type { ReactNode } from "react";
import { Suspense } from "react"; import { Suspense } from "react";
import { BuilderNav } from "@/components/BuilderNav"; import { BuilderNavConditional } from "@/components/BuilderNavConditional";
import { TopNav } from "@/components/TopNav"; import { TopNav } from "@/components/TopNav";
import { Footer } from "@/components/Footer"; import { Footer } from "@/components/Footer";
@@ -11,7 +11,7 @@ export default function BuilderLayout({ children }: { children: ReactNode }) {
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50"> <div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
<TopNav /> <TopNav />
<Suspense fallback={<div className="h-[52px]" />}> <Suspense fallback={<div className="h-[52px]" />}>
<BuilderNav /> <BuilderNavConditional />
</Suspense> </Suspense>
<main className="min-h-screen">{children}</main> <main className="min-h-screen">{children}</main>
<Footer /> <Footer />
+10 -6
View File
@@ -1,6 +1,5 @@
import React from "react"; import React from "react";
import Link from "next/link"; import Link from "next/link";
import MailingAddressComponent from "@/components/MailingAddressComponent";
export const metadata = { export const metadata = {
title: "Privacy Policy | Battl Builder", title: "Privacy Policy | Battl Builder",
@@ -8,8 +7,6 @@ export const metadata = {
"Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.", "Privacy Policy for Battl Builder. Learn how we collect, use, and protect your data.",
}; };
export default function PrivacyPolicy() { export default function PrivacyPolicy() {
const lastUpdated = "January 9, 2026"; const lastUpdated = "January 9, 2026";
@@ -25,8 +22,7 @@ export default function PrivacyPolicy() {
<section> <section>
<h2 className="mb-4 text-xl font-semibold">1. Introduction</h2> <h2 className="mb-4 text-xl font-semibold">1. Introduction</h2>
<p> <p>
Battl Builder (&quot;we,&quot; &quot;us,&quot; &quot;our,&quot; or &quot;Company&quot;) respects Battl Builder (&quot;we,&quot; &quot;us,&quot; &quot;our,&quot; or &quot;Company&quot;) respects your privacy
your privacy
and is committed to protecting it through this Privacy Policy. This and is committed to protecting it through this Privacy Policy. This
policy explains our practices regarding the collection, use, and policy explains our practices regarding the collection, use, and
protection of your personal information when you access our website protection of your personal information when you access our website
@@ -416,7 +412,15 @@ export default function PrivacyPolicy() {
<strong>Email:</strong>{" "} <strong>Email:</strong>{" "}
<span className="text-amber-300">privacy@battlbuilder.com</span> <span className="text-amber-300">privacy@battlbuilder.com</span>
</p> </p>
<MailingAddressComponent/> <p>
<strong>Mailing Address:</strong>
<br />
Battl Builder, LLC.
<br />
{/* [Your Address]
<br />
[City, State, ZIP] */}
</p>
</div> </div>
<p className="mt-4 text-sm"> <p className="mt-4 text-sm">
We will respond to all privacy inquiries within 30 days of receipt. We will respond to all privacy inquiries within 30 days of receipt.
+8 -9
View File
@@ -18,8 +18,7 @@ type BuildDto = {
updatedAt?: string | null; updatedAt?: string | null;
}; };
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// UUID validator (defensive) // UUID validator (defensive)
const isUuid = (v: string) => const isUuid = (v: string) =>
@@ -40,14 +39,14 @@ export default function VaultPage() {
// NOTE: // NOTE:
// - `loading` here is AuthContext hydration, not network. // - `loading` here is AuthContext hydration, not network.
// - `token` is the real gate for /me endpoints. // - Auth is handled by HTTP-only cookies automatically.
const { user, token, loading: authLoading } = useAuth(); const { user, loading: authLoading } = useAuth();
const [builds, setBuilds] = useState<BuildDto[]>([]); const [builds, setBuilds] = useState<BuildDto[]>([]);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
const [busy, setBusy] = useState(false); const [busy, setBusy] = useState(false);
const authed = !!user && !!token; const authed = !!user;
// Redirect if not logged in (after auth hydrates) // Redirect if not logged in (after auth hydrates)
useEffect(() => { useEffect(() => {
@@ -56,12 +55,12 @@ export default function VaultPage() {
} }
}, [authLoading, authed, router]); }, [authLoading, authed, router]);
// When auth identity changes, clear stale list to avoid ghost builds // When auth identity changes, clear stale list to avoid "ghost builds"
useEffect(() => { useEffect(() => {
if (authLoading) return; if (authLoading) return;
setBuilds([]); setBuilds([]);
setError(null); setError(null);
}, [authLoading, user?.uuid, token]); }, [authLoading, user?.uuid]);
// Fetch user builds // Fetch user builds
useEffect(() => { useEffect(() => {
@@ -75,8 +74,8 @@ export default function VaultPage() {
try { try {
// IMPORTANT: // IMPORTANT:
// Use api wrapper so Authorization: Bearer <token> is consistently applied. // Use api wrapper which includes credentials (cookies) automatically.
const res = await api.get("/api/v1/builds/me"); const res = await api.get("/api/builds");
if (!res.ok) { if (!res.ok) {
const text = await res.text().catch(() => ""); const text = await res.text().catch(() => "");
+11 -41
View File
@@ -1,11 +1,7 @@
"use client"; "use client";
import { useEffect, useMemo, useState } from "react"; import { useEffect, useState } from "react";
import { Button, Field, Input } from "@/components/ui/form"; import { Button, Field, Input } from "@/components/ui/form";
import { useAuth } from "@/context/AuthContext";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type AdminBetaRequestDto = { type AdminBetaRequestDto = {
id: number; id: number;
@@ -40,17 +36,12 @@ type AdminInviteResponse = {
token?: string; token?: string;
}; };
async function fetchJson( async function fetchJson(path: string, init: RequestInit = {}) {
path: string, const res = await fetch(path, {
init: RequestInit = {},
authHeaders: HeadersInit = {}
) {
const res = await fetch(`${API_BASE_URL}${path}`, {
...init, ...init,
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(init.headers ?? {}), ...(init.headers ?? {}),
...authHeaders,
}, },
cache: "no-store", cache: "no-store",
}); });
@@ -101,9 +92,6 @@ function Badge({
} }
export default function AdminBetaInvitesPage() { export default function AdminBetaInvitesPage() {
const { getAuthHeaders, user } = useAuth();
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
// ------------------------------ // ------------------------------
// Batch invites // Batch invites
// ------------------------------ // ------------------------------
@@ -126,11 +114,9 @@ export default function AdminBetaInvitesPage() {
tokenMinutes: String(tokenMinutes || "30"), tokenMinutes: String(tokenMinutes || "30"),
}); });
const data = await fetchJson( const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
`/api/v1/admin/beta/invites/send?${qs.toString()}`, method: "POST",
{ method: "POST" }, });
authHeaders
);
setBatchResult(data); setBatchResult(data);
await loadRequests(); await loadRequests();
@@ -166,9 +152,8 @@ export default function AdminBetaInvitesPage() {
try { try {
const data = (await fetchJson( const data = (await fetchJson(
`/api/v1/admin/beta/requests?page=${page}&size=${size}`, `/api/admin/beta/requests?page=${page}&size=${size}`,
{ method: "GET" }, { method: "GET" }
authHeaders
)) as PageResponse<AdminBetaRequestDto>; )) as PageResponse<AdminBetaRequestDto>;
setRequests(data); setRequests(data);
@@ -181,11 +166,9 @@ export default function AdminBetaInvitesPage() {
async function inviteSingle(userId: number): Promise<AdminInviteResponse> { async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite // calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
return (await fetchJson( return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
`/api/v1/admin/beta/requests/${userId}/invite`, method: "POST",
{ method: "POST" }, })) as AdminInviteResponse;
authHeaders
)) as AdminInviteResponse;
} }
async function onSendInviteEmail(userId: number) { async function onSendInviteEmail(userId: number) {
@@ -241,19 +224,6 @@ export default function AdminBetaInvitesPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps // eslint-disable-next-line react-hooks/exhaustive-deps
}, [page]); }, [page]);
const isAdmin = (user?.role ?? "").toUpperCase() === "ADMIN";
if (!isAdmin) {
return (
<div className="space-y-2">
<h1 className="text-xl font-semibold">Admin</h1>
<p className="text-sm text-zinc-400">
You dont have access to this page.
</p>
</div>
);
}
return ( return (
<div className="space-y-6"> <div className="space-y-6">
<div> <div>
+5 -20
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useMemo, useState } from "react"; import { useCallback, useEffect, useMemo, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import { import {
fetchEmailRequests, fetchEmailRequests,
createEmailRequest, createEmailRequest,
@@ -17,7 +16,6 @@ import { formatDate } from "@/lib/dateFormattingUtility";
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER"; type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
export default function AdminEmailPage() { export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]); const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -33,11 +31,9 @@ export default function AdminEmailPage() {
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL"); const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
const loadEmails = useCallback(async () => { const loadEmails = useCallback(async () => {
if (!token) return;
try { try {
setLoading(true); setLoading(true);
const data = await fetchEmailRequests(token); const data = await fetchEmailRequests();
setEmails(data); setEmails(data);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
@@ -46,7 +42,7 @@ export default function AdminEmailPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token]); }, []);
useEffect(() => { useEffect(() => {
loadEmails(); loadEmails();
@@ -108,11 +104,10 @@ export default function AdminEmailPage() {
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return; if (!confirm("Are you sure you want to delete this email request?")) return;
try { try {
await deleteEmailRequest(token, id); await deleteEmailRequest(id);
await loadEmails(); await loadEmails();
} catch (e: any) { } catch (e: any) {
alert(e?.message ?? "Failed to delete email request"); alert(e?.message ?? "Failed to delete email request");
@@ -121,17 +116,15 @@ export default function AdminEmailPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!token) return;
try { try {
setSubmitting(true); setSubmitting(true);
if (modalMode === "create") { if (modalMode === "create") {
await createEmailRequest(token, formData); await createEmailRequest(formData);
} else if (selectedEmail) { } else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {}; const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email; if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status; if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(token, selectedEmail.id, updateData); await updateEmailRequest(selectedEmail.id, updateData);
} }
setShowModal(false); setShowModal(false);
await loadEmails(); await loadEmails();
@@ -142,14 +135,6 @@ export default function AdminEmailPage() {
} }
}; };
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Header */} {/* Header */}
+5 -20
View File
@@ -1,7 +1,6 @@
"use client"; "use client";
import { useCallback, useEffect, useState } from "react"; import { useCallback, useEffect, useState } from "react";
import { useAuth } from "@/context/AuthContext";
import { import {
fetchEmailRequests, fetchEmailRequests,
createEmailRequest, createEmailRequest,
@@ -14,7 +13,6 @@ import {
import { Pencil, Trash2, Plus, X } from "lucide-react"; import { Pencil, Trash2, Plus, X } from "lucide-react";
import { formatDate } from "@/lib/dateFormattingUtility"; import { formatDate } from "@/lib/dateFormattingUtility";
export default function AdminEmailPage() { export default function AdminEmailPage() {
const { token } = useAuth();
const [emails, setEmails] = useState<EmailRequest[]>([]); const [emails, setEmails] = useState<EmailRequest[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
@@ -28,11 +26,9 @@ export default function AdminEmailPage() {
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const loadEmails = useCallback(async () => { const loadEmails = useCallback(async () => {
if (!token) return;
try { try {
setLoading(true); setLoading(true);
const data = await fetchEmailRequests(token); const data = await fetchEmailRequests();
setEmails(data); setEmails(data);
setError(null); setError(null);
} catch (e: any) { } catch (e: any) {
@@ -41,7 +37,7 @@ export default function AdminEmailPage() {
} finally { } finally {
setLoading(false); setLoading(false);
} }
}, [token]); }, []);
useEffect(() => { useEffect(() => {
loadEmails(); loadEmails();
@@ -62,11 +58,10 @@ export default function AdminEmailPage() {
}; };
const handleDelete = async (id: number) => { const handleDelete = async (id: number) => {
if (!token) return;
if (!confirm("Are you sure you want to delete this email request?")) return; if (!confirm("Are you sure you want to delete this email request?")) return;
try { try {
await deleteEmailRequest(token, id); await deleteEmailRequest(id);
await loadEmails(); await loadEmails();
} catch (e: any) { } catch (e: any) {
alert(e?.message ?? "Failed to delete email request"); alert(e?.message ?? "Failed to delete email request");
@@ -75,17 +70,15 @@ export default function AdminEmailPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
if (!token) return;
try { try {
setSubmitting(true); setSubmitting(true);
if (modalMode === "create") { if (modalMode === "create") {
await createEmailRequest(token, formData); await createEmailRequest(formData);
} else if (selectedEmail) { } else if (selectedEmail) {
const updateData: UpdateEmailRequestDto = {}; const updateData: UpdateEmailRequestDto = {};
if (formData.email !== selectedEmail.email) updateData.email = formData.email; if (formData.email !== selectedEmail.email) updateData.email = formData.email;
if (formData.status !== selectedEmail.status) updateData.status = formData.status; if (formData.status !== selectedEmail.status) updateData.status = formData.status;
await updateEmailRequest(token, selectedEmail.id, updateData); await updateEmailRequest(selectedEmail.id, updateData);
} }
setShowModal(false); setShowModal(false);
await loadEmails(); await loadEmails();
@@ -96,14 +89,6 @@ export default function AdminEmailPage() {
} }
}; };
if (!token) {
return (
<div className="p-6 text-sm text-red-500">
You must be logged in to view this page.
</div>
);
}
return ( return (
<div className="p-6 space-y-4"> <div className="p-6 space-y-4">
{/* Header */} {/* Header */}
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import {JSX, useEffect, useRef, useState} from "react"; import { useEffect, useRef, useState } from "react";
import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail"; import { sendEmailAction, type ApiResponse, type EmailRequest } from "@/app/actions/sendEmail";
import { Button, Field, Input, Textarea } from "@/components/ui/form"; import { Button, Field, Input, Textarea } from "@/components/ui/form";
+4 -3
View File
@@ -46,8 +46,8 @@ export default function ImportStatusPage() {
setError(null); setError(null);
const [summaryRes, byMerchantRes] = await Promise.all([ const [summaryRes, byMerchantRes] = await Promise.all([
fetch(`${API_BASE_URL}/api/admin/import-status/summary`), fetch('/api/admin/import-status/summary', { credentials: 'include' }),
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`), fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
]); ]);
if (!summaryRes.ok || !byMerchantRes.ok) { if (!summaryRes.ok || !byMerchantRes.ok) {
@@ -108,9 +108,10 @@ export default function ImportStatusPage() {
try { try {
setImportingId(merchantId); setImportingId(merchantId);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`, `/api/admin/merchants/${merchantId}/import`,
{ {
method: "POST", method: "POST",
credentials: 'include',
} }
); );
+3 -12
View File
@@ -149,7 +149,7 @@ export default function AdminLayout({
return ( return (
// ✅ prevent browser-level horizontal scroll from any wide children // ✅ prevent browser-level horizontal scroll from any wide children
<div className="min-h-screen bg-black text-zinc-50 overflow-x-hidden pt-[52px]"> <div className="h-screen bg-black text-zinc-50 overflow-hidden">
<AdminLeftNavigation <AdminLeftNavigation
collapsed={collapsed} collapsed={collapsed}
onToggleCollapsed={() => setCollapsed((v) => !v)} onToggleCollapsed={() => setCollapsed((v) => !v)}
@@ -158,7 +158,7 @@ export default function AdminLayout({
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */} {/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
<div <div
className="flex min-h-screen flex-1 min-w-0 flex-col transition-all duration-300" className="flex h-full flex-1 min-w-0 flex-col transition-all duration-300"
style={{ marginLeft: collapsed ? '68px' : '280px' }} 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"> <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">
@@ -168,19 +168,10 @@ export default function AdminLayout({
</p> </p>
<p className="text-sm text-zinc-200">Battl Control Panel</p> <p className="text-sm text-zinc-200">Battl Control Panel</p>
</div> </div>
<div className="flex items-center gap-3">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
Internal v0.1
</span>
<div className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
ADMIN
</div>
</div>
</header> </header>
{/* ✅ min-w-0 here prevents table min-width from widening the page */} {/* ✅ min-w-0 here prevents table min-width from widening the page */}
<main className="flex w-full flex-1 min-w-0 flex-col px-4 py-6"> <main className="flex w-full flex-1 min-w-0 flex-col overflow-y-auto px-4 py-6">
{children} {children}
</main> </main>
</div> </div>
+10 -6
View File
@@ -198,8 +198,9 @@ export default function MappingAdminPage() {
// EXPECTED backend endpoint (new): // EXPECTED backend endpoint (new):
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] } // GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, { const res = await fetch('/api/admin/mapping/options', {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store", cache: "no-store",
}); });
@@ -243,8 +244,9 @@ export default function MappingAdminPage() {
if (tab === "roles") { if (tab === "roles") {
// Existing endpoint you already have: // Existing endpoint you already have:
// GET /api/admin/mapping/pending-buckets // GET /api/admin/mapping/pending-buckets
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, { const res = await fetch('/api/admin/mapping/pending-buckets', {
headers: { Accept: "application/json" }, headers: { Accept: "application/json" },
credentials: 'include',
cache: "no-store", cache: "no-store",
}); });
@@ -277,8 +279,8 @@ export default function MappingAdminPage() {
if (q?.trim()) params.set("q", q.trim()); if (q?.trim()) params.set("q", q.trim());
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/raw-categories?${params.toString()}`, `/api/admin/mapping/raw-categories?${params.toString()}`,
{ headers: { Accept: "application/json" }, cache: "no-store" } { headers: { Accept: "application/json" }, credentials: 'include', cache: "no-store" }
); );
if (!res.ok) { if (!res.ok) {
@@ -336,9 +338,10 @@ export default function MappingAdminPage() {
// Existing endpoint you already have: // Existing endpoint you already have:
// POST /api/admin/mapping/apply // POST /api/admin/mapping/apply
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, { const res = await fetch('/api/admin/mapping/apply', {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
merchantId: row.merchantId, merchantId: row.merchantId,
rawCategoryKey: row.rawCategoryKey, rawCategoryKey: row.rawCategoryKey,
@@ -390,9 +393,10 @@ export default function MappingAdminPage() {
// EXPECTED new endpoint: // EXPECTED new endpoint:
// POST /api/admin/mapping/upsert // POST /api/admin/mapping/upsert
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, { const res = await fetch('/api/admin/mapping/upsert', {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
credentials: 'include',
body: JSON.stringify({ body: JSON.stringify({
merchantId: row.merchantId, merchantId: row.merchantId,
platform: row.platform ?? platform ?? null, platform: row.platform ?? platform ?? null,
+6 -7
View File
@@ -2,8 +2,7 @@
import { useEffect, useState } from "react"; import { useEffect, useState } from "react";
const API_BASE_URL = // API routes now handled by Next.js /api routes (server-side)
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
type MerchantAdminDto = { type MerchantAdminDto = {
id: number; id: number;
@@ -91,7 +90,7 @@ export default function MerchantsAdminPage() {
setLoading(true); setLoading(true);
setError(null); setError(null);
const url = `${API_BASE_URL}/api/admin/merchants`; const url = `/api/admin/merchants`;
console.log("Loading merchants from:", url); console.log("Loading merchants from:", url);
const res = await fetch(url, { const res = await fetch(url, {
@@ -150,7 +149,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, { const res = await fetch(`/api/admin/merchants/${id}`, {
method: "PUT", method: "PUT",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
@@ -189,7 +188,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, { const res = await fetch(`/api/admin/merchants/${id}/import`, {
method: "POST", method: "POST",
}); });
@@ -218,7 +217,7 @@ export default function MerchantsAdminPage() {
setBanner(null); setBanner(null);
const res = await fetch( const res = await fetch(
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`, `/api/admin/merchants/${id}/offer-sync`,
{ method: "POST" } { method: "POST" }
); );
@@ -247,7 +246,7 @@ export default function MerchantsAdminPage() {
setError(null); setError(null);
setBanner(null); setBanner(null);
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, { const res = await fetch(`/api/admin/merchants`, {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify({ body: JSON.stringify({
+3 -1
View File
@@ -24,7 +24,9 @@ export default function AdminLandingPage() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`); const res = await fetch('/api/admin/dashboard/overview', {
credentials: 'include',
});
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load dashboard (${res.status})`); throw new Error(`Failed to load dashboard (${res.status})`);
} }
+4 -6
View File
@@ -3,7 +3,6 @@
import { useEffect, useMemo, useState } from "react"; import { useEffect, useMemo, useState } from "react";
import { Plus, Pencil, Trash2, X } from "lucide-react"; import { Plus, Pencil, Trash2, X } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import { import {
createPlatform, createPlatform,
deletePlatform, deletePlatform,
@@ -15,7 +14,6 @@ import {
} from "@/lib/api/platforms"; } from "@/lib/api/platforms";
export default function AdminPlatformsPage() { export default function AdminPlatformsPage() {
const { getAuthHeaders } = useAuth();
const [platforms, setPlatforms] = useState<Platform[]>([]); const [platforms, setPlatforms] = useState<Platform[]>([]);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
@@ -36,7 +34,7 @@ export default function AdminPlatformsPage() {
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const data = await fetchPlatforms(getAuthHeaders()); const data = await fetchPlatforms({});
setPlatforms(data); setPlatforms(data);
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
@@ -77,7 +75,7 @@ export default function AdminPlatformsPage() {
try { try {
setSaving(true); setSaving(true);
setError(null); setError(null);
await deletePlatform(getAuthHeaders(), p.id); await deletePlatform({}, p.id);
await load(); await load();
} catch (e: any) { } catch (e: any) {
console.error(e); console.error(e);
@@ -103,7 +101,7 @@ export default function AdminPlatformsPage() {
setError(null); setError(null);
if (modalMode === "create") { if (modalMode === "create") {
await createPlatform(getAuthHeaders(), { ...form, key, label }); await createPlatform({}, { ...form, key, label });
} else if (selected) { } else if (selected) {
const update: UpdatePlatformDto = {}; const update: UpdatePlatformDto = {};
if (key !== selected.key) update.key = key; if (key !== selected.key) update.key = key;
@@ -111,7 +109,7 @@ export default function AdminPlatformsPage() {
if ((form.isActive ?? true) !== selected.isActive) if ((form.isActive ?? true) !== selected.isActive)
update.isActive = form.isActive; update.isActive = form.isActive;
await updatePlatform(getAuthHeaders(), selected.id, update); await updatePlatform({}, selected.id, update);
} }
setShowModal(false); setShowModal(false);
+1 -1
View File
@@ -1,6 +1,6 @@
"use client"; "use client";
import {JSX, useCallback, useMemo, useState} from "react"; import { useCallback, useMemo, useState } from "react";
import type { CaliberDto, PlatformDto } from "../_lib/types"; import type { CaliberDto, PlatformDto } from "../_lib/types";
import { RightDrawer } from "./RightDrawer"; import { RightDrawer } from "./RightDrawer";
+28 -27
View File
@@ -1,4 +1,4 @@
import { API_BASE, getToken, qs } from "./auth"; import { API_BASE, qs } from "./auth";
import type { import type {
AdminProductRow, AdminProductRow,
PageResponse, PageResponse,
@@ -9,25 +9,14 @@ import type {
import type { CaliberDto } from "./types"; import type { CaliberDto } from "./types";
export async function fetchAdminRoles(params: Record<string, any>) {
async function authedFetch(url: string, init?: RequestInit) { const queryString = qs(params);
const token = getToken(); const res = await fetch(
const res = await fetch(url, { `/api/admin/products/roles${queryString ? `?${queryString}` : ''}`,
{
credentials: "include", credentials: "include",
cache: "no-store", cache: "no-store",
...(init ?? {}),
headers: {
Accept: "application/json",
...(init?.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
} as any,
});
return res;
} }
export async function fetchAdminRoles(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products/roles?${qs(params)}`
); );
if (!res.ok) throw new Error(`Failed to load roles (${res.status}): ${await res.text()}`); if (!res.ok) throw new Error(`Failed to load roles (${res.status}): ${await res.text()}`);
return (await res.json()) as string[]; return (await res.json()) as string[];
@@ -35,8 +24,13 @@ export async function fetchAdminRoles(params: Record<string, any>) {
// Facet/filtering can still use distinct values seen on products (optional) // Facet/filtering can still use distinct values seen on products (optional)
export async function fetchProductCaliberFacet(params: Record<string, any>) { export async function fetchProductCaliberFacet(params: Record<string, any>) {
const res = await authedFetch( const queryString = qs(params);
`${API_BASE}/api/v1/admin/products/calibers?${qs(params)}` const res = await fetch(
`/api/admin/products/calibers${queryString ? `?${queryString}` : ''}`,
{
credentials: "include",
cache: "no-store",
}
); );
if (!res.ok) if (!res.ok)
throw new Error( throw new Error(
@@ -46,8 +40,13 @@ export async function fetchProductCaliberFacet(params: Record<string, any>) {
} }
export async function fetchAdminProducts(params: Record<string, any>) { export async function fetchAdminProducts(params: Record<string, any>) {
const res = await authedFetch( const queryString = qs(params);
`${API_BASE}/api/v1/admin/products?${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()}`); if (!res.ok) throw new Error(`Failed to load admin products (${res.status}): ${await res.text()}`);
return (await res.json()) as PageResponse<AdminProductRow>; return (await res.json()) as PageResponse<AdminProductRow>;
@@ -84,14 +83,11 @@ export async function bulkUpdate(
classificationReason: "Admin bulk override"; classificationReason: "Admin bulk override";
}> }>
) { ) {
const token = getToken(); const res = await fetch(`/api/admin/products/bulk`, {
const res = await fetch(`${API_BASE}/api/v1/admin/products/bulk`, {
method: "PATCH", method: "PATCH",
credentials: "include", credentials: "include",
headers: { headers: {
"Content-Type": "application/json", "Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
}, },
body: JSON.stringify({ productIds, ...set }), body: JSON.stringify({ productIds, ...set }),
}); });
@@ -103,8 +99,13 @@ export async function bulkUpdate(
export async function fetchAdminCalibers(params: Record<string, any>) { export async function fetchAdminCalibers(params: Record<string, any>) {
const res = await authedFetch( const queryString = qs(params);
`${API_BASE}/api/v1/admin/calibers?${qs(params)}` const res = await fetch(
`/api/admin/calibers${queryString ? `?${queryString}` : ''}`,
{
credentials: "include",
cache: "no-store",
}
); );
if (!res.ok) { if (!res.ok) {
throw new Error(`Failed to load calibers (${res.status}): ${await res.text()}`); throw new Error(`Failed to load calibers (${res.status}): ${await res.text()}`);
+2 -1
View File
@@ -17,11 +17,12 @@ export default function AdminUsersPage() {
const [error, setError] = useState<string | null>(null); const [error, setError] = useState<string | null>(null);
async function fetchUsers() { async function fetchUsers() {
console.log("in the users fetch");
try { try {
setLoading(true); setLoading(true);
setError(null); setError(null);
const res = await fetch(`${API_BASE_URL}/admin/users`, { const res = await fetch(`/api/admin/users`, {
headers: { headers: {
...getAuthHeaders(), ...getAuthHeaders(),
}, },
+3 -1
View File
@@ -6,7 +6,9 @@ const API_BASE_URL =
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30 // POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
export async function POST(req: Request) { export async function POST(req: Request) {
const token = (await cookies()).get("bb_access_token")?.value; const cookieStore = await cookies();
const token = cookieStore.get("session_token")?.value;
if (!token) { if (!token) {
return NextResponse.json({ error: "Missing admin token" }, { status: 401 }); return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
+35
View File
@@ -0,0 +1,35 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
export async function POST(req: Request) {
const token = cookies().get("session_token")?.value;
if (!token) {
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
}
const url = new URL(req.url);
const dryRun = url.searchParams.get("dryRun") ?? "true";
const limit = url.searchParams.get("limit") ?? "0";
const tokenMinutes = url.searchParams.get("tokenMinutes") ?? "30";
const upstream = `${API_BASE_URL}/api/v1/admin/beta/invites/send?dryRun=${dryRun}&limit=${limit}&tokenMinutes=${tokenMinutes}`;
const res = await fetch(upstream, {
method: "POST",
headers: {
Authorization: `Bearer ${token}`,
},
cache: "no-store",
});
const text = await res.text();
return new NextResponse(text, {
status: res.status,
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
});
}
@@ -0,0 +1,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 });
}
}
+40
View File
@@ -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 });
}
}
+39
View File
@@ -0,0 +1,39 @@
import { NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function GET(req: Request) {
try {
const headers = await getAuthHeader();
const url = new URL(req.url);
const search = url.searchParams.toString();
const upstream = `${API_BASE_URL}/api/v1/admin/beta/requests${
search ? `?${search}` : ""
}`;
const res = await fetch(upstream, {
headers,
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
+42
View File
@@ -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 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
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 }
);
}
}
+36
View File
@@ -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 }
);
}
}
+66
View File
@@ -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 });
}
}
+63
View File
@@ -0,0 +1,63 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
method: "DELETE",
headers,
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
+57
View File
@@ -0,0 +1,57 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const 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 });
}
}
+56
View File
@@ -0,0 +1,56 @@
import { NextRequest, NextResponse } from "next/server";
import { cookies } from "next/headers";
const API_BASE_URL =
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/email`, {
headers,
cache: "no-store",
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/email`, {
method: "POST",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
}
}
@@ -0,0 +1,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 }
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const 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 }
);
}
}
+46
View File
@@ -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 }
);
}
}
+48
View File
@@ -0,0 +1,48 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const 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 }
);
}
}
+46
View File
@@ -0,0 +1,46 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const token = cookies().get('session_token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const searchParams = request.nextUrl.searchParams;
const queryString = searchParams.toString();
const res = await fetch(
`${API_BASE_URL}/api/admin/enrichment/run${queryString ? `?${queryString}` : ''}`,
{
method: 'POST',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
const contentType = res.headers.get('content-type');
if (contentType?.includes('application/json')) {
return NextResponse.json(await res.json());
}
return NextResponse.json({ success: true });
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Internal server error' },
{ status: 500 }
);
}
}
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/summary`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/summary`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+36
View File
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,36 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,35 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
headers,
cache: 'no-store',
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const { searchParams } = new URL(request.url);
const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/raw-categories?${searchParams.toString()}`,
{
headers,
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const { searchParams } = new URL(request.url);
const res = await fetch(
`${API_BASE_URL}/api/admin/mapping/raw-categories?${searchParams.toString()}`,
{
headers,
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}/import`,
{ method: 'POST', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/import`,
{ method: 'POST', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
@@ -0,0 +1,37 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}/offer-sync`,
{ method: 'POST', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function POST(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/offer-sync`,
{ method: 'POST', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
{
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+90
View File
@@ -0,0 +1,90 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
{
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+55
View File
@@ -0,0 +1,55 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
headers,
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+54
View File
@@ -0,0 +1,54 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET() {
try {
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
headers,
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+71
View File
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const searchParams = request.nextUrl.searchParams;
const queryString = searchParams.toString();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms${queryString ? `?${queryString}` : ''}`,
{
headers: {
...headers,
Accept: 'application/json',
},
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/platforms`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const searchParams = request.nextUrl.searchParams;
const queryString = searchParams.toString();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms${queryString ? `?${queryString}` : ''}`,
{
headers: {
...headers,
Accept: 'application/json',
},
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/platforms`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+43
View File
@@ -0,0 +1,43 @@
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 PATCH(request: NextRequest) {
try {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/products/bulk`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Internal server error' },
{ status: 500 }
);
}
}
+41
View File
@@ -0,0 +1,41 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function PATCH(request: NextRequest) {
try {
const token = cookies().get('session_token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/products/bulk`,
{
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Internal server error' },
{ status: 500 }
);
}
}
+42
View File
@@ -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/products/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/products/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 }
);
}
}
+42
View File
@@ -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/products/roles${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 }
);
}
}
+40
View File
@@ -0,0 +1,40 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
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/products/roles${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 }
);
}
}
+44
View File
@@ -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 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/products${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();
return NextResponse.json(fixImageUrls(data));
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Internal server error' },
{ status: 500 }
);
}
}
+42
View File
@@ -0,0 +1,42 @@
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/v1/admin/products${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();
return NextResponse.json(fixImageUrls(data));
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Internal server error' },
{ status: 500 }
);
}
}
+71
View File
@@ -0,0 +1,71 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function PATCH(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+68
View File
@@ -0,0 +1,68 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function PATCH(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
{
method: 'PATCH',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json({ ok: true });
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+73
View File
@@ -0,0 +1,73 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const searchParams = request.nextUrl.searchParams;
const queryString = searchParams.toString();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/users${queryString ? `?${queryString}` : ''}`,
{
headers: {
...headers,
Accept: 'application/json',
},
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
const data = await res.json();
return NextResponse.json(data);
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/users`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+72
View File
@@ -0,0 +1,72 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const token = cookies().get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const searchParams = request.nextUrl.searchParams;
const queryString = searchParams.toString();
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/users${queryString ? `?${queryString}` : ''}`,
{
headers: {
...headers,
Accept: 'application/json',
},
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
const data = await res.json();
return NextResponse.json(data);
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/admin/users`, {
method: 'POST',
headers: {
...headers,
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Unauthorized' },
{ status: 401 }
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/auth/beta/signup`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Internal server error' },
{ status: 500 }
);
}
}
+47
View File
@@ -0,0 +1,47 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Forward to backend API
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return NextResponse.json(
{ error: text || 'Login failed' },
{ status: res.status }
);
}
const data = await res.json();
// Set HTTP-only cookie instead of returning token
const cookieStore = await cookies();
cookieStore.set('session_token', data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
// Return user data without token
return NextResponse.json({
user: data.user,
});
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Login failed' },
{ status: 500 }
);
}
}
+8
View File
@@ -0,0 +1,8 @@
import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST() {
const cookieStore = await cookies();
cookieStore.delete('session_token');
return NextResponse.json({ success: true });
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/auth/magic/exchange`, {
method: 'POST',
headers: {
'Content-Type': 'application/json',
},
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Internal server error' },
{ status: 500 }
);
}
}
+31
View File
@@ -0,0 +1,31 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
cookieStore.delete('session_token');
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch user' },
{ status: 500 }
);
}
}
+49
View File
@@ -0,0 +1,49 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function POST(request: NextRequest) {
try {
const body = await request.json();
// Forward to backend API
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
const text = await res.text().catch(() => '');
return NextResponse.json(
{ error: text || 'Registration failed' },
{ status: res.status }
);
}
const data = await res.json();
// Set HTTP-only cookie on successful registration
if (data.token) {
const cookieStore = await cookies();
cookieStore.set('session_token', data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
maxAge: 60 * 60 * 24 * 7, // 7 days
path: '/',
});
}
// Return user data without token
return NextResponse.json({
user: data.user,
});
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Registration failed' },
{ status: 500 }
);
}
}
+94
View File
@@ -0,0 +1,94 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds/${id}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function PUT(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
try {
const { id } = await params;
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds/${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 { id } = await params;
const headers = await getAuthHeader();
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds/${id}`,
{ method: 'DELETE', headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// Public build detail - no auth required
const res = await fetch(
`${API_BASE_URL}/api/v1/builds/${id}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch build' },
{ status: 500 }
);
}
}
+32
View File
@@ -0,0 +1,32 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// Public builds endpoint - no auth required
const res = await fetch(
`${API_BASE_URL}/api/v1/builds?${searchParams}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch builds' },
{ status: 500 }
);
}
}
+58
View File
@@ -0,0 +1,58 @@
import { NextRequest, NextResponse } from 'next/server';
import { cookies } from 'next/headers';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
async function getAuthHeader() {
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
export async function GET(request: NextRequest) {
try {
const headers = await getAuthHeader();
const { searchParams } = new URL(request.url);
const res = await fetch(
`${API_BASE_URL}/api/v1/gunbuilder/builds?${searchParams}`,
{ headers }
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
export async function POST(request: NextRequest) {
try {
const headers = await getAuthHeader();
const body = await request.json();
const res = await fetch(`${API_BASE_URL}/api/v1/gunbuilder/builds`, {
method: 'POST',
headers: { ...headers, 'Content-Type': 'application/json' },
body: JSON.stringify(body),
});
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json(), { status: res.status });
} catch (err: any) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(request: NextRequest) {
try {
const { searchParams } = new URL(request.url);
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/catalog/options?${searchParams}`
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch catalog' },
{ status: 500 }
);
}
}
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products/${id}/offers`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch product offers' },
{ status: 500 }
);
}
}
+35
View File
@@ -0,0 +1,35 @@
import { NextRequest, NextResponse } from 'next/server';
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
export async function GET(
request: NextRequest,
{ params }: { params: { id: string } }
) {
try {
const { id } = params;
// No auth required for public catalog
const res = await fetch(
`${API_BASE_URL}/api/v1/products/${id}`,
{
headers: { 'Content-Type': 'application/json' },
cache: 'no-store',
}
);
if (!res.ok) {
return NextResponse.json(
{ error: await res.text() },
{ status: res.status }
);
}
return NextResponse.json(await res.json());
} catch (err: any) {
return NextResponse.json(
{ error: err?.message || 'Failed to fetch product' },
{ status: 500 }
);
}
}

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