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>
This commit is contained in:
saravanakumardb1 2026-05-29 07:03:16 -07:00
parent fd7f12513d
commit 3fc559d066
6 changed files with 103 additions and 87 deletions

View File

@ -8,11 +8,11 @@ import {
Badge, Badge,
StatusBadge, StatusBadge,
StatusDot, StatusDot,
AlertBanner,
Tooltip, Tooltip,
TooltipContent, TooltipContent,
TooltipProvider, TooltipProvider,
TooltipTrigger, TooltipTrigger,
toast,
type StatusTone, type StatusTone,
} from '@/components/ui/Primitives'; } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
@ -41,14 +41,17 @@ const PRIORITY_TONE: Record<string, StatusTone> = {
export default function BoardPage() { export default function BoardPage() {
const { token } = useAuth(); const { token } = useAuth();
const [items, setItems] = useState<TrackerItem[]>([]); const [items, setItems] = useState<TrackerItem[]>([]);
const [error, setError] = useState('');
const fetchItems = useCallback(async () => { const fetchItems = useCallback(async () => {
try { try {
const res = await listItems({ limit: '200' }); const res = await listItems({ limit: '200' });
setItems(res.items); setItems(res.items);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load'); toast({
type: 'error',
title: 'Failed to load board',
description: err instanceof Error ? err.message : undefined,
});
} }
}, []); }, []);
@ -63,7 +66,11 @@ export default function BoardPage() {
prev.map(i => (i.id === itemId ? { ...i, status: newStatus as TrackerItem['status'] } : i)) prev.map(i => (i.id === itemId ? { ...i, status: newStatus as TrackerItem['status'] } : i))
); );
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to update status'); toast({
type: 'error',
title: 'Failed to update status',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -76,12 +83,6 @@ export default function BoardPage() {
/> />
<p className="-mt-4 text-sm text-muted-foreground">Kanban view of all items</p> <p className="-mt-4 text-sm text-muted-foreground">Kanban view of all items</p>
{error && (
<AlertBanner tone="error" title="Something went wrong">
{error}
</AlertBanner>
)}
<div className="grid grid-cols-1 gap-4 md:grid-cols-4"> <div className="grid grid-cols-1 gap-4 md:grid-cols-4">
{COLUMNS.map(col => { {COLUMNS.map(col => {
const colItems = items.filter(i => i.status === col.key); const colItems = items.filter(i => i.status === col.key);

View File

@ -10,7 +10,7 @@ import {
Input, Input,
Textarea, Textarea,
Select, Select,
AlertBanner, toast,
} from '@/components/ui/Primitives'; } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
import { import {
@ -58,7 +58,11 @@ export default function ItemDetailPage() {
const updated = await updateItemStatus(id, status); const updated = await updateItemStatus(id, status);
setItem(updated); setItem(updated);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to update status'); toast({
type: 'error',
title: 'Failed to update status',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -68,7 +72,11 @@ export default function ItemDetailPage() {
const updated = await updateItem(id, { priority } as Partial<TrackerItem>); const updated = await updateItem(id, { priority } as Partial<TrackerItem>);
setItem(updated); setItem(updated);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to update priority'); toast({
type: 'error',
title: 'Failed to update priority',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -78,7 +86,11 @@ export default function ItemDetailPage() {
const updated = await updateItem(id, { visibility } as Partial<TrackerItem>); const updated = await updateItem(id, { visibility } as Partial<TrackerItem>);
setItem(updated); setItem(updated);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to update visibility'); toast({
type: 'error',
title: 'Failed to update visibility',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -91,8 +103,13 @@ export default function ItemDetailPage() {
} as Partial<TrackerItem>); } as Partial<TrackerItem>);
setItem(updated); setItem(updated);
setEditing(false); setEditing(false);
toast({ type: 'success', title: 'Item updated' });
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to update'); toast({
type: 'error',
title: 'Failed to update',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -104,7 +121,11 @@ export default function ItemDetailPage() {
setComments(prev => [...prev, comment]); setComments(prev => [...prev, comment]);
setNewComment(''); setNewComment('');
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to add comment'); toast({
type: 'error',
title: 'Failed to add comment',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -114,7 +135,11 @@ export default function ItemDetailPage() {
const res = await toggleVote(id); const res = await toggleVote(id);
setItem(prev => (prev ? { ...prev, voteCount: res.voteCount } : prev)); setItem(prev => (prev ? { ...prev, voteCount: res.voteCount } : prev));
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to vote'); toast({
type: 'error',
title: 'Failed to vote',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -123,9 +148,14 @@ export default function ItemDetailPage() {
if (!confirm('Delete this item? This cannot be undone.')) return; if (!confirm('Delete this item? This cannot be undone.')) return;
try { try {
await deleteItem(id); await deleteItem(id);
toast({ type: 'success', title: 'Item deleted' });
router.push('/dashboard/items'); router.push('/dashboard/items');
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to delete'); toast({
type: 'error',
title: 'Failed to delete',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -165,12 +195,6 @@ export default function ItemDetailPage() {
} }
/> />
{error && (
<AlertBanner tone="error" title="Something went wrong">
{error}
</AlertBanner>
)}
{/* Title/description editor */} {/* Title/description editor */}
<div className="space-y-2"> <div className="space-y-2">
{editing ? ( {editing ? (

View File

@ -14,7 +14,7 @@ import {
Modal, Modal,
ConfirmDialog, ConfirmDialog,
StatusBadge, StatusBadge,
AlertBanner, toast,
type StatusTone, type StatusTone,
} from '@/components/ui/Primitives'; } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
@ -46,7 +46,6 @@ export default function ItemsListPage() {
const [items, setItems] = useState<TrackerItem[]>([]); const [items, setItems] = useState<TrackerItem[]>([]);
const [total, setTotal] = useState(0); const [total, setTotal] = useState(0);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
// Filters // Filters
const [typeFilter, setTypeFilter] = useState(''); const [typeFilter, setTypeFilter] = useState('');
@ -66,7 +65,6 @@ export default function ItemsListPage() {
const fetchItems = useCallback(async () => { const fetchItems = useCallback(async () => {
setLoading(true); setLoading(true);
setError('');
try { try {
const params: Record<string, string> = {}; const params: Record<string, string> = {};
if (typeFilter) params.type = typeFilter; if (typeFilter) params.type = typeFilter;
@ -77,7 +75,11 @@ export default function ItemsListPage() {
setItems(res.items); setItems(res.items);
setTotal(res.total); setTotal(res.total);
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to load items'); toast({
type: 'error',
title: 'Failed to load items',
description: err instanceof Error ? err.message : undefined,
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -112,9 +114,14 @@ export default function ItemsListPage() {
setShowCreate(false); setShowCreate(false);
setNewTitle(''); setNewTitle('');
setNewDescription(''); setNewDescription('');
toast({ type: 'success', title: 'Item created' });
fetchItems(); fetchItems();
} catch (err: unknown) { } catch (err: unknown) {
setError(err instanceof Error ? err.message : 'Failed to create item'); toast({
type: 'error',
title: 'Failed to create item',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -125,10 +132,15 @@ export default function ItemsListPage() {
try { try {
await deleteItem(deleteId); await deleteItem(deleteId);
setDeleteId(null); setDeleteId(null);
toast({ type: 'success', title: 'Item deleted' });
fetchItems(); fetchItems();
} catch (err: unknown) { } catch (err: unknown) {
setDeleteId(null); setDeleteId(null);
setError(err instanceof Error ? err.message : 'Failed to delete'); toast({
type: 'error',
title: 'Failed to delete',
description: err instanceof Error ? err.message : undefined,
});
} }
}, [deleteId, fetchItems]); }, [deleteId, fetchItems]);
@ -225,12 +237,6 @@ export default function ItemsListPage() {
/> />
<p className="-mt-4 text-sm text-muted-foreground">{total} items total</p> <p className="-mt-4 text-sm text-muted-foreground">{total} items total</p>
{error && (
<AlertBanner tone="error" title="Something went wrong">
{error}
</AlertBanner>
)}
{/* Filters */} {/* Filters */}
<div className="flex flex-wrap gap-3"> <div className="flex flex-wrap gap-3">
<Input <Input

View File

@ -4,7 +4,7 @@ import { useEffect, useState } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { PageHeader, LoadingSpinner } from '@bytelyst/dashboard-components'; import { PageHeader, LoadingSpinner } from '@bytelyst/dashboard-components';
import { KpiCard } from '@bytelyst/data-viz'; import { KpiCard } from '@bytelyst/data-viz';
import { AlertBanner, Skeleton } from '@/components/ui/Primitives'; import { Skeleton, toast } from '@/components/ui/Primitives';
import { useAuth } from '@/lib/auth-context'; import { useAuth } from '@/lib/auth-context';
import { getStats, type TrackerStats } from '@/lib/tracker-client'; import { getStats, type TrackerStats } from '@/lib/tracker-client';
import { overviewKpis } from '@/lib/overview-charts'; import { overviewKpis } from '@/lib/overview-charts';
@ -18,13 +18,14 @@ const OverviewCharts = dynamic(() => import('@/components/overview-charts'), {
export default function DashboardOverview() { export default function DashboardOverview() {
const { token } = useAuth(); const { token } = useAuth();
const [stats, setStats] = useState<TrackerStats | null>(null); const [stats, setStats] = useState<TrackerStats | null>(null);
const [error, setError] = useState('');
useEffect(() => { useEffect(() => {
if (!token) return; if (!token) return;
getStats() getStats()
.then(setStats) .then(setStats)
.catch(err => setError(err.message)); .catch(err =>
toast({ type: 'error', title: 'Failed to load stats', description: err.message })
);
}, [token]); }, [token]);
const kpis = stats ? overviewKpis(stats) : null; const kpis = stats ? overviewKpis(stats) : null;
@ -34,12 +35,6 @@ export default function DashboardOverview() {
<PageHeader title="Dashboard" /> <PageHeader title="Dashboard" />
<p className="-mt-4 text-sm text-muted-foreground">Overview of all tracked items</p> <p className="-mt-4 text-sm text-muted-foreground">Overview of all tracked items</p>
{error && (
<AlertBanner tone="error" title="Something went wrong">
{error}
</AlertBanner>
)}
{stats && kpis ? ( {stats && kpis ? (
<div className="space-y-4"> <div className="space-y-4">
{/* KPI row */} {/* KPI row */}
@ -53,11 +48,11 @@ export default function DashboardOverview() {
{/* Charts */} {/* Charts */}
<OverviewCharts stats={stats} /> <OverviewCharts stats={stats} />
</div> </div>
) : !error ? ( ) : (
<div className="flex justify-center py-10"> <div className="flex justify-center py-10">
<LoadingSpinner /> <LoadingSpinner />
</div> </div>
) : null} )}
</div> </div>
); );
} }

View File

@ -3,6 +3,7 @@
import { useEffect, type ReactNode } from 'react'; import { useEffect, type ReactNode } from 'react';
import dynamic from 'next/dynamic'; import dynamic from 'next/dynamic';
import { CommandRegistryProvider } from '@bytelyst/command-palette'; import { CommandRegistryProvider } from '@bytelyst/command-palette';
import { ToastProvider } from '@/components/ui/Primitives';
import { AuthProvider } from '@/lib/auth-context'; import { AuthProvider } from '@/lib/auth-context';
import { ThemeProvider } from '@/lib/theme-context'; import { ThemeProvider } from '@/lib/theme-context';
import { ProductProvider } from '@/lib/product-context'; import { ProductProvider } from '@/lib/product-context';
@ -23,10 +24,12 @@ export function Providers({ children }: { children: ReactNode }) {
<ThemeProvider> <ThemeProvider>
<ProductProvider> <ProductProvider>
<AuthProvider> <AuthProvider>
<CommandRegistryProvider> <ToastProvider>
{children} <CommandRegistryProvider>
<CommandMenu /> {children}
</CommandRegistryProvider> <CommandMenu />
</CommandRegistryProvider>
</ToastProvider>
</AuthProvider> </AuthProvider>
</ProductProvider> </ProductProvider>
</ThemeProvider> </ThemeProvider>

View File

@ -11,7 +11,7 @@ import {
Badge, Badge,
StatusDot, StatusDot,
MetricCard, MetricCard,
AlertBanner, toast,
type BadgeProps, type BadgeProps,
type StatusTone, type StatusTone,
} from '@/components/ui/Primitives'; } from '@/components/ui/Primitives';
@ -50,7 +50,6 @@ export default function RoadmapPage() {
const [items, setItems] = useState<TrackerItem[]>([]); const [items, setItems] = useState<TrackerItem[]>([]);
const [stats, setStats] = useState<PublicRoadmapStats | null>(null); const [stats, setStats] = useState<PublicRoadmapStats | null>(null);
const [loading, setLoading] = useState(true); const [loading, setLoading] = useState(true);
const [loadError, setLoadError] = useState('');
const [search, setSearch] = useState(''); const [search, setSearch] = useState('');
const [typeFilter, setTypeFilter] = useState(''); const [typeFilter, setTypeFilter] = useState('');
const [view, setView] = useState<'board' | 'list'>('board'); const [view, setView] = useState<'board' | 'list'>('board');
@ -65,14 +64,12 @@ export default function RoadmapPage() {
name: '', name: '',
}); });
const [submitting, setSubmitting] = useState(false); const [submitting, setSubmitting] = useState(false);
const [submitSuccess, setSubmitSuccess] = useState('');
// Vote email (persisted in localStorage) // Vote email (persisted in localStorage)
const [voteEmail, setVoteEmail] = useState(''); const [voteEmail, setVoteEmail] = useState('');
const [showEmailPrompt, setShowEmailPrompt] = useState(false); const [showEmailPrompt, setShowEmailPrompt] = useState(false);
const [pendingVoteId, setPendingVoteId] = useState<string | null>(null); const [pendingVoteId, setPendingVoteId] = useState<string | null>(null);
const [votedItems, setVotedItems] = useState<Set<string>>(new Set()); const [votedItems, setVotedItems] = useState<Set<string>>(new Set());
const [voteError, setVoteError] = useState('');
useEffect(() => { useEffect(() => {
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
@ -85,7 +82,6 @@ export default function RoadmapPage() {
const fetchData = useCallback(async () => { const fetchData = useCallback(async () => {
setLoading(true); setLoading(true);
setLoadError('');
try { try {
const params: Record<string, string> = { const params: Record<string, string> = {
sortBy: 'voteCount', sortBy: 'voteCount',
@ -98,7 +94,11 @@ export default function RoadmapPage() {
setItems(itemsRes.items); setItems(itemsRes.items);
setStats(statsRes); setStats(statsRes);
} catch (err) { } catch (err) {
setLoadError(err instanceof Error ? err.message : 'Failed to load roadmap'); toast({
type: 'error',
title: "Couldn't load the roadmap",
description: err instanceof Error ? err.message : undefined,
});
} finally { } finally {
setLoading(false); setLoading(false);
} }
@ -118,7 +118,6 @@ export default function RoadmapPage() {
}; };
const doVote = async (itemId: string, email: string) => { const doVote = async (itemId: string, email: string) => {
setVoteError('');
try { try {
const res = await publicVote(itemId, email); const res = await publicVote(itemId, email);
setItems(prev => prev.map(i => (i.id === itemId ? { ...i, voteCount: res.voteCount } : i))); setItems(prev => prev.map(i => (i.id === itemId ? { ...i, voteCount: res.voteCount } : i)));
@ -131,7 +130,11 @@ export default function RoadmapPage() {
setVotedItems(newVoted); setVotedItems(newVoted);
localStorage.setItem('roadmap_voted', JSON.stringify([...newVoted])); localStorage.setItem('roadmap_voted', JSON.stringify([...newVoted]));
} catch (err) { } catch (err) {
setVoteError(err instanceof Error ? err.message : 'Vote failed'); toast({
type: 'error',
title: 'Vote failed',
description: err instanceof Error ? err.message : undefined,
});
} }
}; };
@ -148,12 +151,14 @@ export default function RoadmapPage() {
const handleSubmit = async (e: React.FormEvent) => { const handleSubmit = async (e: React.FormEvent) => {
e.preventDefault(); e.preventDefault();
setSubmitting(true); setSubmitting(true);
setSubmitSuccess('');
try { try {
const res = await submitPublicItem(submitForm); const res = await submitPublicItem(submitForm);
setSubmitSuccess( toast({
`Thanks! Your ${submitForm.type} request "${res.title}" has been submitted for review.` type: 'success',
); title: 'Idea submitted',
description: `Your ${submitForm.type} request "${res.title}" has been submitted for review.`,
});
setShowSubmit(false);
setSubmitForm({ setSubmitForm({
title: '', title: '',
description: '', description: '',
@ -169,7 +174,11 @@ export default function RoadmapPage() {
// Refresh data to show new item // Refresh data to show new item
fetchData(); fetchData();
} catch (err) { } catch (err) {
setSubmitSuccess(`Error: ${err instanceof Error ? err.message : 'Submission failed'}`); toast({
type: 'error',
title: 'Submission failed',
description: err instanceof Error ? err.message : undefined,
});
} finally { } finally {
setSubmitting(false); setSubmitting(false);
} }
@ -201,17 +210,6 @@ export default function RoadmapPage() {
</header> </header>
<main className="max-w-7xl mx-auto px-4 sm:px-6 py-6"> <main className="max-w-7xl mx-auto px-4 sm:px-6 py-6">
{loadError ? (
<AlertBanner tone="error" title="Couldn't load the roadmap" className="mb-4">
{loadError}
</AlertBanner>
) : null}
{voteError ? (
<AlertBanner tone="error" title="Vote failed" className="mb-4">
{voteError}
</AlertBanner>
) : null}
{/* Stats bar */} {/* Stats bar */}
{stats && ( {stats && (
<div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6"> <div className="grid grid-cols-2 sm:grid-cols-4 gap-4 mb-6">
@ -395,21 +393,10 @@ export default function RoadmapPage() {
<Modal <Modal
open={showSubmit} open={showSubmit}
onOpenChange={open => { onOpenChange={open => {
if (!open) { if (!open) setShowSubmit(false);
setShowSubmit(false);
setSubmitSuccess('');
}
}} }}
title="Submit an Idea" title="Submit an Idea"
> >
{submitSuccess ? (
<AlertBanner
tone={submitSuccess.startsWith('Error') ? 'error' : 'success'}
className="mb-4"
>
{submitSuccess}
</AlertBanner>
) : null}
<form onSubmit={handleSubmit} className="space-y-3"> <form onSubmit={handleSubmit} className="space-y-3">
<div className="grid grid-cols-2 gap-3"> <div className="grid grid-cols-2 gap-3">
<Input <Input