28 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
111 changed files with 16820 additions and 955 deletions
-4
View File
@@ -7,7 +7,3 @@ NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
# Brevo API key for beta sign up collection
BREVO_API_KEY=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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
# 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
# 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
on:
push:
pull_request:
branches:
- develop
jobs:
test:
deploy:
runs-on: ubuntu
steps:
- 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>
);
}
+61 -18
View File
@@ -28,6 +28,7 @@ import { X, Link2, Square, CheckSquare } from "lucide-react";
import { useApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { trackBuildStarted, trackPartSelected, trackAffiliateLinkClicked, trackBuildShared, trackBuildSaved } from "@/lib/analytics";
import { CATEGORIES } from "@/data/gunbuilderParts";
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
@@ -295,7 +296,7 @@ function buildAssemblyRows(params: {
rows.push({
key: `${groupLabel}-${paths.complete}`,
kind: "category",
label: "Completed Lower (Fastest)",
label: `Completed ${groupLabel.split(" ")[0]} (Fastest)`,
categoryId: paths.complete,
indent: 0,
pill: mode === "complete" ? "Selected" : undefined,
@@ -355,6 +356,8 @@ function GunbuilderPageContent() {
// Parts data state
// -----------------------------
const [parts, setParts] = useState<Part[]>([]);
const previousPartIdsRef = useRef<Set<string>>(new Set());
const isFirstPartsHydrationRef = useRef(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -369,20 +372,33 @@ function GunbuilderPageContent() {
useEffect(() => {
if (typeof window === "undefined") return;
// Hydrate persisted build state only. URL actions (platform/select/remove)
// are handled by the dedicated useSearchParams effect later in this file.
// Intentional: Object.keys(...).length > 0 tightens the original condition.
// 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);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (parsed && typeof parsed === "object") {
if (parsed && typeof parsed === "object" && Object.keys(parsed).length > 0) {
setBuild(normalizeBuildState(parsed));
hasBuild = true;
}
} catch {
// 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
setIsHydrated(true);
}, []);
@@ -602,6 +618,7 @@ function GunbuilderPageContent() {
if (ids.length === 0) {
lastHydrateKeyRef.current = "";
setParts([]);
previousPartIdsRef.current = new Set(); // keep in sync so re-selecting fires the event
setError(null);
setLoading(false);
return;
@@ -654,6 +671,21 @@ function GunbuilderPageContent() {
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
lastHydrateKeyRef.current = key;
} catch (err: any) {
@@ -842,6 +874,7 @@ function GunbuilderPageContent() {
type: "success",
message: "Saved to your Vault ✅ ",
});
trackBuildSaved();
// Close modal + reset inputs for next variant
setSaveAsOpen(false);
@@ -1055,6 +1088,7 @@ function GunbuilderPageContent() {
try {
await navigator.clipboard.writeText(shareUrl);
trackBuildShared();
setShareStatus("Shareable link copied to clipboard.");
} catch {
setShareStatus("Could not copy link to clipboard.");
@@ -1074,6 +1108,7 @@ function GunbuilderPageContent() {
text: "Check out my Battl Build.",
url: shareUrl,
});
trackBuildShared(); // fires only on successful share, not on cancel
setShareStatus("Share dialog opened.");
} catch {
setShareStatus(
@@ -1523,25 +1558,25 @@ function GunbuilderPageContent() {
)}
<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>
<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
</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
</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
</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
</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
</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
</th>
</tr>
@@ -1557,7 +1592,7 @@ function GunbuilderPageContent() {
>
<td
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="font-semibold text-zinc-400">
@@ -1606,7 +1641,7 @@ function GunbuilderPageContent() {
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">
{indent > 0 && (
<div
@@ -1654,24 +1689,24 @@ function GunbuilderPageContent() {
</div>
</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 : "—"}
</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.price.toFixed(2)}`
: "—"}
</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 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 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">
{isLocked ? (
<div className="flex items-center gap-2">
@@ -1704,6 +1739,14 @@ function GunbuilderPageContent() {
target="_blank"
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"
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
</a>
@@ -6,6 +6,8 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { BuilderSlotKey } from "@/types/builderSlots";
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
import { trackAffiliateLinkClicked } from "@/lib/analytics";
import { ProductImage } from "@/components/parts/ProductImage";
/**
* API Shapes
@@ -492,12 +494,10 @@ export default function ProductDetailsPage() {
className="h-full w-full"
title="Click to enlarge"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
<ProductImage
src={imageUrl}
alt={product.name}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
</button>
) : (
@@ -699,6 +699,7 @@ export default function ProductDetailsPage() {
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(o))}
>
Buy
</a>
@@ -726,6 +727,7 @@ export default function ProductDetailsPage() {
target="_blank"
rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(bestOffer))}
>
Buy from {merchantLabel(bestOffer)}
</a>
@@ -768,8 +770,7 @@ export default function ProductDetailsPage() {
Close
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
<ProductImage
src={imageUrl}
alt={product?.name ?? "Product image"}
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
+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 { Suspense } from "react";
import { BuilderNav } from "@/components/BuilderNav";
import { BuilderNavConditional } from "@/components/BuilderNavConditional";
import { TopNav } from "@/components/TopNav";
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">
<TopNav />
<Suspense fallback={<div className="h-[52px]" />}>
<BuilderNav />
<BuilderNavConditional />
</Suspense>
<main className="min-h-screen">{children}</main>
<Footer />
+2 -2
View File
@@ -417,9 +417,9 @@ export default function PrivacyPolicy() {
<br />
Battl Builder, LLC.
<br />
[Your Address]
{/* [Your Address]
<br />
[City, State, ZIP]
[City, State, ZIP] */}
</p>
</div>
<p className="mt-4 text-sm">
-6
View File
@@ -168,12 +168,6 @@ export default function AdminLayout({
</p>
<p className="text-sm text-zinc-200">Battl Control Panel</p>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
Internal v0.1
</span>
</div>
</header>
{/* ✅ min-w-0 here prevents table min-width from widening the page */}
+4 -6
View File
@@ -3,7 +3,6 @@
import { useEffect, useMemo, useState } from "react";
import { Plus, Pencil, Trash2, X } from "lucide-react";
import { useAuth } from "@/context/AuthContext";
import {
createPlatform,
deletePlatform,
@@ -15,7 +14,6 @@ import {
} from "@/lib/api/platforms";
export default function AdminPlatformsPage() {
const { getAuthHeaders } = useAuth();
const [platforms, setPlatforms] = useState<Platform[]>([]);
const [loading, setLoading] = useState(true);
@@ -36,7 +34,7 @@ export default function AdminPlatformsPage() {
try {
setLoading(true);
setError(null);
const data = await fetchPlatforms(getAuthHeaders());
const data = await fetchPlatforms({});
setPlatforms(data);
} catch (e: any) {
console.error(e);
@@ -77,7 +75,7 @@ export default function AdminPlatformsPage() {
try {
setSaving(true);
setError(null);
await deletePlatform(getAuthHeaders(), p.id);
await deletePlatform({}, p.id);
await load();
} catch (e: any) {
console.error(e);
@@ -103,7 +101,7 @@ export default function AdminPlatformsPage() {
setError(null);
if (modalMode === "create") {
await createPlatform(getAuthHeaders(), { ...form, key, label });
await createPlatform({}, { ...form, key, label });
} else if (selected) {
const update: UpdatePlatformDto = {};
if (key !== selected.key) update.key = key;
@@ -111,7 +109,7 @@ export default function AdminPlatformsPage() {
if ((form.isActive ?? true) !== selected.isActive)
update.isActive = form.isActive;
await updatePlatform(getAuthHeaders(), selected.id, update);
await updatePlatform({}, selected.id, update);
}
setShowModal(false);
+30 -29
View File
@@ -1,4 +1,4 @@
import { API_BASE, getToken, qs } from "./auth";
import { API_BASE, qs } from "./auth";
import type {
AdminProductRow,
PageResponse,
@@ -9,25 +9,14 @@ import type {
import type { CaliberDto } from "./types";
async function authedFetch(url: string, init?: RequestInit) {
const token = getToken();
const res = await fetch(url, {
credentials: "include",
cache: "no-store",
...(init ?? {}),
headers: {
Accept: "application/json",
...(init?.headers ?? {}),
...(token ? { Authorization: `Bearer ${token}` } : {}),
} as any,
});
return res;
}
export async function fetchAdminRoles(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products/roles?${qs(params)}`
const queryString = qs(params);
const res = await fetch(
`/api/admin/products/roles${queryString ? `?${queryString}` : ''}`,
{
credentials: "include",
cache: "no-store",
}
);
if (!res.ok) throw new Error(`Failed to load roles (${res.status}): ${await res.text()}`);
return (await res.json()) as string[];
@@ -35,8 +24,13 @@ export async function fetchAdminRoles(params: Record<string, any>) {
// Facet/filtering can still use distinct values seen on products (optional)
export async function fetchProductCaliberFacet(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products/calibers?${qs(params)}`
const queryString = qs(params);
const res = await fetch(
`/api/admin/products/calibers${queryString ? `?${queryString}` : ''}`,
{
credentials: "include",
cache: "no-store",
}
);
if (!res.ok)
throw new Error(
@@ -46,8 +40,13 @@ export async function fetchProductCaliberFacet(params: Record<string, any>) {
}
export async function fetchAdminProducts(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/products?${qs(params)}`
const queryString = qs(params);
const res = await fetch(
`/api/admin/products${queryString ? `?${queryString}` : ''}`,
{
credentials: "include",
cache: "no-store",
}
);
if (!res.ok) throw new Error(`Failed to load admin products (${res.status}): ${await res.text()}`);
return (await res.json()) as PageResponse<AdminProductRow>;
@@ -84,14 +83,11 @@ export async function bulkUpdate(
classificationReason: "Admin bulk override";
}>
) {
const token = getToken();
const res = await fetch(`${API_BASE}/api/v1/admin/products/bulk`, {
const res = await fetch(`/api/admin/products/bulk`, {
method: "PATCH",
credentials: "include",
headers: {
"Content-Type": "application/json",
...(token ? { Authorization: `Bearer ${token}` } : {}),
},
body: JSON.stringify({ productIds, ...set }),
});
@@ -103,8 +99,13 @@ export async function bulkUpdate(
export async function fetchAdminCalibers(params: Record<string, any>) {
const res = await authedFetch(
`${API_BASE}/api/v1/admin/calibers?${qs(params)}`
const queryString = qs(params);
const res = await fetch(
`/api/admin/calibers${queryString ? `?${queryString}` : ''}`,
{
credentials: "include",
cache: "no-store",
}
);
if (!res.ok) {
throw new Error(`Failed to load calibers (${res.status}): ${await res.text()}`);
+2 -1
View File
@@ -17,11 +17,12 @@ export default function AdminUsersPage() {
const [error, setError] = useState<string | null>(null);
async function fetchUsers() {
console.log("in the users fetch");
try {
setLoading(true);
setError(null);
const res = await fetch(`${API_BASE_URL}/admin/users`, {
const res = await fetch(`/api/admin/users`, {
headers: {
...getAuthHeaders(),
},
+3 -1
View File
@@ -6,7 +6,9 @@ const 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;
const cookieStore = await cookies();
const token = cookieStore.get("session_token")?.value;
if (!token) {
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" },
});
}
@@ -5,19 +5,21 @@ 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;
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: { id: string } }
{ 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/${params.id}/invite`,
`${API_BASE_URL}/api/v1/admin/beta/requests/${resolvedParams.id}/invite`,
{
method: "POST",
headers,
@@ -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 });
}
}
+2 -1
View File
@@ -5,7 +5,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
+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 }
);
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
@@ -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 }
);
}
}
+8 -5
View File
@@ -5,20 +5,22 @@ 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;
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: { id: string } }
{ 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/${params.id}`, {
const res = await fetch(`${API_BASE_URL}/api/email/${resolvedParams.id}`, {
method: "PUT",
headers: { ...headers, "Content-Type": "application/json" },
body: JSON.stringify(body),
@@ -39,12 +41,13 @@ export async function PUT(
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
{ params }: { params: Promise<{ id: string }> }
) {
try {
const resolvedParams = await params;
const headers = await getAuthHeader();
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
const res = await fetch(`${API_BASE_URL}/api/email/${resolvedParams.id}`, {
method: "DELETE",
headers,
});
+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 });
}
}
+2 -1
View File
@@ -5,7 +5,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get("session_token")?.value;
if (!token) throw new Error("Unauthorized");
return { Authorization: `Bearer ${token}` };
}
+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 }
);
}
}
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
@@ -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 }
);
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
@@ -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 }
);
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
+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 }
);
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
@@ -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 }
);
}
}
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
@@ -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 }
);
}
}
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
@@ -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 }
);
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
+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 }
);
}
}
+5 -3
View File
@@ -4,20 +4,22 @@ 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;
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: { id: string } }
{ 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/${params.id}/import`,
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}/import`,
{ method: 'POST', headers }
);
@@ -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 });
}
}
@@ -4,20 +4,22 @@ 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;
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: { id: string } }
{ 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/${params.id}/offer-sync`,
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}/offer-sync`,
{ method: 'POST', headers }
);
@@ -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 });
}
}
+11 -7
View File
@@ -4,20 +4,22 @@ 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;
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: { id: string } }
{ 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/${params.id}`,
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
{ headers }
);
@@ -36,14 +38,15 @@ export async function GET(
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
{ 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/${params.id}`,
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
{
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
@@ -66,13 +69,14 @@ export async function PUT(
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
{ 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/${params.id}`,
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
{ method: 'DELETE', headers }
);
+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 });
}
}
+2 -1
View File
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
+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 }
);
}
}
+2 -1
View File
@@ -25,7 +25,8 @@ export async function POST(request: NextRequest) {
const data = await res.json();
// Set HTTP-only cookie instead of returning token
cookies().set('session_token', data.token, {
const cookieStore = await cookies();
cookieStore.set('session_token', data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
+2 -1
View File
@@ -2,6 +2,7 @@ import { NextResponse } from 'next/server';
import { cookies } from 'next/headers';
export async function POST() {
cookies().delete('session_token');
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 }
);
}
}
+3 -2
View File
@@ -4,7 +4,8 @@ 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 token = cookies().get('session_token')?.value;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
@@ -16,7 +17,7 @@ export async function GET(request: NextRequest) {
});
if (!res.ok) {
cookies().delete('session_token');
cookieStore.delete('session_token');
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
}
+2 -1
View File
@@ -26,7 +26,8 @@ export async function POST(request: NextRequest) {
// Set HTTP-only cookie on successful registration
if (data.token) {
cookies().set('session_token', data.token, {
const cookieStore = await cookies();
cookieStore.set('session_token', data.token, {
httpOnly: true,
secure: process.env.NODE_ENV === 'production',
sameSite: 'lax',
+11 -7
View File
@@ -4,20 +4,22 @@ 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;
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: { id: string } }
{ 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/${params.id}`,
`${API_BASE_URL}/api/v1/gunbuilder/builds/${id}`,
{ headers }
);
@@ -36,14 +38,15 @@ export async function GET(
export async function PUT(
request: NextRequest,
{ params }: { params: { id: string } }
{ 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/${params.id}`,
`${API_BASE_URL}/api/v1/gunbuilder/builds/${id}`,
{
method: 'PUT',
headers: { ...headers, 'Content-Type': 'application/json' },
@@ -66,13 +69,14 @@ export async function PUT(
export async function DELETE(
request: NextRequest,
{ params }: { params: { id: string } }
{ 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/${params.id}`,
`${API_BASE_URL}/api/v1/gunbuilder/builds/${id}`,
{ method: 'DELETE', headers }
);
+2 -1
View File
@@ -4,7 +4,8 @@ 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;
const cookieStore = await cookies();
const token = cookieStore.get('session_token')?.value;
if (!token) throw new Error('Unauthorized');
return { Authorization: `Bearer ${token}` };
}
+11 -15
View File
@@ -1,6 +1,7 @@
// app/builds/[buildId]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { ProductImage } from "@/components/parts/ProductImage";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
@@ -29,11 +30,13 @@ function timeAgo(iso?: string | null) {
export default async function BuildBreakdownPage({
params,
}: {
params: { buildId: string };
params: Promise<{ buildId: string }>;
}) {
const { buildId } = await params;
// Public detail endpoint - use Next.js API route
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/builds/public/${params.buildId}`, {
const res = await fetch(`${baseUrl}/api/builds/public/${buildId}`, {
cache: "no-store",
});
@@ -167,18 +170,11 @@ export default async function BuildBreakdownPage({
>
{/* Thumb */}
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
{it.productImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.productImageUrl}
alt={it.productName ?? "Part image"}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
IMG
</div>
)}
<ProductImage
src={it.productImageUrl}
alt={it.productName ?? "Part image"}
className="h-full w-full object-cover"
/>
</div>
{/* Info */}
@@ -359,7 +355,7 @@ export default async function BuildBreakdownPage({
<div className="flex items-center justify-between">
<span className="text-zinc-500">Build ID</span>
<span className="text-zinc-300">
{(build.uuid ?? params.buildId).slice(0, 8)}
{(build.uuid ?? buildId).slice(0, 8)}
</span>
</div>
<div className="flex items-center justify-between">
+6 -9
View File
@@ -2,20 +2,17 @@
import type { ReactNode } from "react";
import { Suspense } from "react";
import { AuthProvider } from "@/context/AuthContext";
import { BuilderNav } from "@/components/BuilderNav";
import { TopNav } from "@/components/TopNav";
export default function BuilderLayout({ children }: { children: ReactNode }) {
return (
<div className="min-h-screen bg-neutral-950 text-zinc-50">
<AuthProvider>
<TopNav />
<Suspense fallback={<div className="h-[52px]" />}>
<BuilderNav />
</Suspense>
<main className="min-h-screen">{children}</main>
</AuthProvider>
<TopNav />
<Suspense fallback={<div className="h-[52px]" />}>
<BuilderNav />
</Suspense>
<main className="min-h-screen">{children}</main>
</div>
);
}
}
+4 -17
View File
@@ -20,27 +20,14 @@ export const metadata = {
},
};
const themeScript = `
(function () {
try {
const storedTheme = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = storedTheme || (prefersDark ? "dark" : "light");
document.documentElement.classList.toggle("dark", theme === "dark");
} catch (_) {}
})();
`;
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className="dark">
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<html lang="en" className="dark">
<head />
<body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
<body className="bg-black text-zinc-50">
{" "}
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="ad310b4a-aa5e-471a-938b-47a6c0f4108d"
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="15c6b9ad-4119-4981-829c-26db69c07a15"
strategy="lazyOnload"
/>
<AuthProvider>
+2 -1
View File
@@ -13,7 +13,7 @@ const API_BASE_URL =
function LoginPageContent() {
const router = useRouter();
const searchParams = useSearchParams();
const { login, loading } = useAuth();
const { login, refreshUser, loading } = useAuth();
const [email, setEmail] = useState("");
const [password, setPassword] = useState("");
@@ -36,6 +36,7 @@ function LoginPageContent() {
try {
await login({ email, password });
await refreshUser();
router.push(next);
} catch (err: any) {
setError(err?.message ?? "Failed to log in");
+1 -4
View File
@@ -13,7 +13,7 @@ export default function NotFound() {
</h1>
<p className="mt-3 text-sm text-zinc-400">
This area of the site isnt publicly accessible yet.
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className="mt-6 flex justify-center gap-3">
@@ -32,9 +32,6 @@ export default function NotFound() {
</a> */}
</div>
<p className="mt-6 text-[11px] text-zinc-600">
Early access features are launching soon.
</p>
</div>
</main>
);
+4 -4
View File
@@ -172,12 +172,12 @@ export default function HomePage() {
</div>
<nav className="flex items-center gap-2 sm:gap-3">
<a
href="#request-access"
<Link
href="/builder"
className="rounded-md border border-zinc-800 bg-zinc-950/60 px-3 py-2 text-xs font-medium text-zinc-200 hover:bg-zinc-900 hover:text-white"
>
Request Access
</a>
Go To Builder
</Link>
<Link
href="/login"
+4 -13
View File
@@ -119,24 +119,15 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
{/* LEFT SIDE: primary nav */}
{/* ------------------------------------------------------------------ */}
<Link
href="/builder"
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
>
Builder
</Link>
{/* Section label — makes the catalog bar purpose unmistakable */}
<span className="text-[9px] font-bold tracking-[0.2em] uppercase text-zinc-600 select-none pr-3 border-r border-zinc-800">
Parts
</span>
{renderDropdown("Lower Parts", lower)}
{renderDropdown("Upper Parts", upper)}
{renderDropdown("Accessories", accessories)}
<Link
href="/builds"
className="font-medium text-neutral-300 hover:text-white tracking-[0.25em] uppercase text-[11px]"
>
Builds
</Link>
{/* ------------------------------------------------------------------ */}
{/* RIGHT SIDE: actions + state */}
{/* IMPORTANT: this is the ONLY `ml-auto` in the nav to avoid conflicts */}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { usePathname } from "next/navigation";
import { BuilderNav } from "./BuilderNav";
/**
* Wraps BuilderNav with pathname-aware visibility.
* The catalog bar is irrelevant on guide pages — hide it there.
*/
export function BuilderNavConditional() {
const pathname = usePathname();
// Hide catalog bar on guide pages — part categories don't apply when reading an article
if (pathname.startsWith("/guides")) return null;
return <BuilderNav />;
}
export default BuilderNavConditional;
+91
View File
@@ -0,0 +1,91 @@
'use client'
import { PortableText, type PortableTextComponents } from 'next-sanity'
import Image from 'next/image'
import { urlFor } from '@/lib/sanity'
const components: PortableTextComponents = {
block: {
normal: ({ children }) => (
<p className="mb-5 leading-relaxed text-zinc-300">{children}</p>
),
h1: ({ children }) => (
<h1 className="mb-4 mt-10 text-3xl font-semibold text-zinc-100">{children}</h1>
),
h2: ({ children }) => (
<h2 className="mb-3 mt-8 text-2xl font-semibold text-zinc-100">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-6 text-xl font-semibold text-zinc-100">{children}</h3>
),
blockquote: ({ children }) => (
<blockquote className="my-6 border-l-2 border-amber-400/60 pl-4 text-zinc-400 italic">
{children}
</blockquote>
),
},
marks: {
strong: ({ children }) => (
<strong className="font-semibold text-zinc-100">{children}</strong>
),
em: ({ children }) => <em className="italic text-zinc-300">{children}</em>,
code: ({ children }) => (
<code className="rounded bg-zinc-800 px-1.5 py-0.5 font-mono text-sm text-amber-300">
{children}
</code>
),
link: ({ value, children }) => (
<a
href={value?.href}
target={value?.href?.startsWith('http') ? '_blank' : undefined}
rel={value?.href?.startsWith('http') ? 'noopener noreferrer' : undefined}
className="text-amber-400 underline underline-offset-2 hover:text-amber-300"
>
{children}
</a>
),
},
list: {
bullet: ({ children }) => (
<ul className="mb-5 ml-6 list-disc space-y-1.5 text-zinc-300">{children}</ul>
),
number: ({ children }) => (
<ol className="mb-5 ml-6 list-decimal space-y-1.5 text-zinc-300">{children}</ol>
),
},
listItem: {
bullet: ({ children }) => <li className="leading-relaxed">{children}</li>,
number: ({ children }) => <li className="leading-relaxed">{children}</li>,
},
types: {
image: ({ value }) => {
if (!value?.asset) return null
return (
<figure className="my-8">
<div className="relative overflow-hidden rounded-lg">
<Image
src={urlFor(value).width(800).url()}
alt={value.alt ?? ''}
width={800}
height={450}
className="w-full object-cover"
/>
</div>
{value.caption && (
<figcaption className="mt-2 text-center text-xs text-zinc-500">
{value.caption}
</figcaption>
)}
</figure>
)
},
},
}
export function PortableTextRenderer({ value }: { value: unknown[] }) {
return (
<div className="prose-battl">
<PortableText value={value as any} components={components} />
</div>
)
}
+16 -10
View File
@@ -6,7 +6,6 @@ import Image from "next/image";
import { usePathname } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import ThemeToggle from "@/components/ThemeToggle";
function NavLink({
href,
@@ -18,15 +17,17 @@ function NavLink({
title?: string;
}) {
const pathname = usePathname();
const active = pathname === href;
const active = pathname === href || pathname.startsWith(href + '/');
return (
<Link
href={href}
title={title}
className={[
"text-xs font-medium transition-colors",
active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100",
"relative text-xs font-medium transition-colors pb-0.5",
active
? "text-zinc-100 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-amber-400"
: "text-zinc-400 hover:text-zinc-100",
].join(" ")}
>
{children}
@@ -55,10 +56,15 @@ export function TopNav() {
/>
</Link>
{/* Right side actions */}
<div className="flex items-center gap-4">
<ThemeToggle />
{/* Centre: primary nav links */}
<nav className="hidden sm:flex items-center gap-5">
<NavLink href="/builder">Builder</NavLink>
<NavLink href="/guides">Guides</NavLink>
<NavLink href="/builds">Community</NavLink>
</nav>
{/* Right side actions */}
<div className="flex items-center justify-end gap-3 sm:gap-4">
{loading ? (
<span className="text-xs text-zinc-500">Checking session</span>
) : isAuthenticated ? (
@@ -83,7 +89,7 @@ export function TopNav() {
</button>
</>
) : (
<>
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center sm:gap-4">
<NavLink href="/login">Log In</NavLink>
<Link
@@ -92,11 +98,11 @@ export function TopNav() {
>
Join Beta
</Link>
</>
</div>
)}
</div>
</div>
</header>
);
}
export default TopNav;
export default TopNav;
+7 -11
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Plus } from "lucide-react";
import type { UiPart } from "@/types/uiPart";
import { ProductImage } from './ProductImage';
export default function PartsGrid(props: {
viewMode: "card" | "list";
@@ -130,17 +131,12 @@ export default function PartsGrid(props: {
>
{/* Image */}
<div className="hidden md:block justify-self-start">
{part.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={part.imageUrl}
alt=""
className="h-10 w-10 rounded object-cover border border-zinc-800 bg-zinc-950"
loading="lazy"
/>
) : (
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-950" />
)}
<ProductImage
src={part.imageUrl}
alt=""
className="h-10 w-10 rounded object-cover border border-zinc-800 bg-zinc-950"
loading="lazy"
/>
</div>
{/* Part */}
+6 -8
View File
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ProductImage } from './ProductImage';
import PlatformSwitcher from "./PlatformSwitcher";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
@@ -149,14 +150,11 @@ export default function ProductDetailPageClient(props: {
<div className="flex flex-col gap-6 md:flex-row">
<div className="w-full md:w-72">
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-800 bg-black/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
{p.imageUrl ? (
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full grid place-items-center text-sm text-zinc-600">
No image
</div>
)}
<ProductImage
src={p.imageUrl}
alt={p.name}
className="h-full w-full object-cover"
/>
</div>
</div>
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useEffect, useRef } from 'react';
const PLACEHOLDER = '/images/product-placeholder.svg';
interface ProductImageProps {
src?: string | null;
alt: string;
className?: string;
width?: number;
height?: number;
loading?: 'lazy' | 'eager';
}
export function ProductImage({ src, alt, className, width, height, loading }: ProductImageProps) {
const hasErrored = useRef(false);
// Reset the loop guard whenever src changes (e.g. navigating between products)
useEffect(() => {
hasErrored.current = false;
}, [src]);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
if (hasErrored.current) return;
hasErrored.current = true;
e.currentTarget.src = PLACEHOLDER;
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src ?? PLACEHOLDER}
alt={alt}
className={className}
width={width}
height={height}
loading={loading}
onError={handleError}
/>
);
}
+10 -10
View File
@@ -3,17 +3,17 @@ import { useMemo } from "react";
import dynamic from "next/dynamic";
import type React from "react";
import "react-quill/dist/quill.snow.css";
// import "react-quill/dist/quill.snow.css";
const ReactQuill = dynamic(() => import("react-quill-new"), { ssr: false });
type RichTextEditorProps = {
value: string;
onChange: (html: string) => void;
placeholder?: string;
className?: string;
};
const ReactQuill = dynamic(() => import("react-quill"), { ssr: false });
type RichTextEditorProps = {
value: string;
onChange: (html: string) => void;
placeholder?: string;
className?: string;
};
export default function RichTextEditor({
value,
onChange,
+21
View File
@@ -0,0 +1,21 @@
import type { BuilderSlotKey } from "@/types/builderSlots";
export function trackBuildStarted() {
window.umami?.track("build_started");
}
export function trackPartSelected(slot: BuilderSlotKey, partName: string, partId: string) {
window.umami?.track("part_selected", { slot, part_name: partName, part_id: partId });
}
export function trackAffiliateLinkClicked(partName: string, retailer: string) {
window.umami?.track("affiliate_link_clicked", { part_name: partName, retailer });
}
export function trackBuildShared() {
window.umami?.track("build_shared");
}
export function trackBuildSaved() {
window.umami?.track("build_saved");
}
+13 -16
View File
@@ -14,9 +14,6 @@ export type CreatePlatformDto = {
export type UpdatePlatformDto = Partial<CreatePlatformDto>;
const API_BASE =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
function normalizePlatform(raw: any): Platform {
const id = Number(raw?.id);
const key = String(raw?.key ?? "").trim();
@@ -33,9 +30,9 @@ function normalizePlatform(raw: any): Platform {
return { id, key, label, isActive };
}
export async function fetchPlatforms(tokenHeaders: HeadersInit): Promise<Platform[]> {
const res = await fetch(`${API_BASE}/api/platforms`, {
headers: { ...tokenHeaders },
export async function fetchPlatforms(_tokenHeaders: HeadersInit): Promise<Platform[]> {
const res = await fetch('/api/admin/platforms', {
credentials: 'include',
});
if (!res.ok) {
@@ -48,15 +45,15 @@ export async function fetchPlatforms(tokenHeaders: HeadersInit): Promise<Platfor
}
export async function createPlatform(
tokenHeaders: HeadersInit,
_tokenHeaders: HeadersInit,
dto: CreatePlatformDto
): Promise<Platform> {
const res = await fetch(`${API_BASE}/api/platforms`, {
const res = await fetch('/api/admin/platforms', {
method: "POST",
headers: {
"Content-Type": "application/json",
...tokenHeaders,
},
credentials: 'include',
body: JSON.stringify(dto),
});
@@ -69,17 +66,17 @@ export async function createPlatform(
}
export async function updatePlatform(
tokenHeaders: HeadersInit,
_tokenHeaders: HeadersInit,
id: number,
dto: UpdatePlatformDto
): Promise<Platform> {
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
const res = await fetch(`/api/admin/platforms/${id}`, {
method: "PATCH",
headers: {
"Content-Type": "application/json",
...tokenHeaders,
},
body: JSON.stringify({ isActive: dto.isActive }),
credentials: 'include',
body: JSON.stringify(dto),
});
if (!res.ok) {
@@ -90,10 +87,10 @@ export async function updatePlatform(
return normalizePlatform(await res.json());
}
export async function deletePlatform(tokenHeaders: HeadersInit, id: number): Promise<void> {
const res = await fetch(`${API_BASE}/api/platforms/${id}`, {
export async function deletePlatform(_tokenHeaders: HeadersInit, id: number): Promise<void> {
const res = await fetch(`/api/admin/platforms/${id}`, {
method: "DELETE",
headers: { ...tokenHeaders },
credentials: 'include',
});
if (!res.ok) {

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