Files
shadow-gunbuilder-ai-proto/docs/superpowers/plans/2026-03-30-changelog-feedback.md
T
sean cdb3a8dbb3
CI / deploy (push) Failing after 5s
v0.1.0 commit
2026-03-31 11:31:40 -04:00

1555 lines
49 KiB
Markdown

# 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<ChangelogEntry[]>(changelogEntriesQuery)
} catch (err) {
console.error('[Changelog] entries fetch failed:', err)
}
try {
roadmapItems = await sanityFetch<RoadmapItemData[]>(roadmapItemsQuery)
} catch (err) {
console.error('[Changelog] roadmap fetch failed:', err)
}
return (
<main className="mx-auto max-w-4xl px-4 py-10">
<header className="mb-10">
<p className="text-xs font-semibold uppercase tracking-[0.2em] text-zinc-500">
Battl Builders
</p>
<h1 className="mt-2 text-3xl font-semibold tracking-tight">Updates</h1>
<p className="mt-2 text-sm text-zinc-400">
What we&apos;ve shipped and what&apos;s coming next.
</p>
</header>
<ChangelogTabs entries={entries} roadmapItems={roadmapItems} />
</main>
)
}
```
- [ ] **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<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')
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 && (
<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 — stub, filled in Task 3 ── */}
{activeTab === 'whats-next' && (
<p className="text-sm text-zinc-500">Roadmap coming soon.</p>
)}
{/* ── Give Feedback — stub, filled in Task 6 ── */}
{activeTab === 'give-feedback' && (
<p className="text-sm text-zinc-500">Feedback form coming soon.</p>
)}
</div>
)
}
```
- [ ] **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 (
<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>
)
}
```
- [ ] **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<Record<string, VoteState>>(
() =>
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' && (
<div>
{roadmapItems.length === 0 ? (
<p className="text-sm text-zinc-500">
No roadmap items yet check back soon.
</p>
) : (
<>
{roadmapItems.filter((i) => i.status === 'in-progress').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">
{roadmapItems
.filter((i) => i.status === 'in-progress')
.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>
)}
{roadmapItems.filter((i) => i.status === '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">
{roadmapItems
.filter((i) => i.status === 'planned')
.map((item) => (
<RoadmapItem
key={item._id}
id={item._id}
title={item.title}
description={item.description}
status={item.status}
vote={
voteState[item._id] ?? {
count: 0,
voted: false,
loading: false,
}
}
onVote={handleVote}
/>
))}
</div>
</div>
)}
<p className="mt-8 text-xs text-zinc-600">
Don&apos;t see what you need?{' '}
<button
onClick={() => handleTabChange('give-feedback')}
className="underline underline-offset-2 hover:text-zinc-300"
>
Give us feedback
</button>
</p>
</>
)}
</div>
)}
```
- [ ] **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<string, string> = { 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<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, 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 && (
<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>
)}
```
- [ ] **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<Status>('idle')
async function handleSubmit(e: React.FormEvent) {
e.preventDefault()
if (!message.trim()) return
setStatus('loading')
try {
const res = await fetch('/api/feedback', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({
category,
message: message.trim(),
email: email.trim() || undefined,
}),
})
if (res.ok) {
setStatus('done')
onSuccess?.()
} else {
setStatus('error')
}
} catch {
setStatus('error')
}
}
if (status === 'done') {
return (
<div className="py-6 text-center">
<p className="text-sm font-semibold text-amber-400">Thanks we got it.</p>
<p className="mt-1 text-xs text-zinc-500">
We read everything. If you left an email we&apos;ll follow up.
</p>
</div>
)
}
return (
<form onSubmit={handleSubmit} className="space-y-5">
<div className="grid grid-cols-1 gap-5 sm:grid-cols-2">
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Category
</label>
<select
value={category}
onChange={(e) => setCategory(e.target.value)}
disabled={status === 'loading'}
className="w-full appearance-none rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-200 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
>
{CATEGORIES.map((c) => (
<option key={c.value} value={c.value}>
{c.label}
</option>
))}
</select>
</div>
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Email{' '}
<span className="font-normal normal-case tracking-normal text-zinc-600">
(optional if you want a reply)
</span>
</label>
<input
type="email"
value={email}
onChange={(e) => setEmail(e.target.value)}
placeholder="you@email.com"
disabled={status === 'loading'}
className="w-full rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm text-zinc-200 placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
/>
</div>
</div>
<div>
<label className="mb-2 block text-[11px] font-semibold uppercase tracking-wider text-zinc-500">
Message
</label>
<textarea
value={message}
onChange={(e) => setMessage(e.target.value.slice(0, 1000))}
placeholder="What's on your mind?"
rows={5}
required
disabled={status === 'loading'}
className="w-full resize-y rounded-lg border border-zinc-800 bg-zinc-950 px-3 py-2.5 text-sm leading-relaxed text-zinc-200 placeholder-zinc-600 focus:border-amber-500/40 focus:outline-none disabled:opacity-50"
/>
<p className="mt-1.5 text-right text-[11px] text-zinc-600">
{message.length} / 1000
</p>
</div>
<div className="flex items-center justify-between gap-4">
<p className="text-[11px] text-zinc-600">
We&apos;ll only use your email to follow up on your feedback.
</p>
<button
type="submit"
disabled={status === 'loading' || !message.trim()}
className="shrink-0 rounded-lg bg-amber-500/90 px-6 py-2.5 text-sm font-bold text-black hover:bg-amber-400 disabled:opacity-50"
>
{status === 'loading' ? 'Sending…' : 'Send Feedback →'}
</button>
</div>
{status === 'error' && (
<p className="text-xs text-red-400">Something went wrong. Try again.</p>
)}
</form>
)
}
```
- [ ] **Step 2: Create `components/FeedbackModal.tsx`**
```tsx
'use client'
import { useEffect } from 'react'
import { XMarkIcon } from '@heroicons/react/20/solid'
import { FeedbackForm } from '@/components/FeedbackForm'
export function FeedbackModal({
open,
onClose,
}: {
open: boolean
onClose: () => void
}) {
useEffect(() => {
if (!open) return
function onKey(e: KeyboardEvent) {
if (e.key === 'Escape') onClose()
}
document.addEventListener('keydown', onKey)
return () => document.removeEventListener('keydown', onKey)
}, [open, onClose])
if (!open) return null
return (
<div
className="fixed inset-0 z-50 flex items-center justify-center p-4"
role="dialog"
aria-modal="true"
>
<div
className="absolute inset-0 bg-black/70 backdrop-blur-sm"
onClick={onClose}
aria-hidden="true"
/>
<div className="relative z-10 w-full max-w-lg rounded-2xl border border-zinc-800 bg-zinc-950 p-7 shadow-2xl">
<div className="mb-5 flex items-center justify-between">
<h2 className="text-base font-bold text-white">Send Feedback</h2>
<button
onClick={onClose}
className="text-zinc-500 hover:text-zinc-300"
aria-label="Close"
>
<XMarkIcon className="h-5 w-5" />
</button>
</div>
<FeedbackForm onSuccess={onClose} />
</div>
</div>
)
}
```
- [ ] **Step 3: Replace the Give Feedback stub in `components/ChangelogTabs.tsx`**
Add import at the top:
```tsx
import { FeedbackForm } from '@/components/FeedbackForm'
```
Replace the `{activeTab === 'give-feedback' && ...}` stub:
```tsx
{/* ── 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>
)}
```
- [ ] **Step 4: Verify build**
```bash
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
```
- [ ] **Step 5: Smoke test**
Open `http://localhost:3000/changelog#give-feedback`. Confirm form renders. Submit — expect error until Spring Boot is ready; success state renders cleanly when it is.
- [ ] **Step 6: Commit**
```bash
git add components/FeedbackForm.tsx components/FeedbackModal.tsx components/ChangelogTabs.tsx
git commit -m "feat: add FeedbackForm, FeedbackModal, and Give Feedback tab"
```
---
## Task 7: Standalone `/feedback` page + TopNav + homepage footer
**Files:**
- Create: `app/(app)/feedback/page.tsx`
- Modify: `components/TopNav.tsx`
- Modify: `app/page.tsx`
- [ ] **Step 1: Create `app/(app)/feedback/page.tsx`**
```tsx
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>
)
}
```
- [ ] **Step 2: Add "Send Feedback" button to `components/TopNav.tsx`**
The file already has `"use client"` and uses `useAuth`. Add the `useState` import and `FeedbackModal` import by adding a new line near the top of the existing imports:
```tsx
import { useState } from 'react'
import { FeedbackModal } from '@/components/FeedbackModal'
```
Inside the `TopNav` component, add state before `return`:
```tsx
const [feedbackOpen, setFeedbackOpen] = useState(false)
```
In the JSX right-side actions `<div>` (the `<div className="flex items-center justify-end gap-3 sm:gap-4">` div), add the button as the first child, before the loading/auth conditional:
```tsx
<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>
```
After the closing `</header>` tag (outside the header element), add the modal:
```tsx
<FeedbackModal open={feedbackOpen} onClose={() => setFeedbackOpen(false)} />
```
- [ ] **Step 3: Add "Send Feedback" to homepage footer in `app/page.tsx`**
In the footer bottom bar, add a "Send Feedback" `<Link>` before the Privacy link:
```tsx
<Link href="/feedback" className="hover:text-zinc-500">
Send Feedback
</Link>
```
The full bottom bar should look like:
```tsx
<div className="mx-auto flex max-w-5xl items-center justify-between border-t border-zinc-900/60 px-4 py-4 text-[11px] text-zinc-700">
<span>© {new Date().getFullYear()} BATTL BUILDERS</span>
<div className="flex gap-4">
<Link href="/feedback" className="hover:text-zinc-500">
Send Feedback
</Link>
<Link href="/privacy" className="hover:text-zinc-500">
Privacy
</Link>
<Link href="/tos" className="hover:text-zinc-500">
Terms
</Link>
</div>
</div>
```
- [ ] **Step 4: Verify build**
```bash
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
```
- [ ] **Step 5: Smoke test**
- `http://localhost:3000/feedback` — standalone page renders with full-width form
- TopNav "Send Feedback" button appears on all `(app)` layout pages; clicking opens the modal; Escape closes it
- Homepage footer has "Send Feedback" link
- [ ] **Step 6: Commit**
```bash
git add "app/(app)/feedback/page.tsx" components/TopNav.tsx app/page.tsx
git commit -m "feat: add /feedback page, FeedbackModal to TopNav, and footer link"
```
---
## Task 8: Admin feedback page + nav link
**Files:**
- Create: `app/admin/feedback/page.tsx`
- Modify: `app/admin/layout.tsx`
- [ ] **Step 1: Create `app/admin/feedback/page.tsx`**
Follows the project's admin page pattern: `"use client"` + `useEffect` + `useState` + `useAuth()`.
```tsx
'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: string
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-hidden 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>
)
}
```
- [ ] **Step 2: Add Feedback nav item to `app/admin/layout.tsx`**
The admin nav uses `lucide-react` icons. Add `MessageSquare` to the existing import:
```tsx
import {
LayoutDashboard,
Download,
Layers,
Boxes,
Store,
Users,
Settings,
Mail,
Wand2,
FolderKanban,
Shield,
MessageSquare, // add this
} from "lucide-react";
```
In `navGroups`, find the `"Growth & Comms"` group and add the Feedback item:
```tsx
{
label: "Growth & Comms",
icon: <Mail className="h-4 w-4" />,
items: [
{
label: "Beta Invites",
href: "/admin/beta-invites",
icon: <Mail className="h-4 w-4" />,
},
{
label: "List Emails",
href: "/admin/email",
icon: <Mail className="h-4 w-4" />,
},
{
label: "Send an Email",
href: "/admin/email/send",
icon: <Mail className="h-4 w-4" />,
},
// Add this item:
{
label: "Feedback",
href: "/admin/feedback",
icon: <MessageSquare className="h-4 w-4" />,
},
],
},
```
- [ ] **Step 3: Verify build**
```bash
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
```
- [ ] **Step 4: Smoke test**
Open `http://localhost:3000/admin` (must be logged in as admin). Confirm "Feedback" appears in the "Growth & Comms" nav section. Click it — page renders with filters and empty table (data loads once Spring Boot is ready).
- [ ] **Step 5: Commit**
```bash
git add app/admin/feedback/page.tsx app/admin/layout.tsx
git commit -m "feat: add admin feedback review page and nav link"
```
---
## Done
All 8 tasks complete. The Next.js side is fully implemented. Remaining work to complete the feature end-to-end:
**Spring Boot (`ballistic-builder-spring` repo) — required for votes and feedback to function:**
- `POST /api/v1/feedback` — persist feedback, read `ipAddress` and `userAgent` from request body
- `GET /api/v1/roadmap/votes?ids=...` — return vote counts + current user voted state (200 for unauthenticated, `voted: false`)
- `POST /api/v1/roadmap/{sanityId}/vote` — toggle vote, return `{ count, voted }`
- `GET /api/v1/admin/feedback` — list submissions, admin-only
- `PATCH /api/v1/admin/feedback/{id}` — update `reviewed` field
**Sanity Studio — required for content to appear:**
- Define and deploy `changelogEntry` schema (fields: `title`, `version`, `publishedAt`, `tags`, `body`)
- Define and deploy `roadmapItem` schema (fields: `title`, `description`, `status`, `order`)
- Publish at least one entry of each type to verify the UI end-to-end