diff --git a/.superpowers/brainstorm/20960-1774881654/.server-stopped b/.superpowers/brainstorm/20960-1774881654/.server-stopped new file mode 100644 index 0000000..bd67f46 --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/.server-stopped @@ -0,0 +1 @@ +{"reason":"idle timeout","timestamp":1774884294221} diff --git a/.superpowers/brainstorm/20960-1774881654/.server.log b/.superpowers/brainstorm/20960-1774881654/.server.log new file mode 100644 index 0000000..42a0580 --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/.server.log @@ -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"} diff --git a/.superpowers/brainstorm/20960-1774881654/.server.pid b/.superpowers/brainstorm/20960-1774881654/.server.pid new file mode 100644 index 0000000..f0d741b --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/.server.pid @@ -0,0 +1 @@ +20969 diff --git a/.superpowers/brainstorm/20960-1774881654/changelog-layout.html b/.superpowers/brainstorm/20960-1774881654/changelog-layout.html new file mode 100644 index 0000000..6c407ef --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/changelog-layout.html @@ -0,0 +1,113 @@ + + +
+ + +
+
What's New
+
What's Next
+
Give Feedback
+
+ + +
+
+
+ +
+ +
Homepage redesign + open access
+
Removed the beta gate. New landing page explains what Battl does without the jargon. Compatibility conflict warnings now surface inline while you build.
+
+
+ +
+ +
+ +
Shareable build links + vault
+
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.
+
+
+ +
+ +
+ +
Live pricing from real retailers
+
AvantLink feed integration. Running total updates as you add parts. Prices are pulled daily — no estimated MSRP, no sponsored rankings.
+
+
+
+
+
diff --git a/.superpowers/brainstorm/20960-1774881654/feedback-form-v2.html b/.superpowers/brainstorm/20960-1774881654/feedback-form-v2.html new file mode 100644 index 0000000..785152e --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/feedback-form-v2.html @@ -0,0 +1,88 @@ + + +
+ + +
+
What's New
+
What's Next
+
Give Feedback
+
+ +
+

Tell us what you think.

+

Bug, feature idea, or general thought — we read everything. Rough language welcome.

