11 Commits

Author SHA1 Message Date
sean 8f2a46d3bb feat: add FeedbackForm, FeedbackModal, and Give Feedback tab 2026-03-30 19:24:11 -04:00
sean 0ca864ff20 fix: clear setTimeout ref on unmount, stable roadmapIdKey dep for votes effect 2026-03-30 19:18:23 -04:00
sean 93aa4469e8 feat: wire up roadmap upvote with optimistic UI and inline auth prompt
Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 19:15:50 -04:00
sean 89e39e7b18 fix: separate 400/500 error cases in feedback proxy route 2026-03-30 19:14:19 -04:00
sean 8e33a5497a feat: add API proxy routes for feedback and roadmap votes 2026-03-30 16:46:49 -04:00
sean 26bc1a8c21 feat: add roadmap tab with static RoadmapItem component 2026-03-30 16:42:15 -04:00
sean 2d55af4de2 fix: make tags optional in ChangelogEntry, remove unused VoteState import 2026-03-30 16:40:35 -04:00
sean d387484872 feat: add /changelog page with What's New tab
RSC page fetches changelog entries and roadmap items from Sanity with
graceful error fallback. ChangelogTabs client component renders tabbed
UI with What's New fully implemented; What's Next and Give Feedback are
stubs for Tasks 3 and 6.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-30 16:37:41 -04:00
sean e2a2af8a6f feat: add changelog/roadmap types, Sanity queries, and public route whitelist 2026-03-30 16:33:53 -04:00
sean c977e69654 docs: address spec review gaps in changelog/feedback design 2026-03-30 11:00:54 -04:00
sean 9d0eb40554 docs: add changelog, roadmap, and feedback design spec 2026-03-30 10:57:36 -04:00
14 changed files with 1045 additions and 0 deletions
+45
View File
@@ -0,0 +1,45 @@
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>
)
}
+39
View File
@@ -0,0 +1,39 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
const API_BASE_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(`${API_BASE_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 })
}
}
+29
View File
@@ -0,0 +1,29 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
const API_BASE_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(
`${API_BASE_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 })
}
}
+38
View File
@@ -0,0 +1,38 @@
import { NextRequest, NextResponse } from 'next/server'
const API_BASE_URL = process.env.API_BASE_URL || process.env.NEXT_PUBLIC_API_BASE_URL
export async function POST(req: NextRequest) {
let body: unknown
try {
body = await req.json()
} catch {
return NextResponse.json({ error: 'Invalid request' }, { status: 400 })
}
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'
try {
const res = await fetch(`${API_BASE_URL}/api/v1/feedback`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ...(body as object), 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: 'Service unavailable' }, { status: 500 })
}
}
+34
View File
@@ -0,0 +1,34 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
const API_BASE_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(
`${API_BASE_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 })
}
}
+28
View File
@@ -0,0 +1,28 @@
import { NextRequest, NextResponse } from 'next/server'
import { cookies } from 'next/headers'
const API_BASE_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(
`${API_BASE_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({})
}
}
+315
View File
@@ -0,0 +1,315 @@
'use client'
import { useEffect, useRef, useState } from 'react'
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
import { RoadmapItem } from '@/components/RoadmapItem'
import { FeedbackForm } from '@/components/FeedbackForm'
import type { ChangelogEntry, RoadmapItemData, Tab, VoteState } from '@/types/changelog'
import { useAuth } from '@/context/AuthContext'
import Link from 'next/link'
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')
const [voteState, setVoteState] = useState<Record<string, VoteState>>(
() =>
Object.fromEntries(
roadmapItems.map((item) => [
item._id,
{ count: 0, voted: false, loading: false },
])
)
)
const { user } = useAuth()
const [showSignInPrompt, setShowSignInPrompt] = useState(false)
const signInTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null)
const [votesFetched, setVotesFetched] = useState(false)
// Clear sign-in prompt timer on unmount
useEffect(() => () => { if (signInTimerRef.current) clearTimeout(signInTimerRef.current) }, [])
// Stable key for roadmapItems identity — avoids unstable array reference in deps
const roadmapIdKey = roadmapItems.map((i) => i._id).join(',')
// Fetch vote state when the What's Next tab is first viewed
useEffect(() => {
if (activeTab !== 'whats-next' || votesFetched || !roadmapIdKey) return
setVotesFetched(true)
fetch(`/api/roadmap/votes?ids=${encodeURIComponent(roadmapIdKey)}`, {
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, roadmapIdKey]) // eslint-disable-line react-hooks/exhaustive-deps
async function handleVote(id: string) {
if (!user) {
if (signInTimerRef.current) clearTimeout(signInTimerRef.current)
setShowSignInPrompt(true)
signInTimerRef.current = 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 } }))
}
}
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) > 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 ── */}
{activeTab === 'whats-next' && (
<div>
{roadmapItems.length === 0 ? (
<p className="text-sm text-zinc-500">
No roadmap items yet check back soon.
</p>
) : (
<>
{(() => {
const inProgress = roadmapItems.filter((i) => i.status === 'in-progress')
const planned = roadmapItems.filter((i) => i.status === 'planned')
return (
<>
{inProgress.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">
{inProgress.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>
)}
{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">
{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>
{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>
)}
</>
)}
</div>
)}
{/* ── 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>
)}
</div>
)
}
+129
View File
@@ -0,0 +1,129 @@
'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>
)
}
+51
View File
@@ -0,0 +1,51 @@
'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>
)
}
+58
View File
@@ -0,0 +1,58 @@
'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>
)
}
@@ -0,0 +1,235 @@
# Changelog, Roadmap & Feedback Design Spec
**Date:** 2026-03-30
**Status:** Approved
---
## Overview
Add a public `/changelog` page with three tabs (What's New, What's Next, Give Feedback), auth-gated upvoting on roadmap items, a shared feedback form available as a standalone page and a modal triggered from the nav/footer, and an admin view for reviewing submissions.
---
## Goals
- Publicly share release notes (changelog) and upcoming work (roadmap)
- Let authenticated users upvote roadmap items to signal priority
- Collect structured user feedback (bug / feature / general) with optional email
- Store feedback in Spring Boot DB, reviewable in the admin panel
- Add "Send Feedback" entry point to TopNav and homepage footer
---
## Routes
| Route | Description |
|---|---|
| `/changelog` | Three-tab page: What's New / What's Next / Give Feedback |
| `/feedback` | Standalone feedback page — full-width `FeedbackForm` |
| `/admin/feedback` | Admin table of feedback submissions |
Tab state on `/changelog` is driven by URL hash (`#whats-new`, `#whats-next`, `#give-feedback`) so tabs are deep-linkable. When no hash is present, the default active tab is **What's New**. The "Don't see what you need? Give us feedback →" nudge on the roadmap tab links to `#give-feedback`.
### Middleware
`/changelog` and `/feedback` are public routes — no authentication required. Both must be added to `isPublicPath()` in `middleware.ts` so they remain accessible when `LAUNCH_ONLY_ROOT=true`. The Next.js proxy routes `/api/feedback` (POST) and `/api/roadmap/votes` (GET) require no auth and will pass through middleware as-is since they are not prefixed with `/api/admin` or matched by `isProtectedLaunchPath()`. This is intentional and requires no middleware change for those API routes.
---
## Sanity Schema (two new document types)
### `changelogEntry`
| Field | Type | Notes |
|---|---|---|
| `title` | string | Display title of the release |
| `version` | string | e.g. `v0.4` |
| `publishedAt` | datetime | Controls ordering and display date |
| `tags` | array of string | Values: `feature`, `fix`, `improvement` |
| `body` | Portable Text | Rich text body |
### `roadmapItem`
| Field | Type | Notes |
|---|---|---|
| `title` | string | Short item title |
| `description` | text | Plain-text description |
| `status` | string enum | `planned` \| `in-progress` \| `shipped` |
| `order` | number | Manual sort order within each status group |
Both use the existing Sanity project (`zal102rv`, dataset `production`). GROQ query strings are added to `lib/sanity.ts` alongside the existing `postsQuery`. The RSC page **must call `sanityFetch()` from `lib/sanityFetch.ts`** to execute those queries — not `client.fetch()` from the Sanity client directly. This is the same pattern used by the guides page and is required for correct behaviour in Docker/server environments where CDN DNS resolution fails.
Pages use `export const revalidate = 60` ISR, consistent with guides.
The Sanity `_id` of each `roadmapItem` serves as the foreign key in Spring Boot's votes table — no additional ID field required. Sanity `_id` values are stable UUIDs.
### GROQ Query Filters
- `changelogEntriesQuery`: filter `_type == "changelogEntry" && publishedAt <= now()`, order by `publishedAt desc`
- `roadmapItemsQuery`: filter `_type == "roadmapItem" && status != "shipped"`, order by `order asc` within each status group. **Shipped items are excluded from the roadmap query** — when an item ships, the author changes its status to `shipped` in Sanity and creates a corresponding `changelogEntry`. The roadmap tab never displays shipped items.
---
## Spring Boot Backend
### New Tables
**`feedback`**
| Column | Type | Notes |
|---|---|---|
| `id` | UUID, PK | |
| `category` | enum | `BUG`, `FEATURE_REQUEST`, `GENERAL` |
| `message` | text | Max 1000 chars, enforced at API level |
| `email` | varchar, nullable | Optional follow-up email |
| `ip_address` | varchar | Captured server-side for spam visibility |
| `user_agent` | varchar | Captured server-side for spam visibility |
| `reviewed` | boolean, default false | Admin marks reviewed |
| `created_at` | timestamp | |
Rate limiting on `POST /api/v1/feedback` is deferred — IP and user agent capture provides enough visibility for manual spam review at this scale.
**`roadmap_votes`**
| Column | Type | Notes |
|---|---|---|
| `user_id` | UUID, FK → users | Part of composite PK |
| `sanity_item_id` | varchar | Part of composite PK; Sanity `_id` of the roadmap item |
| `created_at` | timestamp | |
| | PRIMARY KEY(user_id, sanity_item_id) | Composite PK — enforces one vote per user per item |
### New API Endpoints (Spring Boot)
| Method | Path | Auth | Notes |
|---|---|---|---|
| `POST` | `/api/v1/feedback` | None | Creates feedback record; captures IP + user agent server-side |
| `GET` | `/api/v1/roadmap/votes?ids=...` | Optional | Returns vote counts + current user's voted state for a list of Sanity IDs. For unauthenticated callers, returns vote counts with `voted: false` for all items — never 401. |
| `POST` | `/api/v1/roadmap/{sanityId}/vote` | Required | Toggles vote on/off; returns new count and voted state |
### Next.js Proxy Routes
All three endpoints are proxied via `app/api/` — client code never calls Spring Boot directly, consistent with the existing proxy pattern.
| Next.js route | Proxies to |
|---|---|
| `POST /api/feedback` | `POST /api/v1/feedback` |
| `GET /api/roadmap/votes` | `GET /api/v1/roadmap/votes` |
| `POST /api/roadmap/[sanityId]/vote` | `POST /api/v1/roadmap/:id/vote` |
The Next.js proxy for `POST /api/feedback` captures `x-forwarded-for` and `user-agent` from the incoming request headers and forwards them to Spring Boot — the client form never sends these values.
---
## Frontend Components
### New Files
| File | Type | Responsibility |
|---|---|---|
| `app/(app)/changelog/page.tsx` | RSC | Fetches Sanity changelog + roadmap data via `sanityFetch`, renders tab shell + passes data as props |
| `components/ChangelogTabs.tsx` | `"use client"` | Hash-based tab switcher (default: `#whats-new`), renders all three tab panels |
| `components/RoadmapItem.tsx` | `"use client"` | Single roadmap item with optimistic upvote toggle |
| `components/FeedbackForm.tsx` | `"use client"` | Shared form (category + message + email), posts to `/api/feedback` |
| `components/FeedbackModal.tsx` | `"use client"` | Backdrop + dialog wrapper around `FeedbackForm` |
| `app/(app)/feedback/page.tsx` | RSC | Standalone page rendering `FeedbackForm` full-width |
| `app/admin/feedback/page.tsx` | RSC | Admin table of submissions, sortable + filterable |
| `app/api/feedback/route.ts` | API route | Proxy to Spring Boot; forwards IP + user-agent headers |
| `app/api/roadmap/votes/route.ts` | API route | Proxy to Spring Boot |
| `app/api/roadmap/[sanityId]/vote/route.ts` | API route | Proxy to Spring Boot |
### Modified Files
| File | Change |
|---|---|
| `lib/sanity.ts` | Add `changelogEntriesQuery` and `roadmapItemsQuery` |
| `middleware.ts` | Add `/changelog` and `/feedback` to `isPublicPath()` |
| `components/TopNav.tsx` | Add "Send Feedback" ghost button; modal open state is local to `TopNav` via `useState` |
| `app/page.tsx` | Add "Send Feedback" link to the homepage's inline footer (homepage-only; this is intentional — TopNav covers all other pages) |
### FeedbackModal State
`FeedbackModal` open/close state is managed locally in `TopNav` with `useState`. No global context or portal is required — the modal renders inside `TopNav`'s JSX and is positioned fixed via CSS. This is sufficient given "Send Feedback" is the only trigger point in the nav.
---
## UI Design
### `/changelog` Page Header
```
BATTL BUILDERS (small caps label)
Updates (h1)
What we've shipped and what's coming next. (subhead)
[ What's New ] [ What's Next ] [ Give Feedback ] (tab bar, amber underline on active)
```
### What's New Tab
Two-column entry layout: left column is version + date (120px fixed), right column is tags + title + body. Entries separated by bottom border, ordered newest first.
Tag values rendered as small pill badges:
- `feature` → amber
- `fix` → green
- `improvement` → blue
### What's Next Tab
Items grouped under "In Progress" and "Planned" headings. Shipped items are never shown here (filtered out in the GROQ query). Each item is a card with:
- **Left:** upvote button (chevron up + count) — bordered pill, amber when voted
- **Center:** title + description
- **Right:** status badge (In Progress = amber, Planned = muted)
Unauthenticated users clicking upvote are prompted to sign in (toast). Voted state is toggled optimistically — rolls back on error.
Footer nudge: *"Don't see what you need? Give us feedback →"* links to `#give-feedback`.
### Give Feedback Tab & `/feedback` Page
Full-width form:
- Category + email in a two-column row
- Message textarea (full width, 1000 char limit with counter)
- Submit button bottom-right, privacy note bottom-left
### Feedback Modal
Triggered by "Send Feedback" in TopNav and homepage inline footer. Same fields as the tab form. Full-width submit button inside the modal.
---
## Admin View (`/admin/feedback`)
Table with columns: Date, Category, Message (truncated to 120 characters), Email, Reviewed. Sortable by date. Filterable by category (Bug / Feature / General) and reviewed status. Follows existing admin page patterns.
---
## Feedback Form Fields
| Field | Required | Validation |
|---|---|---|
| Category | Yes | One of: Bug report, Feature request, General feedback |
| Message | Yes | 11000 characters |
| Email | No | Valid email format if provided |
`ip_address` and `user_agent` are captured server-side in the Next.js proxy route — never sent from the client form.
---
## SEO Metadata
Both `/changelog` and `/feedback` export a `metadata` object following the existing page pattern:
- `/changelog`: `title: "What's New — Battl Builders"`, relevant description
- `/feedback`: `title: "Send Feedback — Battl Builders"`, relevant description
---
## Out of Scope
- Email notifications when feedback is submitted (separate task)
- Sorting roadmap items by vote count (manual `order` field only for now)
- Public display of vote counts on the changelog tab
- Sanity Studio schema deployment (handled separately via Sanity CLI)
- Spring Boot entity/migration code (lives in `ballistic-builder-spring` repo, tracked separately)
- Rate limiting on the feedback endpoint (deferred; IP capture provides visibility at current scale)
+16
View File
@@ -55,3 +55,19 @@ export const postBySlugQuery = `
export const postSlugsQuery = `
*[_type == "post" && defined(slug.current)] { "slug": slug.current }
`
/** 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
}
`
+4
View File
@@ -25,6 +25,10 @@ function isPublicPath(pathname: string) {
if (pathname === "/guides") return true;
if (pathname.startsWith("/guides/")) return true;
// Changelog and feedback — always public
if (pathname === "/changelog") return true;
if (pathname === "/feedback") return true;
// Static / framework assets
if (pathname.startsWith("/_next")) return true;
if (pathname === "/favicon.ico") return true;
+24
View File
@@ -0,0 +1,24 @@
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
}