feat: wire up roadmap upvote with optimistic UI and inline auth prompt

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-03-30 19:15:50 -04:00
parent 89e39e7b18
commit 93aa4469e8
+136 -58
View File
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react'
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
import { RoadmapItem } from '@/components/RoadmapItem'
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" },
@@ -44,8 +46,71 @@ export function ChangelogTabs({
)
)
// Filled in Task 5 — no-op for now
function handleVote(_id: string) {}
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])
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 } }))
}
}
useEffect(() => {
const hash = window.location.hash.replace('#', '') as Tab
@@ -140,63 +205,67 @@ export function ChangelogTabs({
</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>
)}
{(() => {
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>
)}
{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>
)}
{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?{' '}
@@ -207,6 +276,15 @@ export function ChangelogTabs({
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>