'use client'; import { useEffect, useState, useCallback } from 'react'; import { SegmentedControl, Button, Input, Select, Textarea, Modal, Badge, StatusDot, MetricCard, toast, type BadgeProps, type StatusTone, } from '@/components/ui/Primitives'; import { getRoadmapItems, getRoadmapStats, submitPublicItem, publicVote, type TrackerItem, type PublicRoadmapStats, } from '@/lib/tracker-client'; type BadgeVariant = NonNullable; // ── Status column config ──────────────────────────────────────────── const STATUS_COLUMNS = [ { key: 'open', label: 'Planned', tone: 'info' as StatusTone }, { key: 'in_progress', label: 'In Progress', tone: 'warning' as StatusTone }, { key: 'done', label: 'Complete', tone: 'success' as StatusTone }, ] as const; const TYPE_BADGES: Record = { feature: { label: 'Feature', variant: 'info' }, bug: { label: 'Bug', variant: 'danger' }, task: { label: 'Task', variant: 'neutral' }, }; const PRIORITY_BADGES: Record = { critical: { label: 'Critical', variant: 'danger' }, high: { label: 'High', variant: 'warning' }, medium: { label: 'Medium', variant: 'neutral' }, low: { label: 'Low', variant: 'success' }, }; export default function RoadmapPage() { const [items, setItems] = useState([]); const [stats, setStats] = useState(null); const [loading, setLoading] = useState(true); 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); // 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()); 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); 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) { toast({ type: 'error', title: "Couldn't load the roadmap", description: err instanceof Error ? err.message : undefined, }); } 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) => { 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) { toast({ type: 'error', title: 'Vote failed', description: err instanceof Error ? err.message : undefined, }); } }; 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); try { const res = await submitPublicItem(submitForm); toast({ type: 'success', title: 'Idea submitted', description: `Your ${submitForm.type} request "${res.title}" has been submitted for review.`, }); setShowSubmit(false); 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) { toast({ type: 'error', title: 'Submission failed', description: err instanceof Error ? err.message : undefined, }); } 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 →
{/* Stats bar */} {stats && (
)} {/* Filters */}
setSearch(e.target.value)} /> setVoteEmail(e.target.value)} onKeyDown={e => e.key === 'Enter' && handleEmailSubmit()} autoFocus />
{/* Submit modal */} { if (!open) setShowSubmit(false); }} title="Submit an Idea" >
setSubmitForm({ ...submitForm, name: e.target.value })} required /> setSubmitForm({ ...submitForm, email: e.target.value })} required />
setSubmitForm({ ...submitForm, title: e.target.value })} required maxLength={500} />