Compare commits
38 Commits
bb-web-app
...
2a97ae4190
| Author | SHA1 | Date | |
|---|---|---|---|
| 2a97ae4190 | |||
| a1bb9e97e1 | |||
| b26bc5263b | |||
| 895a2e7406 | |||
| 47d5d17d29 | |||
| c686da0c03 | |||
| 680d2b21a1 | |||
| c86d49f317 | |||
| b8a0fd1963 | |||
| 00613ebc9f | |||
| 05a4ad2720 | |||
| eb1dfef7b7 | |||
| 10148acaa9 | |||
| 019e1892de | |||
| 668398e66a | |||
| f12e2ffee7 | |||
| 3a345b6c26 | |||
| 754a378782 | |||
| 37921469d5 | |||
| 5526920ff8 | |||
| fb52521495 | |||
| f8265063d5 | |||
| 140b83bd65 | |||
| 775ee0f691 | |||
| 4989bf6de4 | |||
| 4267c34399 | |||
| 6d1697e672 | |||
| 45270f9b18 | |||
| a13687635b | |||
| c2600b6a68 | |||
| b45493f5bd | |||
| cb46430ce4 | |||
| 77c31ae4f9 | |||
| fb2effca47 | |||
| b09ccc542e | |||
| 3aee0e6755 | |||
| e49dc96522 | |||
| 50ef395a38 |
@@ -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
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
Generated
+6
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="UTF-8"?>
|
||||
<project version="4">
|
||||
<component name="db-tree-configuration">
|
||||
<option name="data" value="" />
|
||||
</component>
|
||||
</project>
|
||||
@@ -0,0 +1 @@
|
||||
{"reason":"idle timeout","timestamp":1774866020266}
|
||||
@@ -0,0 +1,10 @@
|
||||
{"type":"server-started","port":60692,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:60692","screen_dir":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/hero-layout.html"}
|
||||
{"source":"user-event","type":"click","text":"AR-15 Build Planner\n Build smarter rifles.No spreadsheets.\n Pick parts by role, catch compatibility issues, compare live prices — all in one place.\n \n START A BUILD →\n \n \n ▶ Builder preview video\n \n \n \n \n A — Centered / Full-width\n Headline + tagline centered, single dominant CTA button, product video below. Clean, magazine-style. Works great on mobile.","choice":"centered","id":null,"timestamp":1774863085356}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/page-sections.html"}
|
||||
{"source":"user-event","type":"click","text":"HERO\n \"Stop Guessing. Start Building.\" + subhead + CTA\n \n ↓\n \n HOW IT WORKS\n 3 steps with icons\n \n ↓\n \n BUILDER PREVIEW VIDEO\n Tool in action\n \n ↓\n \n FEATURE CARDS\n 6 capabilities\n \n ↓\n \n SEO COPY BLOCK\n Short paragraph: \"What is an AR-15 builder tool?\" — targets search\n \n ↓\n \n FINAL CTA\n Second \"Start a Build\" button\n \n \n \n \n C — SEO-Optimized (6 sections)\n Same as B but adds a short prose block at the bottom targeting search keywords (\"AR-15 parts compatibility tool\", \"AR-15 build planner\"). Best for organic discovery over time.","choice":"seo","id":null,"timestamp":1774863262165}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/waiting-1.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/full-design-v1.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/full-design-v2.html"}
|
||||
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/3703-1774862900/full-design-v3.html"}
|
||||
{"type":"server-stopped","reason":"idle timeout"}
|
||||
@@ -0,0 +1 @@
|
||||
3712
|
||||
@@ -0,0 +1,335 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Battl Builders — Homepage Redesign Mockup</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
|
||||
/* ── GRID BACKGROUND ── */
|
||||
.bg-grid {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(251,191,36,0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(251,191,36,0.07) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
.wrap { position: relative; z-index: 1; max-width: 960px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
/* ── NAV ── */
|
||||
nav {
|
||||
position: relative; z-index: 10;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px; max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
.logo { height: 32px; display: flex; align-items: center; gap: 8px; }
|
||||
.logo-text { font-size: 18px; font-weight: 800; letter-spacing: -0.02em; color: #fff; }
|
||||
.logo-text span { color: #fbbf24; }
|
||||
.nav-links { display: flex; gap: 8px; }
|
||||
.btn-ghost {
|
||||
padding: 7px 14px; border-radius: 6px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid #27272a; color: #d4d4d8; background: transparent;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.btn-ghost:hover { background: #18181b; color: #fff; }
|
||||
.btn-amber {
|
||||
padding: 7px 16px; border-radius: 6px; font-size: 12px; font-weight: 700;
|
||||
background: #fbbf24; color: #000; border: none; cursor: pointer;
|
||||
text-decoration: none; letter-spacing: 0.02em;
|
||||
}
|
||||
.btn-amber:hover { background: #f59e0b; }
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero {
|
||||
text-align: center; padding: 80px 24px 72px;
|
||||
max-width: 760px; margin: 0 auto;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.4);
|
||||
border-radius: 99px; padding: 4px 12px;
|
||||
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: rgba(255,255,255,0.6);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.hero-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #fbbf24; }
|
||||
.hero h1 {
|
||||
font-size: clamp(36px, 6vw, 60px); font-weight: 700; line-height: 1.1;
|
||||
letter-spacing: -0.03em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.hero h1 .amber { color: #fbbf24; display: block; }
|
||||
.hero-sub {
|
||||
font-size: 16px; color: #a1a1aa; line-height: 1.6;
|
||||
max-width: 540px; margin: 20px auto 0;
|
||||
}
|
||||
.hero-cta-wrap { margin-top: 36px; display: flex; flex-direction: column; align-items: center; gap: 10px; }
|
||||
.btn-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: #fbbf24; color: #000; font-size: 15px; font-weight: 800;
|
||||
padding: 14px 36px; border-radius: 8px; border: none; cursor: pointer;
|
||||
letter-spacing: 0.02em; text-decoration: none;
|
||||
box-shadow: 0 0 32px rgba(251,191,36,0.25);
|
||||
}
|
||||
.btn-cta:hover { background: #f59e0b; }
|
||||
.hero-micro { font-size: 11px; color: #52525b; }
|
||||
|
||||
/* ── HOW IT WORKS ── */
|
||||
.how { padding: 64px 24px; }
|
||||
.section-label {
|
||||
text-align: center; font-size: 10px; font-weight: 600;
|
||||
letter-spacing: 0.25em; text-transform: uppercase; color: #fbbf24; margin-bottom: 14px;
|
||||
opacity: 0.85;
|
||||
}
|
||||
.how h2 {
|
||||
text-align: center; font-size: 28px; font-weight: 700;
|
||||
letter-spacing: -0.02em; color: #fff; margin-bottom: 48px;
|
||||
}
|
||||
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 24px; max-width: 800px; margin: 0 auto; }
|
||||
.step {
|
||||
text-align: center; padding: 32px 20px;
|
||||
background: rgba(255,255,255,0.03); border: 1px solid #1c1c1f; border-radius: 12px;
|
||||
}
|
||||
.step-num {
|
||||
width: 36px; height: 36px; border-radius: 50%;
|
||||
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3);
|
||||
color: #fbbf24; font-size: 14px; font-weight: 700;
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
margin: 0 auto 16px;
|
||||
}
|
||||
.step h3 { font-size: 14px; font-weight: 600; color: #fff; margin-bottom: 8px; }
|
||||
.step p { font-size: 12px; color: #71717a; line-height: 1.5; }
|
||||
.step-connector { display: none; }
|
||||
|
||||
/* ── VIDEO ── */
|
||||
.video-section { padding: 0 24px 64px; }
|
||||
.video-wrap {
|
||||
max-width: 900px; margin: 0 auto;
|
||||
border-radius: 16px; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.08);
|
||||
background: #000;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
|
||||
}
|
||||
.video-placeholder {
|
||||
aspect-ratio: 16/9; background: #0f0f0f;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 10px;
|
||||
}
|
||||
.play-btn {
|
||||
width: 52px; height: 52px; border-radius: 50%;
|
||||
background: rgba(251,191,36,0.15); border: 1px solid rgba(251,191,36,0.35);
|
||||
display: flex; align-items: center; justify-content: center;
|
||||
font-size: 18px;
|
||||
}
|
||||
.video-placeholder p { font-size: 12px; color: #52525b; }
|
||||
|
||||
/* ── FEATURES ── */
|
||||
.features { padding: 64px 24px; }
|
||||
.features h2 {
|
||||
text-align: center; font-size: 28px; font-weight: 700;
|
||||
letter-spacing: -0.02em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.features-sub { text-align: center; font-size: 14px; color: #71717a; margin-bottom: 44px; }
|
||||
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 16px; max-width: 900px; margin: 0 auto; }
|
||||
.feature-card {
|
||||
padding: 22px; background: rgba(255,255,255,0.025);
|
||||
border: 1px solid #1c1c1f; border-radius: 12px;
|
||||
}
|
||||
.feature-icon { font-size: 20px; margin-bottom: 12px; }
|
||||
.feature-card h3 { font-size: 13px; font-weight: 600; color: #e4e4e7; margin-bottom: 6px; }
|
||||
.feature-card p { font-size: 11px; color: #71717a; line-height: 1.5; }
|
||||
|
||||
/* ── SEO BLOCK ── */
|
||||
.seo-block {
|
||||
padding: 64px 24px;
|
||||
border-top: 1px solid #1c1c1f;
|
||||
}
|
||||
.seo-inner {
|
||||
max-width: 680px; margin: 0 auto;
|
||||
}
|
||||
.seo-inner h2 { font-size: 20px; font-weight: 700; color: #e4e4e7; margin-bottom: 14px; }
|
||||
.seo-inner p { font-size: 13px; color: #71717a; line-height: 1.7; margin-bottom: 12px; }
|
||||
.seo-inner strong { color: #a1a1aa; }
|
||||
|
||||
/* ── FINAL CTA ── */
|
||||
.final-cta {
|
||||
padding: 80px 24px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #1c1c1f;
|
||||
}
|
||||
.final-cta h2 { font-size: 32px; font-weight: 700; color: #fff; margin-bottom: 12px; letter-spacing: -0.02em; }
|
||||
.final-cta p { font-size: 14px; color: #71717a; margin-bottom: 32px; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
footer {
|
||||
border-top: 1px solid #18181b; padding: 20px 24px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 11px; color: #3f3f46; max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
|
||||
/* ── ANNOTATION ── */
|
||||
.annotation {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
background: #18181b; border: 1px solid #27272a; border-radius: 8px;
|
||||
padding: 12px 16px; font-size: 11px; color: #a1a1aa; max-width: 260px;
|
||||
z-index: 100;
|
||||
}
|
||||
.annotation strong { color: #fbbf24; display: block; margin-bottom: 4px; }
|
||||
|
||||
/* Selection overlay */
|
||||
#selection-indicator {
|
||||
display: none;
|
||||
}
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav>
|
||||
<div class="logo">
|
||||
<div class="logo-text">BATTL<span>.</span>BUILDERS</div>
|
||||
</div>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="btn-ghost">Sign In</a>
|
||||
<a href="#" class="btn-amber">Start a Build</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="hero-badge">
|
||||
<span class="hero-badge-dot"></span>
|
||||
AR-15 Build Planner
|
||||
<span style="color:rgba(255,255,255,0.3)">•</span>
|
||||
<span>Free to use</span>
|
||||
</div>
|
||||
<h1>
|
||||
Stop Guessing.
|
||||
<span class="amber">Start Building.</span>
|
||||
</h1>
|
||||
<p class="hero-sub">
|
||||
Pick parts by role, catch compatibility issues before you order, and compare live retailer prices — all without a spreadsheet.
|
||||
</p>
|
||||
<div class="hero-cta-wrap">
|
||||
<a href="#" class="btn-cta">
|
||||
Start a Build →
|
||||
</a>
|
||||
<span class="hero-micro">No account required · Free to use</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT WORKS -->
|
||||
<section class="how wrap">
|
||||
<div class="section-label">How it works</div>
|
||||
<h2>Three steps from idea to order</h2>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">1</div>
|
||||
<h3>Pick your parts</h3>
|
||||
<p>Choose from real parts organized by role — barrel, lower, upper, and more. No part numbers required.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">2</div>
|
||||
<h3>Check compatibility</h3>
|
||||
<p>Catch mismatches before you order. Gas system, buffer weight, caliber — flagged automatically.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">3</div>
|
||||
<h3>Compare prices & buy</h3>
|
||||
<p>See live prices from real retailers. Click through to buy — we never touch your order.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- VIDEO -->
|
||||
<section class="video-section">
|
||||
<div class="video-wrap">
|
||||
<div class="video-placeholder">
|
||||
<div class="play-btn">▶</div>
|
||||
<p>Builder preview — looping screen recording</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="features wrap">
|
||||
<div class="section-label">What's included</div>
|
||||
<h2>Everything you need to plan a build</h2>
|
||||
<p class="features-sub">No fluff. No upsells. Just the tools to plan your AR-15 right.</p>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔧</div>
|
||||
<h3>Guided part roles</h3>
|
||||
<p>Pick parts by role so you're never guessing what fits where.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🛡️</div>
|
||||
<h3>Compatibility warnings</h3>
|
||||
<p>Catch obvious mismatches — gas system, buffer, caliber — before you buy.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">💰</div>
|
||||
<h3>Live pricing totals</h3>
|
||||
<p>Running total as you build, with real retailer listings updated regularly.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔖</div>
|
||||
<h3>Save your builds</h3>
|
||||
<p>Create a free account to save builds and pick up where you left off.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🔗</div>
|
||||
<h3>Shareable build links</h3>
|
||||
<p>Share a build URL with your buddy or your gunsmith. No login needed to view.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<div class="feature-icon">🚫</div>
|
||||
<h3>No account to browse</h3>
|
||||
<p>Explore parts and build freely. Create an account only when you're ready to save.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SEO COPY BLOCK -->
|
||||
<section class="seo-block">
|
||||
<div class="seo-inner">
|
||||
<h2>What is Battl Builders?</h2>
|
||||
<p>
|
||||
Battl Builders is a free <strong>AR-15 build planner</strong> that helps you select compatible parts, compare prices across retailers, and plan your build from start to finish — without juggling spreadsheets or browser tabs.
|
||||
</p>
|
||||
<p>
|
||||
Unlike a generic parts list, Battl Builders understands <strong>AR-15 compatibility rules</strong>: gas system length vs. barrel length, buffer weight vs. stock type, caliber consistency across upper and lower. You get warnings when something doesn't match before you spend money on the wrong part.
|
||||
</p>
|
||||
<p>
|
||||
Whether you're planning your first build or your tenth, Battl Builders gives you a structured, guided way to research parts and <strong>buy with confidence</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FINAL CTA -->
|
||||
<section class="final-cta">
|
||||
<h2>Ready to build?</h2>
|
||||
<p>It's free, no account required, and takes about 5 minutes to start.</p>
|
||||
<a href="#" class="btn-cta">
|
||||
Start a Build →
|
||||
</a>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer>
|
||||
<span>© 2026 BATTL BUILDERS™</span>
|
||||
<span>Free AR-15 build planner</span>
|
||||
</footer>
|
||||
|
||||
<div class="annotation">
|
||||
<strong>📐 Design Mockup</strong>
|
||||
Full homepage redesign preview. Scroll to see all 6 sections. Reply in terminal with feedback.
|
||||
</div>
|
||||
|
||||
<script>
|
||||
// No selection needed for full-page mockup — just viewing
|
||||
</script>
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,312 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Battl Builders — Homepage Redesign Mockup v2</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
|
||||
.bg-grid {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(251,191,36,0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(251,191,36,0.07) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
.wrap { position: relative; z-index: 1; max-width: 960px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
/* ── NAV ── */
|
||||
nav {
|
||||
position: relative; z-index: 10;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px; max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
.logo-text { font-size: 18px; font-weight: 800; letter-spacing: -0.02em; color: #fff; }
|
||||
.logo-text span { color: #fbbf24; }
|
||||
.nav-links { display: flex; gap: 8px; }
|
||||
.btn-ghost {
|
||||
padding: 7px 14px; border-radius: 6px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid #27272a; color: #d4d4d8; background: transparent;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.btn-ghost:hover { background: #18181b; color: #fff; }
|
||||
.btn-amber {
|
||||
padding: 7px 16px; border-radius: 6px; font-size: 12px; font-weight: 700;
|
||||
background: #fbbf24; color: #000; border: none; cursor: pointer;
|
||||
text-decoration: none; letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero {
|
||||
text-align: center; padding: 80px 24px 72px;
|
||||
max-width: 760px; margin: 0 auto; position: relative; z-index: 1;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.4);
|
||||
border-radius: 99px; padding: 5px 14px;
|
||||
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: rgba(255,255,255,0.55);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.hero-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #fbbf24; flex-shrink: 0; }
|
||||
.hero h1 {
|
||||
font-size: clamp(38px, 6.5vw, 64px); font-weight: 700; line-height: 1.05;
|
||||
letter-spacing: -0.035em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.hero h1 .amber { color: #fbbf24; display: block; }
|
||||
.hero-sub {
|
||||
font-size: 17px; color: #a1a1aa; line-height: 1.65;
|
||||
max-width: 520px; margin: 22px auto 0;
|
||||
}
|
||||
.hero-cta-wrap { margin-top: 38px; display: flex; flex-direction: column; align-items: center; gap: 11px; }
|
||||
.btn-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: #fbbf24; color: #000; font-size: 15px; font-weight: 800;
|
||||
padding: 15px 38px; border-radius: 8px; border: none; cursor: pointer;
|
||||
letter-spacing: 0.03em; text-decoration: none;
|
||||
box-shadow: 0 0 40px rgba(251,191,36,0.2);
|
||||
}
|
||||
.btn-cta:hover { background: #f59e0b; }
|
||||
.hero-micro { font-size: 11px; color: #52525b; letter-spacing: 0.05em; }
|
||||
|
||||
/* ── HOW IT WORKS ── */
|
||||
.how { padding: 72px 24px; position: relative; z-index: 1; }
|
||||
.section-label {
|
||||
text-align: center; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: 0.25em; text-transform: uppercase; color: #fbbf24;
|
||||
margin-bottom: 12px; opacity: 0.85;
|
||||
}
|
||||
.how h2 {
|
||||
text-align: center; font-size: 30px; font-weight: 700;
|
||||
letter-spacing: -0.025em; color: #fff; margin-bottom: 6px;
|
||||
}
|
||||
.how-sub { text-align: center; font-size: 14px; color: #71717a; margin-bottom: 48px; }
|
||||
.steps { display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px; max-width: 840px; margin: 0 auto; background: #18181b; border-radius: 12px; overflow: hidden; border: 1px solid #1c1c1f; }
|
||||
.step { padding: 32px 24px; background: #0d0d0f; }
|
||||
.step-num {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.15em;
|
||||
color: #fbbf24; text-transform: uppercase; margin-bottom: 14px;
|
||||
opacity: 0.8;
|
||||
}
|
||||
.step h3 { font-size: 16px; font-weight: 700; color: #fff; margin-bottom: 10px; letter-spacing: -0.01em; }
|
||||
.step p { font-size: 13px; color: #71717a; line-height: 1.6; }
|
||||
|
||||
/* ── VIDEO ── */
|
||||
.video-section { padding: 0 24px 72px; position: relative; z-index: 1; }
|
||||
.video-wrap {
|
||||
max-width: 900px; margin: 0 auto;
|
||||
border-radius: 14px; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.07);
|
||||
background: #000;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(251,191,36,0.05);
|
||||
}
|
||||
.video-placeholder {
|
||||
aspect-ratio: 16/9; background: #0a0a0a;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.play-btn {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 20px;
|
||||
}
|
||||
.video-placeholder p { font-size: 12px; color: #3f3f46; }
|
||||
|
||||
/* ── FEATURES ── */
|
||||
.features { padding: 72px 24px; position: relative; z-index: 1; }
|
||||
.features h2 {
|
||||
text-align: center; font-size: 30px; font-weight: 700;
|
||||
letter-spacing: -0.025em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.features-sub { text-align: center; font-size: 13px; color: #52525b; margin-bottom: 44px; letter-spacing: 0.02em; }
|
||||
.feature-grid { display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px; max-width: 900px; margin: 0 auto; background: #18181b; border-radius: 12px; overflow: hidden; border: 1px solid #1c1c1f; }
|
||||
.feature-card { padding: 24px; background: #0d0d0f; }
|
||||
.feature-icon { font-size: 18px; margin-bottom: 14px; display: block; }
|
||||
.feature-card h3 { font-size: 13px; font-weight: 700; color: #e4e4e7; margin-bottom: 7px; letter-spacing: -0.01em; }
|
||||
.feature-card p { font-size: 12px; color: #52525b; line-height: 1.6; }
|
||||
|
||||
/* ── SEO BLOCK ── */
|
||||
.seo-block {
|
||||
padding: 72px 24px;
|
||||
border-top: 1px solid #18181b;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.seo-inner { max-width: 680px; margin: 0 auto; }
|
||||
.seo-inner h2 { font-size: 22px; font-weight: 700; color: #d4d4d8; margin-bottom: 18px; letter-spacing: -0.02em; }
|
||||
.seo-inner p { font-size: 14px; color: #71717a; line-height: 1.75; margin-bottom: 14px; }
|
||||
.seo-inner strong { color: #a1a1aa; font-weight: 600; }
|
||||
|
||||
/* ── FINAL CTA ── */
|
||||
.final-cta {
|
||||
padding: 96px 24px;
|
||||
text-align: center;
|
||||
border-top: 1px solid #18181b;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
.final-cta h2 { font-size: 36px; font-weight: 700; color: #fff; margin-bottom: 12px; letter-spacing: -0.03em; }
|
||||
.final-cta p { font-size: 14px; color: #52525b; margin-bottom: 32px; }
|
||||
|
||||
/* ── FOOTER ── */
|
||||
footer {
|
||||
border-top: 1px solid #18181b; padding: 20px 24px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 11px; color: #3f3f46; max-width: 960px; margin: 0 auto;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
/* annotation */
|
||||
.annotation {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
background: #18181b; border: 1px solid #27272a; border-radius: 8px;
|
||||
padding: 12px 16px; font-size: 11px; color: #a1a1aa; max-width: 240px;
|
||||
z-index: 200; line-height: 1.5;
|
||||
}
|
||||
.annotation strong { color: #fbbf24; display: block; margin-bottom: 4px; }
|
||||
|
||||
#selection-indicator { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav>
|
||||
<div class="logo-text">BATTL<span>.</span>BUILDERS</div>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="btn-ghost">Sign In</a>
|
||||
<a href="#" class="btn-amber">Start a Build</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<div class="hero-badge">
|
||||
<span class="hero-badge-dot"></span>
|
||||
AR-15 Builds · 100% Free
|
||||
</div>
|
||||
<h1>
|
||||
Stop Guessing.
|
||||
<span class="amber">Start Building.</span>
|
||||
</h1>
|
||||
<p class="hero-sub">
|
||||
Choose components by role. Catch incompatibilities before they cost you.
|
||||
Compare prices across real retailers — no spreadsheets, no guesswork.
|
||||
</p>
|
||||
<div class="hero-cta-wrap">
|
||||
<a href="#" class="btn-cta">Start a Build →</a>
|
||||
<span class="hero-micro">100% Free · No account required</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT WORKS -->
|
||||
<section class="how wrap">
|
||||
<div class="section-label">The process</div>
|
||||
<h2>Three moves. One solid build.</h2>
|
||||
<p class="how-sub">No prior experience required. No part numbers to memorize.</p>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">Step 01</div>
|
||||
<h3>Select your components</h3>
|
||||
<p>Real parts, organized by role — barrel, gas system, upper, lower, and more. Pick what fits your build without needing to know SKUs.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">Step 02</div>
|
||||
<h3>Flag the conflicts</h3>
|
||||
<p>Gas length. Buffer weight. Caliber stack. Battl surfaces the clashes before you spend money on the wrong part.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">Step 03</div>
|
||||
<h3>Price it. Pull the trigger.</h3>
|
||||
<p>Live prices from real retailers, side by side. Click through to buy. We never touch your order.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- VIDEO -->
|
||||
<section class="video-section">
|
||||
<div class="video-wrap">
|
||||
<div class="video-placeholder">
|
||||
<div class="play-btn">▶</div>
|
||||
<p>Builder preview — looping screen recording</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="features wrap">
|
||||
<div class="section-label">What you get</div>
|
||||
<h2>Built for builders who do their homework.</h2>
|
||||
<p class="features-sub">NO SUBSCRIPTIONS · NO SPONSORED RANKINGS · NO BULLSHIT</p>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔧</span>
|
||||
<h3>Build by role, not part number</h3>
|
||||
<p>Parts organized by function. Know what you need, not what SKU to search for.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">⚠️</span>
|
||||
<h3>Conflicts caught early</h3>
|
||||
<p>Gas system, buffer weight, caliber — flagged before checkout, not after the box arrives.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">💰</span>
|
||||
<h3>Real prices. Real retailers.</h3>
|
||||
<p>Running total with live prices. No estimated MSRP. What it actually costs, today.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔖</span>
|
||||
<h3>Save your work</h3>
|
||||
<p>Free account. Save builds, pick up where you left off, run multiple configs in parallel.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔗</span>
|
||||
<h3>Share the build</h3>
|
||||
<p>One link. Your buddy sees the full parts list — no login, no friction, no excuses.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🚫</span>
|
||||
<h3>No gates. No friction.</h3>
|
||||
<p>Browse and build freely. Create an account only when you're ready to save.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SEO COPY BLOCK -->
|
||||
<section class="seo-block">
|
||||
<div class="seo-inner">
|
||||
<h2>What is Battl Builders?</h2>
|
||||
<p>
|
||||
Battl Builders is a free <strong>AR-15 build planner</strong> — the tool you've been doing in spreadsheets, rebuilt for how builders actually work. Select parts by role, validate compatibility as you go, and price your build across multiple retailers without opening a new tab.
|
||||
</p>
|
||||
<p>
|
||||
Most <strong>AR-15 compatibility mistakes</strong> happen at the parts list stage: wrong gas system length for the barrel, buffer weight that fights your stock, caliber mismatches between upper and lower. Battl flags those conflicts automatically, so you catch them before they cost you a return label.
|
||||
</p>
|
||||
<p>
|
||||
Whether you're planning your first rifle or spec'ing a dedicated precision upper, Battl Builders gives you a structured, guided way to research parts and <strong>buy with confidence</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FINAL CTA -->
|
||||
<section class="final-cta">
|
||||
<h2>Your build starts here.</h2>
|
||||
<p>Free. No account required. Takes five minutes to get rolling.</p>
|
||||
<a href="#" class="btn-cta">Start a Build →</a>
|
||||
</section>
|
||||
|
||||
<!-- FOOTER -->
|
||||
<footer>
|
||||
<span>© 2026 BATTL BUILDERS™</span>
|
||||
<span>Free AR-15 build planner</span>
|
||||
</footer>
|
||||
|
||||
<div class="annotation">
|
||||
<strong>v2 — Copy Rewrite</strong>
|
||||
Field ethos tone throughout. Scroll all sections and share feedback in terminal.
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,321 @@
|
||||
<!DOCTYPE html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8">
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||
<title>Battl Builders — Homepage Redesign Mockup v3</title>
|
||||
<style>
|
||||
* { box-sizing: border-box; margin: 0; padding: 0; }
|
||||
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; }
|
||||
|
||||
.bg-grid {
|
||||
position: fixed; inset: 0; pointer-events: none; z-index: 0;
|
||||
background-image:
|
||||
linear-gradient(rgba(251,191,36,0.07) 1px, transparent 1px),
|
||||
linear-gradient(90deg, rgba(251,191,36,0.07) 1px, transparent 1px);
|
||||
background-size: 60px 60px;
|
||||
}
|
||||
|
||||
.wrap { position: relative; z-index: 1; max-width: 960px; margin: 0 auto; padding: 0 24px; }
|
||||
|
||||
nav {
|
||||
position: relative; z-index: 10;
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 20px 24px; max-width: 960px; margin: 0 auto;
|
||||
}
|
||||
.logo-text { font-size: 18px; font-weight: 800; letter-spacing: -0.02em; color: #fff; }
|
||||
.logo-text span { color: #fbbf24; }
|
||||
.nav-links { display: flex; gap: 8px; }
|
||||
.btn-ghost {
|
||||
padding: 7px 14px; border-radius: 6px; font-size: 12px; font-weight: 500;
|
||||
border: 1px solid #27272a; color: #d4d4d8; background: transparent;
|
||||
cursor: pointer; text-decoration: none;
|
||||
}
|
||||
.btn-amber {
|
||||
padding: 7px 16px; border-radius: 6px; font-size: 12px; font-weight: 700;
|
||||
background: #fbbf24; color: #000; border: none; cursor: pointer;
|
||||
text-decoration: none; letter-spacing: 0.02em;
|
||||
}
|
||||
|
||||
/* ── HERO ── */
|
||||
.hero {
|
||||
text-align: center; padding: 80px 24px 72px;
|
||||
max-width: 760px; margin: 0 auto; position: relative; z-index: 1;
|
||||
}
|
||||
.hero-badge {
|
||||
display: inline-flex; align-items: center; gap: 6px;
|
||||
border: 1px solid rgba(255,255,255,0.1); background: rgba(0,0,0,0.4);
|
||||
border-radius: 99px; padding: 5px 14px;
|
||||
font-size: 10px; letter-spacing: 0.2em; text-transform: uppercase; color: rgba(255,255,255,0.55);
|
||||
margin-bottom: 28px;
|
||||
}
|
||||
.hero-badge-dot { width: 6px; height: 6px; border-radius: 50%; background: #fbbf24; flex-shrink: 0; }
|
||||
.hero h1 {
|
||||
font-size: clamp(38px, 6.5vw, 64px); font-weight: 700; line-height: 1.05;
|
||||
letter-spacing: -0.035em; color: #fff;
|
||||
}
|
||||
.hero h1 .amber { color: #fbbf24; display: block; }
|
||||
.hero-sub {
|
||||
font-size: 17px; color: #a1a1aa; line-height: 1.65;
|
||||
max-width: 540px; margin: 24px auto 0;
|
||||
}
|
||||
.hero-cta-wrap { margin-top: 38px; display: flex; flex-direction: column; align-items: center; gap: 11px; }
|
||||
.btn-cta {
|
||||
display: inline-flex; align-items: center; gap: 8px;
|
||||
background: #fbbf24; color: #000; font-size: 15px; font-weight: 800;
|
||||
padding: 15px 38px; border-radius: 8px; border: none; cursor: pointer;
|
||||
letter-spacing: 0.03em; text-decoration: none;
|
||||
box-shadow: 0 0 40px rgba(251,191,36,0.2);
|
||||
}
|
||||
.hero-micro { font-size: 11px; color: #52525b; letter-spacing: 0.05em; }
|
||||
|
||||
/* ── HOW IT WORKS ── */
|
||||
.how { padding: 72px 24px; position: relative; z-index: 1; }
|
||||
.section-label {
|
||||
text-align: center; font-size: 10px; font-weight: 700;
|
||||
letter-spacing: 0.25em; text-transform: uppercase; color: #fbbf24;
|
||||
margin-bottom: 12px; opacity: 0.85;
|
||||
}
|
||||
.how h2 {
|
||||
text-align: center; font-size: 30px; font-weight: 700;
|
||||
letter-spacing: -0.025em; color: #fff; margin-bottom: 6px;
|
||||
}
|
||||
.how-sub { text-align: center; font-size: 14px; color: #71717a; margin-bottom: 48px; }
|
||||
.steps {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 2px;
|
||||
max-width: 840px; margin: 0 auto; background: #18181b;
|
||||
border-radius: 12px; overflow: hidden; border: 1px solid #1c1c1f;
|
||||
}
|
||||
.step { padding: 32px 24px; background: #0d0d0f; }
|
||||
.step-num {
|
||||
font-size: 11px; font-weight: 700; letter-spacing: 0.15em;
|
||||
color: #fbbf24; text-transform: uppercase; margin-bottom: 14px; opacity: 0.8;
|
||||
}
|
||||
.step h3 { font-size: 16px; font-weight: 700; color: #fff; margin-bottom: 10px; letter-spacing: -0.01em; }
|
||||
.step p { font-size: 13px; color: #71717a; line-height: 1.6; }
|
||||
|
||||
/* ── VIDEO ── */
|
||||
.video-section { padding: 0 24px 72px; position: relative; z-index: 1; }
|
||||
.video-wrap {
|
||||
max-width: 900px; margin: 0 auto; border-radius: 14px; overflow: hidden;
|
||||
border: 1px solid rgba(255,255,255,0.07); background: #000;
|
||||
box-shadow: 0 24px 80px rgba(0,0,0,0.7), 0 0 0 1px rgba(251,191,36,0.05);
|
||||
}
|
||||
.video-placeholder {
|
||||
aspect-ratio: 16/9; background: #0a0a0a;
|
||||
display: flex; flex-direction: column; align-items: center; justify-content: center; gap: 12px;
|
||||
}
|
||||
.play-btn {
|
||||
width: 56px; height: 56px; border-radius: 50%;
|
||||
background: rgba(251,191,36,0.12); border: 1px solid rgba(251,191,36,0.3);
|
||||
display: flex; align-items: center; justify-content: center; font-size: 20px;
|
||||
}
|
||||
.video-placeholder p { font-size: 12px; color: #3f3f46; }
|
||||
|
||||
/* ── FEATURES ── */
|
||||
.features { padding: 72px 24px; position: relative; z-index: 1; }
|
||||
.features h2 {
|
||||
text-align: center; font-size: 30px; font-weight: 700;
|
||||
letter-spacing: -0.025em; color: #fff; margin-bottom: 8px;
|
||||
}
|
||||
.features-sub { text-align: center; font-size: 13px; color: #52525b; margin-bottom: 44px; letter-spacing: 0.02em; }
|
||||
.feature-grid {
|
||||
display: grid; grid-template-columns: repeat(3, 1fr); gap: 1px;
|
||||
max-width: 900px; margin: 0 auto; background: #18181b;
|
||||
border-radius: 12px; overflow: hidden; border: 1px solid #1c1c1f;
|
||||
}
|
||||
.feature-card { padding: 24px; background: #0d0d0f; }
|
||||
.feature-icon { font-size: 18px; margin-bottom: 14px; display: block; }
|
||||
.feature-card h3 { font-size: 13px; font-weight: 700; color: #e4e4e7; margin-bottom: 7px; letter-spacing: -0.01em; }
|
||||
.feature-card p { font-size: 12px; color: #52525b; line-height: 1.6; }
|
||||
|
||||
/* ── SEO BLOCK ── */
|
||||
.seo-block { padding: 72px 24px; border-top: 1px solid #18181b; position: relative; z-index: 1; }
|
||||
.seo-inner { max-width: 680px; margin: 0 auto; }
|
||||
.seo-inner h2 { font-size: 22px; font-weight: 700; color: #d4d4d8; margin-bottom: 18px; letter-spacing: -0.02em; }
|
||||
.seo-inner p { font-size: 14px; color: #71717a; line-height: 1.75; margin-bottom: 14px; }
|
||||
.seo-inner strong { color: #a1a1aa; font-weight: 600; }
|
||||
|
||||
/* ── FINAL CTA ── */
|
||||
.final-cta {
|
||||
padding: 96px 24px; text-align: center;
|
||||
border-top: 1px solid #18181b; position: relative; z-index: 1;
|
||||
}
|
||||
.final-cta h2 { font-size: 36px; font-weight: 700; color: #fff; margin-bottom: 12px; letter-spacing: -0.03em; }
|
||||
.final-cta p { font-size: 14px; color: #52525b; margin-bottom: 32px; }
|
||||
|
||||
footer {
|
||||
border-top: 1px solid #18181b; padding: 20px 24px;
|
||||
display: flex; justify-content: space-between; align-items: center;
|
||||
font-size: 11px; color: #3f3f46; max-width: 960px; margin: 0 auto;
|
||||
position: relative; z-index: 1;
|
||||
}
|
||||
|
||||
/* changed copy callouts */
|
||||
.changed {
|
||||
outline: 1px dashed rgba(251,191,36,0.4);
|
||||
outline-offset: 3px;
|
||||
border-radius: 3px;
|
||||
}
|
||||
|
||||
.annotation {
|
||||
position: fixed; bottom: 16px; right: 16px;
|
||||
background: #18181b; border: 1px solid #27272a; border-radius: 8px;
|
||||
padding: 12px 16px; font-size: 11px; color: #a1a1aa; max-width: 240px;
|
||||
z-index: 200; line-height: 1.6;
|
||||
}
|
||||
.annotation strong { color: #fbbf24; display: block; margin-bottom: 4px; }
|
||||
.annotation .legend { display: flex; align-items: center; gap: 6px; margin-top: 8px; font-size: 10px; color: #52525b; }
|
||||
.legend-box { width: 12px; height: 12px; border: 1px dashed rgba(251,191,36,0.5); border-radius: 2px; flex-shrink: 0; }
|
||||
|
||||
#selection-indicator { display: none; }
|
||||
</style>
|
||||
</head>
|
||||
<body>
|
||||
|
||||
<div class="bg-grid"></div>
|
||||
|
||||
<!-- NAV -->
|
||||
<nav>
|
||||
<div class="logo-text">BATTL<span>.</span>BUILDERS</div>
|
||||
<div class="nav-links">
|
||||
<a href="#" class="btn-ghost">Sign In</a>
|
||||
<a href="#" class="btn-amber">Start a Build</a>
|
||||
</div>
|
||||
</nav>
|
||||
|
||||
<!-- HERO -->
|
||||
<section class="hero">
|
||||
<!-- CHANGED: AR-15 Builds → AR-15 Builder -->
|
||||
<div class="hero-badge changed">
|
||||
<span class="hero-badge-dot"></span>
|
||||
AR-15 Builder · 100% Free
|
||||
</div>
|
||||
<h1>
|
||||
Stop Guessing.
|
||||
<span class="amber">Start Building.</span>
|
||||
</h1>
|
||||
<!-- CHANGED: hero subhead — jargon removed, ICP-first language -->
|
||||
<p class="hero-sub changed">
|
||||
Not sure which parts work together? We'll walk you through it. Pick your components, get warned about anything that won't fit, and see real prices from real retailers — all in one place.
|
||||
</p>
|
||||
<div class="hero-cta-wrap">
|
||||
<a href="#" class="btn-cta">Start a Build →</a>
|
||||
<span class="hero-micro">100% Free · No account required</span>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- HOW IT WORKS -->
|
||||
<section class="how wrap">
|
||||
<div class="section-label">The process</div>
|
||||
<!-- CHANGED: "Three moves. One solid build." → "3 Steps. One Battl-Ready Build." -->
|
||||
<h2 class="changed">3 Steps. One Battl-Ready Build.</h2>
|
||||
<p class="how-sub">No experience required. No part numbers to memorize.</p>
|
||||
<div class="steps">
|
||||
<div class="step">
|
||||
<div class="step-num">Step 01</div>
|
||||
<h3>Pick your parts</h3>
|
||||
<p>Browse real components organized by what they do — barrel, trigger, lower, upper, and more. No prior knowledge required.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">Step 02</div>
|
||||
<h3>Flag the conflicts</h3>
|
||||
<p>Gas length, buffer weight, caliber — Battl catches the mismatches before you spend money on the wrong part.</p>
|
||||
</div>
|
||||
<div class="step">
|
||||
<div class="step-num">Step 03</div>
|
||||
<h3>Price it. Pull the trigger.</h3>
|
||||
<p>Live prices from real retailers, side by side. Click through to buy. We never touch your order.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- VIDEO -->
|
||||
<section class="video-section">
|
||||
<div class="video-wrap">
|
||||
<div class="video-placeholder">
|
||||
<div class="play-btn">▶</div>
|
||||
<p>Builder preview — looping screen recording</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FEATURES -->
|
||||
<section class="features wrap">
|
||||
<div class="section-label">What you get</div>
|
||||
<!-- CHANGED: "Built for builders who do their homework." → gun nerd / everyday builder positioning -->
|
||||
<h2 class="changed">Built by gun nerds, for the everyday AR builder.</h2>
|
||||
<p class="features-sub changed">NO SUBSCRIPTIONS · NO SPONSORED RANKINGS · NO BULLSHIT</p>
|
||||
<div class="feature-grid">
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔧</span>
|
||||
<h3>Build by role, not part number</h3>
|
||||
<p>Parts organized by function. Know what you need, not what SKU to search for.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">⚠️</span>
|
||||
<h3>Conflicts caught early</h3>
|
||||
<p>Gas system, buffer weight, caliber — flagged before checkout, not after the box arrives.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">💰</span>
|
||||
<h3>Real prices. Real retailers.</h3>
|
||||
<p>Running total with live prices. No estimated MSRP. What it actually costs, today.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔖</span>
|
||||
<h3>Save your work</h3>
|
||||
<p>Free account. Save builds, pick up where you left off, run multiple configs in parallel.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🔗</span>
|
||||
<h3>Share the build</h3>
|
||||
<p>One link. Your buddy sees the full parts list — no login, no friction, no excuses.</p>
|
||||
</div>
|
||||
<div class="feature-card">
|
||||
<span class="feature-icon">🚫</span>
|
||||
<h3>No gates. No friction.</h3>
|
||||
<p>Browse and build freely. Create an account only when you're ready to save.</p>
|
||||
</div>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- SEO COPY BLOCK -->
|
||||
<section class="seo-block">
|
||||
<div class="seo-inner">
|
||||
<h2>What is Battl Builders?</h2>
|
||||
<p>
|
||||
Battl Builders is a free <strong>AR-15 build planner</strong> — the tool you've been doing in spreadsheets, rebuilt for how builders actually work. Select parts by what they do, validate compatibility as you go, and price your build across multiple retailers without opening a new tab.
|
||||
</p>
|
||||
<p>
|
||||
Most <strong>AR-15 compatibility mistakes</strong> happen at the parts list stage: wrong gas system length for the barrel, buffer weight that fights your stock, caliber mismatches between upper and lower. Battl flags those conflicts automatically, so you catch them before they cost you a return label.
|
||||
</p>
|
||||
<p>
|
||||
Whether you're planning your first rifle or spec'ing a dedicated precision upper, Battl Builders gives you a structured, guided way to research parts and <strong>buy with confidence</strong>.
|
||||
</p>
|
||||
</div>
|
||||
</section>
|
||||
|
||||
<!-- FINAL CTA -->
|
||||
<section class="final-cta">
|
||||
<h2>Your build starts here.</h2>
|
||||
<p>Free. No account required. Takes five minutes to get rolling.</p>
|
||||
<a href="#" class="btn-cta">Start a Build →</a>
|
||||
</section>
|
||||
|
||||
<footer>
|
||||
<span>© 2026 BATTL BUILDERS™</span>
|
||||
<span>Free AR-15 build planner</span>
|
||||
</footer>
|
||||
|
||||
<div class="annotation">
|
||||
<strong>v3 — Copy Revision</strong>
|
||||
Updated copy is outlined in amber dashes.
|
||||
<div class="legend">
|
||||
<div class="legend-box"></div>
|
||||
Changed from v2
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</body>
|
||||
</html>
|
||||
@@ -0,0 +1,66 @@
|
||||
<h2>Hero Layout: What's the main structure?</h2>
|
||||
<p class="subtitle">The hero is the first thing visitors see. Which layout best suits a "start your build now" homepage?</p>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<div class="card" data-choice="centered" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:28px 20px;font-family:sans-serif;min-height:220px;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:12px;border:1px solid #27272a;">
|
||||
<div style="font-size:10px;letter-spacing:0.2em;color:#fbbf24;text-transform:uppercase;opacity:0.8;">AR-15 Build Planner</div>
|
||||
<div style="font-size:22px;font-weight:700;color:#fff;text-align:center;line-height:1.2;">Build smarter rifles.<br><span style="color:#fbbf24;">No spreadsheets.</span></div>
|
||||
<div style="font-size:11px;color:#a1a1aa;text-align:center;max-width:280px;">Pick parts by role, catch compatibility issues, compare live prices — all in one place.</div>
|
||||
<div style="display:flex;gap:10px;margin-top:8px;">
|
||||
<div style="background:#fbbf24;color:#000;font-size:11px;font-weight:700;padding:8px 20px;border-radius:6px;letter-spacing:0.05em;">START A BUILD →</div>
|
||||
</div>
|
||||
<div style="margin-top:10px;background:#1a1a1a;border-radius:6px;border:1px solid #27272a;width:100%;height:60px;display:flex;align-items:center;justify-content:center;">
|
||||
<div style="font-size:10px;color:#52525b;">▶ Builder preview video</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>A — Centered / Full-width</h3>
|
||||
<p>Headline + tagline centered, single dominant CTA button, product video below. Clean, magazine-style. Works great on mobile.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="split" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;min-height:220px;display:flex;align-items:stretch;gap:16px;border:1px solid #27272a;">
|
||||
<div style="flex:1.1;display:flex;flex-direction:column;justify-content:center;gap:10px;">
|
||||
<div style="font-size:10px;letter-spacing:0.2em;color:#fbbf24;text-transform:uppercase;opacity:0.8;">AR-15 Build Planner</div>
|
||||
<div style="font-size:18px;font-weight:700;color:#fff;line-height:1.25;">Build smarter rifles.<br><span style="color:#fbbf24;">No spreadsheets.</span></div>
|
||||
<div style="font-size:10px;color:#a1a1aa;line-height:1.5;">Pick parts, catch clashes, compare prices.</div>
|
||||
<div style="background:#fbbf24;color:#000;font-size:11px;font-weight:700;padding:8px 16px;border-radius:6px;letter-spacing:0.05em;align-self:flex-start;margin-top:4px;">START A BUILD →</div>
|
||||
</div>
|
||||
<div style="flex:1;background:#111;border-radius:8px;border:1px solid #27272a;display:flex;align-items:center;justify-content:center;">
|
||||
<div style="font-size:10px;color:#52525b;text-align:center;">App screenshot<br>or animation</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>B — Split (text left, visual right)</h3>
|
||||
<p>Copy on the left, app screenshot or short animation on the right. Shows the product immediately. Strong for conversion, more complex layout.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="immersive" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;min-height:220px;display:flex;flex-direction:column;align-items:center;justify-content:flex-end;gap:10px;border:1px solid #27272a;position:relative;overflow:hidden;">
|
||||
<div style="position:absolute;inset:0;background:linear-gradient(135deg,#111 0%,#0a0a0a 100%);opacity:0.9;"></div>
|
||||
<div style="position:absolute;top:16px;left:0;right:0;text-align:center;">
|
||||
<div style="font-size:10px;color:#52525b;letter-spacing:0.1em;">Full-bleed product image or video background</div>
|
||||
</div>
|
||||
<div style="position:relative;z-index:1;text-align:center;display:flex;flex-direction:column;align-items:center;gap:8px;padding-bottom:10px;">
|
||||
<div style="font-size:20px;font-weight:700;color:#fff;text-align:center;line-height:1.2;">Build smarter rifles.</div>
|
||||
<div style="font-size:18px;font-weight:700;color:#fbbf24;">No spreadsheets.</div>
|
||||
<div style="background:#fbbf24;color:#000;font-size:11px;font-weight:700;padding:8px 22px;border-radius:6px;letter-spacing:0.05em;margin-top:6px;">START A BUILD →</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>C — Immersive / Full-bleed</h3>
|
||||
<p>Hero image or dark atmospheric background fills the viewport. Copy overlaid with high contrast. Dramatic, brand-forward. Requires great imagery.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,111 @@
|
||||
<h2>Below the Hero: Page Section Flow</h2>
|
||||
<p class="subtitle">What content comes after the hero + CTA? Pick the flow that best supports "I just landed here, convince me to start a build."</p>
|
||||
|
||||
<div class="cards">
|
||||
|
||||
<div class="card" data-choice="lean" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;border:1px solid #27272a;display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HERO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">"Stop Guessing. Start Building." + subhead + CTA</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">BUILDER PREVIEW</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Looping video / animated screenshot of the tool</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">3 FEATURE PILLARS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Guided roles · Compat warnings · Live prices</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:10px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fbbf24;">FINAL CTA</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Second "Start a Build" button</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>A — Lean (4 sections)</h3>
|
||||
<p>Hero → video preview → 3 feature pillars → closing CTA. Fast to read, zero fluff. Gets users to the builder quickly.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="howitworks" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;border:1px solid #27272a;display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HERO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">"Stop Guessing. Start Building." + subhead + CTA</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HOW IT WORKS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">3 steps: Pick parts → See warnings → Buy with confidence</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">BUILDER PREVIEW VIDEO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Show the tool in action</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">FEATURE CARDS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">6 capabilities at a glance</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:10px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fbbf24;">FINAL CTA</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Second "Start a Build" button</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>B — Explain + Show (5 sections)</h3>
|
||||
<p>Adds a "How it Works" 3-step strip between hero and video. Answers "but what does it actually do?" before showing it. Better for first-time visitors unfamiliar with PCPartPicker-style tools.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card" data-choice="seo" onclick="toggleSelect(this)">
|
||||
<div class="card-image">
|
||||
<div style="background:#0a0a0a;border-radius:8px;padding:20px;font-family:sans-serif;border:1px solid #27272a;display:flex;flex-direction:column;gap:8px;">
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HERO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">"Stop Guessing. Start Building." + subhead + CTA</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">HOW IT WORKS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">3 steps with icons</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">BUILDER PREVIEW VIDEO</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Tool in action</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">FEATURE CARDS</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">6 capabilities</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#111;border:1px solid #27272a;border-radius:6px;padding:12px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fff;">SEO COPY BLOCK</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Short paragraph: "What is an AR-15 builder tool?" — targets search</div>
|
||||
</div>
|
||||
<div style="text-align:center;font-size:10px;color:#52525b;">↓</div>
|
||||
<div style="background:#1a1a1a;border:1px solid #fbbf24;border-radius:6px;padding:10px;text-align:center;">
|
||||
<div style="font-size:11px;font-weight:700;color:#fbbf24;">FINAL CTA</div>
|
||||
<div style="font-size:9px;color:#a1a1aa;">Second "Start a Build" button</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<h3>C — SEO-Optimized (6 sections)</h3>
|
||||
<p>Same as B but adds a short prose block at the bottom targeting search keywords ("AR-15 parts compatibility tool", "AR-15 build planner"). Best for organic discovery over time.</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
</div>
|
||||
@@ -0,0 +1,3 @@
|
||||
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
|
||||
<p class="subtitle">Continuing in terminal...</p>
|
||||
</div>
|
||||
@@ -0,0 +1,68 @@
|
||||
# CLAUDE.md
|
||||
|
||||
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
|
||||
|
||||
## Commands
|
||||
|
||||
```bash
|
||||
npm run dev # Start dev server at localhost:3000
|
||||
npm run build # Production build (TS errors and ESLint are suppressed — see next.config.mjs)
|
||||
npm run lint # ESLint check
|
||||
```
|
||||
|
||||
No test framework is configured.
|
||||
|
||||
## Environment Variables
|
||||
|
||||
`.env.local` for development:
|
||||
```
|
||||
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
|
||||
BACKEND_BASE_URL=http://localhost:8080
|
||||
LAUNCH_ONLY_ROOT=false # Set to "true" to restrict unauthenticated users to root page only
|
||||
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
|
||||
```
|
||||
|
||||
In Docker, the Spring Boot service is resolved as `http://battlbuilder-api:8080` (the default in `lib/springProxy.ts` when `SPRING_BASE_URL` is unset).
|
||||
|
||||
## Architecture
|
||||
|
||||
### Two backends
|
||||
1. **Spring Boot** — primary data backend. All product catalog, builds, auth, merchant/admin data lives here.
|
||||
2. **Sanity CMS** — content only (guides/blog posts). Project `zal102rv`, dataset `production`. Queries in `lib/sanity.ts`.
|
||||
|
||||
### API proxy pattern
|
||||
Next.js API routes (`app/api/`) act as a thin proxy to Spring Boot. Client code never calls Spring Boot directly. The helper `lib/springProxy.ts` forwards cookies and auth headers to Spring Boot and handles response serialization.
|
||||
|
||||
### Authentication
|
||||
- **Session cookie**: `session_token` HTTP-only cookie set by Spring Boot, forwarded by the proxy.
|
||||
- **Client state**: `AuthContext` (`context/AuthContext.tsx`) hydrates from `localStorage` on mount, then verifies against `/api/auth/me`.
|
||||
- **Middleware**: `middleware.ts` enforces route protection. Public paths are whitelisted; `/admin/*` requires login; `/builder`, `/builds`, `/vault` are gated only when `LAUNCH_ONLY_ROOT=true`.
|
||||
|
||||
### Route groups
|
||||
- `app/(app)/` — main consumer app layout (TopNav + Footer)
|
||||
- `app/(app)/(builder)/` — builder + parts browser (BuilderNav layout)
|
||||
- `app/(account)/` — account settings pages (AccountChrome sidebar layout)
|
||||
- `app/admin/` — internal admin tools (AdminLeftNavigation layout)
|
||||
- `app/api/` — 50 server-side route handlers proxying to Spring Boot
|
||||
|
||||
### Builder domain model
|
||||
The builder is AR-platform-centric. Key concepts:
|
||||
|
||||
- **`BuilderSlotKey`** (`types/builderSlots.ts`) — the canonical kebab-case category ID (e.g., `"complete-upper"`, `"barrel"`). This is the source of truth for localStorage keys, URL state, and backend part role mapping.
|
||||
- **`CATEGORY_TO_PART_ROLES` / `PART_ROLE_TO_CATEGORY`** (`lib/catalogMappings.ts`) — bidirectional mapping between `BuilderSlotKey` and backend `partRole` strings. Update here when the backend adds new part roles.
|
||||
- **`BUILDER_SLOTS`** (`data/builderSlots.ts`) — defines slot groups (LOWER, UPPER, SYSTEM, ACCESSORIES) and satisfaction patterns (e.g., a complete lower satisfies the lower assembly slot).
|
||||
- **`BuildState`** — `Partial<Record<BuilderSlotKey, string>>` stored in `localStorage`. Product IDs are the values.
|
||||
- **Overlap detection** (`lib/buildOverlaps.ts`) — warns users when they select both a complete assembly and individual sub-parts.
|
||||
|
||||
### Legacy key aliases
|
||||
`types/builderSlots.ts` exports `normalizeSlotKey()` which maps camelCase legacy keys (e.g., `completeUpper`) to current kebab-case keys. This is needed for backwards-compatible URL/localStorage deserialization.
|
||||
|
||||
### API client pattern
|
||||
- Client components use `useApi()` hook (`lib/api.ts`) for fetch calls with `credentials: 'include'`.
|
||||
- Admin-specific API calls live in `lib/api/` (e.g., `adminCategoryMappings.ts`, `adminMerchantMappings.ts`).
|
||||
- Product catalog helpers are in `lib/catalog.ts`.
|
||||
|
||||
### Sanity content
|
||||
- `lib/sanity.ts` — Sanity client + GROQ queries for posts.
|
||||
- `lib/sanityFetch.ts` — low-level HTTPS fetch to Sanity IP (bypasses CDN DNS issues in server/Docker environments).
|
||||
- Guides at `/guides/[slug]` are the only public Sanity-driven pages.
|
||||
@@ -0,0 +1,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>
|
||||
);
|
||||
}
|
||||
@@ -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";
|
||||
@@ -74,8 +75,7 @@ const isUuid = (v: string) =>
|
||||
v
|
||||
);
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
// API routes now handled by Next.js /api routes (server-side)
|
||||
|
||||
const STORAGE_KEY = "gunbuilder-build-state";
|
||||
|
||||
@@ -296,7 +296,7 @@ function buildAssemblyRows(params: {
|
||||
rows.push({
|
||||
key: `${groupLabel}-${paths.complete}`,
|
||||
kind: "category",
|
||||
label: "Complete Assembly (Fastest)",
|
||||
label: `Completed ${groupLabel.split(" ")[0]} (Fastest)`,
|
||||
categoryId: paths.complete,
|
||||
indent: 0,
|
||||
pill: mode === "complete" ? "Selected" : undefined,
|
||||
@@ -337,8 +337,8 @@ function GunbuilderPageContent() {
|
||||
|
||||
const api = useApi();
|
||||
|
||||
// ✅ Auth: we need the token ready before calling /builds/me/*
|
||||
const { token, loading: authLoading, getAuthHeaders } = useAuth();
|
||||
// ✅ Auth: check if user is logged in
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
|
||||
const [platform, setPlatform] = useState<(typeof PLATFORMS)[number]>("AR15");
|
||||
|
||||
@@ -356,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);
|
||||
|
||||
@@ -370,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);
|
||||
}, []);
|
||||
@@ -603,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;
|
||||
@@ -616,7 +632,7 @@ function GunbuilderPageContent() {
|
||||
|
||||
try {
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/catalog/products/by-ids`,
|
||||
`/api/catalog/products/by-ids`,
|
||||
{
|
||||
method: "POST",
|
||||
signal: controller.signal,
|
||||
@@ -655,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) {
|
||||
@@ -728,7 +759,7 @@ function GunbuilderPageContent() {
|
||||
if (authLoading) return;
|
||||
|
||||
// If not logged in, don't spam the backend; show a helpful message instead.
|
||||
if (!token) {
|
||||
if (!user) {
|
||||
setShareStatus("Please log in to load builds from your Vault.");
|
||||
window.setTimeout(() => setShareStatus(null), 4500);
|
||||
return;
|
||||
@@ -738,14 +769,12 @@ function GunbuilderPageContent() {
|
||||
try {
|
||||
setShareStatus("Loading build…");
|
||||
|
||||
// NOTE: using fetch here avoids any “stale api instance” issues and lets us
|
||||
// explicitly attach JWT headers.
|
||||
// NOTE: using fetch here goes through our Next.js API routes with HTTP-only cookies
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/builds/me/${encodeURIComponent(uuidToLoad)}`,
|
||||
`/api/builds/${encodeURIComponent(uuidToLoad)}`,
|
||||
{
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...getAuthHeaders(),
|
||||
},
|
||||
}
|
||||
);
|
||||
@@ -787,9 +816,7 @@ function GunbuilderPageContent() {
|
||||
searchParams,
|
||||
router,
|
||||
authLoading,
|
||||
token,
|
||||
getAuthHeaders,
|
||||
// API_BASE_URL is a module constant; no need to include
|
||||
user,
|
||||
]);
|
||||
|
||||
// -----------------------------
|
||||
@@ -847,6 +874,7 @@ function GunbuilderPageContent() {
|
||||
type: "success",
|
||||
message: "Saved to your Vault ✅ ",
|
||||
});
|
||||
trackBuildSaved();
|
||||
|
||||
// Close modal + reset inputs for next variant
|
||||
setSaveAsOpen(false);
|
||||
@@ -1060,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.");
|
||||
@@ -1079,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(
|
||||
@@ -1200,53 +1230,64 @@ function GunbuilderPageContent() {
|
||||
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
|
||||
{/* Header */}
|
||||
<header className="mb-6">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
BATTL BUILDERS
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Builder <span className="text-amber-300">Early Access</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||
Explore components from trusted brands, choose one part per
|
||||
category, track live prices, and watch your total build cost
|
||||
update as you go. This early-access builder keeps your setup saved
|
||||
locally so you can come back and refine it anytime.
|
||||
</p>
|
||||
<div className="flex flex-wrap items-start justify-between gap-3">
|
||||
<div>
|
||||
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
|
||||
BATTL BUILDERS
|
||||
</p>
|
||||
<h1 className="mt-1 text-2xl md:text-3xl font-semibold tracking-tight">
|
||||
Builder <span className="text-amber-300">Early Access</span>
|
||||
</h1>
|
||||
<p className="mt-2 text-sm text-zinc-400 max-w-xl">
|
||||
Explore components from trusted brands, choose one part per
|
||||
category, track live prices, and watch your total build cost
|
||||
update as you go. This early-access builder keeps your setup saved
|
||||
locally so you can come back and refine it anytime.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
||||
Platform
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as (typeof PLATFORMS)[number];
|
||||
setPlatform(next);
|
||||
{user?.role === "ADMIN" ? (
|
||||
<Link
|
||||
href="/admin"
|
||||
className="inline-flex items-center gap-2 rounded-md border border-amber-400/40 bg-amber-400/10 px-3 py-2 text-xs font-semibold text-amber-200 hover:border-amber-300 hover:text-amber-100"
|
||||
>
|
||||
Admin Toolbar →
|
||||
</Link>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
// Keep URL in sync, and clear transient action params
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", normalizePlatformKey(next));
|
||||
qp.delete("select");
|
||||
qp.delete("remove");
|
||||
<div className="mt-4 flex flex-wrap items-center gap-3">
|
||||
<label className="text-xs font-medium text-zinc-400 flex items-center gap-2">
|
||||
Platform
|
||||
<select
|
||||
value={platform}
|
||||
onChange={(e) => {
|
||||
const next = e.target.value as (typeof PLATFORMS)[number];
|
||||
setPlatform(next);
|
||||
|
||||
router.replace(`/builder?${qp.toString()}`, {
|
||||
scroll: false,
|
||||
});
|
||||
}}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{PLATFORMS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{PLATFORM_LABEL[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-[0.7rem] text-zinc-500">
|
||||
// Keep URL in sync, and clear transient action params
|
||||
const qp = new URLSearchParams(searchParams.toString());
|
||||
qp.set("platform", normalizePlatformKey(next));
|
||||
qp.delete("select");
|
||||
qp.delete("remove");
|
||||
|
||||
router.replace(`/builder?${qp.toString()}`, {
|
||||
scroll: false,
|
||||
});
|
||||
}}
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900 px-2 py-1 text-xs text-zinc-100 focus:outline-none focus:ring-1 focus:ring-amber-400"
|
||||
>
|
||||
{PLATFORMS.map((p) => (
|
||||
<option key={p} value={p}>
|
||||
{PLATFORM_LABEL[p]}
|
||||
</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
<p className="text-[0.7rem] text-zinc-500">
|
||||
Parts list updates automatically when you change platforms.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Build summary panel */}
|
||||
@@ -1475,6 +1516,11 @@ function GunbuilderPageContent() {
|
||||
|
||||
if (groupCategories.length === 0) return null;
|
||||
|
||||
// Check if there are locked/conflicting parts in this group
|
||||
const hasConflicts = tableRows.some(
|
||||
(row) => row.kind === "category" && row.locked
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
id={`group-${group.id}`}
|
||||
@@ -1494,26 +1540,43 @@ function GunbuilderPageContent() {
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{hasConflicts && (
|
||||
<div className="rounded-md border border-amber-500/30 bg-amber-500/10 p-3">
|
||||
<div className="flex items-start gap-2">
|
||||
<span className="text-amber-400 text-sm">⚠</span>
|
||||
<div className="flex-1">
|
||||
<p className="text-sm font-medium text-amber-300">
|
||||
Part Conflict Detected
|
||||
</p>
|
||||
<p className="text-xs text-zinc-300 mt-1">
|
||||
Some parts below are disabled because you've selected a complete assembly that already includes them. To customize individual parts, remove the complete assembly first.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
|
||||
<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>
|
||||
@@ -1529,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">
|
||||
@@ -1578,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
|
||||
@@ -1608,9 +1671,15 @@ function GunbuilderPageContent() {
|
||||
</div>
|
||||
|
||||
{selectedPart ? (
|
||||
<div className="text-[0.7rem] text-zinc-500 line-clamp-1">
|
||||
<Link
|
||||
href={`/parts/p/${canonicalPlatform}/${category.id}/${selectedPart.id}-${selectedPart.name
|
||||
.toLowerCase()
|
||||
.replace(/[^a-z0-9]+/g, "-")
|
||||
.replace(/(^-|-$)/g, "")}`}
|
||||
className="text-[0.7rem] text-zinc-500 hover:text-amber-300 hover:underline line-clamp-1 transition-colors"
|
||||
>
|
||||
{selectedPart.name}
|
||||
</div>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="text-[0.7rem] text-zinc-600">
|
||||
No part selected
|
||||
@@ -1620,28 +1689,47 @@ 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="text-[0.7rem] text-zinc-500">
|
||||
—
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="text-[0.7rem] text-amber-400/70">
|
||||
⚠ Disabled
|
||||
</span>
|
||||
<div className="group relative">
|
||||
<button
|
||||
type="button"
|
||||
className="text-zinc-500 hover:text-zinc-300 text-xs"
|
||||
title="Why is this disabled?"
|
||||
>
|
||||
ⓘ
|
||||
</button>
|
||||
<div className="invisible group-hover:visible absolute right-0 top-6 z-10 w-64 rounded-md border border-zinc-800 bg-zinc-950 p-3 text-xs text-zinc-300 shadow-xl">
|
||||
<p className="font-semibold text-amber-300 mb-1">
|
||||
Part Conflict
|
||||
</p>
|
||||
<p>
|
||||
You've selected a complete assembly which already includes this part. Remove the complete assembly to select individual parts.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
) : selectedPart ? (
|
||||
<>
|
||||
@@ -1651,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
|
||||
@@ -18,6 +20,7 @@ type OfferFromApi = {
|
||||
price?: number | null;
|
||||
originalPrice?: number | null;
|
||||
buyUrl?: string | null;
|
||||
buyShortUrl?: string | null;
|
||||
inStock?: boolean | null;
|
||||
lastUpdated?: string | null;
|
||||
};
|
||||
@@ -29,6 +32,7 @@ type GunbuilderProductFromApi = {
|
||||
platform: string;
|
||||
partRole: string;
|
||||
price: number | null;
|
||||
caliber?: string | null;
|
||||
|
||||
// Optional (legacy fallback label if product.offers is missing)
|
||||
merchantName?: string | null;
|
||||
@@ -37,6 +41,7 @@ type GunbuilderProductFromApi = {
|
||||
mainImageUrl?: string | null;
|
||||
|
||||
buyUrl?: string | null;
|
||||
buyShortUrl?: string | null;
|
||||
inStock?: boolean | null;
|
||||
|
||||
shortDescription?: string | null;
|
||||
@@ -238,14 +243,23 @@ export default function ProductDetailsPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `${API_BASE_URL}/api/v1/products/${numericId}`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
const url = `/api/catalog/products/${numericId}`;
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load product (${res.status})`);
|
||||
}
|
||||
|
||||
const data: GunbuilderProductFromApi = await res.json();
|
||||
console.log('[DEBUG] Product from API:', {
|
||||
id: data.id,
|
||||
name: data.name,
|
||||
buyUrl: data.buyUrl,
|
||||
buyShortUrl: data.buyShortUrl
|
||||
});
|
||||
setProduct(data);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
@@ -272,14 +286,18 @@ export default function ProductDetailsPage() {
|
||||
try {
|
||||
setOffersLoading(true);
|
||||
|
||||
const url = `${API_BASE_URL}/api/v1/products/${numericId}/offers`;
|
||||
const res = await fetch(url, { signal: controller.signal });
|
||||
const url = `/api/catalog/products/${numericId}/offers`;
|
||||
const res = await fetch(url, {
|
||||
signal: controller.signal,
|
||||
credentials: 'include',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load offers (${res.status})`);
|
||||
}
|
||||
|
||||
const data: OfferFromApi[] = await res.json();
|
||||
console.log('[DEBUG] Offers from API:', data);
|
||||
setOffersFromApi(Array.isArray(data) ? data : []);
|
||||
} catch (err: any) {
|
||||
if (err?.name === "AbortError") return;
|
||||
@@ -330,14 +348,23 @@ export default function ProductDetailsPage() {
|
||||
* - Fallback to legacy single-offer derived from product if needed
|
||||
*/
|
||||
const offers = useMemo(() => {
|
||||
if (offersFromApi.length > 0) return sortOffers(offersFromApi);
|
||||
if (offersFromApi.length > 0) {
|
||||
console.log('[DEBUG] Using offers from API, first offer:', offersFromApi[0]);
|
||||
const fallbackShortUrl = product?.buyShortUrl ?? null;
|
||||
const normalized = offersFromApi.map((offer) => ({
|
||||
...offer,
|
||||
buyShortUrl: offer.buyShortUrl || fallbackShortUrl,
|
||||
}));
|
||||
return sortOffers(normalized);
|
||||
}
|
||||
|
||||
if (product?.buyUrl) {
|
||||
if (product?.buyUrl || product?.buyShortUrl) {
|
||||
console.log('[DEBUG] Using fallback from product, buyShortUrl:', product.buyShortUrl, 'buyUrl:', product.buyUrl);
|
||||
return sortOffers([
|
||||
{
|
||||
merchantName: product.merchantName ?? "Retailer",
|
||||
price: product.price,
|
||||
buyUrl: product.buyUrl,
|
||||
buyUrl: product.buyShortUrl || product.buyUrl,
|
||||
inStock: product.inStock ?? true,
|
||||
},
|
||||
]);
|
||||
@@ -467,12 +494,10 @@ export default function ProductDetailsPage() {
|
||||
className="h-full w-full"
|
||||
title="Click to enlarge"
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ProductImage
|
||||
src={imageUrl}
|
||||
alt={product.name}
|
||||
className="h-full w-full object-contain p-2"
|
||||
loading="lazy"
|
||||
/>
|
||||
</button>
|
||||
) : (
|
||||
@@ -559,6 +584,14 @@ export default function ProductDetailsPage() {
|
||||
{product.platform || platform}
|
||||
</span>
|
||||
</span>
|
||||
{product.caliber && (
|
||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||
Caliber:{" "}
|
||||
<span className="font-semibold text-zinc-100">
|
||||
{product.caliber}
|
||||
</span>
|
||||
</span>
|
||||
)}
|
||||
<span className="rounded border border-zinc-800 bg-zinc-900/60 px-2 py-1 text-zinc-300">
|
||||
Role:{" "}
|
||||
<span className="font-semibold text-zinc-100">
|
||||
@@ -660,12 +693,13 @@ export default function ProductDetailsPage() {
|
||||
{o.price != null ? `$${o.price.toFixed(2)}` : "—"}
|
||||
</td>
|
||||
<td className="px-3 py-2 text-right">
|
||||
{o.buyUrl ? (
|
||||
{(o.buyShortUrl || o.buyUrl) ? (
|
||||
<a
|
||||
href={o.buyUrl}
|
||||
href={o.buyShortUrl || o.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
|
||||
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(o))}
|
||||
>
|
||||
Buy
|
||||
</a>
|
||||
@@ -686,13 +720,14 @@ export default function ProductDetailsPage() {
|
||||
)}
|
||||
|
||||
{/* Best Offer CTA */}
|
||||
{bestOffer?.buyUrl ? (
|
||||
{(bestOffer?.buyShortUrl || bestOffer?.buyUrl) ? (
|
||||
<div className="mt-3 flex justify-end">
|
||||
<a
|
||||
href={bestOffer.buyUrl}
|
||||
href={bestOffer.buyShortUrl || bestOffer.buyUrl}
|
||||
target="_blank"
|
||||
rel="noreferrer"
|
||||
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
|
||||
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(bestOffer))}
|
||||
>
|
||||
Buy from {merchantLabel(bestOffer)} →
|
||||
</a>
|
||||
@@ -735,8 +770,7 @@ export default function ProductDetailsPage() {
|
||||
Close
|
||||
</button>
|
||||
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
<ProductImage
|
||||
src={imageUrl}
|
||||
alt={product?.name ?? "Product image"}
|
||||
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
|
||||
@@ -747,4 +781,4 @@ export default function ProductDetailsPage() {
|
||||
</div>
|
||||
</main>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,151 @@
|
||||
import { postBySlugQuery, postSlugsQuery, urlFor } from '@/lib/sanity'
|
||||
import { sanityFetch } from '@/lib/sanityFetch'
|
||||
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
|
||||
import Image from 'next/image'
|
||||
import Link from 'next/link'
|
||||
import { notFound } from 'next/navigation'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const revalidate = 60
|
||||
|
||||
type Params = { slug: string }
|
||||
|
||||
type Post = {
|
||||
_id: string
|
||||
title: string
|
||||
slug: { current: string }
|
||||
publishedAt: string
|
||||
excerpt: string | null
|
||||
mainImage: { asset: object; alt?: string } | null
|
||||
body: unknown[]
|
||||
seoTitle: string | null
|
||||
seoDescription: string | null
|
||||
author: { name: string; image?: object; bio?: string } | null
|
||||
}
|
||||
|
||||
export async function generateStaticParams() {
|
||||
try {
|
||||
const slugs: { slug: string }[] = await sanityFetch<{ slug: string }[]>(postSlugsQuery)
|
||||
return slugs.map((s) => ({ slug: s.slug }))
|
||||
} catch (err) {
|
||||
console.error('[Guides] generateStaticParams fetch failed:', err)
|
||||
return []
|
||||
}
|
||||
}
|
||||
|
||||
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
|
||||
const { slug } = await params
|
||||
let post: Post | null = null
|
||||
try {
|
||||
post = await sanityFetch<Post | null>(postBySlugQuery, { slug })
|
||||
} catch (err) {
|
||||
console.error('[Guides] generateMetadata fetch failed:', err)
|
||||
}
|
||||
if (!post) return {}
|
||||
|
||||
return {
|
||||
title: post.seoTitle ?? post.title,
|
||||
description: post.seoDescription ?? post.excerpt ?? undefined,
|
||||
openGraph: {
|
||||
title: post.seoTitle ?? post.title,
|
||||
description: post.seoDescription ?? post.excerpt ?? undefined,
|
||||
images: post.mainImage?.asset
|
||||
? [{ url: urlFor(post.mainImage).width(1200).height(630).url() }]
|
||||
: [],
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export default async function PostPage({ params }: { params: Promise<Params> }) {
|
||||
const { slug } = await params
|
||||
let post: Post | null = null
|
||||
try {
|
||||
post = await sanityFetch<Post | null>(postBySlugQuery, { slug })
|
||||
} catch (err) {
|
||||
console.error('[Guides] PostPage fetch failed:', err)
|
||||
}
|
||||
|
||||
if (!post) notFound()
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-3xl px-4 py-10">
|
||||
{/* Breadcrumb */}
|
||||
<nav className="mb-6 text-xs text-zinc-500">
|
||||
<Link href="/guides" className="hover:text-zinc-300">
|
||||
← Guides
|
||||
</Link>
|
||||
</nav>
|
||||
|
||||
{/* Header */}
|
||||
<header className="mb-8">
|
||||
<div className="mb-3 flex items-center gap-2 text-[0.7rem] text-zinc-500">
|
||||
{post.author?.name && <span>{post.author.name}</span>}
|
||||
{post.author?.name && <span>·</span>}
|
||||
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
|
||||
</div>
|
||||
|
||||
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-zinc-100 md:text-4xl">
|
||||
{post.title}
|
||||
</h1>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="mt-4 text-base text-zinc-400 leading-relaxed">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{/* Hero image */}
|
||||
{post.mainImage?.asset && (
|
||||
<div className="relative mb-10 h-64 w-full overflow-hidden rounded-xl md:h-80">
|
||||
<Image
|
||||
src={urlFor(post.mainImage).width(1200).height(630).url()}
|
||||
alt={post.mainImage.alt ?? post.title}
|
||||
fill
|
||||
priority
|
||||
className="object-cover"
|
||||
sizes="(max-width: 768px) 100vw, 768px"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Body */}
|
||||
{post.body && (
|
||||
<article className="border-b border-zinc-800 pb-10">
|
||||
<PortableTextRenderer value={post.body} />
|
||||
</article>
|
||||
)}
|
||||
|
||||
{/* CTA footer */}
|
||||
<div className="mt-10 rounded-xl border border-zinc-800 bg-zinc-950/60 p-6 text-center">
|
||||
<p className="text-sm font-medium text-zinc-200">
|
||||
Ready to put this into practice?
|
||||
</p>
|
||||
<p className="mt-1 text-xs text-zinc-500">
|
||||
Use the Battl Builder to price out your parts and track your total.
|
||||
</p>
|
||||
<Link
|
||||
href="/builder"
|
||||
className="mt-4 inline-block rounded-md bg-amber-400 px-5 py-2.5 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
|
||||
>
|
||||
Start Your Build →
|
||||
</Link>
|
||||
</div>
|
||||
|
||||
{/* Back link */}
|
||||
<div className="mt-8 text-center">
|
||||
<Link href="/guides" className="text-xs text-zinc-500 hover:text-zinc-300">
|
||||
← Back to all guides
|
||||
</Link>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,119 @@
|
||||
import { postsQuery, urlFor } from '@/lib/sanity'
|
||||
import { sanityFetch } from '@/lib/sanityFetch'
|
||||
import Link from 'next/link'
|
||||
import Image from 'next/image'
|
||||
import type { Metadata } from 'next'
|
||||
|
||||
export const metadata: Metadata = {
|
||||
title: 'Build Guides',
|
||||
description: 'AR-15 build guides, part breakdowns, and buying advice from Battl Builders.',
|
||||
}
|
||||
|
||||
export const revalidate = 60 // ISR — regenerate every 60s
|
||||
|
||||
type Post = {
|
||||
_id: string
|
||||
title: string
|
||||
slug: { current: string }
|
||||
publishedAt: string
|
||||
excerpt: string | null
|
||||
mainImage: { asset: object; alt?: string } | null
|
||||
author: { name: string; image?: object } | null
|
||||
}
|
||||
|
||||
function formatDate(iso: string) {
|
||||
return new Date(iso).toLocaleDateString('en-US', {
|
||||
year: 'numeric',
|
||||
month: 'long',
|
||||
day: 'numeric',
|
||||
})
|
||||
}
|
||||
|
||||
export default async function GuidesPage() {
|
||||
let posts: Post[] = []
|
||||
try {
|
||||
posts = await sanityFetch<Post[]>(postsQuery)
|
||||
} catch (err) {
|
||||
console.error('[Guides] Sanity fetch failed:', err)
|
||||
}
|
||||
|
||||
return (
|
||||
<main className="mx-auto max-w-4xl px-4 py-10">
|
||||
<header className="mb-10">
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
|
||||
Battl Builders
|
||||
</p>
|
||||
<h1 className="mt-2 text-3xl font-semibold tracking-tight">
|
||||
Build Guides
|
||||
</h1>
|
||||
<p className="mt-3 text-sm text-zinc-400 max-w-xl">
|
||||
Part breakdowns, buying advice, and build walkthroughs to help you
|
||||
make smarter decisions before you spend a dollar.
|
||||
</p>
|
||||
</header>
|
||||
|
||||
{posts.length === 0 ? (
|
||||
<p className="text-sm text-zinc-500">No guides published yet — check back soon.</p>
|
||||
) : (
|
||||
<div className="space-y-8">
|
||||
{posts.map((post) => (
|
||||
<article
|
||||
key={post._id}
|
||||
className="group flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-950/60 p-5 transition-colors hover:border-zinc-700 sm:flex-row"
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
{post.mainImage?.asset && (
|
||||
<div className="relative h-44 w-full shrink-0 overflow-hidden rounded-lg sm:h-32 sm:w-48">
|
||||
<Image
|
||||
src={urlFor(post.mainImage).width(400).height(256).url()}
|
||||
alt={post.mainImage.alt ?? post.title}
|
||||
fill
|
||||
className="object-cover transition-transform duration-300 group-hover:scale-105"
|
||||
sizes="(max-width: 640px) 100vw, 192px"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Text */}
|
||||
<div className="flex flex-col justify-center gap-2">
|
||||
<div className="flex items-center gap-2 text-[0.7rem] text-zinc-500">
|
||||
{post.author?.name && <span>{post.author.name}</span>}
|
||||
{post.author?.name && <span>·</span>}
|
||||
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
|
||||
</div>
|
||||
|
||||
<h2 className="text-lg font-semibold leading-snug text-zinc-100 group-hover:text-amber-300 transition-colors">
|
||||
<Link href={`/guides/${post.slug.current}`} className="stretched-link">
|
||||
{post.title}
|
||||
</Link>
|
||||
</h2>
|
||||
|
||||
{post.excerpt && (
|
||||
<p className="text-sm text-zinc-400 leading-relaxed line-clamp-2">
|
||||
{post.excerpt}
|
||||
</p>
|
||||
)}
|
||||
|
||||
<Link
|
||||
href={`/guides/${post.slug.current}`}
|
||||
className="mt-1 self-start text-xs font-medium text-amber-400 hover:text-amber-300"
|
||||
>
|
||||
Read guide →
|
||||
</Link>
|
||||
</div>
|
||||
</article>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="mt-12 rounded-lg border border-zinc-800 bg-zinc-950/60 p-5 text-center">
|
||||
<p className="text-sm text-zinc-400">
|
||||
Ready to start building?{' '}
|
||||
<Link href="/builder" className="font-semibold text-amber-400 hover:text-amber-300">
|
||||
Open the Builder →
|
||||
</Link>
|
||||
</p>
|
||||
</div>
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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 />
|
||||
|
||||
@@ -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">
|
||||
|
||||
@@ -18,8 +18,7 @@ type BuildDto = {
|
||||
updatedAt?: string | null;
|
||||
};
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
// API routes now handled by Next.js /api routes (server-side)
|
||||
|
||||
// UUID validator (defensive)
|
||||
const isUuid = (v: string) =>
|
||||
@@ -40,14 +39,14 @@ export default function VaultPage() {
|
||||
|
||||
// NOTE:
|
||||
// - `loading` here is AuthContext hydration, not network.
|
||||
// - `token` is the real gate for /me endpoints.
|
||||
const { user, token, loading: authLoading } = useAuth();
|
||||
// - Auth is handled by HTTP-only cookies automatically.
|
||||
const { user, loading: authLoading } = useAuth();
|
||||
|
||||
const [builds, setBuilds] = useState<BuildDto[]>([]);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [busy, setBusy] = useState(false);
|
||||
|
||||
const authed = !!user && !!token;
|
||||
const authed = !!user;
|
||||
|
||||
// Redirect if not logged in (after auth hydrates)
|
||||
useEffect(() => {
|
||||
@@ -56,12 +55,12 @@ export default function VaultPage() {
|
||||
}
|
||||
}, [authLoading, authed, router]);
|
||||
|
||||
// When auth identity changes, clear stale list to avoid “ghost builds”
|
||||
// When auth identity changes, clear stale list to avoid "ghost builds"
|
||||
useEffect(() => {
|
||||
if (authLoading) return;
|
||||
setBuilds([]);
|
||||
setError(null);
|
||||
}, [authLoading, user?.uuid, token]);
|
||||
}, [authLoading, user?.uuid]);
|
||||
|
||||
// Fetch user builds
|
||||
useEffect(() => {
|
||||
@@ -75,8 +74,8 @@ export default function VaultPage() {
|
||||
|
||||
try {
|
||||
// IMPORTANT:
|
||||
// Use api wrapper so Authorization: Bearer <token> is consistently applied.
|
||||
const res = await api.get("/api/v1/builds/me");
|
||||
// Use api wrapper which includes credentials (cookies) automatically.
|
||||
const res = await api.get("/api/builds");
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => "");
|
||||
|
||||
@@ -1,11 +1,7 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Button, Field, Input } from "@/components/ui/form";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
type AdminBetaRequestDto = {
|
||||
id: number;
|
||||
@@ -40,17 +36,12 @@ type AdminInviteResponse = {
|
||||
token?: string;
|
||||
};
|
||||
|
||||
async function fetchJson(
|
||||
path: string,
|
||||
init: RequestInit = {},
|
||||
authHeaders: HeadersInit = {}
|
||||
) {
|
||||
const res = await fetch(`${API_BASE_URL}${path}`, {
|
||||
async function fetchJson(path: string, init: RequestInit = {}) {
|
||||
const res = await fetch(path, {
|
||||
...init,
|
||||
headers: {
|
||||
"Content-Type": "application/json",
|
||||
...(init.headers ?? {}),
|
||||
...authHeaders,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
@@ -101,9 +92,6 @@ function Badge({
|
||||
}
|
||||
|
||||
export default function AdminBetaInvitesPage() {
|
||||
const { getAuthHeaders, user } = useAuth();
|
||||
const authHeaders = useMemo(() => getAuthHeaders(), [getAuthHeaders]);
|
||||
|
||||
// ------------------------------
|
||||
// Batch invites
|
||||
// ------------------------------
|
||||
@@ -126,11 +114,9 @@ export default function AdminBetaInvitesPage() {
|
||||
tokenMinutes: String(tokenMinutes || "30"),
|
||||
});
|
||||
|
||||
const data = await fetchJson(
|
||||
`/api/v1/admin/beta/invites/send?${qs.toString()}`,
|
||||
{ method: "POST" },
|
||||
authHeaders
|
||||
);
|
||||
const data = await fetchJson(`/api/admin/beta-invites?${qs.toString()}`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
setBatchResult(data);
|
||||
await loadRequests();
|
||||
@@ -166,9 +152,8 @@ export default function AdminBetaInvitesPage() {
|
||||
|
||||
try {
|
||||
const data = (await fetchJson(
|
||||
`/api/v1/admin/beta/requests?page=${page}&size=${size}`,
|
||||
{ method: "GET" },
|
||||
authHeaders
|
||||
`/api/admin/beta/requests?page=${page}&size=${size}`,
|
||||
{ method: "GET" }
|
||||
)) as PageResponse<AdminBetaRequestDto>;
|
||||
|
||||
setRequests(data);
|
||||
@@ -181,11 +166,9 @@ export default function AdminBetaInvitesPage() {
|
||||
|
||||
async function inviteSingle(userId: number): Promise<AdminInviteResponse> {
|
||||
// calls backend: POST /api/v1/admin/beta/requests/{userId}/invite
|
||||
return (await fetchJson(
|
||||
`/api/v1/admin/beta/requests/${userId}/invite`,
|
||||
{ method: "POST" },
|
||||
authHeaders
|
||||
)) as AdminInviteResponse;
|
||||
return (await fetchJson(`/api/admin/beta/requests/${userId}/invite`, {
|
||||
method: "POST",
|
||||
})) as AdminInviteResponse;
|
||||
}
|
||||
|
||||
async function onSendInviteEmail(userId: number) {
|
||||
@@ -241,19 +224,6 @@ export default function AdminBetaInvitesPage() {
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [page]);
|
||||
|
||||
const isAdmin = (user?.role ?? "").toUpperCase() === "ADMIN";
|
||||
|
||||
if (!isAdmin) {
|
||||
return (
|
||||
<div className="space-y-2">
|
||||
<h1 className="text-xl font-semibold">Admin</h1>
|
||||
<p className="text-sm text-zinc-400">
|
||||
You don’t have access to this page.
|
||||
</p>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-6">
|
||||
<div>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
fetchEmailRequests,
|
||||
createEmailRequest,
|
||||
@@ -17,7 +16,6 @@ import { formatDate } from "@/lib/dateFormattingUtility";
|
||||
type EmailStatusTab = "ALL" | "PENDING" | "SENT" | "FAILED" | "OTHER";
|
||||
|
||||
export default function AdminEmailPage() {
|
||||
const { token } = useAuth();
|
||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -33,11 +31,9 @@ export default function AdminEmailPage() {
|
||||
const [statusTab, setStatusTab] = useState<EmailStatusTab>("ALL");
|
||||
|
||||
const loadEmails = useCallback(async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchEmailRequests(token);
|
||||
const data = await fetchEmailRequests();
|
||||
setEmails(data);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
@@ -46,7 +42,7 @@ export default function AdminEmailPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
@@ -108,11 +104,10 @@ export default function AdminEmailPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!token) return;
|
||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||
|
||||
try {
|
||||
await deleteEmailRequest(token, id);
|
||||
await deleteEmailRequest(id);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to delete email request");
|
||||
@@ -121,17 +116,15 @@ export default function AdminEmailPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (modalMode === "create") {
|
||||
await createEmailRequest(token, formData);
|
||||
await createEmailRequest(formData);
|
||||
} else if (selectedEmail) {
|
||||
const updateData: UpdateEmailRequestDto = {};
|
||||
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
||||
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
||||
await updateEmailRequest(token, selectedEmail.id, updateData);
|
||||
await updateEmailRequest(selectedEmail.id, updateData);
|
||||
}
|
||||
setShowModal(false);
|
||||
await loadEmails();
|
||||
@@ -142,14 +135,6 @@ export default function AdminEmailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-500">
|
||||
You must be logged in to view this page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
"use client";
|
||||
|
||||
import { useCallback, useEffect, useState } from "react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
fetchEmailRequests,
|
||||
createEmailRequest,
|
||||
@@ -14,7 +13,6 @@ import {
|
||||
import { Pencil, Trash2, Plus, X } from "lucide-react";
|
||||
import { formatDate } from "@/lib/dateFormattingUtility";
|
||||
export default function AdminEmailPage() {
|
||||
const { token } = useAuth();
|
||||
const [emails, setEmails] = useState<EmailRequest[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
@@ -28,11 +26,9 @@ export default function AdminEmailPage() {
|
||||
const [submitting, setSubmitting] = useState(false);
|
||||
|
||||
const loadEmails = useCallback(async () => {
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setLoading(true);
|
||||
const data = await fetchEmailRequests(token);
|
||||
const data = await fetchEmailRequests();
|
||||
setEmails(data);
|
||||
setError(null);
|
||||
} catch (e: any) {
|
||||
@@ -41,7 +37,7 @@ export default function AdminEmailPage() {
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, [token]);
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
loadEmails();
|
||||
@@ -62,11 +58,10 @@ export default function AdminEmailPage() {
|
||||
};
|
||||
|
||||
const handleDelete = async (id: number) => {
|
||||
if (!token) return;
|
||||
if (!confirm("Are you sure you want to delete this email request?")) return;
|
||||
|
||||
try {
|
||||
await deleteEmailRequest(token, id);
|
||||
await deleteEmailRequest(id);
|
||||
await loadEmails();
|
||||
} catch (e: any) {
|
||||
alert(e?.message ?? "Failed to delete email request");
|
||||
@@ -75,17 +70,15 @@ export default function AdminEmailPage() {
|
||||
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
if (!token) return;
|
||||
|
||||
try {
|
||||
setSubmitting(true);
|
||||
if (modalMode === "create") {
|
||||
await createEmailRequest(token, formData);
|
||||
await createEmailRequest(formData);
|
||||
} else if (selectedEmail) {
|
||||
const updateData: UpdateEmailRequestDto = {};
|
||||
if (formData.email !== selectedEmail.email) updateData.email = formData.email;
|
||||
if (formData.status !== selectedEmail.status) updateData.status = formData.status;
|
||||
await updateEmailRequest(token, selectedEmail.id, updateData);
|
||||
await updateEmailRequest(selectedEmail.id, updateData);
|
||||
}
|
||||
setShowModal(false);
|
||||
await loadEmails();
|
||||
@@ -96,14 +89,6 @@ export default function AdminEmailPage() {
|
||||
}
|
||||
};
|
||||
|
||||
if (!token) {
|
||||
return (
|
||||
<div className="p-6 text-sm text-red-500">
|
||||
You must be logged in to view this page.
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="p-6 space-y-4">
|
||||
{/* Header */}
|
||||
|
||||
@@ -46,8 +46,8 @@ export default function ImportStatusPage() {
|
||||
setError(null);
|
||||
|
||||
const [summaryRes, byMerchantRes] = await Promise.all([
|
||||
fetch(`${API_BASE_URL}/api/admin/import-status/summary`),
|
||||
fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`),
|
||||
fetch('/api/admin/import-status/summary', { credentials: 'include' }),
|
||||
fetch('/api/admin/import-status/by-merchant', { credentials: 'include' }),
|
||||
]);
|
||||
|
||||
if (!summaryRes.ok || !byMerchantRes.ok) {
|
||||
@@ -108,9 +108,10 @@ export default function ImportStatusPage() {
|
||||
try {
|
||||
setImportingId(merchantId);
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/merchants/${merchantId}/import`,
|
||||
`/api/admin/merchants/${merchantId}/import`,
|
||||
{
|
||||
method: "POST",
|
||||
credentials: 'include',
|
||||
}
|
||||
);
|
||||
|
||||
|
||||
+4
-13
@@ -149,7 +149,7 @@ export default function AdminLayout({
|
||||
|
||||
return (
|
||||
// ✅ prevent browser-level horizontal scroll from any wide children
|
||||
<div className="min-h-screen bg-black text-zinc-50 overflow-x-hidden pt-[52px]">
|
||||
<div className="h-screen bg-black text-zinc-50 overflow-hidden">
|
||||
<AdminLeftNavigation
|
||||
collapsed={collapsed}
|
||||
onToggleCollapsed={() => setCollapsed((v) => !v)}
|
||||
@@ -158,7 +158,7 @@ export default function AdminLayout({
|
||||
|
||||
{/* ✅ min-w-0 is the critical fix: lets the main column shrink */}
|
||||
<div
|
||||
className="flex min-h-screen flex-1 min-w-0 flex-col transition-all duration-300"
|
||||
className="flex h-full flex-1 min-w-0 flex-col transition-all duration-300"
|
||||
style={{ marginLeft: collapsed ? '68px' : '280px' }}
|
||||
>
|
||||
<header className="flex items-center justify-between border-b border-zinc-900 bg-zinc-950/70 px-4 py-3 sticky top-0 z-30 backdrop-blur-sm">
|
||||
@@ -168,22 +168,13 @@ 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 className="flex h-8 w-8 items-center justify-center rounded-full bg-zinc-900 text-[11px] text-zinc-300">
|
||||
ADMIN
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* ✅ min-w-0 here prevents table min-width from widening the page */}
|
||||
<main className="flex w-full flex-1 min-w-0 flex-col px-4 py-6">
|
||||
<main className="flex w-full flex-1 min-w-0 flex-col overflow-y-auto px-4 py-6">
|
||||
{children}
|
||||
</main>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -198,8 +198,9 @@ export default function MappingAdminPage() {
|
||||
|
||||
// EXPECTED backend endpoint (new):
|
||||
// GET { merchants: [{id,name}], canonicalCategories: [{id,name,slug}] }
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
|
||||
const res = await fetch('/api/admin/mapping/options', {
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: 'include',
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -243,8 +244,9 @@ export default function MappingAdminPage() {
|
||||
if (tab === "roles") {
|
||||
// Existing endpoint you already have:
|
||||
// GET /api/admin/mapping/pending-buckets
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
|
||||
const res = await fetch('/api/admin/mapping/pending-buckets', {
|
||||
headers: { Accept: "application/json" },
|
||||
credentials: 'include',
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
@@ -277,8 +279,8 @@ export default function MappingAdminPage() {
|
||||
if (q?.trim()) params.set("q", q.trim());
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/mapping/raw-categories?${params.toString()}`,
|
||||
{ headers: { Accept: "application/json" }, cache: "no-store" }
|
||||
`/api/admin/mapping/raw-categories?${params.toString()}`,
|
||||
{ headers: { Accept: "application/json" }, credentials: 'include', cache: "no-store" }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
@@ -336,9 +338,10 @@ export default function MappingAdminPage() {
|
||||
|
||||
// Existing endpoint you already have:
|
||||
// POST /api/admin/mapping/apply
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
||||
const res = await fetch('/api/admin/mapping/apply', {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
merchantId: row.merchantId,
|
||||
rawCategoryKey: row.rawCategoryKey,
|
||||
@@ -390,9 +393,10 @@ export default function MappingAdminPage() {
|
||||
|
||||
// EXPECTED new endpoint:
|
||||
// POST /api/admin/mapping/upsert
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
|
||||
const res = await fetch('/api/admin/mapping/upsert', {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({
|
||||
merchantId: row.merchantId,
|
||||
platform: row.platform ?? platform ?? null,
|
||||
|
||||
@@ -2,8 +2,7 @@
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
// API routes now handled by Next.js /api routes (server-side)
|
||||
|
||||
type MerchantAdminDto = {
|
||||
id: number;
|
||||
@@ -91,7 +90,7 @@ export default function MerchantsAdminPage() {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
|
||||
const url = `${API_BASE_URL}/api/admin/merchants`;
|
||||
const url = `/api/admin/merchants`;
|
||||
console.log("Loading merchants from:", url);
|
||||
|
||||
const res = await fetch(url, {
|
||||
@@ -150,7 +149,7 @@ export default function MerchantsAdminPage() {
|
||||
setError(null);
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/merchants/${id}`, {
|
||||
const res = await fetch(`/api/admin/merchants/${id}`, {
|
||||
method: "PUT",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
@@ -189,7 +188,7 @@ export default function MerchantsAdminPage() {
|
||||
setError(null);
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/imports/${id}`, {
|
||||
const res = await fetch(`/api/admin/merchants/${id}/import`, {
|
||||
method: "POST",
|
||||
});
|
||||
|
||||
@@ -218,7 +217,7 @@ export default function MerchantsAdminPage() {
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/imports/${id}/offers-only`,
|
||||
`/api/admin/merchants/${id}/offer-sync`,
|
||||
{ method: "POST" }
|
||||
);
|
||||
|
||||
@@ -247,7 +246,7 @@ export default function MerchantsAdminPage() {
|
||||
setError(null);
|
||||
setBanner(null);
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||
const res = await fetch(`/api/admin/merchants`, {
|
||||
method: "POST",
|
||||
headers: { "Content-Type": "application/json" },
|
||||
body: JSON.stringify({
|
||||
|
||||
+3
-1
@@ -24,7 +24,9 @@ export default function AdminLandingPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`);
|
||||
const res = await fetch('/api/admin/dashboard/overview', {
|
||||
credentials: 'include',
|
||||
});
|
||||
if (!res.ok) {
|
||||
throw new Error(`Failed to load dashboard (${res.status})`);
|
||||
}
|
||||
|
||||
@@ -3,7 +3,6 @@
|
||||
|
||||
import { useEffect, useMemo, useState } from "react";
|
||||
import { Plus, Pencil, Trash2, X } from "lucide-react";
|
||||
import { useAuth } from "@/context/AuthContext";
|
||||
import {
|
||||
createPlatform,
|
||||
deletePlatform,
|
||||
@@ -15,7 +14,6 @@ import {
|
||||
} from "@/lib/api/platforms";
|
||||
|
||||
export default function AdminPlatformsPage() {
|
||||
const { getAuthHeaders } = useAuth();
|
||||
|
||||
const [platforms, setPlatforms] = useState<Platform[]>([]);
|
||||
const [loading, setLoading] = useState(true);
|
||||
@@ -36,7 +34,7 @@ export default function AdminPlatformsPage() {
|
||||
try {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
const data = await fetchPlatforms(getAuthHeaders());
|
||||
const data = await fetchPlatforms({});
|
||||
setPlatforms(data);
|
||||
} catch (e: any) {
|
||||
console.error(e);
|
||||
@@ -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);
|
||||
|
||||
@@ -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()}`);
|
||||
|
||||
@@ -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(),
|
||||
},
|
||||
|
||||
@@ -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("bb_access_token")?.value;
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const token = cookieStore.get("session_token")?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
||||
@@ -32,4 +34,4 @@ export async function POST(req: Request) {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
||||
|
||||
// POST /api/admin/beta-invites?dryRun=true&limit=10&tokenMinutes=30
|
||||
export async function POST(req: Request) {
|
||||
const token = cookies().get("session_token")?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: "Missing admin token" }, { status: 401 });
|
||||
}
|
||||
|
||||
const url = new URL(req.url);
|
||||
const dryRun = url.searchParams.get("dryRun") ?? "true";
|
||||
const limit = url.searchParams.get("limit") ?? "0";
|
||||
const tokenMinutes = url.searchParams.get("tokenMinutes") ?? "30";
|
||||
|
||||
const upstream = `${API_BASE_URL}/api/v1/admin/beta/invites/send?dryRun=${dryRun}&limit=${limit}&tokenMinutes=${tokenMinutes}`;
|
||||
|
||||
const res = await fetch(upstream, {
|
||||
method: "POST",
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
},
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
const text = await res.text();
|
||||
return new NextResponse(text, {
|
||||
status: res.status,
|
||||
headers: { "Content-Type": res.headers.get("content-type") ?? "application/json" },
|
||||
});
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("session_token")?.value;
|
||||
if (!token) throw new Error("Unauthorized");
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/beta/requests/${resolvedParams.id}/invite`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get("session_token")?.value;
|
||||
if (!token) throw new Error("Unauthorized");
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
_req: Request,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/beta/requests/${params.id}/invite`,
|
||||
{
|
||||
method: "POST",
|
||||
headers,
|
||||
cache: "no-store",
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("session_token")?.value;
|
||||
if (!token) throw new Error("Unauthorized");
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const url = new URL(req.url);
|
||||
const search = url.searchParams.toString();
|
||||
|
||||
const upstream = `${API_BASE_URL}/api/v1/admin/beta/requests${
|
||||
search ? `?${search}` : ""
|
||||
}`;
|
||||
|
||||
const res = await fetch(upstream, {
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get("session_token")?.value;
|
||||
if (!token) throw new Error("Unauthorized");
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(req: Request) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const url = new URL(req.url);
|
||||
const search = url.searchParams.toString();
|
||||
|
||||
const upstream = `${API_BASE_URL}/api/v1/admin/beta/requests${
|
||||
search ? `?${search}` : ""
|
||||
}`;
|
||||
|
||||
const res = await fetch(upstream, {
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/calibers${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/calibers${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/dashboard/overview`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("session_token")?.value;
|
||||
if (!token) throw new Error("Unauthorized");
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/email/${resolvedParams.id}`, {
|
||||
method: "PUT",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/email/${resolvedParams.id}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,63 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get("session_token")?.value;
|
||||
if (!token) throw new Error("Unauthorized");
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
|
||||
method: "PUT",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/email/${params.id}`, {
|
||||
method: "DELETE",
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get("session_token")?.value;
|
||||
if (!token) throw new Error("Unauthorized");
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,56 @@
|
||||
import { NextRequest, NextResponse } from "next/server";
|
||||
import { cookies } from "next/headers";
|
||||
|
||||
const API_BASE_URL =
|
||||
process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get("session_token")?.value;
|
||||
if (!token) throw new Error("Unauthorized");
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
||||
headers,
|
||||
cache: "no-store",
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/email`, {
|
||||
method: "POST",
|
||||
headers: { ...headers, "Content-Type": "application/json" },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: "Unauthorized" }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string; action: string }> }
|
||||
) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const resolvedParams = await params;
|
||||
|
||||
const { id, action } = resolvedParams;
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/enrichment/${id}/${action}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string; action: string } }
|
||||
) {
|
||||
try {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const { id, action } = params;
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/enrichment/${id}/${action}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/enrichment/ai/run${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/enrichment/ai/run${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { fixImageUrls } from '@/lib/server/image-utils';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/enrichment/queue2${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Fix mixed content warnings: upgrade HTTP image URLs to HTTPS
|
||||
return NextResponse.json(fixImageUrls(data));
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
import { fixImageUrls } from '@/lib/server/image-utils';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/enrichment/queue2${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Fix mixed content warnings: upgrade HTTP image URLs to HTTPS
|
||||
return NextResponse.json(fixImageUrls(data));
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const cookieStore = await cookies();
|
||||
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/enrichment/run${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,46 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/enrichment/run${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
method: 'POST',
|
||||
headers: {
|
||||
Authorization: `Bearer ${token}`,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const contentType = res.headers.get('content-type');
|
||||
if (contentType?.includes('application/json')) {
|
||||
return NextResponse.json(await res.json());
|
||||
}
|
||||
|
||||
return NextResponse.json({ success: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Internal server error' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/by-merchant`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/summary`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/import-status/summary`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/apply`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/options`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/pending-buckets`, {
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/mapping/raw-categories?${searchParams.toString()}`,
|
||||
{
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,39 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const { searchParams } = new URL(request.url);
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/admin/mapping/raw-categories?${searchParams.toString()}`,
|
||||
{
|
||||
headers,
|
||||
cache: 'no-store',
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,40 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/admin/mapping/upsert`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}/import`,
|
||||
{ method: 'POST', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/import`,
|
||||
{ method: 'POST', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,37 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}/offer-sync`,
|
||||
{ method: 'POST', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,35 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function POST(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}/offer-sync`,
|
||||
{ method: 'POST', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${resolvedParams.id}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,90 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/merchants/${params.id}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||
headers,
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/admin/merchants`, {
|
||||
method: 'POST',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
headers: {
|
||||
...headers,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/admin/platforms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms${queryString ? `?${queryString}` : ''}`,
|
||||
{
|
||||
headers: {
|
||||
...headers,
|
||||
Accept: 'application/json',
|
||||
},
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(`${API_BASE_URL}/api/v1/admin/platforms`, {
|
||||
method: 'POST',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Unauthorized' },
|
||||
{ status: 401 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const resolvedParams = await params;
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms/${resolvedParams.id}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,68 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const token = cookies().get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function PATCH(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
|
||||
{
|
||||
method: 'PATCH',
|
||||
headers: {
|
||||
...headers,
|
||||
'Content-Type': 'application/json',
|
||||
},
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: { id: string } }
|
||||
) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/platforms/${params.id}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,73 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
try {
|
||||
const headers = await getAuthHeader();
|
||||
const searchParams = request.nextUrl.searchParams;
|
||||
const queryString = searchParams.toString();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/admin/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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Forward to backend API
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
return NextResponse.json(
|
||||
{ error: text || 'Login failed' },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Set HTTP-only cookie instead of returning token
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('session_token', data.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
path: '/',
|
||||
});
|
||||
|
||||
// Return user data without token
|
||||
return NextResponse.json({
|
||||
user: data.user,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Login failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,8 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
export async function POST() {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.delete('session_token');
|
||||
return NextResponse.json({ success: true });
|
||||
}
|
||||
@@ -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 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,31 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function GET(request: NextRequest) {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
|
||||
if (!token) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`${API_BASE_URL}/api/users/me`, {
|
||||
headers: { Authorization: `Bearer ${token}` },
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
cookieStore.delete('session_token');
|
||||
return NextResponse.json({ error: 'Session expired' }, { status: 401 });
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Failed to fetch user' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,49 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
export async function POST(request: NextRequest) {
|
||||
try {
|
||||
const body = await request.json();
|
||||
|
||||
// Forward to backend API
|
||||
const res = await fetch(`${API_BASE_URL}/api/auth/register`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
});
|
||||
|
||||
if (!res.ok) {
|
||||
const text = await res.text().catch(() => '');
|
||||
return NextResponse.json(
|
||||
{ error: text || 'Registration failed' },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
// Set HTTP-only cookie on successful registration
|
||||
if (data.token) {
|
||||
const cookieStore = await cookies();
|
||||
cookieStore.set('session_token', data.token, {
|
||||
httpOnly: true,
|
||||
secure: process.env.NODE_ENV === 'production',
|
||||
sameSite: 'lax',
|
||||
maxAge: 60 * 60 * 24 * 7, // 7 days
|
||||
path: '/',
|
||||
});
|
||||
}
|
||||
|
||||
// Return user data without token
|
||||
return NextResponse.json({
|
||||
user: data.user,
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json(
|
||||
{ error: err?.message || 'Registration failed' },
|
||||
{ status: 500 }
|
||||
);
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,94 @@
|
||||
import { NextRequest, NextResponse } from 'next/server';
|
||||
import { cookies } from 'next/headers';
|
||||
|
||||
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL;
|
||||
|
||||
async function getAuthHeader() {
|
||||
const cookieStore = await cookies();
|
||||
const token = cookieStore.get('session_token')?.value;
|
||||
if (!token) throw new Error('Unauthorized');
|
||||
return { Authorization: `Bearer ${token}` };
|
||||
}
|
||||
|
||||
export async function GET(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/gunbuilder/builds/${id}`,
|
||||
{ headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function PUT(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const headers = await getAuthHeader();
|
||||
const body = await request.json();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/gunbuilder/builds/${id}`,
|
||||
{
|
||||
method: 'PUT',
|
||||
headers: { ...headers, 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify(body),
|
||||
}
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
|
||||
export async function DELETE(
|
||||
request: NextRequest,
|
||||
{ params }: { params: Promise<{ id: string }> }
|
||||
) {
|
||||
try {
|
||||
const { id } = await params;
|
||||
const headers = await getAuthHeader();
|
||||
|
||||
const res = await fetch(
|
||||
`${API_BASE_URL}/api/v1/gunbuilder/builds/${id}`,
|
||||
{ method: 'DELETE', headers }
|
||||
);
|
||||
|
||||
if (!res.ok) {
|
||||
return NextResponse.json(
|
||||
{ error: await res.text() },
|
||||
{ status: res.status }
|
||||
);
|
||||
}
|
||||
|
||||
return NextResponse.json(await res.json());
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user