26 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
24 changed files with 2471 additions and 914 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>
+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" />,
},
],
},
{
+5 -21
View File
@@ -1,6 +1,5 @@
import { NextRequest, NextResponse } from 'next/server'
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL
import { springProxy } from '@/lib/springProxy'
export async function POST(req: NextRequest) {
let body: unknown
@@ -16,23 +15,8 @@ export async function POST(req: NextRequest) {
'unknown'
const userAgent = req.headers.get('user-agent') ?? 'unknown'
try {
const res = await fetch(`${API_BASE_URL}/api/v1/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(body as object), ipAddress: ip, userAgent }),
cache: 'no-store',
})
if (!res.ok) {
const text = await res.text().catch(() => '')
console.error('[/api/feedback] Spring Boot error:', res.status, text)
return NextResponse.json({ error: 'Failed to submit' }, { status: res.status })
}
return NextResponse.json({ ok: true })
} catch (e) {
console.error('[/api/feedback] proxy error:', e)
return NextResponse.json({ error: 'Service unavailable' }, { status: 500 })
}
return springProxy(req, '/api/v1/feedback', {
method: 'POST',
body: { ...(body as object), ipAddress: ip, userAgent },
})
}
+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>
+8 -4
View File
@@ -10,7 +10,7 @@ const CATEGORIES = [
{ value: 'GENERAL', label: 'General feedback' },
]
export function FeedbackForm({ onSuccess }: { onSuccess?: () => void }) {
export function FeedbackForm({ onSuccess, autoFocus = false }: { onSuccess?: () => void; autoFocus?: boolean }) {
const [category, setCategory] = useState('BUG')
const [message, setMessage] = useState('')
const [email, setEmail] = useState('')
@@ -56,13 +56,15 @@ export function FeedbackForm({ onSuccess }: { onSuccess?: () => void }) {
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
<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) => (
@@ -73,13 +75,14 @@ export function FeedbackForm({ onSuccess }: { onSuccess?: () => void }) {
</select>
</div>
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
<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)}
@@ -91,10 +94,11 @@ export function FeedbackForm({ onSuccess }: { onSuccess?: () => void }) {
</div>
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
<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?"
+3 -2
View File
@@ -27,6 +27,7 @@ export function FeedbackModal({
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"
@@ -35,7 +36,7 @@ export function FeedbackModal({
/>
<div className="relative z-10 w-full max-w-lg rounded-2xl border border-zinc-800 bg-zinc-950 p-7 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-base font-bold text-white">Send Feedback</h2>
<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"
@@ -44,7 +45,7 @@ export function FeedbackModal({
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<FeedbackForm onSuccess={onClose} />
<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
+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