learning_ai_common_plat/dashboards/tracker-web/src/app/roadmap/page.tsx
saravanakumardb1 3fc559d066 feat(tracker-web): toasts replace inline status divs (UX-6)
Mount the shared ToastProvider in providers.tsx and replace the inline
error/success banners across the overview, items list, board, item detail
and public roadmap with toast() calls — load errors, create, delete, vote,
status/priority/visibility updates, comment add, and idea submission. The
roadmap submit now toasts + closes the dialog instead of an in-modal
banner; the item-detail page keeps a single inline empty-state for the
hard "couldn't load this item" case.

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

Co-Authored-By: Devin <158243242+devin-ai-integration[bot]@users.noreply.github.com>
2026-05-29 07:03:16 -07:00

561 lines
19 KiB
TypeScript

'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<BadgeProps['variant']>;
// ── 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<string, { label: string; variant: BadgeVariant }> = {
feature: { label: 'Feature', variant: 'info' },
bug: { label: 'Bug', variant: 'danger' },
task: { label: 'Task', variant: 'neutral' },
};
const PRIORITY_BADGES: Record<string, { label: string; variant: BadgeVariant }> = {
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<TrackerItem[]>([]);
const [stats, setStats] = useState<PublicRoadmapStats | null>(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<string | null>(null);
const [votedItems, setVotedItems] = useState<Set<string>>(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<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) {
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 (
<div className="min-h-screen bg-background">
{/* Header */}
<header className="border-b border-border bg-card/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-foreground">Product Roadmap</h1>
<p className="text-sm text-muted-foreground 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)}>+ Submit Idea</Button>
<a
href="/login"
className="text-sm text-muted-foreground hover:text-foreground transition-colors"
>
Admin
</a>
</div>
</div>
</header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
{/* Stats bar */}
{stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
<MetricCard label="Total Items" value={stats.total} />
<MetricCard label="Total Votes" value={stats.totalVotes} />
<MetricCard label="In Progress" value={stats.byStatus?.in_progress || 0} />
<MetricCard 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..."
aria-label="Search items"
className="flex-1"
value={search}
onChange={e => setSearch(e.target.value)}
/>
<Select
value={typeFilter}
onChange={e => setTypeFilter(e.target.value)}
aria-label="Filter by type"
options={[
{ value: '', label: 'All Types' },
{ value: 'feature', label: 'Features' },
{ value: 'bug', label: 'Bugs' },
{ value: 'task', label: 'Tasks' },
]}
/>
<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">
<StatusDot tone={col.tone} className="animate-pulse" />
<h2 className="h-5 w-24 bg-muted rounded animate-pulse" />
</div>
{[1, 2, 3].map(i => (
<div key={i} className="bg-card rounded-xl border border-border 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-muted rounded animate-pulse" />
<div className="h-4 w-6 bg-muted rounded mt-1 animate-pulse" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="h-4 w-full bg-muted rounded animate-pulse" />
<div className="h-3 w-3/4 bg-muted rounded animate-pulse" />
<div className="flex gap-2 pt-2">
<div className="h-5 w-12 bg-muted rounded-full animate-pulse" />
<div className="h-5 w-12 bg-muted 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-card rounded-xl border border-border 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-muted rounded animate-pulse" />
<div className="h-4 w-6 bg-muted rounded mt-1 animate-pulse" />
</div>
<div className="flex-1 min-w-0 space-y-2">
<div className="h-4 w-full bg-muted rounded animate-pulse" />
<div className="h-3 w-1/2 bg-muted rounded animate-pulse" />
</div>
<div className="flex items-center gap-2 shrink-0">
<div className="h-5 w-12 bg-muted rounded-full animate-pulse" />
<div className="h-4 w-16 bg-muted 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">
<StatusDot tone={col.tone} />
<h2 className="font-semibold text-foreground">{col.label}</h2>
<span className="text-xs text-muted-foreground ml-auto">
{itemsByStatus(col.key).length}
</span>
</div>
{itemsByStatus(col.key).length === 0 ? (
<p className="text-sm text-muted-foreground 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-muted-foreground">No items found</p>
) : (
items.map(item => (
<ItemRow
key={item.id}
item={item}
votedItems={votedItems}
onVote={handleVoteClick}
/>
))
)}
</div>
)}
</main>
{/* Email prompt modal */}
<Modal
open={showEmailPrompt}
onOpenChange={open => {
if (!open) {
setShowEmailPrompt(false);
setPendingVoteId(null);
}
}}
title="Enter your email to vote"
description="We use your email to track your votes. One vote per item."
size="sm"
>
<div className="space-y-4">
<Input
type="email"
aria-label="Email"
placeholder="you@example.com"
value={voteEmail}
onChange={e => setVoteEmail(e.target.value)}
onKeyDown={e => e.key === 'Enter' && handleEmailSubmit()}
autoFocus
/>
<div className="flex justify-end gap-2">
<Button
variant="ghost"
onClick={() => {
setShowEmailPrompt(false);
setPendingVoteId(null);
}}
>
Cancel
</Button>
<Button onClick={handleEmailSubmit}>Vote</Button>
</div>
</div>
</Modal>
{/* Submit modal */}
<Modal
open={showSubmit}
onOpenChange={open => {
if (!open) setShowSubmit(false);
}}
title="Submit an Idea"
>
<form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3">
<Input
type="text"
aria-label="Your name"
placeholder="Your name"
value={submitForm.name}
onChange={e => setSubmitForm({ ...submitForm, name: e.target.value })}
required
/>
<Input
type="email"
aria-label="Your email"
placeholder="Your email"
value={submitForm.email}
onChange={e => setSubmitForm({ ...submitForm, email: e.target.value })}
required
/>
</div>
<Select
value={submitForm.type}
onChange={e => setSubmitForm({ ...submitForm, type: e.target.value })}
aria-label="Request type"
options={[
{ value: 'feature', label: 'Feature Request' },
{ value: 'bug', label: 'Bug Report' },
{ value: 'task', label: 'Task' },
]}
/>
<Input
type="text"
aria-label="Title"
placeholder="Title — what would you like to see?"
value={submitForm.title}
onChange={e => setSubmitForm({ ...submitForm, title: e.target.value })}
required
maxLength={500}
/>
<Textarea
aria-label="Description"
placeholder="Describe your idea or issue in detail (optional)"
value={submitForm.description}
onChange={e => setSubmitForm({ ...submitForm, description: e.target.value })}
rows={4}
maxLength={5000}
/>
<div className="flex justify-end gap-2 pt-2">
<Button type="button" variant="ghost" onClick={() => setShowSubmit(false)}>
Cancel
</Button>
<Button type="submit" loading={submitting}>
{submitting ? 'Submitting...' : 'Submit'}
</Button>
</div>
</form>
</Modal>
</div>
);
}
// ── Sub-components ──────────────────────────────────────────────────
const voteButtonClass = (hasVoted: boolean) =>
`flex flex-col items-center min-w-[44px] py-1.5 px-2 rounded-lg border text-sm font-semibold transition-colors focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-ring ${
hasVoted
? 'border-primary bg-primary/10 text-primary'
: 'border-border bg-muted/50 text-muted-foreground hover:border-primary hover:text-primary'
}`;
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-card rounded-xl border border-border 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={voteButtonClass(hasVoted)}
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-foreground text-sm leading-snug">{item.title}</h3>
{item.description && (
<p className="text-xs text-muted-foreground mt-1 line-clamp-2">{item.description}</p>
)}
<div className="flex flex-wrap items-center gap-1.5 mt-2">
<Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>
<Badge variant={priorityBadge.variant}>{priorityBadge.label}</Badge>
{item.commentCount > 0 && (
<span className="text-[10px] text-muted-foreground">💬 {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-card rounded-xl border border-border 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={voteButtonClass(hasVoted)}
>
<span className="text-xs"></span>
<span>{item.voteCount}</span>
</button>
<div className="flex-1 min-w-0">
<h3 className="font-medium text-foreground text-sm">{item.title}</h3>
{item.description && (
<p className="text-xs text-muted-foreground mt-0.5 line-clamp-1">{item.description}</p>
)}
</div>
<div className="flex items-center gap-2 shrink-0">
<Badge variant={typeBadge.variant}>{typeBadge.label}</Badge>
{statusCol && (
<span className="flex items-center gap-1 text-xs text-muted-foreground">
<StatusDot tone={statusCol.tone} />
{statusCol.label}
</span>
)}
{item.commentCount > 0 && (
<span className="text-xs text-muted-foreground">💬 {item.commentCount}</span>
)}
</div>
</div>
);
}