+
+ +
+
+ + +
+
+ + +
+
+ +
+ + +
0 / 1000
+
+ + +
diff --git a/.superpowers/brainstorm/20960-1774881654/feedback-form.html b/.superpowers/brainstorm/20960-1774881654/feedback-form.html new file mode 100644 index 0000000..376b1a2 --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/feedback-form.html @@ -0,0 +1,160 @@ + + +
+ + +
+
What's New
+
What's Next
+
Give Feedback
+
+ +
+ + +
+ + +
+ +
+ + +
+ + + +
+ +
+
diff --git a/.superpowers/brainstorm/20960-1774881654/roadmap-tab.html b/.superpowers/brainstorm/20960-1774881654/roadmap-tab.html new file mode 100644 index 0000000..49e6e99 --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/roadmap-tab.html @@ -0,0 +1,104 @@ + + +
+ + +
+
What's New
+
What's Next
+
Give Feedback
+
+ +
+
In Progress
+ +
+
+
+
Compatibility engine — gas system + buffer
+
Automatic warnings when barrel length and gas system length don't match, and when buffer weight conflicts with your stock/brace selection.
+
+ In Progress +
+ +
+
+
+
Mobile builder experience
+
Full 375px breakpoint pass on the builder, parts browser, and build summary. Lighthouse score target ≥70.
+
+ In Progress +
+ +
Planned
+ +
+
+
+
Expanded parts catalog
+
50–75 SKUs across all 10 slots, 3–5 options per category, with live AvantLink pricing.
+
+ Planned +
+ +
+
+
+
Build guides — first wave
+
Three long-form guides targeting "AR-15 build planner" and related keywords. Written for the ICP: experienced shooter, not an expert builder.
+
+ Planned +
+ +
+
+
+
Caliber cross-compatibility checks
+
Flag mismatches between upper and lower when mixing calibers (e.g., .300 Blackout upper on a 5.56 lower).
+
+ Planned +
+ +
+ Have something you want to see? Give us feedback → +
+
+
diff --git a/.superpowers/brainstorm/20960-1774881654/roadmap-votes.html b/.superpowers/brainstorm/20960-1774881654/roadmap-votes.html new file mode 100644 index 0000000..65578c2 --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/roadmap-votes.html @@ -0,0 +1,150 @@ + + +
+ + +
+
What's New
+
What's Next
+
Give Feedback
+
+ +
+
In Progress
+ +
+ +
+
Compatibility engine — gas system + buffer
+
Automatic warnings when barrel length and gas system length don't match, and when buffer weight conflicts with your stock or brace selection.
+
+
+ In Progress +
+
+ +
+ +
+
Mobile builder experience
+
Full 375px breakpoint pass on the builder, parts browser, and build summary. Lighthouse score target ≥70.
+
+
+ In Progress +
+
+ +
Planned
+ +
+ +
+
Expanded parts catalog
+
50–75 SKUs across all 10 slots, 3–5 options per category, with live AvantLink pricing.
+
+
+ Planned +
+
+ +
+ +
+
Caliber cross-compatibility checks
+
Flag mismatches between upper and lower when mixing calibers (e.g., .300 Blackout upper on a 5.56 lower).
+
+
+ Planned +
+
+ +
+ +
+
Build guides — first wave
+
Three long-form guides targeting "AR-15 build planner" and related keywords. Written for the ICP: experienced shooter, not an expert builder.
+
+
+ Planned +
+
+ +
+ Don't see what you need? Give us feedback → +
+
+
diff --git a/.superpowers/brainstorm/20960-1774881654/waiting-2.html b/.superpowers/brainstorm/20960-1774881654/waiting-2.html new file mode 100644 index 0000000..ef07652 --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/waiting-2.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/.superpowers/brainstorm/20960-1774881654/waiting-3.html b/.superpowers/brainstorm/20960-1774881654/waiting-3.html new file mode 100644 index 0000000..ef07652 --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/waiting-3.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/.superpowers/brainstorm/20960-1774881654/waiting.html b/.superpowers/brainstorm/20960-1774881654/waiting.html new file mode 100644 index 0000000..ef07652 --- /dev/null +++ b/.superpowers/brainstorm/20960-1774881654/waiting.html @@ -0,0 +1,3 @@ +
+

Continuing in terminal...

