# 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
