learning_ai_common_plat/dashboards/tracker-web/src/app/roadmap/page.tsx
saravanakumardb1 ddf25cf501 feat(tracker-web): SegmentedControl view toggle + board Tooltips (UX-12.1)
Replace the bespoke roadmap board/list toggle buttons (hardcoded blue-600) with
the shared SegmentedControl, and wrap truncated board-card titles in Tooltip.
Update the e2e toggle selector from button to radio for SegmentedControl.

Generated with [Devin](https://cli.devin.ai/docs)

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-28 21:01:58 -07:00

649 lines
25 KiB
TypeScript

'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<string, { label: string; className: string }> = {
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<string, { label: string; className: string }> = {
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<TrackerItem[]>([]);
const [stats, setStats] = useState<PublicRoadmapStats | null>(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<string | null>(null);
const [votedItems, setVotedItems] = useState<Set<string>>(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<string, string> = {
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 (
<div className="min-h-screen bg-gradient-to-br from-slate-50 to-slate-100 dark:from-slate-950 dark:to-slate-900">
{/* Header */}
<header className="border-b border-slate-200 dark:border-slate-800 bg-white/80 dark:bg-slate-900/80 backdrop-blur-sm sticky top-0 z-20">
<div className="max-w-7xl mx-auto px-4 sm:px-6 py-4 flex items-center justify-between">
<div>
<h1 className="text-2xl font-bold text-slate-900 dark:text-white">Product Roadmap</h1>
<p className="text-sm text-slate-500 dark:text-slate-400 mt-0.5">
Vote on features, report bugs, and shape what we build next
</p>
</div>
<div className="flex items-center gap-3">
<button
onClick={() => setShowSubmit(true)}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium transition-colors"
>
+ Submit Idea
</button>
<a
href="/login"
className="text-sm text-slate-500 hover:text-slate-700 dark:text-slate-400 dark:hover:text-slate-200"
>
Admin
</a>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
{loadError ? (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-900/20 dark:text-red-300">
{loadError}
</div>
) : null}
{voteError ? (
<div className="mb-4 rounded-lg border border-red-200 bg-red-50 px-4 py-3 text-sm text-red-700 dark:border-red-900 dark:bg-red-900/20 dark:text-red-300">
{voteError}
</div>
) : null}
{/* Stats bar */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<StatCard label="Total Items" value={stats.total} />
<StatCard label="Total Votes" value={stats.totalVotes} />
<StatCard label="In Progress" value={stats.byStatus?.in_progress || 0} />
<StatCard label="Completed" value={stats.byStatus?.done || 0} />
</div>
)}
{/* Filters */}
<div className="flex flex-col sm:flex-row gap-3 mb-6">
<input
type="text"
placeholder="Search items..."
value={search}
onChange={e => 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"
/>
<select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
aria-label="Filter by type"
className="px-3 py-2 border border-slate-300 dark:border-slate-600 rounded-lg bg-white dark:bg-slate-800 text-sm"
>
<option value="">All Types</option>
<option value="feature">Features</option>
<option value="bug">Bugs</option>
<option value="task">Tasks</option>
</select>
<SegmentedControl
aria-label="Roadmap view"
value={view}
onValueChange={v => setView(v as 'board' | 'list')}
options={[
{ value: 'board', label: 'Board' },
{ value: 'list', label: 'List' },
]}
/>
</div>
{loading ? (
view === 'board' ? (
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{STATUS_COLUMNS.map(col => (
<div key={col.key} className="space-y-3">
<div className="flex items-center gap-2 mb-2">
<span className={`w-3 h-3 rounded-full ${col.color} animate-pulse`} />
<h2
className={`font-semibold ${col.textColor} h-5 w-24 bg-slate-200 dark:bg-slate-700 rounded animate-pulse`}
/>
</div>
{[1, 2, 3].map(i => (
<div
key={i}
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4"
>
<div className="flex items-start gap-3">
<div className="flex flex-col items-center min-w-[44px] py-1.5 px-2">
<div className="h-4 w-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<div className="h-4 w-6 bg-slate-200 dark:bg-slate-700 rounded mt-1 animate-pulse" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="h-4 w-full bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<div className="h-3 w-3/4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<div className="flex gap-2 pt-2">
<div className="h-5 w-12 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse" />
<div className="h-5 w-12 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse" />
</div>
</div>
</div>
</div>
))}
</div>
))}
</div>
) : (
<div className="space-y-3">
{[1, 2, 3, 4, 5].map(i => (
<div
key={i}
className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex items-center gap-4"
>
<div className="flex flex-col items-center min-w-[44px] py-1.5 px-2">
<div className="h-4 w-4 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<div className="h-4 w-6 bg-slate-200 dark:bg-slate-700 rounded mt-1 animate-pulse" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="h-4 w-full bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
<div className="h-3 w-1/2 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="h-5 w-12 bg-slate-200 dark:bg-slate-700 rounded-full animate-pulse" />
<div className="h-4 w-16 bg-slate-200 dark:bg-slate-700 rounded animate-pulse" />
</div>
</div>
))}
</div>
)
) : view === 'board' ? (
/* Board View */
<div className="grid grid-cols-1 md:grid-cols-3 gap-6">
{STATUS_COLUMNS.map(col => (
<div key={col.key} className="space-y-3">
<div className="flex items-center gap-2 mb-2">
<span className={`w-3 h-3 rounded-full ${col.color}`} />
<h2 className={`font-semibold ${col.textColor}`}>{col.label}</h2>
<span className="text-xs text-slate-400 ml-auto">
{itemsByStatus(col.key).length}
</span>
</div>
{itemsByStatus(col.key).length === 0 ? (
<p className="text-sm text-slate-400 italic py-4 text-center">No items</p>
) : (
itemsByStatus(col.key).map(item => (
<ItemCard
key={item.id}
item={item}
votedItems={votedItems}
onVote={handleVoteClick}
/>
))
)}
</div>
))}
</div>
) : (
/* List View */
<div className="space-y-3">
{items.length === 0 ? (
<p className="text-center py-10 text-slate-400">No items found</p>
) : (
items.map(item => (
<ItemRow
key={item.id}
item={item}
votedItems={votedItems}
onVote={handleVoteClick}
/>
))
)}
</div>
)}
</main>
{/* Email prompt modal */}
{showEmailPrompt && (
<Modal
onClose={() => {
setShowEmailPrompt(false);
setPendingVoteId(null);
}}
>
<h3 className="text-lg font-semibold mb-2">Enter your email to vote</h3>
<p className="text-sm text-slate-500 mb-4">
We use your email to track your votes. One vote per item.
</p>
<input
type="email"
placeholder="you@example.com"
value={voteEmail}
onChange={e => 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
/>
<div className="flex justify-end gap-2">
<button
onClick={() => {
setShowEmailPrompt(false);
setPendingVoteId(null);
}}
className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700"
>
Cancel
</button>
<button
onClick={handleEmailSubmit}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 text-white rounded-lg text-sm font-medium"
>
Vote
</button>
</div>
</Modal>
)}
{/* Submit modal */}
{showSubmit && (
<Modal
onClose={() => {
setShowSubmit(false);
setSubmitSuccess('');
}}
>
<h3 className="text-lg font-semibold mb-4">Submit an Idea</h3>
{submitSuccess ? (
<div
className={`p-3 rounded-lg text-sm mb-4 ${submitSuccess.startsWith('Error') ? 'bg-red-50 text-red-700 dark:bg-red-900/20 dark:text-red-300' : 'bg-green-50 text-green-700 dark:bg-green-900/20 dark:text-green-300'}`}
>
{submitSuccess}
</div>
) : null}
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<input
type="text"
placeholder="Your name"
value={submitForm.name}
onChange={e => 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"
/>
<input
type="email"
placeholder="Your email"
value={submitForm.email}
onChange={e => 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"
/>
</div>
<select
value={submitForm.type}
onChange={e => setSubmitForm({ ...submitForm, type: e.target.value })}
aria-label="Request type"
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"
>
<option value="feature">Feature Request</option>
<option value="bug">Bug Report</option>
<option value="task">Task</option>
</select>
<input
type="text"
placeholder="Title — what would you like to see?"
value={submitForm.title}
onChange={e => 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"
/>
<textarea
placeholder="Describe your idea or issue in detail (optional)"
value={submitForm.description}
onChange={e => setSubmitForm({ ...submitForm, description: e.target.value })}
rows={4}
maxLength={5000}
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 resize-none"
/>
<div className="flex justify-end gap-2 pt-2">
<button
type="button"
onClick={() => setShowSubmit(false)}
className="px-4 py-2 text-sm text-slate-500 hover:text-slate-700"
>
Cancel
</button>
<button
type="submit"
disabled={submitting}
className="px-4 py-2 bg-blue-600 hover:bg-blue-700 disabled:opacity-50 text-white rounded-lg text-sm font-medium"
>
{submitting ? 'Submitting...' : 'Submit'}
</button>
</div>
</form>
</Modal>
)}
</div>
);
}
// ── Sub-components ──────────────────────────────────────────────────
function StatCard({ label, value }: { label: string; value: number }) {
return (
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4">
<p className="text-2xl font-bold text-slate-900 dark:text-white">{value}</p>
<p className="text-xs text-slate-500 dark:text-slate-400">{label}</p>
</div>
);
}
function ItemCard({
item,
votedItems,
onVote,
}: {
item: TrackerItem;
votedItems: Set<string>;
onVote: (id: string) => void;
}) {
const typeBadge = TYPE_BADGES[item.type] || TYPE_BADGES.task;
const priorityBadge = PRIORITY_BADGES[item.priority] || PRIORITY_BADGES.medium;
const hasVoted = votedItems.has(item.id);
return (
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 hover:shadow-md transition-shadow">
<div className="flex items-start gap-3">
<button
onClick={() => onVote(item.id)}
type="button"
aria-pressed={hasVoted}
aria-label={hasVoted ? `Remove vote from ${item.title}` : `Upvote ${item.title}`}
className={`flex flex-col items-center min-w-[44px] py-1.5 px-2 rounded-lg border text-sm font-semibold transition-colors ${
hasVoted
? 'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
: 'bg-slate-50 border-slate-200 text-slate-500 hover:border-blue-300 hover:text-blue-600 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-300'
}`}
title={hasVoted ? 'Remove vote' : 'Upvote'}
>
<span className="text-xs"></span>
<span>{item.voteCount}</span>
</button>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-slate-900 dark:text-white text-sm leading-snug">
{item.title}
</h3>
{item.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-1 line-clamp-2">
{item.description}
</p>
)}
<div className="flex flex-wrap gap-1.5 mt-2">
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeBadge.className}`}
>
{typeBadge.label}
</span>
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${priorityBadge.className}`}
>
{priorityBadge.label}
</span>
{item.commentCount > 0 && (
<span className="text-[10px] text-slate-400">💬 {item.commentCount}</span>
)}
</div>
</div>
</div>
</div>
);
}
function ItemRow({
item,
votedItems,
onVote,
}: {
item: TrackerItem;
votedItems: Set<string>;
onVote: (id: string) => void;
}) {
const typeBadge = TYPE_BADGES[item.type] || TYPE_BADGES.task;
const statusCol = STATUS_COLUMNS.find(c => c.key === item.status);
const hasVoted = votedItems.has(item.id);
return (
<div className="bg-white dark:bg-slate-800 rounded-xl border border-slate-200 dark:border-slate-700 p-4 flex items-center gap-4 hover:shadow-md transition-shadow">
<button
onClick={() => onVote(item.id)}
type="button"
aria-pressed={hasVoted}
aria-label={hasVoted ? `Remove vote from ${item.title}` : `Upvote ${item.title}`}
title={hasVoted ? 'Remove vote' : 'Upvote'}
className={`flex flex-col items-center min-w-[44px] py-1.5 px-2 rounded-lg border text-sm font-semibold transition-colors ${
hasVoted
? 'bg-blue-50 border-blue-300 text-blue-700 dark:bg-blue-900/30 dark:border-blue-700 dark:text-blue-300'
: 'bg-slate-50 border-slate-200 text-slate-500 hover:border-blue-300 hover:text-blue-600 dark:bg-slate-700 dark:border-slate-600 dark:text-slate-300'
}`}
>
<span className="text-xs"></span>
<span>{item.voteCount}</span>
</button>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-slate-900 dark:text-white text-sm">{item.title}</h3>
{item.description && (
<p className="text-xs text-slate-500 dark:text-slate-400 mt-0.5 line-clamp-1">
{item.description}
</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<span
className={`text-[10px] font-medium px-1.5 py-0.5 rounded-full ${typeBadge.className}`}
>
{typeBadge.label}
</span>
{statusCol && (
<span className="flex items-center gap-1 text-xs text-slate-500">
<span className={`w-2 h-2 rounded-full ${statusCol.color}`} />
{statusCol.label}
</span>
)}
{item.commentCount > 0 && (
<span className="text-xs text-slate-400">💬 {item.commentCount}</span>
)}
</div>
</div>
);
}
function Modal({ children, onClose }: { children: React.ReactNode; onClose: () => void }) {
return (
<div className="fixed inset-0 z-50 flex items-center justify-center p-4">
<div className="absolute inset-0 bg-black/40 backdrop-blur-sm" onClick={onClose} />
<div className="relative bg-white dark:bg-slate-800 rounded-2xl shadow-2xl border border-slate-200 dark:border-slate-700 p-6 w-full max-w-md">
{children}
</div>
</div>
);
}