+
diff --git a/docs/superpowers/plans/2026-03-30-changelog-feedback.md b/docs/superpowers/plans/2026-03-30-changelog-feedback.md new file mode 100644 index 0000000..0009373 --- /dev/null +++ b/docs/superpowers/plans/2026-03-30-changelog-feedback.md @@ -0,0 +1,1554 @@ +# Changelog, Roadmap & Feedback Implementation Plan + +> **For agentic workers:** REQUIRED SUB-SKILL: Use superpowers:subagent-driven-development (recommended) or superpowers:executing-plans to implement this plan task-by-task. Steps use checkbox (`- [ ]`) syntax for tracking. + +**Goal:** Build a public `/changelog` page with What's New / What's Next / Give Feedback tabs, auth-gated roadmap upvoting, a reusable feedback form + modal, a standalone `/feedback` page, and an admin view for feedback submissions. + +**Architecture:** Sanity CMS provides changelog and roadmap content (two new document types: `changelogEntry`, `roadmapItem`). The Next.js RSC page fetches both via `sanityFetch()`, passes data as props to a `"use client"` `ChangelogTabs` component. Votes and feedback are stored in Spring Boot (separate repo — endpoints are prerequisites). Next.js `app/api/` routes proxy all Spring Boot calls using the project's established manual `cookies()` / `fetch()` pattern — **not** `lib/springProxy.ts`, which is not used by any existing route in this project. + +**Tech Stack:** Next.js 15 App Router, TypeScript, Tailwind CSS, Sanity CMS (`lib/sanityFetch.ts` for queries), `cookies()` from `next/headers` for auth forwarding, `@heroicons/react/20/solid`, `lucide-react` (admin nav), `PortableTextRenderer` (existing component at `components/PortableTextRenderer.tsx`) + +**Spring Boot prerequisites (out of scope — must exist before votes/feedback work end-to-end):** +- `POST /api/v1/feedback` — body: `{ category, message, email?, ipAddress, userAgent }` +- `GET /api/v1/roadmap/votes?ids=...` — returns `{ [sanityId]: { count: number, voted: boolean } }`, unauthenticated returns `voted: false` for all +- `POST /api/v1/roadmap/{sanityId}/vote` — auth required, toggles vote, returns `{ count: number, voted: boolean }` +- `GET /api/v1/admin/feedback` — returns array of feedback submissions +- `PATCH /api/v1/admin/feedback/{id}` — body: `{ reviewed: boolean }` + +Until those endpoints exist, the UI degrades gracefully (votes show 0/unvoted, feedback form shows error). + +--- + +## File Map + +| File | Action | Responsibility | +|---|---|---| +| `types/changelog.ts` | Create | Shared TypeScript types for changelog + roadmap data | +| `lib/sanity.ts` | Modify | Add `changelogEntriesQuery` and `roadmapItemsQuery` | +| `middleware.ts` | Modify | Whitelist `/changelog` and `/feedback` in `isPublicPath()` | +| `app/(app)/changelog/page.tsx` | Create | RSC — fetch Sanity data, render page header + `ChangelogTabs` | +| `components/ChangelogTabs.tsx` | Create | `"use client"` — hash tab state, renders all three panels, fetches + manages vote state | +| `components/RoadmapItem.tsx` | Create | `"use client"` — single roadmap item with optimistic upvote button | +| `components/FeedbackForm.tsx` | Create | `"use client"` — category + message + email form, posts to `/api/feedback` | +| `components/FeedbackModal.tsx` | Create | `"use client"` — backdrop + dialog wrapping `FeedbackForm` | +| `app/(app)/feedback/page.tsx` | Create | RSC — standalone feedback page rendering `FeedbackForm` | +| `app/api/feedback/route.ts` | Create | Proxy `POST /api/v1/feedback`, captures IP + user-agent server-side | +| `app/api/roadmap/votes/route.ts` | Create | Proxy `GET /api/v1/roadmap/votes`, auth-optional | +| `app/api/roadmap/[sanityId]/vote/route.ts` | Create | Proxy `POST /api/v1/roadmap/:id/vote`, auth-required | +| `app/api/admin/feedback/route.ts` | Create | Proxy `GET /api/v1/admin/feedback`, admin-authed | +| `app/api/admin/feedback/[id]/route.ts` | Create | Proxy `PATCH /api/v1/admin/feedback/:id`, admin-authed | +| `app/admin/feedback/page.tsx` | Create | `"use client"` — admin table of submissions | +| `components/TopNav.tsx` | Modify | Add "Send Feedback" ghost button + `FeedbackModal` local state | +| `app/page.tsx` | Modify | Add "Send Feedback" link to inline footer | +| `app/admin/layout.tsx` | Modify | Add Feedback nav item to "Growth & Comms" group | + +--- + +## Task 1: Shared types + Sanity queries + middleware whitelist + +**Files:** +- Create: `types/changelog.ts` +- Modify: `lib/sanity.ts` +- Modify: `middleware.ts` + +- [ ] **Step 1: Create `types/changelog.ts`** + +```ts +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 +} +``` + +- [ ] **Step 2: Add GROQ queries to `lib/sanity.ts`** + +Append after the existing `postSlugsQuery` export: + +```ts +/** 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 + } +` +``` + +- [ ] **Step 3: Whitelist `/changelog` and `/feedback` in `middleware.ts`** + +In `isPublicPath()`, add after the guides block (around line 25): + +```ts +// Changelog and feedback — always public +if (pathname === '/changelog') return true +if (pathname === '/feedback') return true +``` + +- [ ] **Step 4: Type-check** + +```bash +cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit +``` + +Expected: no new errors. + +- [ ] **Step 5: Commit** + +```bash +git add types/changelog.ts lib/sanity.ts middleware.ts +git commit -m "feat: add changelog/roadmap types, Sanity queries, and public route whitelist" +``` + +--- + +## Task 2: `/changelog` RSC page + `ChangelogTabs` (What's New tab) + +**Files:** +- Create: `app/(app)/changelog/page.tsx` +- Create: `components/ChangelogTabs.tsx` + +- [ ] **Step 1: Create `app/(app)/changelog/page.tsx`** + +```tsx +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(changelogEntriesQuery) + } catch (err) { + console.error('[Changelog] entries fetch failed:', err) + } + + try { + roadmapItems = await sanityFetch(roadmapItemsQuery) + } catch (err) { + console.error('[Changelog] roadmap fetch failed:', err) + } + + return ( +
+
+

