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:
+136
-58
@@ -4,6 +4,8 @@ import { useEffect, useState } from 'react'
|
|||||||
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
|
import { PortableTextRenderer } from '@/components/PortableTextRenderer'
|
||||||
import { RoadmapItem } from '@/components/RoadmapItem'
|
import { RoadmapItem } from '@/components/RoadmapItem'
|
||||||
import type { ChangelogEntry, RoadmapItemData, Tab, VoteState } from '@/types/changelog'
|
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 }[] = [
|
const TABS: { id: Tab; label: string }[] = [
|
||||||
{ id: 'whats-new', label: "What's New" },
|
{ id: 'whats-new', label: "What's New" },
|
||||||
@@ -44,8 +46,71 @@ export function ChangelogTabs({
|
|||||||
)
|
)
|
||||||
)
|
)
|
||||||
|
|
||||||
// Filled in Task 5 — no-op for now
|
const { user } = useAuth()
|
||||||
function handleVote(_id: string) {}
|
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(() => {
|
useEffect(() => {
|
||||||
const hash = window.location.hash.replace('#', '') as Tab
|
const hash = window.location.hash.replace('#', '') as Tab
|
||||||
@@ -140,63 +205,67 @@ export function ChangelogTabs({
|
|||||||
</p>
|
</p>
|
||||||
) : (
|
) : (
|
||||||
<>
|
<>
|
||||||
{roadmapItems.filter((i) => i.status === 'in-progress').length > 0 && (
|
{(() => {
|
||||||
<div className="mb-8">
|
const inProgress = roadmapItems.filter((i) => i.status === 'in-progress')
|
||||||
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
|
const planned = roadmapItems.filter((i) => i.status === 'planned')
|
||||||
In Progress
|
return (
|
||||||
</p>
|
<>
|
||||||
<div className="flex flex-col gap-2.5">
|
{inProgress.length > 0 && (
|
||||||
{roadmapItems
|
<div className="mb-8">
|
||||||
.filter((i) => i.status === 'in-progress')
|
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
|
||||||
.map((item) => (
|
In Progress
|
||||||
<RoadmapItem
|
</p>
|
||||||
key={item._id}
|
<div className="flex flex-col gap-2.5">
|
||||||
id={item._id}
|
{inProgress.map((item) => (
|
||||||
title={item.title}
|
<RoadmapItem
|
||||||
description={item.description}
|
key={item._id}
|
||||||
status={item.status}
|
id={item._id}
|
||||||
vote={
|
title={item.title}
|
||||||
voteState[item._id] ?? {
|
description={item.description}
|
||||||
count: 0,
|
status={item.status}
|
||||||
voted: false,
|
vote={
|
||||||
loading: false,
|
voteState[item._id] ?? {
|
||||||
}
|
count: 0,
|
||||||
}
|
voted: false,
|
||||||
onVote={handleVote}
|
loading: false,
|
||||||
/>
|
}
|
||||||
))}
|
}
|
||||||
</div>
|
onVote={handleVote}
|
||||||
</div>
|
/>
|
||||||
)}
|
))}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
|
||||||
{roadmapItems.filter((i) => i.status === 'planned').length > 0 && (
|
{planned.length > 0 && (
|
||||||
<div>
|
<div>
|
||||||
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
|
<p className="mb-3 text-[10px] font-bold uppercase tracking-[0.18em] text-zinc-600">
|
||||||
Planned
|
Planned
|
||||||
</p>
|
</p>
|
||||||
<div className="flex flex-col gap-2.5">
|
<div className="flex flex-col gap-2.5">
|
||||||
{roadmapItems
|
{planned.map((item) => (
|
||||||
.filter((i) => i.status === 'planned')
|
<RoadmapItem
|
||||||
.map((item) => (
|
key={item._id}
|
||||||
<RoadmapItem
|
id={item._id}
|
||||||
key={item._id}
|
title={item.title}
|
||||||
id={item._id}
|
description={item.description}
|
||||||
title={item.title}
|
status={item.status}
|
||||||
description={item.description}
|
vote={
|
||||||
status={item.status}
|
voteState[item._id] ?? {
|
||||||
vote={
|
count: 0,
|
||||||
voteState[item._id] ?? {
|
voted: false,
|
||||||
count: 0,
|
loading: false,
|
||||||
voted: false,
|
}
|
||||||
loading: false,
|
}
|
||||||
}
|
onVote={handleVote}
|
||||||
}
|
/>
|
||||||
onVote={handleVote}
|
))}
|
||||||
/>
|
</div>
|
||||||
))}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
</>
|
||||||
)}
|
)
|
||||||
|
})()}
|
||||||
|
|
||||||
<p className="mt-8 text-xs text-zinc-600">
|
<p className="mt-8 text-xs text-zinc-600">
|
||||||
Don't see what you need?{' '}
|
Don't see what you need?{' '}
|
||||||
@@ -207,6 +276,15 @@ export function ChangelogTabs({
|
|||||||
Give us feedback →
|
Give us feedback →
|
||||||
</button>
|
</button>
|
||||||
</p>
|
</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>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user