'use client'; import { useEffect, useState, useCallback } from 'react'; import { SegmentedControl } from '@/components/ui/Primitives'; import { getRoadmapItems, getRoadmapStats, submitPublicItem, publicVote, type TrackerItem, type PublicRoadmapStats, } from '@/lib/tracker-client'; // ── Status column config ──────────────────────────────────────────── const STATUS_COLUMNS = [ { key: 'open', label: 'Planned', color: 'bg-blue-500', textColor: 'text-blue-700 dark:text-blue-300', }, { key: 'in_progress', label: 'In Progress', color: 'bg-amber-500', textColor: 'text-amber-700 dark:text-amber-300', }, { key: 'done', label: 'Complete', color: 'bg-emerald-500', textColor: 'text-emerald-700 dark:text-emerald-300', }, ] as const; const TYPE_BADGES: Record = { feature: { label: 'Feature', className: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300', }, bug: { label: 'Bug', className: 'bg-red-100 text-red-700 dark:bg-red-900 dark:text-red-300' }, task: { label: 'Task', className: 'bg-gray-100 text-gray-700 dark:bg-gray-900 dark:text-gray-300', }, }; const PRIORITY_BADGES: Record = { critical: { label: 'Critical', className: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200', }, high: { label: 'High', className: 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200', }, medium: { label: 'Medium', className: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200', }, low: { label: 'Low', className: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200', }, }; export default function RoadmapPage() { const [items, setItems] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); const [loadError, setLoadError] = useState(''); const [search, setSearch] = useState(''); const [typeFilter, setTypeFilter] = useState(''); const [view, setView] = useState<'board' | 'list'>('board'); // Submit form state const [showSubmit, setShowSubmit] = useState(false); const [submitForm, setSubmitForm] = useState({ title: '', description: '', type: 'feature', email: '', name: '', }); const [submitting, setSubmitting] = useState(false); const [submitSuccess, setSubmitSuccess] = useState(''); // Vote email (persisted in localStorage) const [voteEmail, setVoteEmail] = useState(''); const [showEmailPrompt, setShowEmailPrompt] = useState(false); const [pendingVoteId, setPendingVoteId] = useState(null); const [votedItems, setVotedItems] = useState>(new Set()); const [voteError, setVoteError] = useState(''); useEffect(() => { if (typeof window !== 'undefined') { const saved = localStorage.getItem('roadmap_email'); if (saved) setVoteEmail(saved); const voted = localStorage.getItem('roadmap_voted'); if (voted) setVotedItems(new Set(JSON.parse(voted))); } }, []); const fetchData = useCallback(async () => { setLoading(true); setLoadError(''); try { const params: Record = { sortBy: 'voteCount', sortOrder: 'desc', limit: '100', }; if (search) params.q = search; if (typeFilter) params.type = typeFilter; const [itemsRes, statsRes] = await Promise.all([getRoadmapItems(params), getRoadmapStats()]); setItems(itemsRes.items); setStats(statsRes); } catch (err) { setLoadError(err instanceof Error ? err.message : 'Failed to load roadmap'); } finally { setLoading(false); } }, [search, typeFilter]); useEffect(() => { fetchData(); }, [fetchData]); const handleVoteClick = (itemId: string) => { if (!voteEmail) { setPendingVoteId(itemId); setShowEmailPrompt(true); return; } doVote(itemId, voteEmail); }; const doVote = async (itemId: string, email: string) => { setVoteError(''); try { const res = await publicVote(itemId, email); setItems(prev => prev.map(i => (i.id === itemId ? { ...i, voteCount: res.voteCount } : i))); const newVoted = new Set(votedItems); if (res.voted) { newVoted.add(itemId); } else { newVoted.delete(itemId); } setVotedItems(newVoted); localStorage.setItem('roadmap_voted', JSON.stringify([...newVoted])); } catch (err) { setVoteError(err instanceof Error ? err.message : 'Vote failed'); } }; const handleEmailSubmit = () => { if (!voteEmail) return; localStorage.setItem('roadmap_email', voteEmail); setShowEmailPrompt(false); if (pendingVoteId) { doVote(pendingVoteId, voteEmail); setPendingVoteId(null); } }; const handleSubmit = async (e: React.FormEvent) => { e.preventDefault(); setSubmitting(true); setSubmitSuccess(''); try { const res = await submitPublicItem(submitForm); setSubmitSuccess( `Thanks! Your ${submitForm.type} request "${res.title}" has been submitted for review.` ); setSubmitForm({ title: '', description: '', type: 'feature', email: submitForm.email, name: submitForm.name, }); // Save email for voting if (submitForm.email) { setVoteEmail(submitForm.email); localStorage.setItem('roadmap_email', submitForm.email); } // Refresh data to show new item fetchData(); } catch (err) { setSubmitSuccess(`Error: ${err instanceof Error ? err.message : 'Submission failed'}`); } finally { setSubmitting(false); } }; const itemsByStatus = (status: string) => items.filter(i => i.status === status); return (
{/* Header */}

Product Roadmap

Vote on features, report bugs, and shape what we build next

Admin →
{loadError ? (
{loadError}
) : null} {voteError ? (
{voteError}
) : null} {/* Stats bar */} {stats && (
)} {/* Filters */}
setSearch(e.target.value)} className="flex-1 px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm focus:outline-none focus:ring-2 focus:ring-blue-500" /> setView(v as 'board' | 'list')} options={[ { value: 'board', label: 'Board' }, { value: 'list', label: 'List' }, ]} />
{loading ? ( view === 'board' ? (
{STATUS_COLUMNS.map(col => (

{[1, 2, 3].map(i => (
))}
))}
) : (
{[1, 2, 3, 4, 5].map(i => (
))}
) ) : view === 'board' ? ( /* Board View */
{STATUS_COLUMNS.map(col => (

{col.label}

{itemsByStatus(col.key).length}
{itemsByStatus(col.key).length === 0 ? (

No items

) : ( itemsByStatus(col.key).map(item => ( )) )}
))}
) : ( /* List View */
{items.length === 0 ? (

No items found

) : ( items.map(item => ( )) )}
)}
{/* Email prompt modal */} {showEmailPrompt && ( { setShowEmailPrompt(false); setPendingVoteId(null); }} >

Enter your email to vote

We use your email to track your votes. One vote per item.

setVoteEmail(e.target.value)} className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm mb-4" onKeyDown={e => e.key === 'Enter' && handleEmailSubmit()} autoFocus />
)} {/* Submit modal */} {showSubmit && ( { setShowSubmit(false); setSubmitSuccess(''); }} >

Submit an Idea

{submitSuccess ? (
{submitSuccess}
) : null}
setSubmitForm({ ...submitForm, name: e.target.value })} required className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm" /> setSubmitForm({ ...submitForm, email: e.target.value })} required className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm" />
setSubmitForm({ ...submitForm, title: e.target.value })} required maxLength={500} className="w-full px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm" />