# 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" />