+ Battl Builders +

+

Updates

+

+ What we've shipped and what's coming next. +

+
+ +
+ ) +} +``` + +- [ ] **Step 2: Create `components/ChangelogTabs.tsx`** + +What's New tab is fully implemented. Roadmap and Give Feedback tabs are stubs — filled in by Tasks 3 and 6. + +```tsx +'use client' + +import { useEffect, useState } from 'react' +import { PortableTextRenderer } from '@/components/PortableTextRenderer' +import type { ChangelogEntry, RoadmapItemData, Tab, VoteState } from '@/types/changelog' + +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 = { + 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('whats-new') + + 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 ( +
+ {/* Tab bar */} +
+ {TABS.map(({ id, label }) => ( + + ))} +
+ + {/* ── What's New ── */} + {activeTab === 'whats-new' && ( +
+ {entries.length === 0 ? ( +

+ No releases yet — check back soon. +

+ ) : ( +
+ {entries.map((entry) => ( +
+
+

+ {entry.version} +

+

+ {formatDate(entry.publishedAt)} +

+
+
+ {entry.tags?.length > 0 && ( +
+ {entry.tags.map((tag) => ( + + {tag} + + ))} +
+ )} +

+ {entry.title} +

+ {entry.body && ( +
+ +
+ )} +
+
+ ))} +
+ )} +
+ )} + + {/* ── What's Next — stub, filled in Task 3 ── */} + {activeTab === 'whats-next' && ( +

Roadmap coming soon.

+ )} + + {/* ── Give Feedback — stub, filled in Task 6 ── */} + {activeTab === 'give-feedback' && ( +

Feedback form coming soon.

+ )} +
+ ) +} +``` + +- [ ] **Step 3: Verify build** + +```bash +cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit +``` + +Expected: no errors. + +- [ ] **Step 4: Smoke test in browser** + +Run `npm run dev`, open `http://localhost:3000/changelog`. Confirm: +- Page renders with header "Updates" +- Three tabs visible; amber underline on active tab +- URL hash updates on tab click +- What's New shows "No releases yet" (Sanity schema not yet deployed — expected) + +- [ ] **Step 5: Commit** + +```bash +git add "app/(app)/changelog/page.tsx" components/ChangelogTabs.tsx +git commit -m "feat: add /changelog page with What's New tab" +``` + +--- + +## Task 3: `RoadmapItem` component + What's Next tab (static display) + +**Files:** +- Create: `components/RoadmapItem.tsx` +- Modify: `components/ChangelogTabs.tsx` + +- [ ] **Step 1: Create `components/RoadmapItem.tsx`** + +Vote handler is a callback prop — actual vote logic lives in `ChangelogTabs` and is wired in Task 5. + +```tsx +'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 ( +
+ {/* Upvote button */} + + + {/* Content */} +
+

{title}

+

{description}

