Compare commits
37 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
| c0ff28d505 | |||
| a66699b450 | |||
| ef3343b942 | |||
| 5940d60f89 | |||
| f52cf3d176 | |||
| b1c45bb4b5 | |||
| 5c949c1ab8 | |||
| b91e8fee24 | |||
| 975d7acc39 | |||
| ecef11248b | |||
| 7ad4002886 | |||
| 544760e19d | |||
| 119e366361 | |||
| 2091d14340 | |||
| 10ef062a92 | |||
| 691de6526c | |||
| 677f4dd61c | |||
| 20457af028 | |||
| 7fc4d92031 | |||
| 1aafd08af0 | |||
| cdb3a8dbb3 | |||
| 0e4c49928b | |||
| 82a38a7a15 | |||
| ca292c4782 | |||
| e77e6e4e6d | |||
| 98c2b12340 | |||
| 8f2a46d3bb | |||
| 0ca864ff20 | |||
| 93aa4469e8 | |||
| 89e39e7b18 | |||
| 8e33a5497a | |||
| 26bc1a8c21 | |||
| 2d55af4de2 | |||
| d387484872 | |||
| e2a2af8a6f | |||
| c977e69654 | |||
| 9d0eb40554 |
@@ -9,7 +9,20 @@ jobs:
|
|||||||
deploy:
|
deploy:
|
||||||
runs-on: ubuntu
|
runs-on: ubuntu
|
||||||
steps:
|
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
|
- 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
|
- name: Pull latest images
|
||||||
run: |
|
run: |
|
||||||
@@ -17,4 +30,4 @@ jobs:
|
|||||||
docker pull gitea.gofwd.group/sean/shadow-gunbuilder-ai-proto/webui:latest
|
docker pull gitea.gofwd.group/sean/shadow-gunbuilder-ai-proto/webui:latest
|
||||||
|
|
||||||
- name: Recreate containers with new images
|
- 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">50–75 SKUs across all 10 slots, 3–5 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">50–75 SKUs across all 10 slots, 3–5 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>
|
||||||
@@ -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've shipped and what's coming next.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
<ChangelogTabs entries={entries} roadmapItems={roadmapItems} />
|
||||||
|
</main>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -19,6 +19,7 @@ import {
|
|||||||
Wand2,
|
Wand2,
|
||||||
FolderKanban,
|
FolderKanban,
|
||||||
Shield,
|
Shield,
|
||||||
|
MessageSquare,
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
|
|
||||||
const navGroups: AdminNavGroup[] = [
|
const navGroups: AdminNavGroup[] = [
|
||||||
@@ -89,6 +90,11 @@ const navGroups: AdminNavGroup[] = [
|
|||||||
href: "/admin/email/send",
|
href: "/admin/email/send",
|
||||||
icon: <Mail className="h-4 w-4" />,
|
icon: <Mail className="h-4 w-4" />,
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
label: "Feedback",
|
||||||
|
href: "/admin/feedback",
|
||||||
|
icon: <MessageSquare className="h-4 w-4" />,
|
||||||
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
|
|||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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 },
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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 })
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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({})
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,404 +1,6 @@
|
|||||||
// app/builds/[buildId]/page.tsx
|
import { redirect } from "next/navigation";
|
||||||
import Link from "next/link";
|
|
||||||
import { notFound } from "next/navigation";
|
|
||||||
import { ProductImage } from "@/components/parts/ProductImage";
|
|
||||||
|
|
||||||
const API_BASE_URL =
|
// Community Builds is temporarily disabled — redirect to home until ready.
|
||||||
process.env.NEXT_PUBLIC_API_BASE_URL ?? "";
|
export default function BuildDetailPage() {
|
||||||
|
redirect("/");
|
||||||
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 — we’ll 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. I’ll 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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
+3
-478
@@ -1,481 +1,6 @@
|
|||||||
"use client";
|
import { redirect } from "next/navigation";
|
||||||
|
|
||||||
/**
|
|
||||||
* 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; we’ll 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,
|
|
||||||
};
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// Community Builds is temporarily disabled — redirect to home until ready.
|
||||||
export default function BuildsPage() {
|
export default function BuildsPage() {
|
||||||
// --- Remote data state ---
|
redirect("/");
|
||||||
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>
|
|
||||||
);
|
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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">
|
<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>
|
<span>© {new Date().getFullYear()} BATTL BUILDERS™</span>
|
||||||
<div className="flex gap-4">
|
<div className="flex gap-4">
|
||||||
|
<Link href="/feedback" className="hover:text-zinc-500">
|
||||||
|
Send Feedback
|
||||||
|
</Link>
|
||||||
<Link href="/privacy" className="hover:text-zinc-500">
|
<Link href="/privacy" className="hover:text-zinc-500">
|
||||||
Privacy
|
Privacy
|
||||||
</Link>
|
</Link>
|
||||||
|
|||||||
@@ -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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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'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'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -37,11 +37,7 @@ export function Footer() {
|
|||||||
Builder
|
Builder
|
||||||
</Link>
|
</Link>
|
||||||
</li>
|
</li>
|
||||||
<li>
|
|
||||||
<Link href="/builds" className={linkClass}>
|
|
||||||
Community Builds
|
|
||||||
</Link>
|
|
||||||
</li>
|
|
||||||
<li>
|
<li>
|
||||||
<Link href="/vault" className={linkClass}>
|
<Link href="/vault" className={linkClass}>
|
||||||
My Builds
|
My Builds
|
||||||
|
|||||||
@@ -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
@@ -3,9 +3,11 @@
|
|||||||
|
|
||||||
import Link from "next/link";
|
import Link from "next/link";
|
||||||
import Image from "next/image";
|
import Image from "next/image";
|
||||||
|
import { useState } from "react";
|
||||||
import { usePathname } from "next/navigation";
|
import { usePathname } from "next/navigation";
|
||||||
|
|
||||||
import { useAuth } from "@/context/AuthContext";
|
import { useAuth } from "@/context/AuthContext";
|
||||||
|
import { FeedbackModal } from "@/components/FeedbackModal";
|
||||||
|
|
||||||
function NavLink({
|
function NavLink({
|
||||||
href,
|
href,
|
||||||
@@ -38,8 +40,10 @@ function NavLink({
|
|||||||
export function TopNav() {
|
export function TopNav() {
|
||||||
const { user, logout, loading } = useAuth();
|
const { user, logout, loading } = useAuth();
|
||||||
const isAuthenticated = !!user;
|
const isAuthenticated = !!user;
|
||||||
|
const [feedbackOpen, setFeedbackOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
|
<>
|
||||||
<header className="border-b border-zinc-200 bg-white/95 dark:border-zinc-800 dark:bg-black/95 backdrop-blur">
|
<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">
|
<div className="mx-auto flex max-w-6xl items-center justify-between px-4 py-2">
|
||||||
{/* Left: Brand / Home */}
|
{/* Left: Brand / Home */}
|
||||||
@@ -60,11 +64,17 @@ export function TopNav() {
|
|||||||
<nav className="hidden sm:flex items-center gap-5">
|
<nav className="hidden sm:flex items-center gap-5">
|
||||||
<NavLink href="/builder">Builder</NavLink>
|
<NavLink href="/builder">Builder</NavLink>
|
||||||
<NavLink href="/guides">Guides</NavLink>
|
<NavLink href="/guides">Guides</NavLink>
|
||||||
<NavLink href="/builds">Community</NavLink>
|
|
||||||
</nav>
|
</nav>
|
||||||
|
|
||||||
{/* Right side actions */}
|
{/* Right side actions */}
|
||||||
<div className="flex items-center justify-end gap-3 sm:gap-4">
|
<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 ? (
|
{loading ? (
|
||||||
<span className="text-xs text-zinc-500">Checking session…</span>
|
<span className="text-xs text-zinc-500">Checking session…</span>
|
||||||
) : isAuthenticated ? (
|
) : isAuthenticated ? (
|
||||||
@@ -103,6 +113,8 @@ export function TopNav() {
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</header>
|
</header>
|
||||||
|
<FeedbackModal open={feedbackOpen} onClose={() => setFeedbackOpen(false)} />
|
||||||
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
export default TopNav;
|
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 | 1–1000 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)
|
||||||
@@ -55,3 +55,19 @@ export const postBySlugQuery = `
|
|||||||
export const postSlugsQuery = `
|
export const postSlugsQuery = `
|
||||||
*[_type == "post" && defined(slug.current)] { "slug": slug.current }
|
*[_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
|
||||||
|
}
|
||||||
|
`
|
||||||
|
|||||||
@@ -25,6 +25,10 @@ function isPublicPath(pathname: string) {
|
|||||||
if (pathname === "/guides") return true;
|
if (pathname === "/guides") return true;
|
||||||
if (pathname.startsWith("/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
|
// Static / framework assets
|
||||||
if (pathname.startsWith("/_next")) return true;
|
if (pathname.startsWith("/_next")) return true;
|
||||||
if (pathname === "/favicon.ico") return true;
|
if (pathname === "/favicon.ico") return true;
|
||||||
|
|||||||
@@ -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
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user