35 Commits

Author SHA1 Message Date
sean 8f2a46d3bb feat: add FeedbackForm, FeedbackModal, and Give Feedback tab 2026-03-30 19:24:11 -04:00
sean 0ca864ff20 fix: clear setTimeout ref on unmount, stable roadmapIdKey dep for votes effect 2026-03-30 19:18:23 -04:00
sean 93aa4469e8 feat: wire up roadmap upvote with optimistic UI and inline auth prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:15:50 -04:00
sean 89e39e7b18 fix: separate 400/500 error cases in feedback proxy route 2026-03-30 19:14:19 -04:00
sean 8e33a5497a feat: add API proxy routes for feedback and roadmap votes 2026-03-30 16:46:49 -04:00
sean 26bc1a8c21 feat: add roadmap tab with static RoadmapItem component 2026-03-30 16:42:15 -04:00
sean 2d55af4de2 fix: make tags optional in ChangelogEntry, remove unused VoteState import 2026-03-30 16:40:35 -04:00
sean d387484872 feat: add /changelog page with What's New tab
RSC page fetches changelog entries and roadmap items from Sanity with
graceful error fallback. ChangelogTabs client component renders tabbed
UI with What's New fully implemented; What's Next and Give Feedback are
stubs for Tasks 3 and 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:37:41 -04:00
sean e2a2af8a6f feat: add changelog/roadmap types, Sanity queries, and public route whitelist 2026-03-30 16:33:53 -04:00
sean c977e69654 docs: address spec review gaps in changelog/feedback design 2026-03-30 11:00:54 -04:00
sean 9d0eb40554 docs: add changelog, roadmap, and feedback design spec 2026-03-30 10:57:36 -04:00
sean 2a97ae4190 new homepage and event tracking
CI / deploy (push) Failing after 6s
2026-03-30 10:40:24 -04:00
sean a1bb9e97e1 fix: replace opacity-85 no-op and inline gap styles with Tailwind classes 2026-03-30 06:51:31 -04:00
sean b26bc5263b feat: redesign homepage — RSC, 8 sections, remove beta form
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 06:48:37 -04:00
sean 895a2e7406 feat: suppress Early Access Beta banner on homepage 2026-03-30 06:45:12 -04:00
sean 47d5d17d29 placeholder images
CI / deploy (push) Failing after 35s
2026-03-29 07:54:52 -04:00
sean c686da0c03 feat: use ProductImage for part thumbnails on build detail page 2026-03-29 07:43:55 -04:00
sean 680d2b21a1 feat: use ProductImage on product detail page and modal 2026-03-29 07:42:15 -04:00
sean c86d49f317 feat: use ProductImage in ProductDetailPageClient 2026-03-29 07:37:46 -04:00
sean b8a0fd1963 feat: use ProductImage in PartsGrid list view 2026-03-29 07:37:44 -04:00
sean 00613ebc9f feat: add ProductImage component with onError fallback 2026-03-29 07:34:13 -04:00
sean 05a4ad2720 feat: add product image placeholder SVG 2026-03-29 07:28:40 -04:00
sean eb1dfef7b7 analytics
CI / deploy (push) Failing after 5s
2026-03-25 07:16:14 -04:00
sean 10148acaa9 fix: use merchantLabel(o) for retailer in offer row affiliate tracking 2026-03-25 07:10:52 -04:00
sean 019e1892de feat: wire affiliate_link_clicked event in product detail page 2026-03-25 07:09:01 -04:00
sean 668398e66a fix: reset previousPartIdsRef on build clear; clarify first-hydration skip comment 2026-03-25 07:05:57 -04:00
sean f12e2ffee7 fix: fire part_selected after hydration to get real part name
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 06:57:13 -04:00
sean 3a345b6c26 feat: wire Umami events in builder page (build_started, part_selected, affiliate_link_clicked, build_shared, build_saved)
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-25 06:50:44 -04:00
sean 754a378782 feat: add Umami analytics helper and window type declaration 2026-03-25 06:41:59 -04:00
sean 37921469d5 builder link
CI / deploy (push) Failing after 6s
2026-03-24 06:10:51 -04:00
dstrawsb 5526920ff8 modded ci.yaml file to deploy both images
CI / deploy (push) Failing after 7s
2026-03-20 22:58:11 -04:00
sean fb52521495 fixed nav, added sanity and guides pages
CI / test (push) Successful in 5s
2026-03-12 07:10:37 -04:00
sean f8265063d5 clean up and disabled beta lock
CI / test (push) Successful in 27s
2026-03-11 07:32:58 -04:00
sean 140b83bd65 small stuff
CI / test (push) Successful in 5s
2026-02-01 08:43:31 -05:00
sean 775ee0f691 small stuff 2026-02-01 07:51:42 -05:00
58 changed files with 16839 additions and 810 deletions
-4
View File
@@ -7,7 +7,3 @@ NEXT_PUBLIC_LAUNCH_ONLY_ROOT=false
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
# Brevo API key for beta sign up collection
BREVO_API_KEY=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
BREVO_LIST_ID=9
+2 -4
View File
@@ -2,10 +2,8 @@
NEXT_PUBLIC_API_BASE_URL=http://battlbuilder-api:8080
# Middleware to limited site to only root page
LAUNCH_ONLY_ROOT=true
LAUNCH_ONLY_ROOT=false
NEXT_PUBLIC_SHORTLINK_BASE_URL=https://battl.build
# Brevo API key for beta sign up collection
BREVO_API_KEY=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
BREVO_LIST_ID=9
+12 -3
View File
@@ -1,11 +1,20 @@
name: CI
on:
push:
pull_request:
branches:
- develop
jobs:
test:
deploy:
runs-on: ubuntu
steps:
- uses: actions/checkout@v4
- run: echo "Hello from Gitea Actions"
- name: Pull latest images
run: |
docker pull gitea.gofwd.group/forward_group/ballistic-builder-spring/spring-api:latest
docker pull gitea.gofwd.group/sean/shadow-gunbuilder-ai-proto/webui:latest
- name: Recreate containers with new images
run: docker compose up -d
@@ -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 &nbsp;
</a>
<span class="hero-micro">No account required &nbsp;·&nbsp; 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 &amp; 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 &nbsp;
</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 &nbsp;·&nbsp; 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 &nbsp;</a>
<span class="hero-micro">100% Free &nbsp;·&nbsp; 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 &nbsp;·&nbsp; NO SPONSORED RANKINGS &nbsp;·&nbsp; 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 &nbsp;</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 &nbsp;·&nbsp; 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 &nbsp;</a>
<span class="hero-micro">100% Free &nbsp;·&nbsp; 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 &nbsp;·&nbsp; NO SPONSORED RANKINGS &nbsp;·&nbsp; 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 &nbsp;</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>
+68
View File
@@ -0,0 +1,68 @@
# CLAUDE.md
This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.
## Commands
```bash
npm run dev # Start dev server at localhost:3000
npm run build # Production build (TS errors and ESLint are suppressed — see next.config.mjs)
npm run lint # ESLint check
```
No test framework is configured.
## Environment Variables
`.env.local` for development:
```
NEXT_PUBLIC_API_BASE_URL=http://localhost:8080
BACKEND_BASE_URL=http://localhost:8080
LAUNCH_ONLY_ROOT=false # Set to "true" to restrict unauthenticated users to root page only
NEXT_PUBLIC_SHORTLINK_BASE_URL=http://localhost:8080
```
In Docker, the Spring Boot service is resolved as `http://battlbuilder-api:8080` (the default in `lib/springProxy.ts` when `SPRING_BASE_URL` is unset).
## Architecture
### Two backends
1. **Spring Boot** — primary data backend. All product catalog, builds, auth, merchant/admin data lives here.
2. **Sanity CMS** — content only (guides/blog posts). Project `zal102rv`, dataset `production`. Queries in `lib/sanity.ts`.
### API proxy pattern
Next.js API routes (`app/api/`) act as a thin proxy to Spring Boot. Client code never calls Spring Boot directly. The helper `lib/springProxy.ts` forwards cookies and auth headers to Spring Boot and handles response serialization.
### Authentication
- **Session cookie**: `session_token` HTTP-only cookie set by Spring Boot, forwarded by the proxy.
- **Client state**: `AuthContext` (`context/AuthContext.tsx`) hydrates from `localStorage` on mount, then verifies against `/api/auth/me`.
- **Middleware**: `middleware.ts` enforces route protection. Public paths are whitelisted; `/admin/*` requires login; `/builder`, `/builds`, `/vault` are gated only when `LAUNCH_ONLY_ROOT=true`.
### Route groups
- `app/(app)/` — main consumer app layout (TopNav + Footer)
- `app/(app)/(builder)/` — builder + parts browser (BuilderNav layout)
- `app/(account)/` — account settings pages (AccountChrome sidebar layout)
- `app/admin/` — internal admin tools (AdminLeftNavigation layout)
- `app/api/` — 50 server-side route handlers proxying to Spring Boot
### Builder domain model
The builder is AR-platform-centric. Key concepts:
- **`BuilderSlotKey`** (`types/builderSlots.ts`) — the canonical kebab-case category ID (e.g., `"complete-upper"`, `"barrel"`). This is the source of truth for localStorage keys, URL state, and backend part role mapping.
- **`CATEGORY_TO_PART_ROLES` / `PART_ROLE_TO_CATEGORY`** (`lib/catalogMappings.ts`) — bidirectional mapping between `BuilderSlotKey` and backend `partRole` strings. Update here when the backend adds new part roles.
- **`BUILDER_SLOTS`** (`data/builderSlots.ts`) — defines slot groups (LOWER, UPPER, SYSTEM, ACCESSORIES) and satisfaction patterns (e.g., a complete lower satisfies the lower assembly slot).
- **`BuildState`** — `Partial<Record<BuilderSlotKey, string>>` stored in `localStorage`. Product IDs are the values.
- **Overlap detection** (`lib/buildOverlaps.ts`) — warns users when they select both a complete assembly and individual sub-parts.
### Legacy key aliases
`types/builderSlots.ts` exports `normalizeSlotKey()` which maps camelCase legacy keys (e.g., `completeUpper`) to current kebab-case keys. This is needed for backwards-compatible URL/localStorage deserialization.
### API client pattern
- Client components use `useApi()` hook (`lib/api.ts`) for fetch calls with `credentials: 'include'`.
- Admin-specific API calls live in `lib/api/` (e.g., `adminCategoryMappings.ts`, `adminMerchantMappings.ts`).
- Product catalog helpers are in `lib/catalog.ts`.
### Sanity content
- `lib/sanity.ts` — Sanity client + GROQ queries for posts.
- `lib/sanityFetch.ts` — low-level HTTPS fetch to Sanity IP (bypasses CDN DNS issues in server/Docker environments).
- Guides at `/guides/[slug]` are the only public Sanity-driven pages.
@@ -0,0 +1,43 @@
"use client";
import { useEffect } from "react";
import { useSearchParams, useRouter } from "next/navigation";
import { Suspense } from "react";
const STORAGE_KEY = "gunbuilder-build-state";
function BuildLoader() {
const searchParams = useSearchParams();
const router = useRouter();
useEffect(() => {
const encoded = searchParams.get("build");
if (encoded) {
try {
const decoded = JSON.parse(atob(encoded));
if (decoded && typeof decoded === "object") {
localStorage.setItem(STORAGE_KEY, JSON.stringify(decoded));
}
} catch {
// Bad payload — just go to builder anyway, it'll load fresh
}
}
router.replace("/builder");
}, [searchParams, router]);
return (
<main className="min-h-screen bg-black text-zinc-50 flex items-center justify-center">
<p className="text-sm text-zinc-400">Loading build</p>
</main>
);
}
export default function BuildPage() {
return (
<Suspense>
<BuildLoader />
</Suspense>
);
}
+61 -18
View File
@@ -28,6 +28,7 @@ import { X, Link2, Square, CheckSquare } from "lucide-react";
import { useApi } from "@/lib/api";
import { useAuth } from "@/context/AuthContext";
import { trackBuildStarted, trackPartSelected, trackAffiliateLinkClicked, trackBuildShared, trackBuildSaved } from "@/lib/analytics";
import { CATEGORIES } from "@/data/gunbuilderParts";
import { BUILDER_SLOTS, isSlotSatisfied } from "@/data/builderSlots";
@@ -295,7 +296,7 @@ function buildAssemblyRows(params: {
rows.push({
key: `${groupLabel}-${paths.complete}`,
kind: "category",
label: "Completed Lower (Fastest)",
label: `Completed ${groupLabel.split(" ")[0]} (Fastest)`,
categoryId: paths.complete,
indent: 0,
pill: mode === "complete" ? "Selected" : undefined,
@@ -355,6 +356,8 @@ function GunbuilderPageContent() {
// Parts data state
// -----------------------------
const [parts, setParts] = useState<Part[]>([]);
const previousPartIdsRef = useRef<Set<string>>(new Set());
const isFirstPartsHydrationRef = useRef(true);
const [loading, setLoading] = useState(false);
const [error, setError] = useState<string | null>(null);
@@ -369,20 +372,33 @@ function GunbuilderPageContent() {
useEffect(() => {
if (typeof window === "undefined") return;
// Hydrate persisted build state only. URL actions (platform/select/remove)
// are handled by the dedicated useSearchParams effect later in this file.
// Intentional: Object.keys(...).length > 0 tightens the original condition.
// Previously, an empty {} from localStorage would call setBuild(normalizeBuildState({})),
// which is a no-op. The new check skips that call, which is safe and correct.
let hasBuild = false;
const stored = localStorage.getItem(STORAGE_KEY);
if (stored) {
try {
const parsed = JSON.parse(stored);
if (parsed && typeof parsed === "object") {
if (parsed && typeof parsed === "object" && Object.keys(parsed).length > 0) {
setBuild(normalizeBuildState(parsed));
hasBuild = true;
}
} catch {
// ignore
}
}
// Fire build_started only for genuinely new builds:
// - No existing parts in localStorage
// - No ?select= param (share-link arrivals have empty localStorage but are not starting fresh)
// Use window.location.search directly: this effect runs at mount before any router
// processing, making window.location.search the authoritative source of the landing URL.
const hasSelectParam = new URLSearchParams(window.location.search).has("select");
if (!hasBuild && !hasSelectParam) {
trackBuildStarted();
}
// Signal that hydration is complete - this allows URL param processing to run
setIsHydrated(true);
}, []);
@@ -602,6 +618,7 @@ function GunbuilderPageContent() {
if (ids.length === 0) {
lastHydrateKeyRef.current = "";
setParts([]);
previousPartIdsRef.current = new Set(); // keep in sync so re-selecting fires the event
setError(null);
setLoading(false);
return;
@@ -654,6 +671,21 @@ function GunbuilderPageContent() {
setParts(hydrated);
// Track newly selected parts after API fetch — fires with real name, not a numeric ID.
// Skip the first hydration: this covers both localStorage build restoration and ?select=
// share-link arrivals. Neither represents a live user interaction in the current session.
if (isFirstPartsHydrationRef.current) {
isFirstPartsHydrationRef.current = false;
} else {
const prevIds = previousPartIdsRef.current;
for (const p of hydrated) {
if (!prevIds.has(String(p.id))) {
trackPartSelected(p.categoryId as BuilderSlotKey, p.name, String(p.id));
}
}
}
previousPartIdsRef.current = new Set(hydrated.map((p) => String(p.id)));
// ✅ Mark key as hydrated ONLY after success
lastHydrateKeyRef.current = key;
} catch (err: any) {
@@ -842,6 +874,7 @@ function GunbuilderPageContent() {
type: "success",
message: "Saved to your Vault ✅ ",
});
trackBuildSaved();
// Close modal + reset inputs for next variant
setSaveAsOpen(false);
@@ -1055,6 +1088,7 @@ function GunbuilderPageContent() {
try {
await navigator.clipboard.writeText(shareUrl);
trackBuildShared();
setShareStatus("Shareable link copied to clipboard.");
} catch {
setShareStatus("Could not copy link to clipboard.");
@@ -1074,6 +1108,7 @@ function GunbuilderPageContent() {
text: "Check out my Battl Build.",
url: shareUrl,
});
trackBuildShared(); // fires only on successful share, not on cancel
setShareStatus("Share dialog opened.");
} catch {
setShareStatus(
@@ -1523,25 +1558,25 @@ function GunbuilderPageContent() {
)}
<div className="overflow-x-auto rounded-md border border-zinc-800 bg-zinc-950/80">
<table className="min-w-full text-xs md:text-sm">
<table className="min-w-full text-[11px] sm:text-xs md:text-sm">
<thead>
<tr className="border-b border-zinc-800 bg-zinc-900/60">
<th className="px-3 py-2 text-left font-semibold text-zinc-400">
<th className="px-2 sm:px-3 py-2 text-left font-semibold text-zinc-400">
Component / Part Type
</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">
<th className="hidden sm:table-cell px-2 sm:px-3 py-2 text-left font-semibold text-zinc-400">
Brand
</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">
<th className="px-2 sm:px-3 py-2 text-right font-semibold text-zinc-400">
Price
</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">
<th className="hidden md:table-cell px-2 sm:px-3 py-2 text-right font-semibold text-zinc-400">
Sale Price
</th>
<th className="px-3 py-2 text-left font-semibold text-zinc-400">
<th className="hidden md:table-cell px-2 sm:px-3 py-2 text-left font-semibold text-zinc-400">
Caliber
</th>
<th className="px-3 py-2 text-right font-semibold text-zinc-400">
<th className="px-2 sm:px-3 py-2 text-right font-semibold text-zinc-400">
Buy / Choose
</th>
</tr>
@@ -1557,7 +1592,7 @@ function GunbuilderPageContent() {
>
<td
colSpan={6}
className="px-3 py-2 text-[0.7rem] text-zinc-500 bg-zinc-950/70"
className="px-2 sm:px-3 py-2 text-[0.7rem] text-zinc-500 bg-zinc-950/70"
>
<div className="flex items-center justify-between gap-2">
<div className="font-semibold text-zinc-400">
@@ -1606,7 +1641,7 @@ function GunbuilderPageContent() {
row.tone === "muted" ? "opacity-70" : ""
}`}
>
<td className="px-3 py-2 align-top">
<td className="px-2 sm:px-3 py-2 align-top">
<div className="flex items-start gap-2">
{indent > 0 && (
<div
@@ -1654,24 +1689,24 @@ function GunbuilderPageContent() {
</div>
</td>
<td className="px-3 py-2 align-top text-zinc-300">
<td className="hidden sm:table-cell px-2 sm:px-3 py-2 align-top text-zinc-300">
{selectedPart ? selectedPart.brand : "—"}
</td>
<td className="px-3 py-2 align-top text-right text-amber-300">
<td className="px-2 sm:px-3 py-2 align-top text-right text-amber-300">
{selectedPart
? `$${selectedPart.price.toFixed(2)}`
: "—"}
</td>
<td className="px-3 py-2 align-top text-right text-zinc-400">
<td className="hidden md:table-cell px-2 sm:px-3 py-2 align-top text-right text-zinc-400">
</td>
<td className="px-3 py-2 align-top text-zinc-400">
<td className="hidden md:table-cell px-2 sm:px-3 py-2 align-top text-zinc-400">
</td>
<td className="px-3 py-2 align-top">
<td className="px-2 sm:px-3 py-2 align-top">
<div className="flex justify-end gap-2">
{isLocked ? (
<div className="flex items-center gap-2">
@@ -1704,6 +1739,14 @@ function GunbuilderPageContent() {
target="_blank"
rel="noopener noreferrer"
className="inline-flex items-center gap-1.5 whitespace-nowrap rounded-md border border-amber-600 bg-amber-500 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-400 transition-colors"
onClick={() => {
try {
const retailer = new URL(selectedPart.affiliateUrl).hostname.replace(/^www\./, "");
trackAffiliateLinkClicked(selectedPart.name, retailer);
} catch {
// ignore malformed URLs — if affiliateUrl is malformed, the link href is also broken
}
}}
>
Buy
</a>
@@ -6,6 +6,8 @@ import { useParams, useRouter, useSearchParams } from "next/navigation";
import type { BuilderSlotKey } from "@/types/builderSlots";
import { PART_ROLE_TO_CATEGORY, normalizePartRole } from "@/lib/catalogMappings";
import { trackAffiliateLinkClicked } from "@/lib/analytics";
import { ProductImage } from "@/components/parts/ProductImage";
/**
* API Shapes
@@ -492,12 +494,10 @@ export default function ProductDetailsPage() {
className="h-full w-full"
title="Click to enlarge"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
<ProductImage
src={imageUrl}
alt={product.name}
className="h-full w-full object-contain p-2"
loading="lazy"
/>
</button>
) : (
@@ -699,6 +699,7 @@ export default function ProductDetailsPage() {
target="_blank"
rel="noreferrer"
className="inline-flex items-center justify-center rounded-md bg-amber-400 px-3 py-1.5 text-xs font-semibold text-black hover:bg-amber-300"
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(o))}
>
Buy
</a>
@@ -726,6 +727,7 @@ export default function ProductDetailsPage() {
target="_blank"
rel="noreferrer"
className="rounded-md border border-zinc-700 bg-zinc-900/70 px-4 py-2 text-sm font-semibold text-zinc-200 hover:bg-zinc-800"
onClick={() => trackAffiliateLinkClicked(product?.name ?? "", merchantLabel(bestOffer))}
>
Buy from {merchantLabel(bestOffer)}
</a>
@@ -768,8 +770,7 @@ export default function ProductDetailsPage() {
Close
</button>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
<ProductImage
src={imageUrl}
alt={product?.name ?? "Product image"}
className="max-h-[90vh] max-w-[90vw] rounded-lg border border-zinc-800 bg-black object-contain"
+45
View File
@@ -0,0 +1,45 @@
import type { Metadata } from 'next'
import { changelogEntriesQuery, roadmapItemsQuery } from '@/lib/sanity'
import { sanityFetch } from '@/lib/sanityFetch'
import { ChangelogTabs } from '@/components/ChangelogTabs'
import type { ChangelogEntry, RoadmapItemData } from '@/types/changelog'
export const revalidate = 60
export const metadata: Metadata = {
title: "What's New — Battl Builders",
description:
'Release notes, upcoming features, and product updates from Battl Builders.',
}
export default async function ChangelogPage() {
let entries: ChangelogEntry[] = []
let roadmapItems: RoadmapItemData[] = []
try {
entries = await sanityFetch<ChangelogEntry[]>(changelogEntriesQuery)
} catch (err) {
console.error('[Changelog] entries fetch failed:', err)
}
try {
roadmapItems = await sanityFetch<RoadmapItemData[]>(roadmapItemsQuery)
} catch (err) {
console.error('[Changelog] roadmap fetch failed:', err)
}
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<header className="mb-10">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
Battl Builders
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Updates</h1>
<p className="mt-2 text-sm text-zinc-400">
What we&apos;ve shipped and what&apos;s coming next.
</p>
</header>
<ChangelogTabs entries={entries} roadmapItems={roadmapItems} />
</main>
)
}
+151
View File
@@ -0,0 +1,151 @@
import { postBySlugQuery, postSlugsQuery, urlFor } from '@/lib/sanity'
import { sanityFetch } from '@/lib/sanityFetch'
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
import Image from 'next/image'
import Link from 'next/link'
import { notFound } from 'next/navigation'
import type { Metadata } from 'next'
export const revalidate = 60
type Params = { slug: string }
type Post = {
_id: string
title: string
slug: { current: string }
publishedAt: string
excerpt: string | null
mainImage: { asset: object; alt?: string } | null
body: unknown[]
seoTitle: string | null
seoDescription: string | null
author: { name: string; image?: object; bio?: string } | null
}
export async function generateStaticParams() {
try {
const slugs: { slug: string }[] = await sanityFetch<{ slug: string }[]>(postSlugsQuery)
return slugs.map((s) => ({ slug: s.slug }))
} catch (err) {
console.error('[Guides] generateStaticParams fetch failed:', err)
return []
}
}
export async function generateMetadata({ params }: { params: Promise<Params> }): Promise<Metadata> {
const { slug } = await params
let post: Post | null = null
try {
post = await sanityFetch<Post | null>(postBySlugQuery, { slug })
} catch (err) {
console.error('[Guides] generateMetadata fetch failed:', err)
}
if (!post) return {}
return {
title: post.seoTitle ?? post.title,
description: post.seoDescription ?? post.excerpt ?? undefined,
openGraph: {
title: post.seoTitle ?? post.title,
description: post.seoDescription ?? post.excerpt ?? undefined,
images: post.mainImage?.asset
? [{ url: urlFor(post.mainImage).width(1200).height(630).url() }]
: [],
},
}
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export default async function PostPage({ params }: { params: Promise<Params> }) {
const { slug } = await params
let post: Post | null = null
try {
post = await sanityFetch<Post | null>(postBySlugQuery, { slug })
} catch (err) {
console.error('[Guides] PostPage fetch failed:', err)
}
if (!post) notFound()
return (
<main className="mx-auto max-w-3xl px-4 py-10">
{/* Breadcrumb */}
<nav className="mb-6 text-xs text-zinc-500">
<Link href="/guides" className="hover:text-zinc-300">
Guides
</Link>
</nav>
{/* Header */}
<header className="mb-8">
<div className="mb-3 flex items-center gap-2 text-[0.7rem] text-zinc-500">
{post.author?.name && <span>{post.author.name}</span>}
{post.author?.name && <span>·</span>}
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
</div>
<h1 className="text-3xl font-semibold leading-tight tracking-tight text-zinc-100 md:text-4xl">
{post.title}
</h1>
{post.excerpt && (
<p className="mt-4 text-base text-zinc-400 leading-relaxed">
{post.excerpt}
</p>
)}
</header>
{/* Hero image */}
{post.mainImage?.asset && (
<div className="relative mb-10 h-64 w-full overflow-hidden rounded-xl md:h-80">
<Image
src={urlFor(post.mainImage).width(1200).height(630).url()}
alt={post.mainImage.alt ?? post.title}
fill
priority
className="object-cover"
sizes="(max-width: 768px) 100vw, 768px"
/>
</div>
)}
{/* Body */}
{post.body && (
<article className="border-b border-zinc-800 pb-10">
<PortableTextRenderer value={post.body} />
</article>
)}
{/* CTA footer */}
<div className="mt-10 rounded-xl border border-zinc-800 bg-zinc-950/60 p-6 text-center">
<p className="text-sm font-medium text-zinc-200">
Ready to put this into practice?
</p>
<p className="mt-1 text-xs text-zinc-500">
Use the Battl Builder to price out your parts and track your total.
</p>
<Link
href="/builder"
className="mt-4 inline-block rounded-md bg-amber-400 px-5 py-2.5 text-sm font-semibold text-black hover:bg-amber-300 transition-colors"
>
Start Your Build
</Link>
</div>
{/* Back link */}
<div className="mt-8 text-center">
<Link href="/guides" className="text-xs text-zinc-500 hover:text-zinc-300">
Back to all guides
</Link>
</div>
</main>
)
}
+119
View File
@@ -0,0 +1,119 @@
import { postsQuery, urlFor } from '@/lib/sanity'
import { sanityFetch } from '@/lib/sanityFetch'
import Link from 'next/link'
import Image from 'next/image'
import type { Metadata } from 'next'
export const metadata: Metadata = {
title: 'Build Guides',
description: 'AR-15 build guides, part breakdowns, and buying advice from Battl Builders.',
}
export const revalidate = 60 // ISR — regenerate every 60s
type Post = {
_id: string
title: string
slug: { current: string }
publishedAt: string
excerpt: string | null
mainImage: { asset: object; alt?: string } | null
author: { name: string; image?: object } | null
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export default async function GuidesPage() {
let posts: Post[] = []
try {
posts = await sanityFetch<Post[]>(postsQuery)
} catch (err) {
console.error('[Guides] Sanity fetch failed:', err)
}
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<header className="mb-10">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
Battl Builders
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">
Build Guides
</h1>
<p className="mt-3 text-sm text-zinc-400 max-w-xl">
Part breakdowns, buying advice, and build walkthroughs to help you
make smarter decisions before you spend a dollar.
</p>
</header>
{posts.length === 0 ? (
<p className="text-sm text-zinc-500">No guides published yet check back soon.</p>
) : (
<div className="space-y-8">
{posts.map((post) => (
<article
key={post._id}
className="group flex flex-col gap-4 rounded-xl border border-zinc-800 bg-zinc-950/60 p-5 transition-colors hover:border-zinc-700 sm:flex-row"
>
{/* Thumbnail */}
{post.mainImage?.asset && (
<div className="relative h-44 w-full shrink-0 overflow-hidden rounded-lg sm:h-32 sm:w-48">
<Image
src={urlFor(post.mainImage).width(400).height(256).url()}
alt={post.mainImage.alt ?? post.title}
fill
className="object-cover transition-transform duration-300 group-hover:scale-105"
sizes="(max-width: 640px) 100vw, 192px"
/>
</div>
)}
{/* Text */}
<div className="flex flex-col justify-center gap-2">
<div className="flex items-center gap-2 text-[0.7rem] text-zinc-500">
{post.author?.name && <span>{post.author.name}</span>}
{post.author?.name && <span>·</span>}
<time dateTime={post.publishedAt}>{formatDate(post.publishedAt)}</time>
</div>
<h2 className="text-lg font-semibold leading-snug text-zinc-100 group-hover:text-amber-300 transition-colors">
<Link href={`/guides/${post.slug.current}`} className="stretched-link">
{post.title}
</Link>
</h2>
{post.excerpt && (
<p className="text-sm text-zinc-400 leading-relaxed line-clamp-2">
{post.excerpt}
</p>
)}
<Link
href={`/guides/${post.slug.current}`}
className="mt-1 self-start text-xs font-medium text-amber-400 hover:text-amber-300"
>
Read guide
</Link>
</div>
</article>
))}
</div>
)}
<div className="mt-12 rounded-lg border border-zinc-800 bg-zinc-950/60 p-5 text-center">
<p className="text-sm text-zinc-400">
Ready to start building?{' '}
<Link href="/builder" className="font-semibold text-amber-400 hover:text-amber-300">
Open the Builder
</Link>
</p>
</div>
</main>
)
}
+2 -2
View File
@@ -2,7 +2,7 @@
import type { ReactNode } from "react";
import { Suspense } from "react";
import { BuilderNav } from "@/components/BuilderNav";
import { BuilderNavConditional } from "@/components/BuilderNavConditional";
import { TopNav } from "@/components/TopNav";
import { Footer } from "@/components/Footer";
@@ -11,7 +11,7 @@ export default function BuilderLayout({ children }: { children: ReactNode }) {
<div className="min-h-screen bg-white text-zinc-900 dark:bg-neutral-950 dark:text-zinc-50">
<TopNav />
<Suspense fallback={<div className="h-[52px]" />}>
<BuilderNav />
<BuilderNavConditional />
</Suspense>
<main className="min-h-screen">{children}</main>
<Footer />
-6
View File
@@ -168,12 +168,6 @@ export default function AdminLayout({
</p>
<p className="text-sm text-zinc-200">Battl Control Panel</p>
</div>
<div className="flex items-center gap-3">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-3 py-1 text-[11px] font-medium text-amber-300">
Internal v0.1
</span>
</div>
</header>
{/* ✅ min-w-0 here prevents table min-width from widening the page */}
+39
View File
@@ -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
export async function PATCH(
req: NextRequest,
{ params }: { params: Promise<{ id: string }> }
) {
const cookieStore = await cookies()
const token = cookieStore.get('session_token')?.value
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { id } = await params
const body = await req.json()
try {
const res = await fetch(`${API_BASE_URL}/api/v1/admin/feedback/${id}`, {
method: 'PATCH',
headers: {
Authorization: `Bearer ${token}`,
'Content-Type': 'application/json',
Accept: 'application/json',
},
body: JSON.stringify(body),
cache: 'no-store',
})
if (!res.ok) {
return NextResponse.json({ error: await res.text() }, { status: res.status })
}
return res.status === 204
? new NextResponse(null, { status: 204 })
: NextResponse.json(await res.json())
} catch (e) {
return NextResponse.json({ error: 'Request failed' }, { status: 500 })
}
}
+29
View File
@@ -0,0 +1,29 @@
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(req: NextRequest) {
const cookieStore = await cookies()
const token = cookieStore.get('session_token')?.value
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const params = req.nextUrl.searchParams.toString()
try {
const res = await fetch(
`${API_BASE_URL}/api/v1/admin/feedback${params ? `?${params}` : ''}`,
{
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
cache: 'no-store',
}
)
if (!res.ok) {
return NextResponse.json({ error: await res.text() }, { status: res.status })
}
return NextResponse.json(await res.json())
} catch (e) {
return NextResponse.json({ error: 'Request failed' }, { status: 500 })
}
}
+38
View File
@@ -0,0 +1,38 @@
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(req: NextRequest) {
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}
const ip =
req.headers.get('x-forwarded-for')?.split(',')[0]?.trim() ??
req.headers.get('x-real-ip') ??
'unknown'
const userAgent = req.headers.get('user-agent') ?? 'unknown'
try {
const res = await fetch(`${API_BASE_URL}/api/v1/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(body as object), ipAddress: ip, userAgent }),
cache: 'no-store',
})
if (!res.ok) {
const text = await res.text().catch(() => '')
console.error('[/api/feedback] Spring Boot error:', res.status, text)
return NextResponse.json({ error: 'Failed to submit' }, { status: res.status })
}
return NextResponse.json({ ok: true })
} catch (e) {
console.error('[/api/feedback] proxy error:', e)
return NextResponse.json({ error: 'Service unavailable' }, { status: 500 })
}
}
+6
View File
@@ -0,0 +1,6 @@
import { NextResponse } from "next/server";
// TODO: integrate with email service (Resend, ConvertKit, etc.)
export async function POST() {
return NextResponse.json({ ok: true });
}
+34
View File
@@ -0,0 +1,34 @@
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(
req: NextRequest,
{ params }: { params: Promise<{ sanityId: string }> }
) {
const cookieStore = await cookies()
const token = cookieStore.get('session_token')?.value
if (!token) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 })
}
const { sanityId } = await params
try {
const res = await fetch(
`${API_BASE_URL}/api/v1/roadmap/${encodeURIComponent(sanityId)}/vote`,
{
method: 'POST',
headers: { Authorization: `Bearer ${token}`, Accept: 'application/json' },
cache: 'no-store',
}
)
if (!res.ok) {
return NextResponse.json({ error: await res.text() }, { status: res.status })
}
return NextResponse.json(await res.json())
} catch (e) {
return NextResponse.json({ error: 'Request failed' }, { status: 500 })
}
}
+28
View File
@@ -0,0 +1,28 @@
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(req: NextRequest) {
const cookieStore = await cookies()
const token = cookieStore.get('session_token')?.value
const headers: Record<string, string> = { Accept: 'application/json' }
if (token) headers['Authorization'] = `Bearer ${token}`
const ids = req.nextUrl.searchParams.get('ids') ?? ''
try {
const res = await fetch(
`${API_BASE_URL}/api/v1/roadmap/votes?ids=${encodeURIComponent(ids)}`,
{ headers, cache: 'no-store' }
)
if (!res.ok) {
// Degrade gracefully — return empty map so UI shows 0 votes
return NextResponse.json({})
}
return NextResponse.json(await res.json())
} catch {
return NextResponse.json({})
}
}
+6 -12
View File
@@ -1,6 +1,7 @@
// app/builds/[buildId]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { ProductImage } from "@/components/parts/ProductImage";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
@@ -169,18 +170,11 @@ export default async function BuildBreakdownPage({
>
{/* Thumb */}
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
{it.productImageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={it.productImageUrl}
alt={it.productName ?? "Part image"}
className="h-full w-full object-cover"
/>
) : (
<div className="flex h-full w-full items-center justify-center text-[10px] text-zinc-600">
IMG
</div>
)}
<ProductImage
src={it.productImageUrl}
alt={it.productName ?? "Part image"}
className="h-full w-full object-cover"
/>
</div>
{/* Info */}
+3 -16
View File
@@ -20,25 +20,12 @@ export const metadata = {
},
};
const themeScript = `
(function () {
try {
const storedTheme = localStorage.getItem("theme");
const prefersDark = window.matchMedia("(prefers-color-scheme: dark)").matches;
const theme = storedTheme || (prefersDark ? "dark" : "light");
document.documentElement.classList.toggle("dark", theme === "dark");
} catch (_) {}
})();
`;
export default function RootLayout({ children }: { children: ReactNode }) {
return (
<html lang="en" suppressHydrationWarning className="dark">
<head>
<script dangerouslySetInnerHTML={{ __html: themeScript }} />
</head>
<html lang="en" className="dark">
<head />
<body className="bg-dark text-zinc-950 dark:bg-black dark:text-zinc-50">
<body className="bg-black text-zinc-50">
{" "}
<Script src="https://umami.ash.gofwd.group/script.js" data-website-id="15c6b9ad-4119-4981-829c-26db69c07a15"
strategy="lazyOnload"
+1 -4
View File
@@ -13,7 +13,7 @@ export default function NotFound() {
</h1>
<p className="mt-3 text-sm text-zinc-400">
This area of the site isnt publicly accessible yet.
The page you&apos;re looking for doesn&apos;t exist or has been moved.
</p>
<div className="mt-6 flex justify-center gap-3">
@@ -32,9 +32,6 @@ export default function NotFound() {
</a> */}
</div>
<p className="mt-6 text-[11px] text-zinc-600">
Early access features are launching soon.
</p>
</div>
</main>
);
+289 -341
View File
@@ -1,119 +1,27 @@
"use client";
import { FormEvent, useState } from "react";
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import { NewsletterForm } from "@/components/NewsletterForm";
import {
WrenchScrewdriverIcon,
ShieldExclamationIcon,
CurrencyDollarIcon,
BookmarkSquareIcon,
BuildingStorefrontIcon,
ChatBubbleLeftRightIcon,
LinkIcon,
LockOpenIcon,
} from "@heroicons/react/20/solid";
export const metadata: Metadata = {
title: { absolute: "Battl Builders — Free AR-15 Build Planner" },
description:
"Pick compatible AR-15 parts, catch mismatches before you order, and compare live prices from real retailers. Free, no account required.",
};
export default function HomePage() {
const [email, setEmail] = useState("");
const [useCase, setUseCase] = useState("");
const [status, setStatus] = useState<
"idle" | "loading" | "success" | "error"
>("idle");
const [message, setMessage] = useState<string | null>(null);
const [accepted, setAccepted] = useState(false);
const TOS_VERSION = "2025-12-27";
async function handleSubmit(e: FormEvent) {
e.preventDefault();
if (!email) {
setMessage("Drop an email in first, operator.");
setStatus("error");
return;
}
if (!accepted) {
setMessage(
"You must accept the Terms and confirm youre responsible for safe/legal assembly."
);
setStatus("error");
return;
}
try {
setStatus("loading");
setMessage(null);
const res = await fetch("/api/beta-signup", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({
email,
useCase,
acceptedTos: accepted,
tosVersion: TOS_VERSION,
}),
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || "Failed to save your signup.");
}
setStatus("success");
setMessage(
"✅ Youre on the list. Invites drop soon — well email your access link when its go-time."
);
setEmail("");
setUseCase("");
} catch (err) {
console.error(err);
setStatus("error");
setMessage("Something broke. Try again in a minute.");
}
}
const features = [
{
name: "Guided part roles",
description: "Pick parts by role so youre not guessing what fits where.",
icon: WrenchScrewdriverIcon,
},
{
name: "Basic compatibility warnings",
description:
"Early guardrails to catch obvious mismatches before you buy.",
icon: ShieldExclamationIcon,
},
{
name: "Live pricing totals",
description:
"See the running total as you build, with real retailer listings.",
icon: CurrencyDollarIcon,
},
{
name: "Save builds",
description: "Save a build to your account so you can finish it later.",
icon: BookmarkSquareIcon,
},
{
name: "Limited retailers (for now)",
description:
"Were starting small with a couple merchants and expanding fast.",
icon: BuildingStorefrontIcon,
},
{
name: "Feedback-driven roadmap",
description:
"Tell us whats confusing, wrong, or missing — well fix that first.",
icon: ChatBubbleLeftRightIcon,
},
];
return (
<main className="min-h-screen bg-black text-zinc-50">
{/* Abstract Brand Background */}
<div className="pointer-events-none absolute inset-0 overflow-hidden opacity-[0.55]">
{/* Grid background — retained from previous implementation */}
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden opacity-[0.55]">
<svg
width="100%"
height="100%"
@@ -137,7 +45,6 @@ export default function HomePage() {
strokeOpacity="0.35"
/>
</pattern>
<pattern
id="battlHex"
width="140"
@@ -153,214 +60,133 @@ export default function HomePage() {
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#battlGrid)" />
<rect width="100%" height="100%" fill="url(#battlHex)" />
</svg>
</div>
<div className="mx-auto flex min-h-screen max-w-5xl flex-col px-4 py-10">
{/* Top Bar / Logo */}
<header className="flex items-center justify-between gap-4">
<div className="flex items-center gap-3">
<Image
src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders Logo"
width={260}
height={48}
className="block"
/>
</div>
<nav className="flex items-center gap-2 sm:gap-3">
<a
href="#request-access"
className="rounded-md border border-zinc-800 bg-zinc-950/60 px-3 py-2 text-xs font-medium text-zinc-200 hover:bg-zinc-900 hover:text-white"
>
Request Access
</a>
<div className="relative z-10">
{/* ── 1. Nav ── */}
<nav className="mx-auto flex max-w-5xl items-center justify-between px-4 py-5">
<Image
src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders"
width={260}
height={48}
/>
<div className="flex items-center gap-2">
<Link
href="/login"
className="rounded-md border border-amber-500/60 bg-amber-500/15 px-3 py-2 text-xs font-medium text-amber-200 hover:bg-amber-500/20"
data-umami-event="nav_signin_clicked"
className="rounded-md border border-zinc-800 bg-zinc-950/60 px-3 py-2 text-xs font-medium text-zinc-200 hover:bg-zinc-900 hover:text-white"
>
Sign In
</Link>
</nav>
</header>
<Link
href="/builder"
data-umami-event="cta_start_build_clicked"
data-umami-event-location="nav"
className="rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-400"
>
Start a Build
</Link>
</div>
</nav>
{/* Hero */}
<section className="mt-16 grid gap-10 md:grid-cols-[minmax(0,1.25fr)_minmax(0,1fr)] md:items-start">
<div>
<div className="inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/30 px-3 py-1 text-xs uppercase tracking-[0.25em] text-white/70">
<span className="h-1.5 w-1.5 rounded-full bg-amber-400" />
Private Beta
<span className="text-white/40"></span>
<span className="text-white/60">AR-15 focus</span>
</div>
<h1 className="mt-6 text-balance text-3xl font-semibold tracking-tight sm:text-4xl md:text-5xl">
Stop Guessing.
<span className="block text-amber-300">Start Building.</span>
</h1>
<p className="mt-4 max-w-xl text-sm text-zinc-400 sm:text-base">
Battl Builders helps you plan an AR-15 build faster: choose parts
by role, get basic compatibility guidance, and compare real
listingswithout juggling tabs or spreadsheets.
</p>
<ul className="mt-6 space-y-2 text-sm text-zinc-300">
<li> Guided part roles (no where does this go?)</li>
<li> Basic compatibility warnings</li>
<li> Live pricing from current retailers</li>
<li> Save builds to your account</li>
</ul>
<p className="mt-4 text-xs text-white/50">
Early access beta. Data, pricing, and available parts are still
evolving.
</p>
{/* ── 2. Hero ── */}
<section className="mx-auto max-w-[760px] px-4 pb-16 pt-20 text-center">
<div className="mb-7 inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/40 px-4 py-[5px] text-[10px] uppercase tracking-[0.2em] text-white/55">
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-amber-400" />
AR-15 Builder &nbsp;·&nbsp; 100% Free
</div>
{/* Beta Form */}
<div
id="request-access"
className="scroll-mt-24 rounded-lg border border-zinc-800 bg-zinc-950/70 p-5 shadow-[0_0_0_1px_rgba(24,24,27,0.9)]"
>
<h2 className="text-sm font-semibold uppercase tracking-[0.18em] text-zinc-500">
Join the Beta List
</h2>
<p className="mt-2 text-xs text-zinc-400">
Drop your email and we&apos;ll send early access when the Builder
goes hot. No spam, just builds.
</p>
<h1 className="text-[clamp(38px,6.5vw,64px)] font-bold leading-[1.05] tracking-[-0.035em] text-white">
Stop Guessing.
<span className="block text-amber-300">Start Building.</span>
</h1>
<form onSubmit={handleSubmit} className="mt-4 space-y-3">
<div>
<label
htmlFor="email"
className="text-xs font-medium text-zinc-300"
>
Email
</label>
<input
id="email"
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@gearjunkie.com"
disabled={status === "loading" || status === "success"}
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-sm text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
required
/>
</div>
<p className="mx-auto mt-6 max-w-[540px] text-[17px] leading-[1.65] text-zinc-400">
Not sure which parts work together? We&apos;ll walk you through it.
Pick your components, get warned about anything that won&apos;t fit,
and see real prices from real retailers all in one place.
</p>
<div>
<label
htmlFor="useCase"
className="text-xs font-medium text-zinc-300"
>
What will you use Battl Builders for?
<span className="text-zinc-500"> (optional)</span>
</label>
<textarea
id="useCase"
value={useCase}
onChange={(e) => setUseCase(e.target.value)}
rows={3}
disabled={status === "loading" || status === "success"}
placeholder="E.g. Planning a build, comparing costs, sanity-checking compatibility..."
className="mt-1 w-full rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-50 outline-none ring-amber-500/30 placeholder:text-zinc-600 focus:border-amber-400/80 focus:ring-2 disabled:cursor-not-allowed disabled:opacity-60"
/>
</div>
<div className="rounded-md border border-zinc-800 bg-black/30 p-3">
<label className="flex items-start gap-2 text-[11px] text-zinc-400">
<input
type="checkbox"
checked={accepted}
onChange={(e) => setAccepted(e.target.checked)}
disabled={status === "loading" || status === "success"}
className="mt-0.5 h-4 w-4 rounded border-zinc-700 bg-zinc-950 text-amber-400 focus:ring-2 focus:ring-amber-500/40"
required
/>
<span>
I agree to the{" "}
<Link
href="/tos"
className="text-amber-300 underline hover:text-amber-200"
>
Terms of Service
</Link>{" "}
and{" "}
<Link
href="/privacy"
className="text-amber-300 underline hover:text-amber-200"
>
Privacy Policy
</Link>
. I understand Battl Builders is informational only and Im
solely responsible for legality, compatibility, and safe
assembly/use of any firearm or components.
</span>
</label>
</div>
<button
type="submit"
disabled={
status === "loading" || status === "success" || !accepted
}
className="flex w-full items-center justify-center rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-sm font-medium text-black transition hover:bg-amber-400 disabled:cursor-not-allowed disabled:opacity-70"
>
{status === "loading"
? "Enlisting…"
: status === "success"
? "On the List ✅"
: "Get Beta Access"}
</button>
{message && (
<p
className={`text-xs ${
status === "success"
? "text-emerald-400"
: status === "error"
? "text-red-400"
: "text-zinc-400"
}`}
>
{message}
</p>
)}
<div className="mt-6 text-center text-[11px] text-zinc-500">
Already invited?{" "}
<a href="/login" className="underline hover:text-zinc-300">
Log in
</a>
</div>
</form>
<div className="mt-10 flex flex-col items-center gap-3">
<Link
href="/builder"
data-umami-event="cta_start_build_clicked"
data-umami-event-location="hero"
className="inline-flex items-center gap-2 rounded-lg bg-amber-500/90 px-[38px] py-[15px] text-[15px] font-extrabold tracking-[0.03em] text-black shadow-[0_0_40px_rgba(251,191,36,0.2)] hover:bg-amber-400"
>
Start a Build
</Link>
<span className="text-[11px] tracking-[0.05em] text-zinc-600">
100% Free · No account required
</span>
</div>
</section>
{/* Screen Recording Section */}
<section className="relative mt-24">
<div className="mx-auto max-w-6xl px-4 sm:px-6 lg:px-8">
<div className="relative overflow-hidden rounded-2xl border border-white/10 bg-black shadow-2xl">
{/* Screen Recording */}
{/* ── 3. How It Works ── */}
<section className="mx-auto max-w-5xl px-4 py-[72px]">
<p className="mb-3 text-center text-[10px] font-bold uppercase tracking-[0.25em] text-amber-400 opacity-[85%]">
THE PROCESS
</p>
<h2 className="mb-1.5 text-center text-[30px] font-bold tracking-[-0.025em] text-white">
3 Steps. One Battl-Ready Build.
</h2>
<p className="mb-12 text-center text-sm text-zinc-500">
No experience required. No part numbers to memorize.
</p>
<div
className="mx-auto grid max-w-[840px] grid-cols-1 gap-[2px] overflow-hidden rounded-xl border border-zinc-900 bg-zinc-900 sm:grid-cols-3"
>
{[
{
num: "Step 01",
title: "Pick your parts",
body: "Browse real components organized by what they do — barrel, trigger, lower, upper, and more. No prior knowledge required.",
},
{
num: "Step 02",
title: "Flag the conflicts",
body: "Gas length, buffer weight, caliber — Battl catches the mismatches before you spend money on the wrong part.",
},
{
num: "Step 03",
title: "Price it. Pull the trigger.",
body: "Live prices from real retailers, side by side. Click through to buy. We never touch your order.",
},
].map((step) => (
<div key={step.num} className="bg-[#0d0d0f] px-6 py-8">
<p className="mb-3.5 text-[11px] font-bold uppercase tracking-[0.15em] text-amber-400 opacity-80">
{step.num}
</p>
<h3 className="mb-2.5 text-base font-bold tracking-[-0.01em] text-white">
{step.title}
</h3>
<p className="text-[13px] leading-[1.6] text-zinc-500">
{step.body}
</p>
</div>
))}
</div>
</section>
{/* ── 4. Builder Preview Video ── */}
<section className="px-4 pb-[72px]">
<div className="mx-auto max-w-[900px] overflow-hidden rounded-[14px] border border-white/[0.07] bg-black shadow-[0_24px_80px_rgba(0,0,0,0.7),0_0_0_1px_rgba(251,191,36,0.05)]">
<div className="relative">
<video
autoPlay
loop
muted
playsInline
poster="/builder-preview.png"
className="w-full object-cover"
>
<source src="/builder-preview.mp4" type="video/mp4" />
Your browser does not support the video tag.
</video>
{/* Bottom fade */}
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black"
@@ -369,69 +195,191 @@ export default function HomePage() {
</div>
</section>
{/* Info Section */}
<section className="mt-14 rounded-2xl border border-white/10 bg-white/5 px-6 py-14 sm:px-8">
<div className="mx-auto max-w-2xl text-left sm:text-center">
<p className="text-xs font-semibold uppercase tracking-[0.25em] text-amber-300/90">
Everything you need to test the core
</p>
<h2 className="mt-3 text-balance text-3xl font-semibold tracking-tight sm:text-4xl">
Build smarter rifles without the spreadsheet lifestyle.
</h2>
<p className="mt-4 text-sm leading-relaxed text-white/70 sm:text-base">
This private beta is focused on one thing: helping you plan an
AR-15 build faster and with fewer mistakes. Its early, its
evolving, and your feedback will decide what we build next.
</p>
</div>
{/* ── 5. Feature Cards ── */}
<section className="mx-auto max-w-5xl px-4 py-[72px]">
<p className="mb-3 text-center text-[10px] font-bold uppercase tracking-[0.25em] text-amber-400 opacity-[85%]">
WHAT YOU GET
</p>
<h2 className="mb-2 text-center text-[30px] font-bold tracking-[-0.025em] text-white">
Built by gun nerds, for the everyday AR builder.
</h2>
<p className="mb-11 text-center text-[13px] uppercase tracking-[0.02em] text-zinc-600">
NO SUBSCRIPTIONS · NO SPONSORED RANKINGS · NO BULLSHIT
</p>
<dl className="mx-auto mt-10 grid max-w-2xl grid-cols-1 gap-x-8 gap-y-8 text-sm text-white/70 sm:grid-cols-2 lg:max-w-none lg:grid-cols-3">
{features.map((feature) => (
<div
key={feature.name}
className="relative rounded-xl border border-white/10 bg-black/30 p-5"
>
<dt className="flex items-center gap-2 text-sm font-semibold text-white">
<feature.icon
aria-hidden="true"
className="h-5 w-5 text-amber-300"
/>
{feature.name}
</dt>
<dd className="mt-2 text-sm text-white/70">
{feature.description}
</dd>
<div
className="mx-auto grid max-w-[900px] grid-cols-1 gap-[1px] overflow-hidden rounded-xl border border-zinc-900 bg-zinc-900 sm:grid-cols-2 lg:grid-cols-3"
>
{(
[
{
Icon: WrenchScrewdriverIcon,
title: "Build by role, not part number",
body: "Parts organized by function. Know what you need, not what SKU to search for.",
},
{
Icon: ShieldExclamationIcon,
title: "Conflicts caught early",
body: "Gas system, buffer weight, caliber — flagged before checkout, not after the box arrives.",
},
{
Icon: CurrencyDollarIcon,
title: "Real prices. Real retailers.",
body: "Running total with live prices. No estimated MSRP. What it actually costs, today.",
},
{
Icon: BookmarkSquareIcon,
title: "Save your work",
body: "Free account. Save builds, pick up where you left off, run multiple configs in parallel.",
},
{
Icon: LinkIcon,
title: "Share the build",
body: "One link. Your buddy sees the full parts list — no login, no friction, no excuses.",
},
{
Icon: LockOpenIcon,
title: "No gates. No friction.",
body: "Browse and build freely. Create an account only when you're ready to save.",
},
] as const
).map(({ Icon, title, body }) => (
<div key={title} className="bg-[#0d0d0f] p-6">
<Icon
className="mb-3.5 h-5 w-5 text-amber-400"
aria-hidden="true"
/>
<h3 className="mb-1.5 text-[13px] font-bold tracking-[-0.01em] text-zinc-200">
{title}
</h3>
<p className="text-xs leading-[1.6] text-zinc-600">{body}</p>
</div>
))}
</dl>
</div>
</section>
<div className="mx-auto mt-10 max-w-2xl rounded-xl border border-white/10 bg-black/30 p-5">
<div className="text-xs font-semibold uppercase tracking-[0.25em] text-white/60">
What feedback we want
</div>
<ul className="mt-3 space-y-2 text-sm text-white/80">
<li> Where you got confused or stuck</li>
<li> Warnings that felt wrong (or missing)</li>
<li> Parts you expected to find but couldnt</li>
<li> If this beats your current build-planning process</li>
</ul>
<p className="mt-3 text-xs text-white/50">
By joining the private beta, you agree features, data, and pricing
may change.
{/* ── 6. SEO Copy Block ── */}
<section className="border-t border-zinc-900 px-4 py-[72px]">
<div className="mx-auto max-w-[680px]">
<h2 className="mb-4 text-[22px] font-bold tracking-[-0.02em] text-zinc-300">
What is Battl Builders?
</h2>
<p className="mb-3.5 text-sm leading-[1.75] text-zinc-500">
Battl Builders is a free{" "}
<strong className="font-semibold text-zinc-300">
AR-15 build planner
</strong>{" "}
the tool you&apos;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 className="mb-3.5 text-sm leading-[1.75] text-zinc-500">
Most{" "}
<strong className="font-semibold text-zinc-300">
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 className="text-sm leading-[1.75] text-zinc-500">
Whether you&apos;re planning your first rifle or spec&apos;ing a
dedicated precision upper, Battl Builders gives you a structured,
guided way to research parts and{" "}
<strong className="font-semibold text-zinc-300">
buy with confidence
</strong>
.
</p>
</div>
</section>
{/* Footer strip */}
<footer className="mt-12 border-t border-zinc-900 pt-4 text-xs text-zinc-500">
<div className="flex flex-wrap items-center justify-between gap-3">
<span>
© {new Date().getFullYear()} BATTL BUILDERS
<span className="align-super text-[0.9em] ml-0.5"></span>
</span>
<span className="text-zinc-600">
v0.1 Import engine online Builder UI in progress
</span>
{/* ── 7. Final CTA ── */}
<section className="border-t border-zinc-900 px-4 py-24 text-center">
<h2 className="mb-3 text-[36px] font-bold tracking-[-0.03em] text-white">
Your build starts here.
</h2>
<p className="mb-8 text-sm text-zinc-600">
Free. No account required. Takes five minutes to get rolling.
</p>
<Link
href="/builder"
data-umami-event="cta_start_build_clicked"
data-umami-event-location="final_cta"
className="inline-flex items-center gap-2 rounded-lg bg-amber-500/90 px-[38px] py-[15px] text-[15px] font-extrabold tracking-[0.03em] text-black shadow-[0_0_40px_rgba(251,191,36,0.2)] hover:bg-amber-400"
>
Start a Build
</Link>
</section>
{/* ── 8. Footer ── */}
<footer className="border-t border-zinc-900">
<div className="mx-auto max-w-5xl px-4 py-12">
<div className="grid grid-cols-1 gap-10 sm:grid-cols-3">
{/* Brand */}
<div>
<Image
src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders"
width={160}
height={32}
className="mb-3 opacity-70"
/>
<p className="text-xs leading-relaxed text-zinc-600">
Free AR-15 build planner. No subscriptions. No sponsored rankings.
</p>
</div>
{/* Product links */}
<div>
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
Product
</p>
<ul className="space-y-2 text-sm text-zinc-500">
<li>
<Link href="/builder" className="hover:text-zinc-300">
Builder
</Link>
</li>
<li>
<Link href="/guides" className="hover:text-zinc-300">
Guides
</Link>
</li>
<li>
<Link href="/about" className="hover:text-zinc-300">
About
</Link>
</li>
</ul>
</div>
{/* Newsletter */}
<div>
<p className="mb-1.5 text-[10px] font-bold uppercase tracking-[0.2em] text-zinc-500">
Stay in the loop
</p>
<p className="mb-3 text-xs text-zinc-600">
Build tips and new features. No spam.
</p>
<NewsletterForm />
</div>
</div>
</div>
{/* Bottom bar */}
<div className="mx-auto flex max-w-5xl items-center justify-between border-t border-zinc-900/60 px-4 py-4 text-[11px] text-zinc-700">
<span>© {new Date().getFullYear()} BATTL BUILDERS</span>
<div className="flex gap-4">
<Link href="/privacy" className="hover:text-zinc-500">
Privacy
</Link>
<Link href="/tos" className="hover:text-zinc-500">
Terms
</Link>
</div>
</div>
</footer>
</div>
+1 -1
View File
@@ -5,7 +5,7 @@ import { usePathname } from "next/navigation";
export function Banner() {
const pathname = usePathname();
if (pathname?.startsWith("/admin")) return null;
if (pathname?.startsWith("/admin") || pathname === "/") return null;
return (
<div className="w-full">
+4 -13
View File
@@ -119,24 +119,15 @@ export function BuilderNav({ activeCategoryId }: BuilderNavProps) {
{/* LEFT SIDE: primary nav */}
{/* ------------------------------------------------------------------ */}
<Link
href="/builder"
className="font-semibold text-neutral-100 hover:text-white tracking-[0.25em] uppercase text-[11px]"
>
Builder
</Link>
{/* Section label — makes the catalog bar purpose unmistakable */}
<span className="text-[9px] font-bold tracking-[0.2em] uppercase text-zinc-600 select-none pr-3 border-r border-zinc-800">
Parts
</span>
{renderDropdown("Lower Parts", lower)}
{renderDropdown("Upper Parts", upper)}
{renderDropdown("Accessories", accessories)}
<Link
href="/builds"
className="font-medium text-neutral-300 hover:text-white tracking-[0.25em] uppercase text-[11px]"
>
Builds
</Link>
{/* ------------------------------------------------------------------ */}
{/* RIGHT SIDE: actions + state */}
{/* IMPORTANT: this is the ONLY `ml-auto` in the nav to avoid conflicts */}
+19
View File
@@ -0,0 +1,19 @@
"use client";
import { usePathname } from "next/navigation";
import { BuilderNav } from "./BuilderNav";
/**
* Wraps BuilderNav with pathname-aware visibility.
* The catalog bar is irrelevant on guide pages — hide it there.
*/
export function BuilderNavConditional() {
const pathname = usePathname();
// Hide catalog bar on guide pages — part categories don't apply when reading an article
if (pathname.startsWith("/guides")) return null;
return <BuilderNav />;
}
export default BuilderNavConditional;
+315
View File
@@ -0,0 +1,315 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
import { RoadmapItem } from '@/components/RoadmapItem'
import { FeedbackForm } from '@/components/FeedbackForm'
import type { ChangelogEntry, RoadmapItemData, Tab, VoteState } from '@/types/changelog'
import { useAuth } from '@/context/AuthContext'
import Link from 'next/link'
const TABS: { id: Tab; label: string }[] = [
{ id: 'whats-new', label: "What's New" },
{ id: 'whats-next', label: "What's Next" },
{ id: 'give-feedback', label: 'Give Feedback' },
]
const TAG_STYLES: Record<string, string> = {
feature: 'bg-amber-500/10 text-amber-400 border border-amber-500/25',
fix: 'bg-green-500/10 text-green-400 border border-green-500/20',
improvement: 'bg-blue-400/10 text-blue-400 border border-blue-400/20',
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'long',
day: 'numeric',
})
}
export function ChangelogTabs({
entries,
roadmapItems,
}: {
entries: ChangelogEntry[]
roadmapItems: RoadmapItemData[]
}) {
const [activeTab, setActiveTab] = useState<Tab>('whats-new')
const [voteState, setVoteState] = useState<Record<string, VoteState>>(
() =>
Object.fromEntries(
roadmapItems.map((item) => [
item._id,
{ count: 0, voted: false, loading: false },
])
)
)
const { user } = useAuth()
const [showSignInPrompt, setShowSignInPrompt] = useState(false)
const signInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [votesFetched, setVotesFetched] = useState(false)
// Clear sign-in prompt timer on unmount
useEffect(() => () => { if (signInTimerRef.current) clearTimeout(signInTimerRef.current) }, [])
// Stable key for roadmapItems identity — avoids unstable array reference in deps
const roadmapIdKey = roadmapItems.map((i) => i._id).join(',')
// Fetch vote state when the What's Next tab is first viewed
useEffect(() => {
if (activeTab !== 'whats-next' || votesFetched || !roadmapIdKey) return
setVotesFetched(true)
fetch(`/api/roadmap/votes?ids=${encodeURIComponent(roadmapIdKey)}`, {
credentials: 'include',
})
.then((r) => (r.ok ? r.json() : {}))
.then((data: Record<string, { count: number; voted: boolean }>) => {
setVoteState((prev) => {
const next = { ...prev }
for (const [id, v] of Object.entries(data)) {
next[id] = { count: v.count, voted: v.voted, loading: false }
}
return next
})
})
.catch(() => {
// Spring Boot not yet available — silently keep count=0
})
}, [activeTab, votesFetched, roadmapIdKey]) // eslint-disable-line react-hooks/exhaustive-deps
async function handleVote(id: string) {
if (!user) {
if (signInTimerRef.current) clearTimeout(signInTimerRef.current)
setShowSignInPrompt(true)
signInTimerRef.current = setTimeout(() => setShowSignInPrompt(false), 4000)
return
}
const current = voteState[id] ?? { count: 0, voted: false, loading: false }
// Optimistic update
setVoteState((prev) => ({
...prev,
[id]: {
count: current.voted ? current.count - 1 : current.count + 1,
voted: !current.voted,
loading: true,
},
}))
try {
const res = await fetch(`/api/roadmap/${encodeURIComponent(id)}/vote`, {
method: 'POST',
credentials: 'include',
})
if (res.ok) {
const data: { count: number; voted: boolean } = await res.json()
setVoteState((prev) => ({
...prev,
[id]: { count: data.count, voted: data.voted, loading: false },
}))
} else {
setVoteState((prev) => ({ ...prev, [id]: { ...current, loading: false } }))
}
} catch {
setVoteState((prev) => ({ ...prev, [id]: { ...current, loading: false } }))
}
}
useEffect(() => {
const hash = window.location.hash.replace('#', '') as Tab
if (TABS.some((t) => t.id === hash)) setActiveTab(hash)
}, [])
function handleTabChange(tab: Tab) {
setActiveTab(tab)
window.history.replaceState(null, '', `#${tab}`)
}
return (
<div>
{/* Tab bar */}
<div className="mb-10 flex border-b border-zinc-800">
{TABS.map(({ id, label }) => (
<button
key={id}
onClick={() => handleTabChange(id)}
className={[
'-mb-px border-b-2 px-5 py-2.5 text-sm font-medium transition-colors',
activeTab === id
? 'border-amber-400 text-amber-400'
: 'border-transparent text-zinc-500 hover:text-zinc-300',
].join(' ')}
>
{label}
</button>
))}
</div>
{/* ── What's New ── */}
{activeTab === 'whats-new' && (
<div>
{entries.length === 0 ? (
<p className="text-sm text-zinc-500">
No releases yet check back soon.
</p>
) : (
<div className="divide-y divide-zinc-900">
{entries.map((entry) => (
<div
key={entry._id}
className="grid grid-cols-[120px_1fr] gap-8 py-8"
>
<div className="pt-1">
<p className="font-mono text-xs font-bold text-amber-400">
{entry.version}
</p>
<p className="mt-1 text-[11px] text-zinc-600">
{formatDate(entry.publishedAt)}
</p>
</div>
<div>
{(entry.tags?.length ?? 0) > 0 && (
<div className="mb-2.5 flex flex-wrap gap-1.5">
{(entry.tags ?? []).map((tag) => (
<span
key={tag}
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
TAG_STYLES[tag] ??
'border border-zinc-700 bg-zinc-800 text-zinc-400'
}`}
>
{tag}
</span>
))}
</div>
)}
<h3 className="mb-3 text-[17px] font-semibold text-white">
{entry.title}
</h3>
{entry.body && (
<div className="text-sm text-zinc-400">
<PortableTextRenderer value={entry.body} />
</div>
)}
</div>
</div>
))}
</div>
)}
</div>
)}
{/* ── What's Next ── */}
{activeTab === 'whats-next' && (
<div>
{roadmapItems.length === 0 ? (
<p className="text-sm text-zinc-500">
No roadmap items yet check back soon.
</p>
) : (
<>
{(() => {
const inProgress = roadmapItems.filter((i) => i.status === 'in-progress')
const planned = roadmapItems.filter((i) => i.status === 'planned')
return (
<>
{inProgress.length > 0 && (
<div className="mb-8">
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
In Progress
</p>
<div className="flex flex-col gap-2.5">
{inProgress.map((item) => (
<RoadmapItem
key={item._id}
id={item._id}
title={item.title}
description={item.description}
status={item.status}
vote={
voteState[item._id] ?? {
count: 0,
voted: false,
loading: false,
}
}
onVote={handleVote}
/>
))}
</div>
</div>
)}
{planned.length > 0 && (
<div>
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
Planned
</p>
<div className="flex flex-col gap-2.5">
{planned.map((item) => (
<RoadmapItem
key={item._id}
id={item._id}
title={item.title}
description={item.description}
status={item.status}
vote={
voteState[item._id] ?? {
count: 0,
voted: false,
loading: false,
}
}
onVote={handleVote}
/>
))}
</div>
</div>
)}
</>
)
})()}
<p className="mt-8 text-xs text-zinc-600">
Don&apos;t see what you need?{' '}
<button
onClick={() => handleTabChange('give-feedback')}
className="underline underline-offset-2 hover:text-zinc-300"
>
Give us feedback
</button>
</p>
{showSignInPrompt && (
<p className="mt-3 text-xs text-amber-400">
<Link href="/login" className="underline underline-offset-2 hover:text-amber-300">
Sign in
</Link>{' '}
to upvote roadmap items.
</p>
)}
</>
)}
</div>
)}
{/* ── Give Feedback ── */}
{activeTab === 'give-feedback' && (
<div>
<div className="mb-8">
<h2 className="text-xl font-bold text-white">Tell us what you think.</h2>
<p className="mt-1.5 text-sm text-zinc-500">
Bug, feature idea, or general thought we read everything.
</p>
</div>
<FeedbackForm />
</div>
)}
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
'use client'
import { useState } from 'react'
type Status = 'idle' | 'loading' | 'done' | 'error'
const CATEGORIES = [
{ value: 'BUG', label: 'Bug report' },
{ value: 'FEATURE_REQUEST', label: 'Feature request' },
{ value: 'GENERAL', label: 'General feedback' },
]
export function FeedbackForm({ onSuccess }: { onSuccess?: () => void }) {
const [category, setCategory] = useState('BUG')
const [message, setMessage] = useState('')
const [email, setEmail] = useState('')
const [status, setStatus] = useState<Status>('idle')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!message.trim()) return
setStatus('loading')
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
category,
message: message.trim(),
email: email.trim() || undefined,
}),
})
if (res.ok) {
setStatus('done')
onSuccess?.()
} else {
setStatus('error')
}
} catch {
setStatus('error')
}
}
if (status === 'done') {
return (
<div className="py-6 text-center">
<p className="text-sm font-semibold text-amber-400">Thanks we got it.</p>
<p className="mt-1 text-xs text-zinc-500">
We read everything. If you left an email we&apos;ll follow up.
</p>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Category
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
disabled={status === 'loading'}
className="w-full appearance-none rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-200 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Email{' '}
<span className="font-normal normal-case tracking-normal text-zinc-600">
(optional if you want a reply)
</span>
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@email.com"
disabled={status === 'loading'}
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-200 placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
/>
</div>
</div>
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Message
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, 1000))}
placeholder="What's on your mind?"
rows={5}
required
disabled={status === 'loading'}
className="w-full resize-y rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm leading-relaxed text-zinc-200 placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
/>
<p className="mt-1.5 text-right text-[11px] text-zinc-600">
{message.length} / 1000
</p>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-[11px] text-zinc-600">
We&apos;ll only use your email to follow up on your feedback.
</p>
<button
type="submit"
disabled={status === 'loading' || !message.trim()}
className="shrink-0 rounded-lg bg-amber-500/90 px-6 py-2.5 text-sm font-bold text-black hover:bg-amber-400 disabled:opacity-50"
>
{status === 'loading' ? 'Sending…' : 'Send Feedback →'}
</button>
</div>
{status === 'error' && (
<p className="text-xs text-red-400">Something went wrong. Try again.</p>
)}
</form>
)
}
+51
View File
@@ -0,0 +1,51 @@
'use client'
import { useEffect } from 'react'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { FeedbackForm } from '@/components/FeedbackForm'
export function FeedbackModal({
open,
onClose,
}: {
open: boolean
onClose: () => void
}) {
useEffect(() => {
if (!open) return
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative z-10 w-full max-w-lg rounded-2xl border border-zinc-800 bg-zinc-950 p-7 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-base font-bold text-white">Send Feedback</h2>
<button
onClick={onClose}
className="text-zinc-500 hover:text-zinc-300"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<FeedbackForm onSuccess={onClose} />
</div>
</div>
)
}
+56
View File
@@ -0,0 +1,56 @@
"use client";
import { useState } from "react";
export function NewsletterForm() {
const [email, setEmail] = useState("");
const [status, setStatus] = useState<"idle" | "loading" | "done" | "error">(
"idle"
);
async function handleSubmit(e: React.FormEvent) {
e.preventDefault();
if (!email) return;
setStatus("loading");
try {
const res = await fetch("/api/newsletter", {
method: "POST",
headers: { "Content-Type": "application/json" },
body: JSON.stringify({ email }),
});
setStatus(res.ok ? "done" : "error");
} catch {
setStatus("error");
}
}
if (status === "done") {
return <p className="text-xs text-amber-400">You&apos;re on the list.</p>;
}
return (
<div>
<form onSubmit={handleSubmit} className="flex gap-2">
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="your@email.com"
required
disabled={status === "loading"}
className="min-w-0 flex-1 rounded-md border border-zinc-800 bg-zinc-950 px-3 py-2 text-xs text-zinc-200 placeholder-zinc-600 focus:border-amber-500/50 focus:outline-none disabled:opacity-50"
/>
<button
type="submit"
disabled={status === "loading"}
className="rounded-md border border-amber-500/50 bg-amber-500/10 px-3 py-2 text-xs font-semibold text-amber-400 hover:bg-amber-500/20 disabled:opacity-50"
>
{status === "loading" ? "..." : "Subscribe"}
</button>
</form>
{status === "error" && (
<p className="mt-2 text-[11px] text-red-400">Something went wrong. Try again.</p>
)}
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
'use client'
import { PortableText, type PortableTextComponents } from 'next-sanity'
import Image from 'next/image'
import { urlFor } from '@/lib/sanity'
const components: PortableTextComponents = {
block: {
normal: ({ children }) => (
<p className="mb-5 leading-relaxed text-zinc-300">{children}</p>
),
h1: ({ children }) => (
<h1 className="mb-4 mt-10 text-3xl font-semibold text-zinc-100">{children}</h1>
),
h2: ({ children }) => (
<h2 className="mb-3 mt-8 text-2xl font-semibold text-zinc-100">{children}</h2>
),
h3: ({ children }) => (
<h3 className="mb-2 mt-6 text-xl font-semibold text-zinc-100">{children}</h3>
),
blockquote: ({ children }) => (
<blockquote className="my-6 border-l-2 border-amber-400/60 pl-4 text-zinc-400 italic">
{children}
</blockquote>
),
},
marks: {
strong: ({ children }) => (
<strong className="font-semibold text-zinc-100">{children}</strong>
),
em: ({ children }) => <em className="italic text-zinc-300">{children}</em>,
code: ({ children }) => (
<code className="rounded bg-zinc-800 px-1.5 py-0.5 font-mono text-sm text-amber-300">
{children}
</code>
),
link: ({ value, children }) => (
<a
href={value?.href}
target={value?.href?.startsWith('http') ? '_blank' : undefined}
rel={value?.href?.startsWith('http') ? 'noopener noreferrer' : undefined}
className="text-amber-400 underline underline-offset-2 hover:text-amber-300"
>
{children}
</a>
),
},
list: {
bullet: ({ children }) => (
<ul className="mb-5 ml-6 list-disc space-y-1.5 text-zinc-300">{children}</ul>
),
number: ({ children }) => (
<ol className="mb-5 ml-6 list-decimal space-y-1.5 text-zinc-300">{children}</ol>
),
},
listItem: {
bullet: ({ children }) => <li className="leading-relaxed">{children}</li>,
number: ({ children }) => <li className="leading-relaxed">{children}</li>,
},
types: {
image: ({ value }) => {
if (!value?.asset) return null
return (
<figure className="my-8">
<div className="relative overflow-hidden rounded-lg">
<Image
src={urlFor(value).width(800).url()}
alt={value.alt ?? ''}
width={800}
height={450}
className="w-full object-cover"
/>
</div>
{value.caption && (
<figcaption className="mt-2 text-center text-xs text-zinc-500">
{value.caption}
</figcaption>
)}
</figure>
)
},
},
}
export function PortableTextRenderer({ value }: { value: unknown[] }) {
return (
<div className="prose-battl">
<PortableText value={value as any} components={components} />
</div>
)
}
+58
View File
@@ -0,0 +1,58 @@
'use client'
import { ChevronUpIcon } from '@heroicons/react/20/solid'
import type { VoteState } from '@/types/changelog'
export function RoadmapItem({
id,
title,
description,
status,
vote,
onVote,
}: {
id: string
title: string
description: string
status: 'planned' | 'in-progress'
vote: VoteState
onVote: (id: string) => void
}) {
const statusBadgeClass =
status === 'in-progress'
? 'border-amber-500/25 bg-amber-500/10 text-amber-400'
: 'border-zinc-700 bg-zinc-800/40 text-zinc-500'
return (
<div className="grid grid-cols-[52px_1fr_auto] items-center gap-4 rounded-xl border border-zinc-800/60 bg-zinc-950/60 p-5">
{/* Upvote button */}
<button
onClick={() => onVote(id)}
disabled={vote.loading}
title={vote.voted ? 'Remove vote' : 'Upvote'}
className={[
'flex h-[52px] w-11 flex-col items-center justify-center gap-0.5 rounded-lg border text-xs font-bold transition-colors disabled:opacity-50',
vote.voted
? 'border-amber-500/40 bg-amber-500/10 text-amber-400'
: 'border-zinc-700 text-zinc-500 hover:border-amber-500/40 hover:text-amber-400',
].join(' ')}
>
<ChevronUpIcon className="h-4 w-4" />
<span>{vote.count}</span>
</button>
{/* Content */}
<div>
<p className="text-sm font-semibold text-zinc-200">{title}</p>
<p className="mt-1 text-xs leading-relaxed text-zinc-500">{description}</p>
</div>
{/* Status badge */}
<span
className={`rounded-full border px-2.5 py-0.5 text-[10px] font-bold uppercase tracking-wider ${statusBadgeClass}`}
>
{status === 'in-progress' ? 'In Progress' : 'Planned'}
</span>
</div>
)
}
+16 -10
View File
@@ -6,7 +6,6 @@ import Image from "next/image";
import { usePathname } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import ThemeToggle from "@/components/ThemeToggle";
function NavLink({
href,
@@ -18,15 +17,17 @@ function NavLink({
title?: string;
}) {
const pathname = usePathname();
const active = pathname === href;
const active = pathname === href || pathname.startsWith(href + '/');
return (
<Link
href={href}
title={title}
className={[
"text-xs font-medium transition-colors",
active ? "text-zinc-100" : "text-zinc-400 hover:text-zinc-100",
"relative text-xs font-medium transition-colors pb-0.5",
active
? "text-zinc-100 after:absolute after:bottom-0 after:left-0 after:right-0 after:h-px after:bg-amber-400"
: "text-zinc-400 hover:text-zinc-100",
].join(" ")}
>
{children}
@@ -55,10 +56,15 @@ export function TopNav() {
/>
</Link>
{/* Right side actions */}
<div className="flex items-center gap-4">
<ThemeToggle />
{/* Centre: primary nav links */}
<nav className="hidden sm:flex items-center gap-5">
<NavLink href="/builder">Builder</NavLink>
<NavLink href="/guides">Guides</NavLink>
<NavLink href="/builds">Community</NavLink>
</nav>
{/* Right side actions */}
<div className="flex items-center justify-end gap-3 sm:gap-4">
{loading ? (
<span className="text-xs text-zinc-500">Checking session</span>
) : isAuthenticated ? (
@@ -83,7 +89,7 @@ export function TopNav() {
</button>
</>
) : (
<>
<div className="flex flex-col items-end gap-2 sm:flex-row sm:items-center sm:gap-4">
<NavLink href="/login">Log In</NavLink>
<Link
@@ -92,11 +98,11 @@ export function TopNav() {
>
Join Beta
</Link>
</>
</div>
)}
</div>
</div>
</header>
);
}
export default TopNav;
export default TopNav;
+7 -11
View File
@@ -3,6 +3,7 @@
import Link from "next/link";
import { Plus } from "lucide-react";
import type { UiPart } from "@/types/uiPart";
import { ProductImage } from './ProductImage';
export default function PartsGrid(props: {
viewMode: "card" | "list";
@@ -130,17 +131,12 @@ export default function PartsGrid(props: {
>
{/* Image */}
<div className="hidden md:block justify-self-start">
{part.imageUrl ? (
// eslint-disable-next-line @next/next/no-img-element
<img
src={part.imageUrl}
alt=""
className="h-10 w-10 rounded object-cover border border-zinc-800 bg-zinc-950"
loading="lazy"
/>
) : (
<div className="h-10 w-10 rounded border border-zinc-800 bg-zinc-950" />
)}
<ProductImage
src={part.imageUrl}
alt=""
className="h-10 w-10 rounded object-cover border border-zinc-800 bg-zinc-950"
loading="lazy"
/>
</div>
{/* Part */}
+6 -8
View File
@@ -2,6 +2,7 @@
import { useEffect, useMemo, useState } from "react";
import Link from "next/link";
import { ProductImage } from './ProductImage';
import PlatformSwitcher from "./PlatformSwitcher";
const API_BASE_URL = process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
@@ -149,14 +150,11 @@ export default function ProductDetailPageClient(props: {
<div className="flex flex-col gap-6 md:flex-row">
<div className="w-full md:w-72">
<div className="aspect-square overflow-hidden rounded-lg border border-zinc-800 bg-black/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
{p.imageUrl ? (
<img src={p.imageUrl} alt={p.name} className="h-full w-full object-cover" />
) : (
<div className="h-full w-full grid place-items-center text-sm text-zinc-600">
No image
</div>
)}
<ProductImage
src={p.imageUrl}
alt={p.name}
className="h-full w-full object-cover"
/>
</div>
</div>
+42
View File
@@ -0,0 +1,42 @@
'use client';
import { useEffect, useRef } from 'react';
const PLACEHOLDER = '/images/product-placeholder.svg';
interface ProductImageProps {
src?: string | null;
alt: string;
className?: string;
width?: number;
height?: number;
loading?: 'lazy' | 'eager';
}
export function ProductImage({ src, alt, className, width, height, loading }: ProductImageProps) {
const hasErrored = useRef(false);
// Reset the loop guard whenever src changes (e.g. navigating between products)
useEffect(() => {
hasErrored.current = false;
}, [src]);
function handleError(e: React.SyntheticEvent<HTMLImageElement>) {
if (hasErrored.current) return;
hasErrored.current = true;
e.currentTarget.src = PLACEHOLDER;
}
return (
// eslint-disable-next-line @next/next/no-img-element
<img
src={src ?? PLACEHOLDER}
alt={alt}
className={className}
width={width}
height={height}
loading={loading}
onError={handleError}
/>
);
}
@@ -0,0 +1,415 @@
# Homepage Redesign Implementation Plan
> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking.
**Goal:** Replace the beta-gated homepage with an open, conversion-focused landing page that drives users directly into the builder.
**Architecture:** Two files change. `components/Banner.tsx` gets a one-line suppression for `/`. `app/page.tsx` is fully rewritten as a React Server Component with 8 sections and a page-level metadata export. No new files, no new components, no client state.
**Tech Stack:** Next.js 15 App Router, React Server Components, TypeScript, Tailwind CSS, `@heroicons/react/20/solid`, `next/image`, `next/link`
---
## File Map
| File | Change |
|------|--------|
| `components/Banner.tsx` | One-line change — add `|| pathname === "/"` to suppression check |
| `app/page.tsx` | Full rewrite — RSC, metadata export, 8-section layout, new copy |
---
## Task 1: Suppress Banner on the Homepage
**Files:**
- Modify: `components/Banner.tsx:8`
Context: `Banner` renders an "Early Access Beta" bar on every non-admin route. The new homepage is no longer beta-gated, so it must not show this bar.
- [ ] **Step 1: Edit `components/Banner.tsx`**
Change line 8 from:
```tsx
if (pathname?.startsWith("/admin")) return null;
```
To:
```tsx
if (pathname?.startsWith("/admin") || pathname === "/") return null;
```
- [ ] **Step 2: Verify manually**
Run `npm run dev` and open `http://localhost:3000`. Confirm the amber "Early Access Beta" bar is **not** visible. Navigate to `/builder` and confirm the banner **is** visible.
- [ ] **Step 3: Commit**
```bash
git add components/Banner.tsx
git commit -m "feat: suppress Early Access Beta banner on homepage"
```
---
## Task 2: Rewrite `app/page.tsx` as a React Server Component
**Files:**
- Modify: `app/page.tsx` (full rewrite)
Context: The current file is `"use client"` with a beta signup form. Remove all client state, the form, and the `/api/beta-signup` dependency. Replace with 8 static sections. Add a `metadata` export using `{ absolute: "..." }` to bypass the root layout's `template: "%s — Battl Builders"` pattern.
- [ ] **Step 1: Replace `app/page.tsx` with the following content**
```tsx
import type { Metadata } from "next";
import Image from "next/image";
import Link from "next/link";
import {
WrenchScrewdriverIcon,
ShieldExclamationIcon,
CurrencyDollarIcon,
BookmarkSquareIcon,
LinkIcon,
LockOpenIcon,
} from "@heroicons/react/20/solid";
export const metadata: Metadata = {
title: { absolute: "Battl Builders — Free AR-15 Build Planner" },
description:
"Pick compatible AR-15 parts, catch mismatches before you order, and compare live prices from real retailers. Free, no account required.",
};
export default function HomePage() {
return (
<main className="min-h-screen bg-black text-zinc-50">
{/* Grid background — retained from previous implementation */}
<div className="pointer-events-none fixed inset-0 z-0 overflow-hidden opacity-[0.55]">
<svg
width="100%"
height="100%"
preserveAspectRatio="xMidYMid slice"
xmlns="http://www.w3.org/2000/svg"
>
<defs>
<pattern
id="battlGrid"
x="0"
y="0"
width="80"
height="80"
patternUnits="userSpaceOnUse"
>
<path
d="M 80 0 L 0 0 0 80"
fill="none"
stroke="#fbbf24"
strokeWidth="0.5"
strokeOpacity="0.35"
/>
</pattern>
<pattern
id="battlHex"
width="140"
height="140"
patternUnits="userSpaceOnUse"
>
<path
d="M70 4 L136 40 L136 110 L70 146 L4 110 L4 40 Z"
fill="none"
stroke="#fbbf24"
strokeWidth="0.4"
strokeOpacity="0.25"
/>
</pattern>
</defs>
<rect width="100%" height="100%" fill="url(#battlGrid)" />
<rect width="100%" height="100%" fill="url(#battlHex)" />
</svg>
</div>
<div className="relative z-10">
{/* ── 1. Nav ── */}
<nav className="mx-auto flex max-w-5xl items-center justify-between px-4 py-5">
<Image
src="/battl/battl-logo-mark-f.svg"
alt="Battl Builders"
width={260}
height={48}
/>
<div className="flex items-center gap-2">
<Link
href="/login"
className="rounded-md border border-zinc-800 bg-zinc-950/60 px-3 py-2 text-xs font-medium text-zinc-200 hover:bg-zinc-900 hover:text-white"
>
Sign In
</Link>
<Link
href="/builder"
className="rounded-md border border-amber-500/70 bg-amber-500/90 px-3 py-2 text-xs font-semibold text-black hover:bg-amber-400"
>
Start a Build
</Link>
</div>
</nav>
{/* ── 2. Hero ── */}
<section className="mx-auto max-w-[760px] px-4 pb-16 pt-20 text-center">
<div className="mb-7 inline-flex items-center gap-2 rounded-full border border-white/10 bg-black/40 px-4 py-[5px] text-[10px] uppercase tracking-[0.2em] text-white/55">
<span className="h-1.5 w-1.5 flex-shrink-0 rounded-full bg-amber-400" />
AR-15 Builder &nbsp;·&nbsp; 100% Free
</div>
<h1 className="text-[clamp(38px,6.5vw,64px)] font-bold leading-[1.05] tracking-[-0.035em] text-white">
Stop Guessing.
<span className="block text-amber-300">Start Building.</span>
</h1>
<p className="mx-auto mt-6 max-w-[540px] text-[17px] leading-[1.65] text-zinc-400">
Not sure which parts work together? We&apos;ll walk you through it.
Pick your components, get warned about anything that won&apos;t fit,
and see real prices from real retailers — all in one place.
</p>
<div className="mt-10 flex flex-col items-center gap-3">
<Link
href="/builder"
className="inline-flex items-center gap-2 rounded-lg bg-amber-500/90 px-[38px] py-[15px] text-[15px] font-extrabold tracking-[0.03em] text-black shadow-[0_0_40px_rgba(251,191,36,0.2)] hover:bg-amber-400"
>
Start a Build →
</Link>
<span className="text-[11px] tracking-[0.05em] text-zinc-600">
100% Free · No account required
</span>
</div>
</section>
{/* ── 3. How It Works ── */}
<section className="mx-auto max-w-5xl px-4 py-[72px]">
<p className="mb-3 text-center text-[10px] font-bold uppercase tracking-[0.25em] text-amber-400 opacity-85">
THE PROCESS
</p>
<h2 className="mb-1.5 text-center text-[30px] font-bold tracking-[-0.025em] text-white">
3 Steps. One Battl-Ready Build.
</h2>
<p className="mb-12 text-center text-sm text-zinc-500">
No experience required. No part numbers to memorize.
</p>
<div
className="mx-auto grid max-w-[840px] grid-cols-1 overflow-hidden rounded-xl border border-zinc-900 bg-zinc-900 sm:grid-cols-3"
style={{ gap: "2px" }}
>
{[
{
num: "Step 01",
title: "Pick your parts",
body: "Browse real components organized by what they do — barrel, trigger, lower, upper, and more. No prior knowledge required.",
},
{
num: "Step 02",
title: "Flag the conflicts",
body: "Gas length, buffer weight, caliber — Battl catches the mismatches before you spend money on the wrong part.",
},
{
num: "Step 03",
title: "Price it. Pull the trigger.",
body: "Live prices from real retailers, side by side. Click through to buy. We never touch your order.",
},
].map((step) => (
<div key={step.num} className="bg-[#0d0d0f] px-6 py-8">
<p className="mb-3.5 text-[11px] font-bold uppercase tracking-[0.15em] text-amber-400 opacity-80">
{step.num}
</p>
<h3 className="mb-2.5 text-base font-bold tracking-[-0.01em] text-white">
{step.title}
</h3>
<p className="text-[13px] leading-[1.6] text-zinc-500">
{step.body}
</p>
</div>
))}
</div>
</section>
{/* ── 4. Builder Preview Video ── */}
<section className="px-4 pb-[72px]">
<div className="mx-auto max-w-[900px] overflow-hidden rounded-[14px] border border-white/[0.07] bg-black shadow-[0_24px_80px_rgba(0,0,0,0.7),0_0_0_1px_rgba(251,191,36,0.05)]">
<div className="relative">
<video
autoPlay
loop
muted
playsInline
poster="/builder-preview.png"
className="w-full object-cover"
>
<source src="/builder-preview.mp4" type="video/mp4" />
</video>
<div
aria-hidden="true"
className="pointer-events-none absolute inset-x-0 bottom-0 h-24 bg-gradient-to-t from-black"
/>
</div>
</div>
</section>
{/* ── 5. Feature Cards ── */}
<section className="mx-auto max-w-5xl px-4 py-[72px]">
<p className="mb-3 text-center text-[10px] font-bold uppercase tracking-[0.25em] text-amber-400 opacity-85">
WHAT YOU GET
</p>
<h2 className="mb-2 text-center text-[30px] font-bold tracking-[-0.025em] text-white">
Built by gun nerds, for the everyday AR builder.
</h2>
<p className="mb-11 text-center text-[13px] uppercase tracking-[0.02em] text-zinc-600">
NO SUBSCRIPTIONS · NO SPONSORED RANKINGS · NO BULLSHIT
</p>
<div
className="mx-auto grid max-w-[900px] grid-cols-1 overflow-hidden rounded-xl border border-zinc-900 bg-zinc-900 sm:grid-cols-2 lg:grid-cols-3"
style={{ gap: "1px" }}
>
{(
[
{
Icon: WrenchScrewdriverIcon,
title: "Build by role, not part number",
body: "Parts organized by function. Know what you need, not what SKU to search for.",
},
{
Icon: ShieldExclamationIcon,
title: "Conflicts caught early",
body: "Gas system, buffer weight, caliber — flagged before checkout, not after the box arrives.",
},
{
Icon: CurrencyDollarIcon,
title: "Real prices. Real retailers.",
body: "Running total with live prices. No estimated MSRP. What it actually costs, today.",
},
{
Icon: BookmarkSquareIcon,
title: "Save your work",
body: "Free account. Save builds, pick up where you left off, run multiple configs in parallel.",
},
{
Icon: LinkIcon,
title: "Share the build",
body: "One link. Your buddy sees the full parts list — no login, no friction, no excuses.",
},
{
Icon: LockOpenIcon,
title: "No gates. No friction.",
body: "Browse and build freely. Create an account only when you're ready to save.",
},
] as const
).map(({ Icon, title, body }) => (
<div key={title} className="bg-[#0d0d0f] p-6">
<Icon
className="mb-3.5 h-5 w-5 text-amber-400"
aria-hidden="true"
/>
<h3 className="mb-1.5 text-[13px] font-bold tracking-[-0.01em] text-zinc-200">
{title}
</h3>
<p className="text-xs leading-[1.6] text-zinc-600">{body}</p>
</div>
))}
</div>
</section>
{/* ── 6. SEO Copy Block ── */}
<section className="border-t border-zinc-900 px-4 py-[72px]">
<div className="mx-auto max-w-[680px]">
<h2 className="mb-4 text-[22px] font-bold tracking-[-0.02em] text-zinc-300">
What is Battl Builders?
</h2>
<p className="mb-3.5 text-sm leading-[1.75] text-zinc-500">
Battl Builders is a free{" "}
<strong className="font-semibold text-zinc-300">
AR-15 build planner
</strong>{" "}
— the tool you&apos;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 className="mb-3.5 text-sm leading-[1.75] text-zinc-500">
Most{" "}
<strong className="font-semibold text-zinc-300">
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 className="text-sm leading-[1.75] text-zinc-500">
Whether you&apos;re planning your first rifle or spec&apos;ing a
dedicated precision upper, Battl Builders gives you a structured,
guided way to research parts and{" "}
<strong className="font-semibold text-zinc-300">
buy with confidence
</strong>
.
</p>
</div>
</section>
{/* ── 7. Final CTA ── */}
<section className="border-t border-zinc-900 px-4 py-24 text-center">
<h2 className="mb-3 text-[36px] font-bold tracking-[-0.03em] text-white">
Your build starts here.
</h2>
<p className="mb-8 text-sm text-zinc-600">
Free. No account required. Takes five minutes to get rolling.
</p>
<Link
href="/builder"
className="inline-flex items-center gap-2 rounded-lg bg-amber-500/90 px-[38px] py-[15px] text-[15px] font-extrabold tracking-[0.03em] text-black shadow-[0_0_40px_rgba(251,191,36,0.2)] hover:bg-amber-400"
>
Start a Build →
</Link>
</section>
{/* ── 8. Footer ── */}
<footer className="mx-auto flex max-w-5xl items-center justify-between border-t border-zinc-900 px-4 py-5 text-[11px] text-zinc-700">
<span>© {new Date().getFullYear()} BATTL BUILDERS™</span>
<span>Free AR-15 build planner</span>
</footer>
</div>
</main>
);
}
```
- [ ] **Step 2: Verify the build compiles**
```bash
npm run build
```
Expected: no TypeScript or Next.js errors related to `app/page.tsx`. (TS errors and ESLint are suppressed in this project per `next.config.mjs`, but the file should still render without runtime errors.)
- [ ] **Step 3: Verify manually in the dev server**
Run `npm run dev` and open `http://localhost:3000`. Check all 8 sections are visible:
- Nav: logo left, "Sign In" (ghost) + "Start a Build" (amber) right
- Hero: badge pill, H1 with amber "Start Building.", subhead, amber CTA button, micro-copy
- How It Works: 3-step flush grid (Step 0103)
- Video: rounded container playing `builder-preview.mp4` (or showing poster)
- Feature Cards: 6-card 3-col flush grid with Heroicons
- SEO Copy: left-aligned prose with bold keyword terms
- Final CTA: "Your build starts here." with amber button
- Footer: copyright left, "Free AR-15 build planner" right (no version string)
Also verify:
- No "Early Access Beta" banner visible on homepage
- Page `<title>` is "Battl Builders — Free AR-15 Build Planner" (not double-suffixed)
- Both CTA buttons link to `/builder`
- "Sign In" links to `/login`
- [ ] **Step 4: Commit**
```bash
git add app/page.tsx
git commit -m "feat: redesign homepage — RSC, 8 sections, remove beta form"
```
@@ -0,0 +1,235 @@
# Changelog, Roadmap & Feedback Design Spec
**Date:** 2026-03-30
**Status:** Approved
---
## Overview
Add a public `/changelog` page with three tabs (What's New, What's Next, Give Feedback), auth-gated upvoting on roadmap items, a shared feedback form available as a standalone page and a modal triggered from the nav/footer, and an admin view for reviewing submissions.
---
## Goals
- Publicly share release notes (changelog) and upcoming work (roadmap)
- Let authenticated users upvote roadmap items to signal priority
- Collect structured user feedback (bug / feature / general) with optional email
- Store feedback in Spring Boot DB, reviewable in the admin panel
- Add "Send Feedback" entry point to TopNav and homepage footer
---
## Routes
| Route | Description |
|---|---|
| `/changelog` | Three-tab page: What's New / What's Next / Give Feedback |
| `/feedback` | Standalone feedback page — full-width `FeedbackForm` |
| `/admin/feedback` | Admin table of feedback submissions |
Tab state on `/changelog` is driven by URL hash (`#whats-new`, `#whats-next`, `#give-feedback`) so tabs are deep-linkable. When no hash is present, the default active tab is **What's New**. The "Don't see what you need? Give us feedback →" nudge on the roadmap tab links to `#give-feedback`.
### Middleware
`/changelog` and `/feedback` are public routes — no authentication required. Both must be added to `isPublicPath()` in `middleware.ts` so they remain accessible when `LAUNCH_ONLY_ROOT=true`. The Next.js proxy routes `/api/feedback` (POST) and `/api/roadmap/votes` (GET) require no auth and will pass through middleware as-is since they are not prefixed with `/api/admin` or matched by `isProtectedLaunchPath()`. This is intentional and requires no middleware change for those API routes.
---
## Sanity Schema (two new document types)
### `changelogEntry`
| Field | Type | Notes |
|---|---|---|
| `title` | string | Display title of the release |
| `version` | string | e.g. `v0.4` |
| `publishedAt` | datetime | Controls ordering and display date |
| `tags` | array of string | Values: `feature`, `fix`, `improvement` |
| `body` | Portable Text | Rich text body |
### `roadmapItem`
| Field | Type | Notes |
|---|---|---|
| `title` | string | Short item title |
| `description` | text | Plain-text description |
| `status` | string enum | `planned` \| `in-progress` \| `shipped` |
| `order` | number | Manual sort order within each status group |
Both use the existing Sanity project (`zal102rv`, dataset `production`). GROQ query strings are added to `lib/sanity.ts` alongside the existing `postsQuery`. The RSC page **must call `sanityFetch()` from `lib/sanityFetch.ts`** to execute those queries — not `client.fetch()` from the Sanity client directly. This is the same pattern used by the guides page and is required for correct behaviour in Docker/server environments where CDN DNS resolution fails.
Pages use `export const revalidate = 60` ISR, consistent with guides.
The Sanity `_id` of each `roadmapItem` serves as the foreign key in Spring Boot's votes table — no additional ID field required. Sanity `_id` values are stable UUIDs.
### GROQ Query Filters
- `changelogEntriesQuery`: filter `_type == "changelogEntry" && publishedAt <= now()`, order by `publishedAt desc`
- `roadmapItemsQuery`: filter `_type == "roadmapItem" && status != "shipped"`, order by `order asc` within each status group. **Shipped items are excluded from the roadmap query** — when an item ships, the author changes its status to `shipped` in Sanity and creates a corresponding `changelogEntry`. The roadmap tab never displays shipped items.
---
## Spring Boot Backend
### New Tables
**`feedback`**
| Column | Type | Notes |
|---|---|---|
| `id` | UUID, PK | |
| `category` | enum | `BUG`, `FEATURE_REQUEST`, `GENERAL` |
| `message` | text | Max 1000 chars, enforced at API level |
| `email` | varchar, nullable | Optional follow-up email |
| `ip_address` | varchar | Captured server-side for spam visibility |
| `user_agent` | varchar | Captured server-side for spam visibility |
| `reviewed` | boolean, default false | Admin marks reviewed |
| `created_at` | timestamp | |
Rate limiting on `POST /api/v1/feedback` is deferred — IP and user agent capture provides enough visibility for manual spam review at this scale.
**`roadmap_votes`**
| Column | Type | Notes |
|---|---|---|
| `user_id` | UUID, FK → users | Part of composite PK |
| `sanity_item_id` | varchar | Part of composite PK; Sanity `_id` of the roadmap item |
| `created_at` | timestamp | |
| | PRIMARY KEY(user_id, sanity_item_id) | Composite PK — enforces one vote per user per item |
### New API Endpoints (Spring Boot)
| Method | Path | Auth | Notes |
|---|---|---|---|
| `POST` | `/api/v1/feedback` | None | Creates feedback record; captures IP + user agent server-side |
| `GET` | `/api/v1/roadmap/votes?ids=...` | Optional | Returns vote counts + current user's voted state for a list of Sanity IDs. For unauthenticated callers, returns vote counts with `voted: false` for all items — never 401. |
| `POST` | `/api/v1/roadmap/{sanityId}/vote` | Required | Toggles vote on/off; returns new count and voted state |
### Next.js Proxy Routes
All three endpoints are proxied via `app/api/` — client code never calls Spring Boot directly, consistent with the existing proxy pattern.
| Next.js route | Proxies to |
|---|---|
| `POST /api/feedback` | `POST /api/v1/feedback` |
| `GET /api/roadmap/votes` | `GET /api/v1/roadmap/votes` |
| `POST /api/roadmap/[sanityId]/vote` | `POST /api/v1/roadmap/:id/vote` |
The Next.js proxy for `POST /api/feedback` captures `x-forwarded-for` and `user-agent` from the incoming request headers and forwards them to Spring Boot — the client form never sends these values.
---
## Frontend Components
### New Files
| File | Type | Responsibility |
|---|---|---|
| `app/(app)/changelog/page.tsx` | RSC | Fetches Sanity changelog + roadmap data via `sanityFetch`, renders tab shell + passes data as props |
| `components/ChangelogTabs.tsx` | `"use client"` | Hash-based tab switcher (default: `#whats-new`), renders all three tab panels |
| `components/RoadmapItem.tsx` | `"use client"` | Single roadmap item with optimistic upvote toggle |
| `components/FeedbackForm.tsx` | `"use client"` | Shared form (category + message + email), posts to `/api/feedback` |
| `components/FeedbackModal.tsx` | `"use client"` | Backdrop + dialog wrapper around `FeedbackForm` |
| `app/(app)/feedback/page.tsx` | RSC | Standalone page rendering `FeedbackForm` full-width |
| `app/admin/feedback/page.tsx` | RSC | Admin table of submissions, sortable + filterable |
| `app/api/feedback/route.ts` | API route | Proxy to Spring Boot; forwards IP + user-agent headers |
| `app/api/roadmap/votes/route.ts` | API route | Proxy to Spring Boot |
| `app/api/roadmap/[sanityId]/vote/route.ts` | API route | Proxy to Spring Boot |
### Modified Files
| File | Change |
|---|---|
| `lib/sanity.ts` | Add `changelogEntriesQuery` and `roadmapItemsQuery` |
| `middleware.ts` | Add `/changelog` and `/feedback` to `isPublicPath()` |
| `components/TopNav.tsx` | Add "Send Feedback" ghost button; modal open state is local to `TopNav` via `useState` |
| `app/page.tsx` | Add "Send Feedback" link to the homepage's inline footer (homepage-only; this is intentional — TopNav covers all other pages) |
### FeedbackModal State
`FeedbackModal` open/close state is managed locally in `TopNav` with `useState`. No global context or portal is required — the modal renders inside `TopNav`'s JSX and is positioned fixed via CSS. This is sufficient given "Send Feedback" is the only trigger point in the nav.
---
## UI Design
### `/changelog` Page Header
```
BATTL BUILDERS (small caps label)
Updates (h1)
What we've shipped and what's coming next. (subhead)
[ What's New ] [ What's Next ] [ Give Feedback ] (tab bar, amber underline on active)
```
### What's New Tab
Two-column entry layout: left column is version + date (120px fixed), right column is tags + title + body. Entries separated by bottom border, ordered newest first.
Tag values rendered as small pill badges:
- `feature` → amber
- `fix` → green
- `improvement` → blue
### What's Next Tab
Items grouped under "In Progress" and "Planned" headings. Shipped items are never shown here (filtered out in the GROQ query). Each item is a card with:
- **Left:** upvote button (chevron up + count) — bordered pill, amber when voted
- **Center:** title + description
- **Right:** status badge (In Progress = amber, Planned = muted)
Unauthenticated users clicking upvote are prompted to sign in (toast). Voted state is toggled optimistically — rolls back on error.
Footer nudge: *"Don't see what you need? Give us feedback →"* links to `#give-feedback`.
### Give Feedback Tab & `/feedback` Page
Full-width form:
- Category + email in a two-column row
- Message textarea (full width, 1000 char limit with counter)
- Submit button bottom-right, privacy note bottom-left
### Feedback Modal
Triggered by "Send Feedback" in TopNav and homepage inline footer. Same fields as the tab form. Full-width submit button inside the modal.
---
## Admin View (`/admin/feedback`)
Table with columns: Date, Category, Message (truncated to 120 characters), Email, Reviewed. Sortable by date. Filterable by category (Bug / Feature / General) and reviewed status. Follows existing admin page patterns.
---
## Feedback Form Fields
| Field | Required | Validation |
|---|---|---|
| Category | Yes | One of: Bug report, Feature request, General feedback |
| Message | Yes | 11000 characters |
| Email | No | Valid email format if provided |
`ip_address` and `user_agent` are captured server-side in the Next.js proxy route — never sent from the client form.
---
## SEO Metadata
Both `/changelog` and `/feedback` export a `metadata` object following the existing page pattern:
- `/changelog`: `title: "What's New — Battl Builders"`, relevant description
- `/feedback`: `title: "Send Feedback — Battl Builders"`, relevant description
---
## Out of Scope
- Email notifications when feedback is submitted (separate task)
- Sorting roadmap items by vote count (manual `order` field only for now)
- Public display of vote counts on the changelog tab
- Sanity Studio schema deployment (handled separately via Sanity CLI)
- Spring Boot entity/migration code (lives in `ballistic-builder-spring` repo, tracked separately)
- Rate limiting on the feedback endpoint (deferred; IP capture provides visibility at current scale)
@@ -0,0 +1,262 @@
# Homepage Redesign — Design Spec
**Date:** 2026-03-30
**Status:** Approved
---
## Overview
Replace the current beta-signup homepage with an open, conversion-focused landing page that drives users directly into the builder. Remove all gated beta content. Add a strong hero, explain the product clearly for the ICP (2550 yr old male, firearm experience, not an expert builder), and provide two clear CTAs to start a build.
---
## Goals
- Remove the beta signup form entirely
- Add a strong, centered hero section
- Explain what Battl Builder does in plain language (no jargon)
- Drive users to `/builder` with a clear, prominent CTA
- Add SEO-optimized copy targeting "AR-15 build planner" and "AR-15 compatibility" keywords
- Convert `page.tsx` from a `"use client"` component to a React Server Component (no client state needed without the form)
---
## ICP
**Primary:** 2550 yr old male, owns or has shot an AR-15, wants to build one, not an expert builder. Doesn't know gas system lengths or buffer weights off the top of their head. Gets frustrated by forum rabbit holes and spreadsheets.
**Tone:** Field ethos — direct, confident, no fluff. Sounds like a knowledgeable builder talking to someone who wants to learn. Not military cosplay. Respects the reader's intelligence without assuming expertise.
---
## Implementation Approach
**Two files change: `app/page.tsx` (full rewrite) and `components/Banner.tsx` (one line).**
### `app/page.tsx`
- Remove `"use client"` directive — this becomes a React Server Component. There is no need to extract a client component because the form (the only source of client state) is being deleted entirely. The `metadata` export will work correctly once `"use client"` is removed.
- Remove all beta signup state (`email`, `useCase`, `status`, `message`, `accepted`)
- Remove `handleSubmit`, `TOS_VERSION`, and the `<form>` block
- Remove the `/api/beta-signup` dependency from this page
- Build all 8 sections as pure JSX with no client-side interactivity
- Keep existing amber/zinc/black design tokens and grid background SVG
- Keep `/builder-preview.mp4` video (autoplay, loop, muted, playsInline) with `poster="/builder-preview.png"` as fallback
- Add a page-level `metadata` export (see SEO section below)
- `new Date().getFullYear()` in the footer is safe in an RSC — evaluated at request time
### `components/Banner.tsx`
The `Banner` component currently renders an "Early Access Beta" bar on every non-admin route. Since the homepage is no longer beta-gated, suppress it on `/` by adding `|| pathname === "/"` to the existing exclusion check:
```tsx
if (pathname?.startsWith("/admin") || pathname === "/") return null;
```
No other changes to `Banner.tsx`.
### Route group note
`app/page.tsx` renders under `app/layout.tsx` only — it does **not** belong to the `(app)` route group and does not inherit `TopNav` or `Footer` from `app/(app)/layout.tsx`. The homepage renders its own inline nav and footer directly.
---
## Page-Level Metadata (SEO)
Add the following export to `app/page.tsx`:
```tsx
import type { Metadata } from "next";
export const metadata: Metadata = {
title: { absolute: "Battl Builders — Free AR-15 Build Planner" },
description:
"Pick compatible AR-15 parts, catch mismatches before you order, and compare live prices from real retailers. Free, no account required.",
};
```
*Use `{ absolute: "..." }` to bypass the root layout's `template: "%s — Battl Builders"` pattern — otherwise the title renders as "Battl Builders — Free AR-15 Build Planner — Battl Builders".*
---
## Page Structure (8 Sections)
### 1. Nav
Inline nav baked into `page.tsx` (same pattern as current page).
- **Left:** Battl Builders logo — `<Image src="/battl/battl-logo-mark-f.svg" alt="Battl Builders" width={260} height={48} />`
- **Right (left to right):**
- "Sign In" — ghost style (`border border-zinc-800 bg-zinc-950/60 text-zinc-200`) → `/login`
- "Start a Build" — amber style (`border border-amber-500/70 bg-amber-500/90 text-black font-semibold`) → `/builder`
- *Intentional order change from current page: amber weight now goes to "Start a Build", not "Sign In". This is the primary conversion action.*
---
### 2. Hero
**Layout:** Centered, full-width. Max-width ~760px, `text-center`.
**Badge pill:**
> AR-15 Builder · 100% Free
**H1:**
> Stop Guessing.
> **Start Building.** *(amber — `text-amber-300`)*
**Subhead:**
> 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.
**Primary CTA button** (amber, full-shadow glow, links to `/builder`):
> Start a Build →
**Micro-copy below button:**
> 100% Free · No account required
---
### 3. How It Works
**Section label (small caps):** THE PROCESS
**H2:**
> 3 Steps. One Battl-Ready Build.
**Subhead:**
> No experience required. No part numbers to memorize.
**3 steps in a flush-bordered grid panel (no gap between cards, shared border):**
| Step | Title | Body |
|------|-------|------|
| Step 01 | Pick your parts | Browse real components organized by what they do — barrel, trigger, lower, upper, and more. No prior knowledge required. |
| Step 02 | Flag the conflicts | Gas length, buffer weight, caliber — Battl catches the mismatches before you spend money on the wrong part. |
| Step 03 | Price it. Pull the trigger. | Live prices from real retailers, side by side. Click through to buy. We never touch your order. |
---
### 4. Builder Preview Video
Full-width rounded container with subtle border glow.
```tsx
<video
autoPlay
loop
muted
playsInline
poster="/builder-preview.png"
className="w-full object-cover"
>
<source src="/builder-preview.mp4" type="video/mp4" />
</video>
```
- `poster="/builder-preview.png"` ensures the section always has visible content if the video hasn't loaded or is missing
- Bottom fade gradient overlay: `bg-gradient-to-t from-black` (retain from current implementation)
---
### 5. Feature Cards
**Section label:** WHAT YOU GET
**H2:**
> Built by gun nerds, for the everyday AR builder.
**Subhead (uppercase, muted):**
> NO SUBSCRIPTIONS · NO SPONSORED RANKINGS · NO BULLSHIT
**6 cards in a 3-column flush-bordered grid panel:**
| Heroicon | Title | Body |
|----------|-------|------|
| `WrenchScrewdriverIcon` | Build by role, not part number | Parts organized by function. Know what you need, not what SKU to search for. |
| `ShieldExclamationIcon` | Conflicts caught early | Gas system, buffer weight, caliber — flagged before checkout, not after the box arrives. |
| `CurrencyDollarIcon` | Real prices. Real retailers. | Running total with live prices. No estimated MSRP. What it actually costs, today. |
| `BookmarkSquareIcon` | Save your work | Free account. Save builds, pick up where you left off, run multiple configs in parallel. |
| `LinkIcon` | Share the build | One link. Your buddy sees the full parts list — no login, no friction, no excuses. |
| `LockOpenIcon` | No gates. No friction. | Browse and build freely. Create an account only when you're ready to save. |
All icons are from `@heroicons/react/20/solid`. `LinkIcon`, `LockOpenIcon`, and `BookmarkSquareIcon` are confirmed present in the installed version. `LinkIcon` and `LockOpenIcon` replace `BuildingStorefrontIcon` and `ChatBubbleLeftRightIcon` (the two beta-specific cards being removed).
---
### 6. SEO Copy Block
Separated by a top border. Max-width ~680px, left-aligned prose.
**H2:**
> What is Battl Builders?
**Paragraph 1:**
> Battl Builders is a free **AR-15 build planner** — 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.
**Paragraph 2:**
> Most **AR-15 compatibility mistakes** 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.
**Paragraph 3:**
> 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 **buy with confidence**.
Use `<strong>` tags (or `font-semibold text-zinc-300`) on the bolded terms above to reinforce keyword signal.
**Target keywords:** "AR-15 build planner", "AR-15 compatibility tool", "AR-15 parts list builder"
---
### 7. Final CTA Section
**H2:**
> Your build starts here.
**Subhead:**
> Free. No account required. Takes five minutes to get rolling.
**CTA button** (amber, same style as hero, links to `/builder`):
> Start a Build →
---
### 8. Footer
Stripped down from current version.
- **Left:** `© {new Date().getFullYear()} BATTL BUILDERS™`
- **Right:** "Free AR-15 build planner"
- Remove `v0.1 • Import engine online • Builder UI in progress` version string entirely
---
## Design Tokens (unchanged)
| Token | Value |
|-------|-------|
| Background | `bg-black` / `#09090b` |
| Primary accent | `text-amber-300` / `border-amber-500` |
| CTA button | `bg-amber-500/90``bg-amber-400` on hover |
| Body text | `text-zinc-50` |
| Muted text | `text-zinc-400` / `text-zinc-500` |
| Card background | `bg-zinc-950` / `bg-black/30` |
| Card border | `border-zinc-800` / `border-white/10` |
| Grid background SVG | Retain existing pattern from current `page.tsx` lines 116160 |
---
## Files Changed
| File | Change |
|------|--------|
| `app/page.tsx` | Full rewrite — RSC, 8-section layout, new copy, metadata export |
| `components/Banner.tsx` | One-line change — suppress banner on `/` |
---
## Out of Scope
- Changes to `TopNav`, `Footer`, or any other shared layout component beyond `Banner.tsx`
- Changes to the builder page, auth flow, or any API routes
- Removing the `/api/beta-signup` route (separate cleanup task)
- Mobile-specific breakpoint overrides beyond Tailwind responsive defaults
- Animation or scroll effects
- Any changes to `app/(app)/layout.tsx` or `app/layout.tsx`
+21
View File
@@ -0,0 +1,21 @@
import type { BuilderSlotKey } from "@/types/builderSlots";
export function trackBuildStarted() {
window.umami?.track("build_started");
}
export function trackPartSelected(slot: BuilderSlotKey, partName: string, partId: string) {
window.umami?.track("part_selected", { slot, part_name: partName, part_id: partId });
}
export function trackAffiliateLinkClicked(partName: string, retailer: string) {
window.umami?.track("affiliate_link_clicked", { part_name: partName, retailer });
}
export function trackBuildShared() {
window.umami?.track("build_shared");
}
export function trackBuildSaved() {
window.umami?.track("build_saved");
}
+73
View File
@@ -0,0 +1,73 @@
import { createClient } from 'next-sanity'
import imageUrlBuilder from '@sanity/image-url'
import type { SanityImageSource } from '@sanity/image-url/lib/types/types'
export const projectId = 'zal102rv'
export const dataset = 'production'
export const apiVersion = '2024-01-01'
export const client = createClient({
projectId,
dataset,
apiVersion,
useCdn: false, // false = direct API; avoids CDN DNS issues in dev/server
})
const builder = imageUrlBuilder(client)
export function urlFor(source: SanityImageSource) {
return builder.image(source)
}
// ─── GROQ Queries ────────────────────────────────────────────────────────────
/** All published posts for the listing page, newest first */
export const postsQuery = `
*[_type == "post" && defined(slug.current) && publishedAt <= now()]
| order(publishedAt desc) {
_id,
title,
slug,
publishedAt,
excerpt,
mainImage { ..., alt },
"author": author->{ name, image }
}
`
/** Single post by slug for the detail page */
export const postBySlugQuery = `
*[_type == "post" && slug.current == $slug][0] {
_id,
title,
slug,
publishedAt,
excerpt,
mainImage { ..., alt },
body,
seoTitle,
seoDescription,
"author": author->{ name, image, bio }
}
`
/** All slugs — used for generateStaticParams */
export const postSlugsQuery = `
*[_type == "post" && defined(slug.current)] { "slug": slug.current }
`
/** All changelog entries — published, newest first */
export const changelogEntriesQuery = `
*[_type == "changelogEntry" && publishedAt <= now()]
| order(publishedAt desc) {
_id, title, version, publishedAt, tags, body
}
`
/** Roadmap items — excludes shipped, sorted by manual order */
export const roadmapItemsQuery = `
*[_type == "roadmapItem" && status != "shipped"]
| order(order asc) {
_id, title, description, status, order
}
`
+45
View File
@@ -0,0 +1,45 @@
import https from 'https'
import { projectId, dataset, apiVersion } from './sanity'
const SANITY_IP = '34.102.242.91'
const SANITY_HOST = `${projectId}.api.sanity.io`
export function sanityFetch<T = unknown>(
query: string,
params?: Record<string, unknown>
): Promise<T> {
let path = `/v${apiVersion}/data/query/${dataset}?query=${encodeURIComponent(query)}`
if (params) {
for (const [key, value] of Object.entries(params)) {
path += `&$${key}=${encodeURIComponent(JSON.stringify(value))}`
}
}
return new Promise((resolve, reject) => {
const req = https.request(
{
method: 'GET',
hostname: SANITY_IP,
port: 443,
path,
servername: SANITY_HOST,
headers: { Host: SANITY_HOST, Accept: 'application/json', 'User-Agent': 'battl-builders/1.0' },
},
(res) => {
let data = ''
res.on('data', (c) => (data += c))
res.on('end', () => {
try {
const parsed = JSON.parse(data)
res.statusCode && res.statusCode >= 400
? reject(new Error(`Sanity ${res.statusCode}: ${JSON.stringify(parsed)}`))
: resolve(parsed.result as T)
} catch {
reject(new Error(`Sanity parse error: ${data.slice(0, 200)}`))
}
})
}
)
req.on('error', reject)
req.end()
})
}
+16 -8
View File
@@ -13,6 +13,7 @@ function isPublicPath(pathname: string) {
// Public pages
if (pathname === "/") return true;
if (pathname === "/login") return true;
if (pathname === "/register") return true;
if (pathname === "/tos") return true;
if (pathname === "/privacy") return true;
if (pathname === "/beta") return true;
@@ -20,12 +21,23 @@ function isPublicPath(pathname: string) {
if (pathname.startsWith("/auth")) return true;
if (pathname.startsWith("/api/auth")) return true;
// Content / guides — always public, no auth required
if (pathname === "/guides") return true;
if (pathname.startsWith("/guides/")) return true;
// Changelog and feedback — always public
if (pathname === "/changelog") return true;
if (pathname === "/feedback") return true;
// Static / framework assets
if (pathname.startsWith("/_next")) return true;
if (pathname === "/favicon.ico") return true;
if (pathname === "/robots.txt") return true;
if (pathname === "/sitemap.xml") return true;
// Static images in /public
if (pathname.startsWith("/battl/")) return true;
// Health / misc APIs you may want public
if (pathname === "/api/health") return true;
@@ -45,11 +57,12 @@ function isProtectedLaunchPath(pathname: string) {
export function middleware(req: NextRequest) {
const { pathname } = req.nextUrl;
// Short-circuit: always let public paths through without any token checks.
if (isPublicPath(pathname)) return NextResponse.next();
const token = req.cookies.get("session_token")?.value;
const isLoggedIn = Boolean(token);
console.log("is logged in: " + isLoggedIn);
console.log("MW:", req.nextUrl.pathname)
// IMPORTANT: Use a server-only env var for middleware behavior.
// Set LAUNCH_ONLY_ROOT=true in your Docker compose.
const launchOnlyRoot = process.env.LAUNCH_ONLY_ROOT === "true";
@@ -77,11 +90,6 @@ export function middleware(req: NextRequest) {
if (!isLoggedIn) return redirectToLogin(req);
}
// Protect the builder UI
if (pathname === "/builder" || pathname.startsWith("/builder/")) {
if (!isLoggedIn) return redirectToLogin(req);
}
return NextResponse.next();
}
+12
View File
@@ -6,6 +6,18 @@ const nextConfig = {
eslint: {
ignoreDuringBuilds: true,
},
images: {
remotePatterns: [
{
protocol: 'https',
hostname: 'cdn.sanity.io',
},
{
protocol: 'https',
hostname: 'minio.dev.gofwd.group',
},
],
},
/* async rewrites() {
return [
+12771 -154
View File
File diff suppressed because it is too large Load Diff
+2
View File
@@ -27,9 +27,11 @@
"dependencies": {
"@headlessui/react": "^2.2.9",
"@heroicons/react": "^2.2.0",
"@sanity/image-url": "^1.1.0",
"clsx": "^2.1.1",
"lucide-react": "^0.555.0",
"next": "15.5.11",
"next-sanity": "^9.8.19",
"quill-image-resize-module": "^3.0.0",
"react": "^19.2.4",
"react-dom": "^19.2.4",
+21
View File
@@ -0,0 +1,21 @@
<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 4 3" preserveAspectRatio="xMidYMid meet">
<rect width="4" height="3" fill="#27272a"/>
<text
x="2"
y="1.35"
font-family="system-ui, sans-serif"
font-size="0.28"
fill="#71717a"
text-anchor="middle"
dominant-baseline="middle"
>Image</text>
<text
x="2"
y="1.75"
font-family="system-ui, sans-serif"
font-size="0.28"
fill="#71717a"
text-anchor="middle"
dominant-baseline="middle"
>Coming Soon</text>
</svg>

After

Width:  |  Height:  |  Size: 519 B

-190
View File
@@ -1,190 +0,0 @@
-- ============================================
-- Gunbuilder / AvantLink Schema DDL
-- ============================================
-- Enable extension for UUID generation (if not already enabled)
CREATE EXTENSION IF NOT EXISTS pgcrypto;
-- ============================================
-- 1. merchants
-- ============================================
CREATE TABLE merchants (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL,
avantlink_mid TEXT NOT NULL UNIQUE, -- AvantLink merchant ID
feed_url TEXT NOT NULL, -- Product datafeed URL
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_merchants_active ON merchants (is_active);
-- ============================================
-- 2. part_categories
-- Your internal Gunbuilder categories
-- ============================================
CREATE TABLE part_categories (
id SERIAL PRIMARY KEY,
slug TEXT NOT NULL UNIQUE, -- 'lower', 'upper', 'barrel', etc.
name TEXT NOT NULL, -- 'Lower Receiver'
description TEXT
);
-- ============================================
-- 3. brands
-- ============================================
CREATE TABLE brands (
id SERIAL PRIMARY KEY,
name TEXT NOT NULL UNIQUE, -- 'Aero Precision'
website TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- ============================================
-- 4. products
-- Canonical parts, independent of merchants
-- ============================================
CREATE TABLE products (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
brand_id INTEGER REFERENCES brands(id),
part_category_id INTEGER NOT NULL REFERENCES part_categories(id),
name TEXT NOT NULL,
slug TEXT NOT NULL UNIQUE,
description TEXT,
caliber TEXT,
barrel_length_mm INTEGER,
gas_system TEXT,
handguard_length_mm INTEGER,
image_url TEXT,
spec_sheet_url TEXT,
msrp NUMERIC(10,2),
current_lowest_price NUMERIC(10,2),
current_lowest_merchant_id INTEGER REFERENCES merchants(id),
is_active BOOLEAN NOT NULL DEFAULT TRUE,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_products_category ON products (part_category_id);
CREATE INDEX idx_products_brand ON products (brand_id);
CREATE INDEX idx_products_active ON products (is_active);
CREATE INDEX idx_products_lowest_price ON products (current_lowest_price);
-- Optional: a "natural-ish" uniqueness constraint if you want it
-- (Can be relaxed later if needed)
CREATE UNIQUE INDEX uniq_products_brand_name_cat
ON products (brand_id, name, part_category_id);
-- ============================================
-- 5. product_offers
-- Merchant-specific offers for a product
-- ============================================
CREATE TABLE product_offers (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
product_id UUID NOT NULL REFERENCES products(id) ON DELETE CASCADE,
merchant_id INTEGER NOT NULL REFERENCES merchants(id),
avantlink_product_id TEXT NOT NULL,
sku TEXT,
upc TEXT,
buy_url TEXT NOT NULL,
price NUMERIC(10,2) NOT NULL,
original_price NUMERIC(10,2),
currency TEXT NOT NULL DEFAULT 'USD',
in_stock BOOLEAN NOT NULL DEFAULT TRUE,
last_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
first_seen_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
-- Each merchant + avantlink_product_id combination must be unique
CREATE UNIQUE INDEX uniq_product_offers_merchant_product
ON product_offers (merchant_id, avantlink_product_id);
CREATE INDEX idx_product_offers_product ON product_offers (product_id);
CREATE INDEX idx_product_offers_merchant ON product_offers (merchant_id);
CREATE INDEX idx_product_offers_in_stock ON product_offers (in_stock);
CREATE INDEX idx_product_offers_price ON product_offers (price);
-- ============================================
-- 6. price_history
-- For tracking price changes over time
-- ============================================
CREATE TABLE price_history (
id BIGSERIAL PRIMARY KEY,
product_offer_id UUID NOT NULL REFERENCES product_offers(id) ON DELETE CASCADE,
price NUMERIC(10,2) NOT NULL,
recorded_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX idx_price_history_offer ON price_history (product_offer_id);
CREATE INDEX idx_price_history_recorded_at ON price_history (recorded_at);
-- ============================================
-- 7. category_mappings
-- Map raw AvantLink categories to internal part_categories
-- ============================================
CREATE TABLE category_mappings (
id SERIAL PRIMARY KEY,
merchant_id INTEGER NOT NULL REFERENCES merchants(id),
raw_category_path TEXT NOT NULL, -- e.g. 'Firearms > AR-15 Parts > Lower Receivers'
part_category_id INTEGER NOT NULL REFERENCES part_categories(id),
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (merchant_id, raw_category_path)
);
CREATE INDEX idx_category_mappings_merchant ON category_mappings (merchant_id);
CREATE INDEX idx_category_mappings_part_category ON category_mappings (part_category_id);
-- ============================================
-- 8. feed_imports
-- Track each feed import run for observability
-- ============================================
CREATE TABLE feed_imports (
id BIGSERIAL PRIMARY KEY,
merchant_id INTEGER NOT NULL REFERENCES merchants(id),
started_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
finished_at TIMESTAMPTZ,
rows_total INTEGER,
rows_processed INTEGER,
rows_new INTEGER,
rows_updated INTEGER,
status TEXT NOT NULL DEFAULT 'running', -- 'running','success','error'
error_message TEXT
);
CREATE INDEX idx_feed_imports_merchant ON feed_imports (merchant_id);
CREATE INDEX idx_feed_imports_status ON feed_imports (status);
CREATE INDEX idx_feed_imports_started_at ON feed_imports (started_at);
-- ============================================
-- 9. Helpful seed data (optional)
-- ============================================
-- Part categories seed (adjust as needed)
INSERT INTO part_categories (slug, name) VALUES
('lower', 'Lower Receiver'),
('upper', 'Upper Receiver'),
('barrel', 'Barrel'),
('handguard', 'Handguard'),
('stock', 'Stock'),
('optic', 'Optic')
ON CONFLICT (slug) DO NOTHING;
-- Example merchant skeleton (fill real datafeed URL + AvantLink MID)
-- INSERT INTO merchants (name, avantlink_mid, feed_url)
-- VALUES ('Example Merchant', '12345', 'https://www.avantlink.com/api/...');
+24
View File
@@ -0,0 +1,24 @@
export type ChangelogEntry = {
_id: string
title: string
version: string
publishedAt: string
tags?: string[]
body: unknown[]
}
export type RoadmapItemData = {
_id: string
title: string
description: string
status: 'planned' | 'in-progress'
order: number
}
export type Tab = 'whats-new' | 'whats-next' | 'give-feedback'
export type VoteState = {
count: number
voted: boolean
loading: boolean
}
+5
View File
@@ -0,0 +1,5 @@
interface Window {
umami?: {
track: (eventName: string, properties?: Record<string, string | number | boolean>) => void;
};
}