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>
This commit is contained in:
@@ -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,131 @@
|
|||||||
|
'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>
|
||||||
|
)
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user