+
+ + {/* Status badge */} + + {status === 'in-progress' ? 'In Progress' : 'Planned'} + +
+ ) +} +``` + +- [ ] **Step 2: Replace the What's Next stub in `components/ChangelogTabs.tsx`** + +Add import at the top (after existing imports): + +```tsx +import { RoadmapItem } from '@/components/RoadmapItem' +import type { VoteState } from '@/types/changelog' +``` + +Add `voteState` state inside the component (after the `activeTab` state): + +```tsx +const [voteState, setVoteState] = useState>( + () => + Object.fromEntries( + roadmapItems.map((item) => [ + item._id, + { count: 0, voted: false, loading: false }, + ]) + ) +) + +// Filled in Task 5 — no-op for now +function handleVote(_id: string) {} +``` + +Replace the `{activeTab === 'whats-next' && ...}` stub block with: + +```tsx +{/* ── What's Next ── */} +{activeTab === 'whats-next' && ( +
+ {roadmapItems.length === 0 ? ( +

+ No roadmap items yet — check back soon. +

+ ) : ( + <> + {roadmapItems.filter((i) => i.status === 'in-progress').length > 0 && ( +
+

+ In Progress +

+
+ {roadmapItems + .filter((i) => i.status === 'in-progress') + .map((item) => ( + + ))} +
+
+ )} + + {roadmapItems.filter((i) => i.status === 'planned').length > 0 && ( +
+

+ Planned +

+
+ {roadmapItems + .filter((i) => i.status === 'planned') + .map((item) => ( + + ))} +
+
+ )} + +

+ Don't see what you need?{' '} + +

+ + )} +
+)} +``` + +- [ ] **Step 3: Verify build** + +```bash +cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit +``` + +- [ ] **Step 4: Smoke test** + +Open `http://localhost:3000/changelog#whats-next`. Confirm roadmap tab renders (empty state with message, or items if Sanity has data). Vote buttons render; clicking them does nothing yet (no-op). + +- [ ] **Step 5: Commit** + +```bash +git add components/RoadmapItem.tsx components/ChangelogTabs.tsx +git commit -m "feat: add roadmap tab with static RoadmapItem component" +``` + +--- + +## Task 4: API proxy routes + +**Files:** +- Create: `app/api/feedback/route.ts` +- Create: `app/api/roadmap/votes/route.ts` +- Create: `app/api/roadmap/[sanityId]/vote/route.ts` +- Create: `app/api/admin/feedback/route.ts` +- Create: `app/api/admin/feedback/[id]/route.ts` + +All routes use the project's established manual `cookies()` + `fetch()` pattern — the same pattern used by `app/api/builds/route.ts` and all other routes in this project. + +- [ ] **Step 1: Create `app/api/feedback/route.ts`** + +No auth required. Captures IP and user-agent server-side and adds them to the JSON body before forwarding to Spring Boot. + +```ts +import { NextRequest, NextResponse } from 'next/server' + +const BACKEND_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL || '' + +export async function POST(req: NextRequest) { + try { + const body = await req.json() + + 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' + + const res = await fetch(`${BACKEND_URL}/api/v1/feedback`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ ...body, ipAddress: ip, userAgent }), + cache: 'no-store', + }) + + if (!res.ok) { + const text = await res.text().catch(() => '') + console.error('[/api/feedback] Spring Boot error:', res.status, text) + return NextResponse.json({ error: 'Failed to submit' }, { status: res.status }) + } + + return NextResponse.json({ ok: true }) + } catch (e) { + console.error('[/api/feedback] proxy error:', e) + return NextResponse.json({ error: 'Invalid request' }, { status: 400 }) + } +} +``` + +- [ ] **Step 2: Create `app/api/roadmap/votes/route.ts`** + +Auth is optional. If a session token is present, it is forwarded so Spring Boot can return the current user's voted state. + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const BACKEND_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 = { Accept: 'application/json' } + if (token) headers['Authorization'] = `Bearer ${token}` + + const ids = req.nextUrl.searchParams.get('ids') ?? '' + + try { + const res = await fetch( + `${BACKEND_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({}) + } +} +``` + +- [ ] **Step 3: Create `app/api/roadmap/[sanityId]/vote/route.ts`** + +Auth required — returns 401 if no session token. + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const BACKEND_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( + `${BACKEND_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 }) + } +} +``` + +- [ ] **Step 4: Create `app/api/admin/feedback/route.ts`** + +Auth required. Session token is forwarded; Spring Boot enforces admin role. + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const BACKEND_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( + `${BACKEND_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 }) + } +} +``` + +- [ ] **Step 5: Create `app/api/admin/feedback/[id]/route.ts`** + +```ts +import { NextRequest, NextResponse } from 'next/server' +import { cookies } from 'next/headers' + +const BACKEND_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(`${BACKEND_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 }) + } +} +``` + +- [ ] **Step 6: Verify build** + +```bash +cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit +``` + +- [ ] **Step 7: Commit** + +```bash +git add app/api/feedback/route.ts app/api/roadmap/votes/route.ts "app/api/roadmap/[sanityId]/vote/route.ts" app/api/admin/feedback/route.ts "app/api/admin/feedback/[id]/route.ts" +git commit -m "feat: add API proxy routes for feedback and roadmap votes" +``` + +--- + +## Task 5: Wire up upvote functionality + +**Files:** +- Modify: `components/ChangelogTabs.tsx` + +Replaces the no-op `handleVote` with real vote toggle logic. Unauthenticated users see an inline sign-in prompt (not a redirect) — keeps the user on the page. + +- [ ] **Step 1: Update `components/ChangelogTabs.tsx`** + +Add this import at the top (after existing imports): + +```tsx +import { useAuth } from '@/context/AuthContext' +import Link from 'next/link' +``` + +Inside the component, add these after the `voteState` state declaration: + +```tsx +const { user } = useAuth() +const [showSignInPrompt, setShowSignInPrompt] = useState(false) +const [votesFetched, setVotesFetched] = useState(false) + +// Fetch vote state when the What's Next tab is first viewed +useEffect(() => { + if (activeTab !== 'whats-next' || votesFetched || roadmapItems.length === 0) return + setVotesFetched(true) + + const ids = roadmapItems.map((i) => i._id).join(',') + fetch(`/api/roadmap/votes?ids=${encodeURIComponent(ids)}`, { + credentials: 'include', + }) + .then((r) => (r.ok ? r.json() : {})) + .then((data: Record) => { + 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, roadmapItems]) +``` + +Replace the no-op `handleVote` with: + +```tsx +async function handleVote(id: string) { + if (!user) { + setShowSignInPrompt(true) + 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 } })) + } +} +``` + +Add the sign-in prompt below the roadmap items (inside the What's Next block, after the "Don't see what you need?" nudge): + +```tsx +{showSignInPrompt && ( +

