Compare commits
11 Commits
2a97ae4190
...
v0.1.0
| Author | SHA1 | Date | |
|---|---|---|---|
| 8f2a46d3bb | |||
| 0ca864ff20 | |||
| 93aa4469e8 | |||
| 89e39e7b18 | |||
| 8e33a5497a | |||
| 26bc1a8c21 | |||
| 2d55af4de2 | |||
| d387484872 | |||
| e2a2af8a6f | |||
| c977e69654 | |||
| 9d0eb40554 |
@@ -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've shipped and what's coming next.
|
||||
</p>
|
||||
</header>
|
||||
<ChangelogTabs entries={entries} roadmapItems={roadmapItems} />
|
||||
</main>
|
||||
)
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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 })
|
||||
}
|
||||
}
|
||||
@@ -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({})
|
||||
}
|
||||
}
|
||||
@@ -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'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>
|
||||
)
|
||||
}
|
||||
@@ -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'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'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>
|
||||
)
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
}
|
||||
@@ -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 | 1–1000 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)
|
||||
@@ -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
|
||||
}
|
||||
`
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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
|
||||
}
|
||||
Reference in New Issue
Block a user