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

49 KiB

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

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:

/** 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):

// Changelog and feedback — always public
if (pathname === '/changelog') return true
if (pathname === '/feedback') return true
  • Step 4: Type-check
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit

Expected: no new errors.

  • Step 5: Commit
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

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.

'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
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

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.

'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):

import { RoadmapItem } from '@/components/RoadmapItem'
import type { VoteState } from '@/types/changelog'

Add voteState state inside the component (after the activeTab state):

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:

{/* ── 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
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
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.

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.

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.

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.

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
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
cd /Users/sean/Dev/battl/gunbuilder-prototype && npx tsc --noEmit
  • Step 7: Commit
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):

import { useAuth } from '@/context/AuthContext'
import Link from 'next/link'

Inside the component, add these after the voteState state declaration:

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:

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):

{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
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

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

'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
'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:

import { FeedbackForm } from '@/components/FeedbackForm'

Replace the {activeTab === 'give-feedback' && ...} stub:

{/* ── 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
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
git add components/FeedbackForm.tsx components/FeedbackModal.tsx components/ChangelogTabs.tsx
git commit -m "feat: add FeedbackForm, FeedbackModal, and Give Feedback tab"

Files:

  • Create: app/(app)/feedback/page.tsx

  • Modify: components/TopNav.tsx

  • Modify: app/page.tsx

  • Step 1: Create app/(app)/feedback/page.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:

import { useState } from 'react'
import { FeedbackModal } from '@/components/FeedbackModal'

Inside the TopNav component, add state before return:

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:

<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:

<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:

<Link href="/feedback" className="hover:text-zinc-500">
  Send Feedback
</Link>

The full bottom bar should look like:

<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
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

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"

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().

'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:

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:

{
  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
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
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