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>
561 lines
19 KiB
TypeScript
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>
|
|
);
|
|
}
|