37 Commits

Author SHA1 Message Date
dstrawsb c0ff28d505 fix 18
CI / deploy (push) Successful in 27s
2026-03-31 22:29:53 -04:00
dstrawsb a66699b450 fix 17
CI / deploy (push) Failing after 31s
2026-03-31 22:12:18 -04:00
dstrawsb ef3343b942 fix 16
CI / deploy (push) Failing after 4s
2026-03-31 22:11:32 -04:00
dstrawsb 5940d60f89 fix 15
CI / deploy (push) Failing after 5s
2026-03-31 22:07:54 -04:00
dstrawsb f52cf3d176 fix 14
CI / deploy (push) Failing after 24s
2026-03-31 22:00:01 -04:00
dstrawsb b1c45bb4b5 fix 13
CI / deploy (push) Failing after 15s
2026-03-31 21:48:15 -04:00
dstrawsb 5c949c1ab8 Merge branch 'develop' of ssh://gitea.gofwd.group:2225/sean/shadow-gunbuilder-ai-proto into develop
CI / deploy (push) Failing after 16s
2026-03-31 21:45:55 -04:00
dstrawsb b91e8fee24 fix 12 2026-03-31 21:45:49 -04:00
dstrawsb 975d7acc39 Update .gitea/workflows/ci.yaml
CI / deploy (push) Failing after 15s
2026-03-31 20:17:57 -04:00
dstrawsb ecef11248b fix 11
CI / deploy (push) Failing after 16s
2026-03-31 18:23:39 -04:00
dstrawsb 7ad4002886 fix 10
CI / deploy (push) Failing after 15s
2026-03-31 17:25:49 -04:00
dstrawsb 544760e19d fix 9
CI / deploy (push) Failing after 14s
2026-03-31 17:17:49 -04:00
dstrawsb 119e366361 fixing ci again 8
CI / deploy (push) Failing after 19s
2026-03-31 17:06:18 -04:00
dstrawsb 2091d14340 fixing ci again 7
CI / deploy (push) Failing after 18s
2026-03-31 16:59:03 -04:00
dstrawsb 10ef062a92 fixing ci again 6
CI / deploy (push) Failing after 16s
2026-03-31 16:57:17 -04:00
dstrawsb 691de6526c fixing ci again 5
CI / deploy (push) Failing after 19s
2026-03-31 16:54:02 -04:00
dstrawsb 677f4dd61c fixing ci again 3
CI / deploy (push) Failing after 20s
2026-03-31 16:48:31 -04:00
dstrawsb 20457af028 fixing ci again
CI / deploy (push) Failing after 21s
2026-03-31 16:34:13 -04:00
dstrawsb 7fc4d92031 fixing ci
CI / deploy (push) Has been cancelled
2026-03-31 16:20:55 -04:00
sean 1aafd08af0 bug fixes. remove builds page for now
CI / deploy (push) Failing after 6s
2026-03-31 14:15:16 -04:00
sean cdb3a8dbb3 v0.1.0 commit
CI / deploy (push) Failing after 5s
2026-03-31 11:31:40 -04:00
sean 0e4c49928b fix: make autoFocus a prop on FeedbackForm, only apply in modal context 2026-03-30 20:51:08 -04:00
sean 82a38a7a15 fix: overflow-x-auto on table, remove unused ipAddress from type 2026-03-30 20:47:20 -04:00
sean ca292c4782 feat: add admin feedback review page and nav link 2026-03-30 19:32:50 -04:00
sean e77e6e4e6d feat: add /feedback page, FeedbackModal to TopNav, and footer link
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:29:12 -04:00
sean 98c2b12340 fix: add aria-labelledby to modal, htmlFor/id to form labels, autoFocus on open 2026-03-30 19:27:21 -04:00
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
35 changed files with 3489 additions and 887 deletions
+14 -1
View File
@@ -9,7 +9,20 @@ jobs:
deploy:
runs-on: ubuntu
steps:
- name: Install Docker and Docker Compose
run: |
apt-get update
# Adding docker-compose-v2 installs the 'docker compose' plugin
apt-get install -y docker.io docker-compose
- name: Login to Docker Hub
run: echo '${{ secrets.DOCKER_PASSWORD }}' | docker login --username "${{ secrets.DOCKER_USERNAME }}" --password-stdin
- uses: actions/checkout@v4
- name: Login to Gitea Registry
run: |
echo "${{ secrets.THE_GITEA_TOKEN }}" | docker login gitea.gofwd.group -u "${{ secrets.THE_GITEA_USERNAME }}" --password-stdin
- name: Pull latest images
run: |
@@ -17,4 +30,4 @@ jobs:
docker pull gitea.gofwd.group/sean/shadow-gunbuilder-ai-proto/webui:latest
- name: Recreate containers with new images
run: docker compose up -d
run: docker-compose up -d -f /opt/docker/battlbuilder/docker-compose.yaml
@@ -0,0 +1 @@
{"reason":"idle timeout","timestamp":1774884294221}
@@ -0,0 +1,10 @@
{"type":"server-started","port":63304,"host":"127.0.0.1","url_host":"localhost","url":"http://localhost:63304","screen_dir":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654"}
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/waiting.html"}
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/waiting-2.html"}
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/changelog-layout.html"}
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/roadmap-tab.html"}
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/roadmap-votes.html"}
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/feedback-form.html"}
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/feedback-form-v2.html"}
{"type":"screen-added","file":"/Users/sean/Dev/battl/gunbuilder-prototype/.superpowers/brainstorm/20960-1774881654/waiting-3.html"}
{"type":"server-stopped","reason":"idle timeout"}
@@ -0,0 +1 @@
20969
@@ -0,0 +1,113 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
.wrap { max-width: 900px; margin: 0 auto; }
h2 { font-size: 13px; font-weight: 600; color: #a1a1aa; margin-bottom: 20px; letter-spacing: 0.05em; text-transform: uppercase; }
/* ── Tabs ── */
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
/* ── Changelog entries ── */
.entries { display: flex; flex-direction: column; gap: 0; }
.entry { display: grid; grid-template-columns: 120px 1fr; gap: 0 32px; border-bottom: 1px solid #18181b; padding: 32px 0; }
.entry:last-child { border-bottom: none; }
.entry-meta { padding-top: 4px; }
.entry-version { font-size: 12px; font-weight: 700; color: #fbbf24; font-family: monospace; margin-bottom: 4px; }
.entry-date { font-size: 11px; color: #3f3f46; }
.entry-title { font-size: 17px; font-weight: 600; color: #fff; margin-bottom: 8px; }
.entry-body { font-size: 13px; color: #71717a; line-height: 1.7; }
.entry-tags { display: flex; gap: 6px; margin-bottom: 10px; flex-wrap: wrap; }
.tag { font-size: 10px; font-weight: 600; padding: 2px 8px; border-radius: 99px; letter-spacing: 0.05em; text-transform: uppercase; }
.tag-feature { background: rgba(251,191,36,0.1); color: #fbbf24; border: 1px solid rgba(251,191,36,0.25); }
.tag-fix { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.2); }
.tag-improvement { background: rgba(147,197,253,0.1); color: #93c5fd; border: 1px solid rgba(147,197,253,0.2); }
/* ── Roadmap items ── */
.roadmap { display: flex; flex-direction: column; gap: 12px; }
.roadmap-item { display: flex; align-items: flex-start; gap: 16px; padding: 20px; border: 1px solid #1c1c1f; border-radius: 10px; background: #0d0d0f; }
.status-dot { width: 8px; height: 8px; border-radius: 50%; flex-shrink: 0; margin-top: 6px; }
.status-planned { background: #3f3f46; }
.status-inprogress { background: #fbbf24; box-shadow: 0 0 8px rgba(251,191,36,0.4); }
.status-shipped { background: #4ade80; }
.roadmap-item-title { font-size: 14px; font-weight: 600; color: #e4e4e7; margin-bottom: 4px; }
.roadmap-item-desc { font-size: 12px; color: #52525b; line-height: 1.6; }
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 8px; border-radius: 99px; letter-spacing: 0.06em; text-transform: uppercase; margin-left: auto; flex-shrink: 0; }
.badge-planned { background: rgba(63,63,70,0.4); color: #71717a; border: 1px solid #27272a; }
.badge-inprogress { background: rgba(251,191,36,0.1); color: #fbbf24; border: 1px solid rgba(251,191,36,0.3); }
.badge-shipped { background: rgba(74,222,128,0.1); color: #4ade80; border: 1px solid rgba(74,222,128,0.2); }
.page-header { margin-bottom: 40px; }
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
.page-sub { font-size: 14px; color: #52525b; }
/* ── Section separators for roadmap ── */
.roadmap-group-label { font-size: 10px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: #3f3f46; margin: 24px 0 10px; }
.roadmap-group-label:first-child { margin-top: 0; }
</style>
<div class="wrap">
<div class="page-header">
<p class="page-label">Battl Builders</p>
<h1 class="page-title">Updates</h1>
<p class="page-sub">What we've shipped and what's coming next.</p>
</div>
<div class="tabs">
<div class="tab active">What's New</div>
<div class="tab">What's Next</div>
<div class="tab">Give Feedback</div>
</div>
<!-- WHAT'S NEW -->
<div id="whats-new">
<div class="entries">
<div class="entry">
<div class="entry-meta">
<div class="entry-version">v0.4</div>
<div class="entry-date">Mar 28, 2026</div>
</div>
<div>
<div class="entry-tags">
<span class="tag tag-feature">Feature</span>
<span class="tag tag-improvement">Improvement</span>
</div>
<div class="entry-title">Homepage redesign + open access</div>
<div class="entry-body">Removed the beta gate. New landing page explains what Battl does without the jargon. Compatibility conflict warnings now surface inline while you build.</div>
</div>
</div>
<div class="entry">
<div class="entry-meta">
<div class="entry-version">v0.3</div>
<div class="entry-date">Mar 14, 2026</div>
</div>
<div>
<div class="entry-tags">
<span class="tag tag-feature">Feature</span>
<span class="tag tag-fix">Fix</span>
</div>
<div class="entry-title">Shareable build links + vault</div>
<div class="entry-body">Save and share builds with a single link. Anyone can view the parts list without an account. Fixed gas system validation that was incorrectly flagging mid-length configs.</div>
</div>
</div>
<div class="entry">
<div class="entry-meta">
<div class="entry-version">v0.2</div>
<div class="entry-date">Feb 28, 2026</div>
</div>
<div>
<div class="entry-tags">
<span class="tag tag-feature">Feature</span>
</div>
<div class="entry-title">Live pricing from real retailers</div>
<div class="entry-body">AvantLink feed integration. Running total updates as you add parts. Prices are pulled daily — no estimated MSRP, no sponsored rankings.</div>
</div>
</div>
</div>
</div>
</div>
@@ -0,0 +1,88 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
.wrap { max-width: 900px; margin: 0 auto; }
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
.page-header { margin-bottom: 40px; }
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
.page-sub { font-size: 14px; color: #52525b; }
.feedback-header { margin-bottom: 28px; }
.feedback-header h2 { font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -0.015em; margin-bottom: 6px; }
.feedback-header p { font-size: 13px; color: #52525b; line-height: 1.6; }
.form-row { display: grid; grid-template-columns: 1fr 1fr; gap: 20px; }
.form-field { margin-bottom: 20px; }
.form-label { font-size: 11px; font-weight: 600; color: #71717a; letter-spacing: 0.05em; text-transform: uppercase; margin-bottom: 8px; display: block; }
.form-input, .form-select, .form-textarea {
width: 100%; padding: 10px 14px; font-size: 13px; font-family: inherit;
background: #0d0d0f; border: 1px solid #27272a; border-radius: 8px;
color: #e4e4e7; outline: none;
}
.form-input:focus, .form-select:focus, .form-textarea:focus { border-color: rgba(251,191,36,0.4); }
.form-textarea { resize: vertical; min-height: 160px; line-height: 1.6; }
.form-select { appearance: none; cursor: pointer; }
.form-hint { font-size: 11px; color: #3f3f46; margin-top: 6px; }
.char-count { font-size: 11px; color: #3f3f46; text-align: right; margin-top: 6px; }
.form-footer { display: flex; align-items: center; justify-content: space-between; margin-top: 4px; }
.form-footer-note { font-size: 11px; color: #3f3f46; }
.btn-submit {
display: inline-flex; align-items: center; gap: 8px;
background: rgba(251,191,36,0.9); color: #000; font-size: 13px; font-weight: 700;
padding: 10px 28px; border-radius: 8px; border: none; cursor: pointer;
letter-spacing: 0.02em; font-family: inherit;
}
.btn-submit:hover { background: #fbbf24; }
</style>
<div class="wrap">
<div class="page-header">
<p class="page-label">Battl Builders</p>
<h1 class="page-title">Updates</h1>
<p class="page-sub">What we've shipped and what's coming next.</p>
</div>
<div class="tabs">
<div class="tab">What's New</div>
<div class="tab">What's Next</div>
<div class="tab active">Give Feedback</div>
</div>
<div class="feedback-header">
<h2>Tell us what you think.</h2>
<p>Bug, feature idea, or general thought — we read everything. Rough language welcome.</p>
</div>
<div class="form-row">
<div class="form-field">
<label class="form-label">Category</label>
<select class="form-select">
<option>Bug report</option>
<option>Feature request</option>
<option>General feedback</option>
</select>
</div>
<div class="form-field">
<label class="form-label">Email <span style="color:#3f3f46; font-weight:400; text-transform:none; letter-spacing:0;">(optional — if you want a reply)</span></label>
<input class="form-input" type="email" placeholder="you@email.com" />
</div>
</div>
<div class="form-field">
<label class="form-label">Message</label>
<textarea class="form-textarea" placeholder="What's on your mind?"></textarea>
<div class="char-count">0 / 1000</div>
</div>
<div class="form-footer">
<span class="form-footer-note">We'll only use your email to follow up on your feedback.</span>
<button class="btn-submit">Send Feedback →</button>
</div>
</div>
@@ -0,0 +1,160 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
.wrap { max-width: 900px; margin: 0 auto; }
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
.page-header { margin-bottom: 40px; }
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
.page-sub { font-size: 14px; color: #52525b; }
/* ── Shared form styles ── */
.form-label { font-size: 11px; font-weight: 600; color: #71717a; letter-spacing: 0.05em; text-transform: uppercase; margin-bottom: 8px; display: block; }
.form-field { margin-bottom: 20px; }
.form-input, .form-select, .form-textarea {
width: 100%; padding: 10px 14px; font-size: 13px; font-family: inherit;
background: #0d0d0f; border: 1px solid #27272a; border-radius: 8px;
color: #e4e4e7; outline: none;
}
.form-input:focus, .form-select:focus, .form-textarea:focus { border-color: rgba(251,191,36,0.4); }
.form-textarea { resize: vertical; min-height: 120px; line-height: 1.6; }
.form-select { appearance: none; cursor: pointer; }
.form-hint { font-size: 11px; color: #3f3f46; margin-top: 6px; }
.btn-submit {
display: inline-flex; align-items: center; gap-8px; gap: 8px;
background: rgba(251,191,36,0.9); color: #000; font-size: 13px; font-weight: 700;
padding: 10px 24px; border-radius: 8px; border: none; cursor: pointer;
letter-spacing: 0.02em; font-family: inherit;
}
.btn-submit:hover { background: #fbbf24; }
/* ── Split view ── */
.split { display: grid; grid-template-columns: 1fr 1fr; gap: 32px; align-items: start; }
/* ── Page form (tab) ── */
.feedback-page { max-width: 560px; }
.feedback-page-header { margin-bottom: 28px; }
.feedback-page-header h2 { font-size: 20px; font-weight: 700; color: #fff; letter-spacing: -0.015em; margin-bottom: 6px; }
.feedback-page-header p { font-size: 13px; color: #52525b; line-height: 1.6; }
/* ── Modal ── */
.modal-wrap { position: relative; }
.modal-label { font-size: 10px; font-weight: 700; letter-spacing: 0.15em; text-transform: uppercase; color: #52525b; margin-bottom: 12px; text-align: center; }
.modal {
background: #111113; border: 1px solid #27272a; border-radius: 14px;
padding: 28px; width: 100%;
box-shadow: 0 24px 80px rgba(0,0,0,0.6);
}
.modal-header { display: flex; align-items: center; justify-content: space-between; margin-bottom: 20px; }
.modal-title { font-size: 16px; font-weight: 700; color: #fff; letter-spacing: -0.01em; }
.modal-close { font-size: 18px; color: #3f3f46; cursor: pointer; background: none; border: none; font-family: inherit; padding: 0; line-height: 1; }
.modal-close:hover { color: #71717a; }
/* Nav trigger button (shown for context) */
.nav-context { display: flex; align-items: center; justify-content: flex-end; gap: 8px; margin-bottom: 12px; padding-bottom: 12px; border-bottom: 1px solid #18181b; }
.nav-context-label { font-size: 10px; color: #3f3f46; letter-spacing: 0.05em; }
.btn-feedback-nav {
padding: 6px 12px; border-radius: 6px; font-size: 11px; font-weight: 600;
border: 1px solid #27272a; color: #71717a; background: transparent;
cursor: pointer; font-family: inherit; letter-spacing: 0.02em;
}
.divider { width: 1px; background: #18181b; align-self: stretch; }
.char-count { font-size: 11px; color: #3f3f46; text-align: right; margin-top: 6px; }
</style>
<div class="wrap">
<div class="page-header">
<p class="page-label">Battl Builders</p>
<h1 class="page-title">Updates</h1>
<p class="page-sub">What we've shipped and what's coming next.</p>
</div>
<div class="tabs">
<div class="tab">What's New</div>
<div class="tab">What's Next</div>
<div class="tab active">Give Feedback</div>
</div>
<div class="split">
<!-- LEFT: Tab / page form -->
<div>
<p class="modal-label" style="text-align:left; margin-bottom: 20px;">Give Feedback tab (full page)</p>
<div class="feedback-page">
<div class="feedback-page-header">
<h2>Tell us what you think.</h2>
<p>Bug, feature idea, or general thought — we read everything. Rough language welcome.</p>
</div>
<div class="form-field">
<label class="form-label">Category</label>
<select class="form-select">
<option>Bug report</option>
<option>Feature request</option>
<option>General feedback</option>
</select>
</div>
<div class="form-field">
<label class="form-label">Message</label>
<textarea class="form-textarea" placeholder="What's on your mind?"></textarea>
<div class="char-count">0 / 1000</div>
</div>
<div class="form-field">
<label class="form-label">Email <span style="color:#3f3f46; font-weight:400; text-transform:none; letter-spacing:0;">(optional — if you want a reply)</span></label>
<input class="form-input" type="email" placeholder="you@email.com" />
<p class="form-hint">We'll only use this to follow up on your feedback.</p>
</div>
<button class="btn-submit">Send Feedback →</button>
</div>
</div>
<div class="divider"></div>
<!-- RIGHT: Modal (triggered from nav/footer) -->
<div>
<div class="nav-context">
<span class="nav-context-label">Triggered from nav/footer →</span>
<button class="btn-feedback-nav">Send Feedback</button>
</div>
<p class="modal-label">Modal</p>
<div class="modal">
<div class="modal-header">
<span class="modal-title">Send Feedback</span>
<button class="modal-close"></button>
</div>
<div class="form-field">
<label class="form-label">Category</label>
<select class="form-select">
<option>Bug report</option>
<option>Feature request</option>
<option>General feedback</option>
</select>
</div>
<div class="form-field">
<label class="form-label">Message</label>
<textarea class="form-textarea" style="min-height:100px;" placeholder="What's on your mind?"></textarea>
<div class="char-count">0 / 1000</div>
</div>
<div class="form-field">
<label class="form-label">Email <span style="color:#3f3f46; font-weight:400; text-transform:none; letter-spacing:0;">(optional)</span></label>
<input class="form-input" type="email" placeholder="you@email.com" />
</div>
<button class="btn-submit" style="width:100%; justify-content:center;">Send Feedback →</button>
</div>
</div>
</div>
</div>
@@ -0,0 +1,104 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
.wrap { max-width: 900px; margin: 0 auto; }
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
.page-header { margin-bottom: 40px; }
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
.page-sub { font-size: 14px; color: #52525b; }
.roadmap { display: flex; flex-direction: column; gap: 10px; }
.roadmap-item { display: flex; align-items: flex-start; gap: 16px; padding: 20px; border: 1px solid #1c1c1f; border-radius: 10px; background: #0d0d0f; }
.dot-col { display: flex; flex-direction: column; align-items: center; gap: 0; padding-top: 5px; flex-shrink: 0; }
.status-dot { width: 9px; height: 9px; border-radius: 50%; flex-shrink: 0; }
.status-planned .status-dot { background: #3f3f46; }
.status-inprogress .status-dot { background: #fbbf24; box-shadow: 0 0 10px rgba(251,191,36,0.5); }
.status-shipped .status-dot { background: #4ade80; }
.roadmap-body { flex: 1; }
.roadmap-item-title { font-size: 14px; font-weight: 600; color: #e4e4e7; margin-bottom: 4px; }
.roadmap-item-desc { font-size: 12px; color: #52525b; line-height: 1.65; }
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 9px; border-radius: 99px; letter-spacing: 0.06em; text-transform: uppercase; flex-shrink: 0; margin-top: 2px; }
.status-planned .status-badge { background: rgba(63,63,70,0.4); color: #52525b; border: 1px solid #27272a; }
.status-inprogress .status-badge { background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.25); }
.status-shipped .status-badge { background: rgba(74,222,128,0.08); color: #4ade80; border: 1px solid rgba(74,222,128,0.2); }
.group-label { font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: #3f3f46; margin: 28px 0 10px; }
.group-label:first-child { margin-top: 0; }
.roadmap-note { margin-top: 32px; padding: 16px 20px; border: 1px solid #18181b; border-radius: 8px; font-size: 12px; color: #3f3f46; line-height: 1.6; }
.roadmap-note a { color: #52525b; text-decoration: underline; text-underline-offset: 3px; }
</style>
<div class="wrap">
<div class="page-header">
<p class="page-label">Battl Builders</p>
<h1 class="page-title">Updates</h1>
<p class="page-sub">What we've shipped and what's coming next.</p>
</div>
<div class="tabs">
<div class="tab">What's New</div>
<div class="tab active">What's Next</div>
<div class="tab">Give Feedback</div>
</div>
<div class="roadmap">
<div class="group-label">In Progress</div>
<div class="roadmap-item status-inprogress">
<div class="dot-col"><span class="status-dot"></span></div>
<div class="roadmap-body">
<div class="roadmap-item-title">Compatibility engine — gas system + buffer</div>
<div class="roadmap-item-desc">Automatic warnings when barrel length and gas system length don't match, and when buffer weight conflicts with your stock/brace selection.</div>
</div>
<span class="status-badge">In Progress</span>
</div>
<div class="roadmap-item status-inprogress">
<div class="dot-col"><span class="status-dot"></span></div>
<div class="roadmap-body">
<div class="roadmap-item-title">Mobile builder experience</div>
<div class="roadmap-item-desc">Full 375px breakpoint pass on the builder, parts browser, and build summary. Lighthouse score target ≥70.</div>
</div>
<span class="status-badge">In Progress</span>
</div>
<div class="group-label">Planned</div>
<div class="roadmap-item status-planned">
<div class="dot-col"><span class="status-dot"></span></div>
<div class="roadmap-body">
<div class="roadmap-item-title">Expanded parts catalog</div>
<div class="roadmap-item-desc">5075 SKUs across all 10 slots, 35 options per category, with live AvantLink pricing.</div>
</div>
<span class="status-badge">Planned</span>
</div>
<div class="roadmap-item status-planned">
<div class="dot-col"><span class="status-dot"></span></div>
<div class="roadmap-body">
<div class="roadmap-item-title">Build guides — first wave</div>
<div class="roadmap-item-desc">Three long-form guides targeting "AR-15 build planner" and related keywords. Written for the ICP: experienced shooter, not an expert builder.</div>
</div>
<span class="status-badge">Planned</span>
</div>
<div class="roadmap-item status-planned">
<div class="dot-col"><span class="status-dot"></span></div>
<div class="roadmap-body">
<div class="roadmap-item-title">Caliber cross-compatibility checks</div>
<div class="roadmap-item-desc">Flag mismatches between upper and lower when mixing calibers (e.g., .300 Blackout upper on a 5.56 lower).</div>
</div>
<span class="status-badge">Planned</span>
</div>
<div class="roadmap-note">
Have something you want to see? <a href="#">Give us feedback →</a>
</div>
</div>
</div>
@@ -0,0 +1,150 @@
<style>
* { box-sizing: border-box; margin: 0; padding: 0; }
body { background: #09090b; color: #f4f4f5; font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; padding: 32px 24px; }
.wrap { max-width: 900px; margin: 0 auto; }
.tabs { display: flex; gap: 0; border-bottom: 1px solid #27272a; margin-bottom: 40px; }
.tab { padding: 10px 20px; font-size: 13px; font-weight: 500; color: #71717a; border-bottom: 2px solid transparent; margin-bottom: -1px; cursor: pointer; }
.tab.active { color: #fbbf24; border-bottom-color: #fbbf24; }
.page-header { margin-bottom: 40px; }
.page-label { font-size: 10px; font-weight: 700; letter-spacing: 0.2em; text-transform: uppercase; color: #52525b; margin-bottom: 8px; }
.page-title { font-size: 28px; font-weight: 700; color: #fff; letter-spacing: -0.02em; margin-bottom: 6px; }
.page-sub { font-size: 14px; color: #52525b; }
.roadmap { display: flex; flex-direction: column; gap: 10px; }
.roadmap-item {
display: grid;
grid-template-columns: 52px 1fr auto;
gap: 0 16px;
align-items: center;
padding: 20px;
border: 1px solid #1c1c1f;
border-radius: 10px;
background: #0d0d0f;
}
/* Upvote button */
.upvote {
display: flex; flex-direction: column; align-items: center; justify-content: center;
gap: 3px; width: 44px; height: 52px;
border: 1px solid #27272a; border-radius: 8px; background: transparent;
cursor: pointer; color: #52525b; transition: all 0.15s;
font-size: 11px; font-weight: 700; font-family: inherit;
}
.upvote:hover { border-color: #fbbf24; color: #fbbf24; background: rgba(251,191,36,0.06); }
.upvote.voted { border-color: rgba(251,191,36,0.4); color: #fbbf24; background: rgba(251,191,36,0.08); }
.upvote svg { width: 14px; height: 14px; }
.roadmap-body { }
.roadmap-item-title { font-size: 14px; font-weight: 600; color: #e4e4e7; margin-bottom: 4px; }
.roadmap-item-desc { font-size: 12px; color: #52525b; line-height: 1.65; }
.right-col { display: flex; flex-direction: column; align-items: flex-end; gap: 8px; }
.status-badge { font-size: 10px; font-weight: 700; padding: 2px 9px; border-radius: 99px; letter-spacing: 0.06em; text-transform: uppercase; white-space: nowrap; }
.badge-planned { background: rgba(63,63,70,0.4); color: #52525b; border: 1px solid #27272a; }
.badge-inprogress { background: rgba(251,191,36,0.08); color: #fbbf24; border: 1px solid rgba(251,191,36,0.25); }
.group-label { font-size: 10px; font-weight: 700; letter-spacing: 0.18em; text-transform: uppercase; color: #3f3f46; margin: 28px 0 10px; }
.group-label:first-child { margin-top: 0; }
.roadmap-note { margin-top: 32px; padding: 16px 20px; border: 1px solid #18181b; border-radius: 8px; font-size: 12px; color: #3f3f46; line-height: 1.6; }
.roadmap-note a { color: #52525b; text-decoration: underline; text-underline-offset: 3px; }
.auth-hint { font-size: 10px; color: #3f3f46; margin-top: 4px; }
</style>
<div class="wrap">
<div class="page-header">
<p class="page-label">Battl Builders</p>
<h1 class="page-title">Updates</h1>
<p class="page-sub">What we've shipped and what's coming next.</p>
</div>
<div class="tabs">
<div class="tab">What's New</div>
<div class="tab active">What's Next</div>
<div class="tab">Give Feedback</div>
</div>
<div class="roadmap">
<div class="group-label">In Progress</div>
<div class="roadmap-item">
<button class="upvote voted" title="Remove vote">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
24
</button>
<div class="roadmap-body">
<div class="roadmap-item-title">Compatibility engine — gas system + buffer</div>
<div class="roadmap-item-desc">Automatic warnings when barrel length and gas system length don't match, and when buffer weight conflicts with your stock or brace selection.</div>
</div>
<div class="right-col">
<span class="status-badge badge-inprogress">In Progress</span>
</div>
</div>
<div class="roadmap-item">
<button class="upvote" title="Upvote">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
11
</button>
<div class="roadmap-body">
<div class="roadmap-item-title">Mobile builder experience</div>
<div class="roadmap-item-desc">Full 375px breakpoint pass on the builder, parts browser, and build summary. Lighthouse score target ≥70.</div>
</div>
<div class="right-col">
<span class="status-badge badge-inprogress">In Progress</span>
</div>
</div>
<div class="group-label">Planned</div>
<div class="roadmap-item">
<button class="upvote" title="Upvote">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
38
</button>
<div class="roadmap-body">
<div class="roadmap-item-title">Expanded parts catalog</div>
<div class="roadmap-item-desc">5075 SKUs across all 10 slots, 35 options per category, with live AvantLink pricing.</div>
</div>
<div class="right-col">
<span class="status-badge badge-planned">Planned</span>
</div>
</div>
<div class="roadmap-item">
<button class="upvote" title="Upvote">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
17
</button>
<div class="roadmap-body">
<div class="roadmap-item-title">Caliber cross-compatibility checks</div>
<div class="roadmap-item-desc">Flag mismatches between upper and lower when mixing calibers (e.g., .300 Blackout upper on a 5.56 lower).</div>
</div>
<div class="right-col">
<span class="status-badge badge-planned">Planned</span>
</div>
</div>
<div class="roadmap-item">
<button class="upvote" title="Upvote">
<svg viewBox="0 0 16 16" fill="currentColor"><path d="M8 2l5 7H3z"/></svg>
9
</button>
<div class="roadmap-body">
<div class="roadmap-item-title">Build guides — first wave</div>
<div class="roadmap-item-desc">Three long-form guides targeting "AR-15 build planner" and related keywords. Written for the ICP: experienced shooter, not an expert builder.</div>
</div>
<div class="right-col">
<span class="status-badge badge-planned">Planned</span>
</div>
</div>
<div class="roadmap-note">
Don't see what you need? <a href="#">Give us feedback →</a>
</div>
</div>
</div>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
@@ -0,0 +1,3 @@
<div style="display:flex;align-items:center;justify-content:center;min-height:60vh">
<p class="subtitle">Continuing in terminal...</p>
</div>
+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>
)
}
+27
View File
@@ -0,0 +1,27 @@
import type { Metadata } from 'next'
import { FeedbackForm } from '@/components/FeedbackForm'
export const metadata: Metadata = {
title: 'Send Feedback — Battl Builders',
description:
'Report a bug, suggest a feature, or share a thought with the Battl Builders team.',
}
export default function FeedbackPage() {
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<header className="mb-10">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
Battl Builders
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">
Send Feedback
</h1>
<p className="mt-2 text-sm text-zinc-400">
Bug, feature idea, or general thought we read everything.
</p>
</header>
<FeedbackForm />
</main>
)
}
+194
View File
@@ -0,0 +1,194 @@
'use client'
import { useEffect, useState } from 'react'
import { useAuth } from '@/context/AuthContext'
type FeedbackSubmission = {
id: string
category: 'BUG' | 'FEATURE_REQUEST' | 'GENERAL'
message: string
email: string | null
// ipAddress is returned by the API but not displayed in the table (privacy/noise)
reviewed: boolean
createdAt: string
}
const CATEGORY_LABELS: Record<string, string> = {
BUG: 'Bug',
FEATURE_REQUEST: 'Feature Request',
GENERAL: 'General',
}
const CATEGORY_STYLES: Record<string, string> = {
BUG: 'bg-red-500/10 text-red-400 border border-red-500/20',
FEATURE_REQUEST: 'bg-amber-500/10 text-amber-400 border border-amber-500/20',
GENERAL: 'bg-zinc-800 text-zinc-400 border border-zinc-700',
}
function formatDate(iso: string) {
return new Date(iso).toLocaleDateString('en-US', {
year: 'numeric',
month: 'short',
day: 'numeric',
})
}
export default function AdminFeedbackPage() {
const { getAuthHeaders } = useAuth()
const [items, setItems] = useState<FeedbackSubmission[]>([])
const [loading, setLoading] = useState(true)
const [error, setError] = useState<string | null>(null)
const [categoryFilter, setCategoryFilter] = useState('ALL')
const [reviewedFilter, setReviewedFilter] = useState('ALL')
async function load() {
try {
setLoading(true)
setError(null)
const res = await fetch('/api/admin/feedback', {
headers: getAuthHeaders(),
credentials: 'include',
})
if (!res.ok) throw new Error(`Failed to load feedback (${res.status})`)
setItems(await res.json())
} catch (e: unknown) {
setError(e instanceof Error ? e.message : 'Failed to load')
} finally {
setLoading(false)
}
}
useEffect(() => {
load()
}, []) // eslint-disable-line react-hooks/exhaustive-deps
async function toggleReviewed(item: FeedbackSubmission) {
const prev = items
setItems((cur) =>
cur.map((i) => (i.id === item.id ? { ...i, reviewed: !i.reviewed } : i))
)
try {
const res = await fetch(`/api/admin/feedback/${item.id}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json', ...getAuthHeaders() },
credentials: 'include',
body: JSON.stringify({ reviewed: !item.reviewed }),
})
if (!res.ok) setItems(prev)
} catch {
setItems(prev)
}
}
const filtered = items.filter((i) => {
if (categoryFilter !== 'ALL' && i.category !== categoryFilter) return false
if (reviewedFilter === 'REVIEWED' && !i.reviewed) return false
if (reviewedFilter === 'UNREVIEWED' && i.reviewed) return false
return true
})
return (
<div>
<div className="mb-6 flex items-center justify-between">
<div>
<h1 className="text-xl font-semibold tracking-tight">Feedback</h1>
<p className="mt-1 text-sm text-zinc-400">
User-submitted feedback. Mark items reviewed to track triage.
</p>
</div>
<button
onClick={load}
className="rounded-md border border-zinc-800 px-3 py-1.5 text-xs text-zinc-400 hover:text-zinc-200"
>
Refresh
</button>
</div>
<div className="mb-6 flex gap-3">
<select
value={categoryFilter}
onChange={(e) => setCategoryFilter(e.target.value)}
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300"
>
<option value="ALL">All categories</option>
<option value="BUG">Bug</option>
<option value="FEATURE_REQUEST">Feature Request</option>
<option value="GENERAL">General</option>
</select>
<select
value={reviewedFilter}
onChange={(e) => setReviewedFilter(e.target.value)}
className="rounded-md border border-zinc-800 bg-zinc-950 px-3 py-1.5 text-xs text-zinc-300"
>
<option value="ALL">All statuses</option>
<option value="UNREVIEWED">Unreviewed</option>
<option value="REVIEWED">Reviewed</option>
</select>
<span className="ml-auto self-center text-xs text-zinc-600">
{filtered.length} of {items.length} submissions
</span>
</div>
{loading && <p className="text-sm text-zinc-500">Loading</p>}
{error && <p className="text-sm text-red-400">{error}</p>}
{!loading && !error && filtered.length === 0 && (
<p className="text-sm text-zinc-500">No submissions match your filters.</p>
)}
{!loading && !error && filtered.length > 0 && (
<div className="overflow-x-auto rounded-xl border border-zinc-800">
<table className="w-full text-sm">
<thead>
<tr className="border-b border-zinc-800 text-left text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
<th className="px-4 py-3">Date</th>
<th className="px-4 py-3">Category</th>
<th className="px-4 py-3">Message</th>
<th className="px-4 py-3">Email</th>
<th className="px-4 py-3">Reviewed</th>
</tr>
</thead>
<tbody className="divide-y divide-zinc-900">
{filtered.map((item) => (
<tr key={item.id} className="hover:bg-zinc-950/60">
<td className="whitespace-nowrap px-4 py-3 text-xs text-zinc-500">
{formatDate(item.createdAt)}
</td>
<td className="px-4 py-3">
<span
className={`rounded-full px-2 py-0.5 text-[10px] font-bold uppercase tracking-wide ${
CATEGORY_STYLES[item.category]
}`}
>
{CATEGORY_LABELS[item.category]}
</span>
</td>
<td className="max-w-sm px-4 py-3 text-xs text-zinc-300">
{item.message.length > 120
? `${item.message.slice(0, 120)}`
: item.message}
</td>
<td className="px-4 py-3 text-xs text-zinc-500">
{item.email ?? '—'}
</td>
<td className="px-4 py-3">
<button
onClick={() => toggleReviewed(item)}
className={`rounded px-2 py-0.5 text-[10px] font-semibold transition-colors ${
item.reviewed
? 'bg-green-500/10 text-green-400 hover:bg-red-500/10 hover:text-red-400'
: 'bg-zinc-800 text-zinc-500 hover:bg-green-500/10 hover:text-green-400'
}`}
>
{item.reviewed ? 'Reviewed' : 'Mark reviewed'}
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
)}
</div>
)
}
+6
View File
@@ -19,6 +19,7 @@ import {
Wand2,
FolderKanban,
Shield,
MessageSquare,
} from "lucide-react";
const navGroups: AdminNavGroup[] = [
@@ -89,6 +90,11 @@ const navGroups: AdminNavGroup[] = [
href: "/admin/email/send",
icon: <Mail className="h-4 w-4" />,
},
{
label: "Feedback",
href: "/admin/feedback",
icon: <MessageSquare className="h-4 w-4" />,
},
],
},
{
+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 })
}
}
+22
View File
@@ -0,0 +1,22 @@
import { NextRequest, NextResponse } from 'next/server'
import { springProxy } from '@/lib/springProxy'
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'
return springProxy(req, '/api/v1/feedback', {
method: 'POST',
body: { ...(body as object), ipAddress: ip, userAgent },
})
}
+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({})
}
}
+4 -402
View File
@@ -1,404 +1,6 @@
// app/builds/[buildId]/page.tsx
import Link from "next/link";
import { notFound } from "next/navigation";
import { ProductImage } from "@/components/parts/ProductImage";
import { redirect } from "next/navigation";
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
function formatMoney(n?: number | null) {
if (n == null) return "—";
return new Intl.NumberFormat("en-US", {
style: "currency",
currency: "USD",
maximumFractionDigits: 0,
}).format(n);
}
function timeAgo(iso?: string | null) {
if (!iso) return "";
const d = new Date(iso);
const diff = Math.max(0, Date.now() - d.getTime());
const mins = Math.floor(diff / 60000);
if (mins < 60) return `${mins}m ago`;
const hrs = Math.floor(mins / 60);
if (hrs < 24) return `${hrs}h ago`;
const days = Math.floor(hrs / 24);
return `${days}d ago`;
}
export default async function BuildBreakdownPage({
params,
}: {
params: Promise<{ buildId: string }>;
}) {
const { buildId } = await params;
// Public detail endpoint - use Next.js API route
const baseUrl = process.env.NEXT_PUBLIC_APP_URL || 'http://localhost:3000';
const res = await fetch(`${baseUrl}/api/builds/public/${buildId}`, {
cache: "no-store",
});
if (res.status === 404) return notFound();
const build = await res.json().catch(() => null);
if (!build) return notFound();
const items: any[] = build.items ?? [];
const total = items.reduce((sum, it) => {
const qty = Number(it.quantity ?? 1);
const price = it.bestPrice == null ? 0 : Number(it.bestPrice);
return sum + qty * price;
}, 0);
const images: any[] = build.images ?? build.photos ?? build.media ?? [];
return (
<main className="mx-auto max-w-6xl px-4 py-8">
{/* Header crumb */}
<div className="flex items-center justify-between">
<Link
href="/builds"
className="text-sm text-zinc-400 hover:text-zinc-200"
>
Back to Community Builds
</Link>
<div className="flex items-center gap-2 text-[11px] text-zinc-500">
<span className="rounded-full border border-zinc-800 bg-zinc-950 px-2 py-0.5">
Public Build
</span>
{build.updatedAt && (
<span className="hidden md:inline">
updated {timeAgo(build.updatedAt)}
</span>
)}
</div>
</div>
{/* Layout */}
<div className="mt-6 grid grid-cols-1 gap-6 lg:grid-cols-[1fr_320px]">
{/* Post column */}
<div className="space-y-4">
{/* “Reddit-ish” Post Card */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60">
<div className="flex">
{/* Vote rail */}
<div className="flex w-12 flex-col items-center gap-2 border-r border-zinc-900 bg-black/20 py-4 text-zinc-500">
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
</button>
<div className="text-xs font-semibold text-zinc-400"></div>
<button className="rounded-md px-2 py-1 hover:bg-zinc-900 hover:text-amber-300">
</button>
</div>
{/* Post body */}
<div className="flex-1 p-5">
{/* Meta */}
<div className="flex flex-wrap items-center gap-x-2 gap-y-1 text-[11px] text-zinc-500">
<span className="rounded-full border border-amber-500/30 bg-amber-500/10 px-2 py-0.5 text-amber-300">
r/BattlBuilders
</span>
<span></span>
<span>posted by</span>
<span className="text-zinc-300">u/anonymous</span>
{build.createdAt && (
<>
<span></span>
<span>{timeAgo(build.createdAt)}</span>
</>
)}
</div>
{/* Title */}
<h1 className="mt-3 text-2xl font-semibold tracking-tight text-zinc-50">
{build.title ?? "Untitled Build"}
</h1>
{/* Description */}
{build.description ? (
<p className="mt-2 text-sm text-zinc-300/90">
{build.description}
</p>
) : (
<p className="mt-2 text-sm text-zinc-500">
{/* placeholder copy */}
Field notes coming soon. This build breakdown will include
purpose, tradeoffs, and why each part made the cut.
</p>
)}
{/* Action bar */}
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Comment
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Share
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-300 hover:border-amber-500/40 hover:text-amber-300">
Save
</button>
<div className="ml-auto flex items-center gap-2 text-zinc-500">
<span className="hidden sm:inline">Est. Total</span>
<span className="rounded-md border border-zinc-800 bg-black/30 px-2 py-1 text-zinc-200">
{formatMoney(total)}
</span>
</div>
</div>
</div>
</div>
</div>
{/* Parts card */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">Parts</h2>
<div className="text-xs text-zinc-500">{items.length} items</div>
</div>
<div className="mt-4 space-y-2">
{items.map((it) => (
<div
key={it.uuid ?? it.id}
className="flex items-center gap-3 rounded-lg border border-zinc-900 bg-black/30 px-3 py-3"
>
{/* Thumb */}
<div className="h-12 w-12 flex-none overflow-hidden rounded-md border border-zinc-800 bg-zinc-950">
<ProductImage
src={it.productImageUrl}
alt={it.productName ?? "Part image"}
className="h-full w-full object-cover"
/>
</div>
{/* Info */}
<div className="min-w-0 flex-1">
<div className="truncate text-sm text-zinc-100">
{it.productName ?? "Part"}
</div>
<div className="mt-0.5 flex flex-wrap items-center gap-2 text-[11px] text-zinc-500">
<span className="rounded-md border border-zinc-800 bg-black/20 px-1.5 py-0.5">
{it.slot ?? "slot"}
</span>
{it.productBrand && <span>{it.productBrand}</span>}
<span className="text-zinc-600"></span>
<span>x{it.quantity ?? 1}</span>
</div>
</div>
{/* Price */}
<div className="flex flex-col items-end gap-1">
<div className="text-sm font-medium text-zinc-200">
{formatMoney(it.bestPrice)}
</div>
<button className="text-[11px] text-amber-300/90 hover:text-amber-200">
View part
</button>
</div>
</div>
))}
</div>
</div>
{/* Gallery */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">
Gallery
</h2>
<span className="text-xs text-zinc-500">user submitted</span>
</div>
{images.length === 0 ? (
<div className="mt-4 rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4">
<p className="text-sm text-zinc-400">
No photos yet. Be the first to add a build pic.
</p>
<p className="mt-1 text-[11px] text-zinc-600">
(Upload UI coming soon well store and moderate images
per build.)
</p>
{/* Mock thumbnails */}
<div className="mt-4 grid grid-cols-3 gap-2 sm:grid-cols-4">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="aspect-square rounded-md border border-zinc-800 bg-zinc-950 flex items-center justify-center text-[10px] text-zinc-600"
>
IMG
</div>
))}
</div>
<button
className="mt-4 rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
disabled
title="Upload UI coming soon"
>
Add Photo (coming soon)
</button>
</div>
) : (
<div className="mt-4">
{/* “Hero” preview */}
<div className="aspect-[16/9] overflow-hidden rounded-lg border border-zinc-900 bg-black/30">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={images[0].url ?? images[0]}
alt="Build photo"
className="h-full w-full object-cover"
/>
</div>
{/* Thumbs */}
<div className="mt-3 grid grid-cols-4 gap-2 sm:grid-cols-6">
{images.slice(0, 12).map((img, idx) => (
<div
key={img.uuid ?? img.id ?? idx}
className="aspect-square overflow-hidden rounded-md border border-zinc-900 bg-black/30"
>
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={img.thumbUrl ?? img.url ?? img}
alt={`Build photo ${idx + 1}`}
className="h-full w-full object-cover"
/>
</div>
))}
</div>
<div className="mt-4 flex flex-wrap items-center gap-2 text-xs">
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
View all
</button>
<button
className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-zinc-200 hover:border-amber-500/40 hover:text-amber-300"
disabled
title="Upload UI coming soon"
>
Add Photo
</button>
<span className="ml-auto text-[11px] text-zinc-600">
{images.length} photos
</span>
</div>
</div>
)}
</div>
{/* Comments (placeholder) */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<div className="flex items-center justify-between">
<h2 className="text-sm font-semibold text-zinc-200">Comments</h2>
<span className="text-xs text-zinc-500">mocked</span>
</div>
<div className="mt-4 space-y-4">
{[
{
user: "u/gearhead",
body: "Solid parts list. Any reason you went with that upper over a BCM?",
},
{
user: "u/OP",
body: "Mostly availability + price. Ill probably swap once I track deals for a week.",
},
{
user: "u/boltcarrier",
body: "Would love to see this with a pinned/weld option for 14.5 builds.",
},
].map((c, idx) => (
<div key={idx} className="flex gap-3">
<div className="mt-1 h-6 w-6 rounded-full bg-zinc-900 text-[10px] text-zinc-300 flex items-center justify-center">
{c.user === "u/OP" ? "OP" : "u"}
</div>
<div className="flex-1">
<div className="text-[11px] text-zinc-500">
<span className="text-zinc-300">{c.user}</span> 1h ago
</div>
<div className="mt-1 text-sm text-zinc-300/90">
{c.body}
</div>
<div className="mt-2 flex items-center gap-3 text-[11px] text-zinc-500">
<button className="hover:text-amber-300">Reply</button>
<button className="hover:text-amber-300">Share</button>
<button className="hover:text-amber-300">Save</button>
</div>
</div>
</div>
))}
<div className="rounded-lg border border-dashed border-zinc-800 bg-black/20 p-4 text-sm text-zinc-500">
Commenting is coming soon. This will become the breakdown
thread for the build (notes, tradeoffs, deal alerts, revisions).
</div>
</div>
</div>
</div>
{/* Sidebar */}
<aside className="space-y-4">
{/* Build stats */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
Build Summary
</h3>
<div className="mt-3 space-y-2 text-sm">
<div className="flex items-center justify-between">
<span className="text-zinc-500">Build ID</span>
<span className="text-zinc-300">
{(build.uuid ?? buildId).slice(0, 8)}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Visibility</span>
<span className="text-zinc-300">
{build.isPublic ? "Public" : "Private"}
</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Items</span>
<span className="text-zinc-300">{items.length}</span>
</div>
<div className="flex items-center justify-between">
<span className="text-zinc-500">Est. Total</span>
<span className="text-zinc-50 font-medium">
{formatMoney(total)}
</span>
</div>
</div>
<div className="mt-4 flex flex-col gap-2">
<button className="rounded-md bg-amber-400 px-3 py-2 text-sm font-semibold text-black hover:bg-amber-300 transition">
Open in Builder
</button>
<button className="rounded-md border border-zinc-800 bg-black/30 px-3 py-2 text-sm text-zinc-200 hover:border-amber-500/40 hover:text-amber-300">
Copy share link
</button>
</div>
</div>
{/* Placeholder “OP notes” */}
<div className="rounded-xl border border-zinc-900 bg-zinc-950/60 p-5">
<h3 className="text-xs font-semibold uppercase tracking-[0.18em] text-zinc-500">
OP Notes
</h3>
<ul className="mt-3 space-y-2 text-sm text-zinc-400">
<li> Purpose: home defense / range</li>
<li> Priority: reliability first</li>
<li> Next upgrades: optic, sling, light</li>
</ul>
</div>
</aside>
</div>
</main>
);
// Community Builds is temporarily disabled — redirect to home until ready.
export default function BuildDetailPage() {
redirect("/");
}
+3 -478
View File
@@ -1,481 +1,6 @@
"use client";
/**
* app/builds/page.tsx
* Community Builds feed (public)
*
* Dev Notes:
* - We are moving to Option B:
* Build (core) + BuildProfile (meta) + Votes/Comments/Media as separate tables.
* - This page expects a lightweight "feed card" DTO from the backend, NOT full build items.
* - Keep UI/filters here client-side for MVP; move to server-side query params later if needed.
*
* Backend (target contract):
* GET /api/v1/builds?limit=50 -> BuildFeedCardDto[]
* (Optional later) POST /api/v1/builds/{uuid}/vote { delta: 1 | -1 }
*/
import { useEffect, useMemo, useState, useCallback } from "react";
import Link from "next/link";
type BuildClass = "Rifle" | "Pistol" | "NFA";
/**
* Keep this loose. Backend will control the allowed calibers via BuildProfile.
* We can tighten once the controlled vocab is finalized.
*/
type Caliber = string;
/**
* This matches what the UI needs (card format).
* It maps cleanly to: builds + build_profiles + aggregated votes (+ optional price).
*/
type BuildCard = {
id: string; // we use uuid here (public identifier)
title: string;
slug: string; // can be uuid for now; keep property so UI does not change
creator: string; // placeholder until user profiles are real
caliber: Caliber;
buildClass: BuildClass;
price: number; // optional server-side later; for now allow 0 fallback
votes: number;
tags: string[];
coverImageUrl?: string | null;
};
type BuildFeedCardDto = {
uuid: string;
title?: string | null;
slug?: string | null; // optional (we can generate from title later)
creator?: string | null; // optional
caliber?: string | null;
buildClass?: BuildClass | null;
price?: number | null;
votes?: number | null;
tags?: string[] | null;
coverImageUrl?: string | null;
};
const API_BASE_URL =
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
// --- UI Filter Sets (keep; well generate options from data too) ---
const CLASS_FILTERS: (BuildClass | "all")[] = ["all", "Rifle", "Pistol", "NFA"];
const PRICE_FILTERS = [
{ id: "all", label: "All", min: 0, max: Infinity },
{ id: "sub1k", label: "< $1k", min: 0, max: 1000_00 },
{ id: "1to2k", label: "$1k$2k", min: 1000_00, max: 2000_00 },
{ id: "2to3k", label: "$2k$3k", min: 2000_00, max: 3000_00 },
{ id: "3kplus", label: "$3k+", min: 3000_00, max: Infinity },
];
function safeArray(v: unknown): string[] {
return Array.isArray(v) ? v.filter(Boolean).map(String) : [];
}
function fallbackSlug(dto: BuildFeedCardDto) {
// MVP: stable route id is uuid. Keep a "slug" field for UI continuity.
return dto.slug?.trim() || dto.uuid;
}
function normalizeCard(dto: BuildFeedCardDto): BuildCard {
const title = (dto.title ?? "Untitled Build").trim() || "Untitled Build";
return {
id: dto.uuid,
title,
slug: fallbackSlug(dto),
// Until we wire real users: keep a placeholder creator string.
creator: (dto.creator ?? "anonymous").trim() || "anonymous",
caliber: (dto.caliber ?? "—").trim() || "—",
// Default to Rifle for display if missing; backend should set this via profile.
buildClass: (dto.buildClass ?? "Rifle") as BuildClass,
// Price is optional (we can compute later from BuildItems + offers).
price: typeof dto.price === "number" ? dto.price : 0,
votes: typeof dto.votes === "number" ? dto.votes : 0,
tags: safeArray(dto.tags),
coverImageUrl: dto.coverImageUrl ?? null,
};
}
import { redirect } from "next/navigation";
// Community Builds is temporarily disabled — redirect to home until ready.
export default function BuildsPage() {
// --- Remote data state ---
const [loading, setLoading] = useState(true);
const [error, setError] = useState<string | null>(null);
const [builds, setBuilds] = useState<BuildCard[]>([]);
// --- UI state (filters/votes) ---
const [caliberFilter, setCaliberFilter] = useState<Caliber | "all">("all");
const [classFilter, setClassFilter] = useState<BuildClass | "all">("all");
const [priceFilterId, setPriceFilterId] = useState<string>("all");
// Local vote state for optimistic UI
const [votes, setVotes] = useState<Record<string, number>>({});
const activePriceFilter =
PRICE_FILTERS.find((p) => p.id === priceFilterId) ?? PRICE_FILTERS[0];
// --- Fetch public builds feed ---
useEffect(() => {
let cancelled = false;
async function run() {
try {
setLoading(true);
setError(null);
const res = await fetch('/api/builds/public?limit=50', {
method: "GET",
credentials: "include",
headers: { "Content-Type": "application/json" },
});
if (!res.ok) {
const txt = await res.text().catch(() => "");
throw new Error(txt || `Failed to load builds (${res.status})`);
}
const data = (await res.json()) as BuildFeedCardDto[];
const cards = (Array.isArray(data) ? data : []).map(normalizeCard);
if (cancelled) return;
setBuilds(cards);
// Initialize vote UI from payload totals
setVotes(Object.fromEntries(cards.map((b) => [b.id, b.votes])));
} catch (e: any) {
if (!cancelled) setError(e?.message || "Failed to load builds feed");
} finally {
if (!cancelled) setLoading(false);
}
}
run();
return () => {
cancelled = true;
};
}, []);
// Build the caliber filters dynamically from data, but keep "all" first.
const CALIBER_FILTERS = useMemo<(Caliber | "all")[]>(() => {
const uniq = new Set<string>();
for (const b of builds) {
const c = (b.caliber ?? "").trim();
if (c && c !== "—") uniq.add(c);
}
return ["all", ...Array.from(uniq).sort()];
}, [builds]);
const filteredBuilds = useMemo(() => {
return builds.filter((build) => {
const matchesCaliber =
caliberFilter === "all" || build.caliber === caliberFilter;
const matchesClass =
classFilter === "all" || build.buildClass === classFilter;
const matchesPrice =
build.price >= activePriceFilter.min &&
build.price < activePriceFilter.max;
return matchesCaliber && matchesClass && matchesPrice;
});
}, [builds, caliberFilter, classFilter, activePriceFilter]);
const handleVote = useCallback((id: string, delta: 1 | -1) => {
// MVP: optimistic local vote only.
// Later: POST /api/v1/builds/{uuid}/vote { delta } and reconcile.
setVotes((prev) => ({
...prev,
[id]: (prev[id] ?? 0) + delta,
}));
}, []);
return (
<main className="min-h-screen bg-black text-zinc-50">
<div className="mx-auto max-w-6xl px-4 py-6 lg:py-10">
{/* Header */}
<header className="mb-6 flex flex-col gap-4 md:flex-row md:items-end md:justify-between">
<div>
<p className="text-xs font-semibold tracking-[0.2em] uppercase text-zinc-500">
Battl Builder · Build Book
</p>
<h1
className="mt-2 text-3xl md:text-4xl font-extrabold tracking-tight
bg-gradient-to-r from-amber-400 via-amber-200 to-amber-400
text-transparent bg-clip-text drop-shadow-[0_2px_6px_rgba(255,193,7,0.25)]"
>
Community Builds
</h1>
<p className="mt-2 text-sm md:text-base text-zinc-400 max-w-xl">
Browse community builds, vote them up or down, and steal good
ideas without shame. This feed is backed by public builds
(isPublic=true).
</p>
</div>
<div className="flex gap-2 md:gap-3">
<Link
href="/builder"
className="inline-flex items-center justify-center rounded-md border border-zinc-700 bg-zinc-900/60 px-4 py-2 text-xs md:text-sm font-medium text-zinc-200 hover:bg-zinc-800 hover:border-zinc-600 transition-colors"
>
Open Builder
</Link>
{/* Later: route to /vault + choose build to submit, or direct /builder submit flow */}
<Link
href="/vault"
className="inline-flex items-center justify-center rounded-md border border-amber-400/70 bg-amber-400/10 px-4 py-2 text-xs md:text-sm font-medium text-amber-200 hover:bg-amber-400/15 transition-colors"
>
+ Submit Build
</Link>
</div>
</header>
{/* Loading / Error */}
{loading && (
<div className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 text-sm text-zinc-400">
Loading community builds
</div>
)}
{error && !loading && (
<div className="mb-6 rounded-lg border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-200">
{error}
<div className="mt-2 text-xs text-red-200/80">
Dev tip: confirm backend route{" "}
<span className="font-mono">GET /api/v1/builds</span> is
implemented + CORS is configured.
</div>
</div>
)}
{/* Filters */}
<section className="mb-6 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4 space-y-4">
<div className="flex flex-col gap-3 md:flex-row md:items-center md:justify-between">
<div>
<h2 className="text-xs font-semibold tracking-[0.16em] uppercase text-zinc-400">
Filter Builds
</h2>
<p className="text-xs text-zinc-500 mt-1">
Filter by caliber, class, and price range. (Client-side for
MVP.)
</p>
</div>
<div className="text-xs text-zinc-500">
Showing{" "}
<span className="text-zinc-200 font-medium">
{filteredBuilds.length}
</span>{" "}
of{" "}
<span className="text-zinc-200 font-medium">{builds.length}</span>{" "}
builds
</div>
</div>
<div className="grid gap-3 md:grid-cols-3">
{/* Caliber filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Caliber
</div>
<div className="flex flex-wrap gap-2">
{CALIBER_FILTERS.map((caliber) => (
<button
key={caliber}
type="button"
onClick={() =>
setCaliberFilter(caliber === "all" ? "all" : caliber)
}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
caliberFilter === caliber
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{caliber === "all" ? "All" : caliber}
</button>
))}
</div>
</div>
{/* Class filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Class
</div>
<div className="flex flex-wrap gap-2">
{CLASS_FILTERS.map((cls) => (
<button
key={cls}
type="button"
onClick={() => setClassFilter(cls === "all" ? "all" : cls)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
classFilter === cls
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{cls === "all" ? "All" : cls}
</button>
))}
</div>
</div>
{/* Price filter */}
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
Price Range
</div>
<div className="flex flex-wrap gap-2">
{PRICE_FILTERS.map((range) => (
<button
key={range.id}
type="button"
onClick={() => setPriceFilterId(range.id)}
className={`rounded-full border px-3 py-1 text-xs transition-colors ${
priceFilterId === range.id
? "border-amber-400/70 bg-amber-400/10 text-amber-200"
: "border-zinc-700 bg-zinc-900/60 text-zinc-300 hover:bg-zinc-800 hover:border-zinc-600"
}`}
>
{range.label}
</button>
))}
</div>
</div>
</div>
</section>
{/* Build list */}
<section>
<div className="space-y-4">
{filteredBuilds.map((build) => {
const dollars = Math.floor((build.price ?? 0) / 100);
return (
<article
key={build.id}
className="flex gap-4 rounded-lg border border-zinc-800 bg-zinc-950/70 p-4"
>
{/* Votes column */}
<div className="flex flex-col items-center justify-center gap-1">
<button
type="button"
onClick={() => handleVote(build.id, 1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Upvote"
title="Upvote"
>
</button>
<div className="text-xs font-semibold text-amber-200">
{votes[build.id] ?? build.votes}
</div>
<button
type="button"
onClick={() => handleVote(build.id, -1)}
className="flex h-6 w-6 items-center justify-center rounded-full border border-zinc-700 bg-zinc-900/70 text-xs text-zinc-300 hover:bg-zinc-800 hover:border-zinc-500"
aria-label="Downvote"
title="Downvote"
>
</button>
</div>
{/* Thumbnail */}
<div className="hidden sm:block">
<div className="overflow-hidden rounded-md border border-zinc-800 bg-zinc-900/80 h-24 w-40">
{/* eslint-disable-next-line @next/next/no-img-element */}
<img
src={
build.coverImageUrl ||
"https://placehold.co/320x200/png?text=Build+Photo"
}
alt={`${build.title} cover`}
className="h-full w-full object-cover"
/>
</div>
</div>
{/* Main content */}
<div className="flex-1">
<div className="flex flex-col gap-2 md:flex-row md:items-start md:justify-between">
<div>
<div className="text-[0.7rem] uppercase tracking-[0.16em] text-zinc-500 mb-1">
{build.buildClass} · {build.caliber}
</div>
<h3 className="text-lg md:text-xl font-semibold text-zinc-50">
<Link
href={`/builds/${build.slug}`}
className="hover:text-amber-200 transition-colors"
>
{build.title}
</Link>
</h3>
<p className="text-xs text-zinc-500 mt-1">
Posted by{" "}
<span className="text-zinc-300">
b/{build.creator}
</span>{" "}
· Build detail page will show the parts list +
comments.
</p>
</div>
<div className="text-right mt-1 md:mt-0">
<div className="text-xs uppercase tracking-[0.16em] text-zinc-500">
Est. Build Cost
</div>
<div className="text-lg font-semibold text-amber-300">
{build.price > 0 ? (
<span>
${Math.floor(build.price / 100).toLocaleString()}
</span>
) : (
"—"
)}
</div>
</div>
</div>
<div className="mt-3 flex flex-wrap items-center gap-2">
{build.tags.map((tag) => (
<span
key={tag}
className="rounded-full border border-zinc-700 bg-zinc-900/60 px-2 py-0.5 text-[0.7rem] text-zinc-400"
>
{tag}
</span>
))}
<span className="ml-auto text-[0.7rem] text-zinc-500">
Comments + save coming soon.
</span>
</div>
</div>
</article>
);
})}
{!loading && !error && filteredBuilds.length === 0 && (
<div className="rounded-lg border border-zinc-800 bg-zinc-950/70 p-6 text-center text-sm text-zinc-500">
No builds match those filters yet.
</div>
)}
</div>
</section>
</div>
</main>
);
redirect("/");
}
+3
View File
@@ -373,6 +373,9 @@ export default function HomePage() {
<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="/feedback" className="hover:text-zinc-500">
Send Feedback
</Link>
<Link href="/privacy" className="hover:text-zinc-500">
Privacy
</Link>
+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>
)
}
+133
View File
@@ -0,0 +1,133 @@
'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, autoFocus = false }: { onSuccess?: () => void; autoFocus?: boolean }) {
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 htmlFor="feedback-category" className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Category
</label>
<select
id="feedback-category"
value={category}
onChange={(e) => setCategory(e.target.value)}
disabled={status === 'loading'}
autoFocus={autoFocus}
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 htmlFor="feedback-email" 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
id="feedback-email"
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 htmlFor="feedback-message" className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Message
</label>
<textarea
id="feedback-message"
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>
)
}
+52
View File
@@ -0,0 +1,52 @@
'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"
aria-labelledby="feedback-modal-title"
>
<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 id="feedback-modal-title" 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} autoFocus />
</div>
</div>
)
}
+1 -5
View File
@@ -37,11 +37,7 @@ export function Footer() {
Builder
</Link>
</li>
<li>
<Link href="/builds" className={linkClass}>
Community Builds
</Link>
</li>
<li>
<Link href="/vault" className={linkClass}>
My Builds
+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>
)
}
+13 -1
View File
@@ -3,9 +3,11 @@
import Link from "next/link";
import Image from "next/image";
import { useState } from "react";
import { usePathname } from "next/navigation";
import { useAuth } from "@/context/AuthContext";
import { FeedbackModal } from "@/components/FeedbackModal";
function NavLink({
href,
@@ -38,8 +40,10 @@ function NavLink({
export function TopNav() {
const { user, logout, loading } = useAuth();
const isAuthenticated = !!user;
const [feedbackOpen, setFeedbackOpen] = useState(false);
return (
<>
<header className="border-b border-zinc-200 bg-white/95 dark:border-zinc-800 dark:bg-black/95 backdrop-blur">
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
{/* Left: Brand / Home */}
@@ -60,11 +64,17 @@ export function TopNav() {
<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">
<button
type="button"
onClick={() => setFeedbackOpen(true)}
className="hidden text-xs font-medium text-zinc-500 hover:text-zinc-300 transition-colors sm:inline"
>
Send Feedback
</button>
{loading ? (
<span className="text-xs text-zinc-500">Checking session</span>
) : isAuthenticated ? (
@@ -103,6 +113,8 @@ export function TopNav() {
</div>
</div>
</header>
<FeedbackModal open={feedbackOpen} onClose={() => setFeedbackOpen(false)} />
</>
);
}
export default TopNav;
File diff suppressed because it is too large Load Diff
@@ -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)
+16
View File
@@ -55,3 +55,19 @@ export const postBySlugQuery = `
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
}
`
+4
View File
@@ -25,6 +25,10 @@ function isPublicPath(pathname: string) {
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;
+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
}