+ + Sign in + {' '} + to upvote roadmap items. +

+)} +``` + +- [ ] **Step 2: Verify build** + +```bash +cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit +``` + +- [ ] **Step 3: Smoke test** + +Open `http://localhost:3000/changelog#whats-next`: +- Logged out: click upvote → "Sign in to upvote roadmap items." prompt appears below the list, fades after 4 seconds +- Logged in: click upvote → optimistic count update, request fires to `/api/roadmap/{id}/vote` (rolls back cleanly until Spring Boot is ready) + +- [ ] **Step 4: Commit** + +```bash +git add components/ChangelogTabs.tsx +git commit -m "feat: wire up roadmap upvote with optimistic UI and inline auth prompt" +``` + +--- + +## Task 6: `FeedbackForm` + `FeedbackModal` + Give Feedback tab + +**Files:** +- Create: `components/FeedbackForm.tsx` +- Create: `components/FeedbackModal.tsx` +- Modify: `components/ChangelogTabs.tsx` (replace Give Feedback stub) + +- [ ] **Step 1: Create `components/FeedbackForm.tsx`** + +```tsx +'use client' + +import { useState } from 'react' + +type Status = 'idle' | 'loading' | 'done' | 'error' + +const CATEGORIES = [ + { value: 'BUG', label: 'Bug report' }, + { value: 'FEATURE_REQUEST', label: 'Feature request' }, + { value: 'GENERAL', label: 'General feedback' }, +] + +export function FeedbackForm({ onSuccess }: { onSuccess?: () => void }) { + const [category, setCategory] = useState('BUG') + const [message, setMessage] = useState('') + const [email, setEmail] = useState('') + const [status, setStatus] = useState('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 ( +
+

Thanks — we got it.

+

+ We read everything. If you left an email we'll follow up. +

+
+ ) + } + + return ( +
+
+
+ + +
+
+ + 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" + /> +
+
+ +
+ +