From 93aa4469e851e9186cc6329a9b3a969f089dbbe3 Mon Sep 17 00:00:00 2001 From: Sean Date: Mon, 30 Mar 2026 19:15:50 -0400 Subject: [PATCH] feat: wire up roadmap upvote with optimistic UI and inline auth prompt Co-Authored-By: Claude Sonnet 4.6 --- components/ChangelogTabs.tsx | 194 ++++++++++++++++++++++++----------- 1 file changed, 136 insertions(+), 58 deletions(-) diff --git a/components/ChangelogTabs.tsx b/components/ChangelogTabs.tsx index d73ae00..a92d8f5 100644 --- a/components/ChangelogTabs.tsx +++ b/components/ChangelogTabs.tsx @@ -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) => { + 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({

) : ( <> - {roadmapItems.filter((i) => i.status === 'in-progress').length > 0 && ( -
-

- In Progress -

-
- {roadmapItems - .filter((i) => i.status === 'in-progress') - .map((item) => ( - - ))} -
-
- )} + {(() => { + const inProgress = roadmapItems.filter((i) => i.status === 'in-progress') + const planned = roadmapItems.filter((i) => i.status === 'planned') + return ( + <> + {inProgress.length > 0 && ( +
+

+ In Progress +

+
+ {inProgress.map((item) => ( + + ))} +
+
+ )} - {roadmapItems.filter((i) => i.status === 'planned').length > 0 && ( -
-

- Planned -

-
- {roadmapItems - .filter((i) => i.status === 'planned') - .map((item) => ( - - ))} -
-
- )} + {planned.length > 0 && ( +
+

+ Planned +

+
+ {planned.map((item) => ( + + ))} +
+
+ )} + + ) + })()}

Don't see what you need?{' '} @@ -207,6 +276,15 @@ export function ChangelogTabs({ Give us feedback →

+ + {showSignInPrompt && ( +

+ + Sign in + {' '} + to upvote roadmap items. +

+ )} )}